From acb1a58e50e209162dd086a818e9c24a2591a4c1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Apr 2026 17:05:10 +0000 Subject: [PATCH 01/15] Initial plan From 87f55b817423ef55dd62d2686b52c00e8206eb18 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Apr 2026 17:21:59 +0000 Subject: [PATCH 02/15] refactor: split ast.rs into ast/mod.rs + ast/tests.rs Move the 1847-line test module out of ast.rs into a separate ast/tests.rs file, reducing both files below 3000 lines: - ast/mod.rs: 1767 lines (production code) - ast/tests.rs: 1837 lines (test body) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: mikenrafter <88250914+mikenrafter@users.noreply.github.com> --- .../diffcore-core/src/{ast.rs => ast/mod.rs} | 1840 +---------------- crates/diffcore-core/src/ast/tests.rs | 1837 ++++++++++++++++ 2 files changed, 1838 insertions(+), 1839 deletions(-) rename crates/diffcore-core/src/{ast.rs => ast/mod.rs} (53%) create mode 100644 crates/diffcore-core/src/ast/tests.rs diff --git a/crates/diffcore-core/src/ast.rs b/crates/diffcore-core/src/ast/mod.rs similarity index 53% rename from crates/diffcore-core/src/ast.rs rename to crates/diffcore-core/src/ast/mod.rs index 7da606c..c7da5bf 100644 --- a/crates/diffcore-core/src/ast.rs +++ b/crates/diffcore-core/src/ast/mod.rs @@ -1764,1842 +1764,4 @@ fn extract_go_argument_texts(call_node: &Node, source: &[u8]) -> Vec { clippy::print_stdout, clippy::print_stderr )] -mod tests { - use super::*; - - // === TypeScript imports === - - #[test] - fn test_parse_ts_imports() { - let source = r#" -import React from 'react'; -import { useState, useEffect } from 'react'; -import * as path from 'path'; -import { foo as bar } from './utils'; -"#; - let result = parse_file("app.ts", source).unwrap(); - assert_eq!(result.imports.len(), 4); - - // Default import - assert_eq!(result.imports[0].source, "react"); - assert!(result.imports[0].is_default); - assert!(!result.imports[0].is_namespace); - assert_eq!(result.imports[0].names.len(), 1); - assert_eq!(result.imports[0].names[0].name, "React"); - - // Named imports - assert_eq!(result.imports[1].source, "react"); - assert!(!result.imports[1].is_default); - assert_eq!(result.imports[1].names.len(), 2); - assert_eq!(result.imports[1].names[0].name, "useState"); - assert_eq!(result.imports[1].names[1].name, "useEffect"); - - // Namespace import - assert_eq!(result.imports[2].source, "path"); - assert!(result.imports[2].is_namespace); - assert_eq!(result.imports[2].names[0].name, "path"); - - // Aliased import - assert_eq!(result.imports[3].source, "./utils"); - assert_eq!(result.imports[3].names[0].name, "foo"); - assert_eq!(result.imports[3].names[0].alias, Some("bar".to_string())); - } - - #[test] - fn test_parse_ts_default_and_named_import() { - let source = r#"import React, { useState } from 'react';"#; - let result = parse_file("app.ts", source).unwrap(); - assert_eq!(result.imports.len(), 1); - let imp = &result.imports[0]; - assert!(imp.is_default); - assert_eq!(imp.names.len(), 2); - assert_eq!(imp.names[0].name, "React"); - assert_eq!(imp.names[1].name, "useState"); - } - - #[test] - fn test_parse_ts_side_effect_import() { - let source = r#"import './polyfill';"#; - let result = parse_file("app.ts", source).unwrap(); - assert_eq!(result.imports.len(), 1); - assert_eq!(result.imports[0].source, "./polyfill"); - assert!(result.imports[0].names.is_empty()); - } - - // === TypeScript exports === - - #[test] - fn test_parse_ts_exports() { - let source = r#" -export function greet() {} -export default function main() {} -export { foo, bar }; -export { baz } from './other'; -export const VALUE = 42; -"#; - let result = parse_file("lib.ts", source).unwrap(); - - // export function greet - let greet_export = result.exports.iter().find(|e| e.name == "greet").unwrap(); - assert!(!greet_export.is_default); - assert!(!greet_export.is_reexport); - - // export default function main - let main_export = result.exports.iter().find(|e| e.name == "main").unwrap(); - assert!(main_export.is_default); - - // export { foo, bar } - let foo_export = result.exports.iter().find(|e| e.name == "foo").unwrap(); - assert!(!foo_export.is_default); - assert!(!foo_export.is_reexport); - - let bar_export = result.exports.iter().find(|e| e.name == "bar").unwrap(); - assert!(!bar_export.is_reexport); - - // export { baz } from './other' - let baz_export = result.exports.iter().find(|e| e.name == "baz").unwrap(); - assert!(baz_export.is_reexport); - assert_eq!(baz_export.source, Some("./other".to_string())); - - // export const VALUE - let val_export = result.exports.iter().find(|e| e.name == "VALUE").unwrap(); - assert!(!val_export.is_default); - } - - #[test] - fn test_parse_ts_wildcard_reexport() { - let source = r#"export * from './all';"#; - let result = parse_file("index.ts", source).unwrap(); - assert_eq!(result.exports.len(), 1); - assert_eq!(result.exports[0].name, "*"); - assert!(result.exports[0].is_reexport); - assert_eq!(result.exports[0].source, Some("./all".to_string())); - } - - #[test] - fn test_parse_ts_export_default_expression() { - let source = r#" -const app = createApp(); -export default app; -"#; - let result = parse_file("app.ts", source).unwrap(); - let default_export = result.exports.iter().find(|e| e.is_default).unwrap(); - assert_eq!(default_export.name, "app"); - } - - // === TypeScript definitions === - - #[test] - fn test_parse_ts_functions() { - let source = r#" -function greet(name: string): string { - return `Hello ${name}`; -} - -const double = (x: number) => x * 2; - -class Calculator { - add(a: number, b: number): number { - return a + b; - } - subtract(a: number, b: number): number { - return a - b; - } -} -"#; - let result = parse_file("math.ts", source).unwrap(); - - // function declaration - let greet = result - .definitions - .iter() - .find(|d| d.name == "greet") - .unwrap(); - assert_eq!(greet.kind, SymbolKind::Function); - - // arrow function - let double = result - .definitions - .iter() - .find(|d| d.name == "double") - .unwrap(); - assert_eq!(double.kind, SymbolKind::Function); - - // class - let calc = result - .definitions - .iter() - .find(|d| d.name == "Calculator") - .unwrap(); - assert_eq!(calc.kind, SymbolKind::Class); - - // methods - let add = result.definitions.iter().find(|d| d.name == "add").unwrap(); - assert_eq!(add.kind, SymbolKind::Function); - - let sub = result - .definitions - .iter() - .find(|d| d.name == "subtract") - .unwrap(); - assert_eq!(sub.kind, SymbolKind::Function); - } - - #[test] - fn test_parse_ts_interface_and_type() { - let source = r#" -interface User { - name: string; - age: number; -} - -type UserId = string; -"#; - let result = parse_file("types.ts", source).unwrap(); - - let user_iface = result - .definitions - .iter() - .find(|d| d.name == "User") - .unwrap(); - assert_eq!(user_iface.kind, SymbolKind::Interface); - - let user_id = result - .definitions - .iter() - .find(|d| d.name == "UserId") - .unwrap(); - assert_eq!(user_id.kind, SymbolKind::TypeAlias); - } - - #[test] - fn test_parse_ts_constants() { - let source = r#" -const MAX_RETRIES = 3; -const API_URL = "https://example.com"; -"#; - let result = parse_file("config.ts", source).unwrap(); - assert_eq!(result.definitions.len(), 2); - - let max = result - .definitions - .iter() - .find(|d| d.name == "MAX_RETRIES") - .unwrap(); - assert_eq!(max.kind, SymbolKind::Constant); - } - - // === TypeScript call sites === - - #[test] - fn test_parse_ts_call_sites() { - let source = r#" -function processUser(user: User) { - const validated = validateUser(user); - const saved = db.save(validated); - notifyAdmin(saved.id); -} -"#; - let result = parse_file("handler.ts", source).unwrap(); - - let call_names: Vec<&str> = result - .call_sites - .iter() - .map(|c| c.callee.as_str()) - .collect(); - assert!(call_names.contains(&"validateUser")); - assert!(call_names.contains(&"db.save")); - assert!(call_names.contains(&"notifyAdmin")); - - // All calls should be inside processUser - for call in &result.call_sites { - assert_eq!(call.containing_function, Some("processUser".to_string())); - } - } - - #[test] - fn test_parse_ts_call_sites_in_arrow() { - let source = r#" -const handler = (req: Request) => { - const data = parseBody(req); - return respond(data); -}; -"#; - let result = parse_file("handler.ts", source).unwrap(); - let call_names: Vec<&str> = result - .call_sites - .iter() - .map(|c| c.callee.as_str()) - .collect(); - assert!(call_names.contains(&"parseBody")); - assert!(call_names.contains(&"respond")); - - for call in &result.call_sites { - assert_eq!(call.containing_function, Some("handler".to_string())); - } - } - - // === Python imports === - - #[test] - fn test_parse_python_imports() { - let source = r#" -import os -import json as j -from pathlib import Path -from typing import List, Optional -from . import utils -from ..models import User as U -"#; - let result = parse_file("app.py", source).unwrap(); - assert_eq!(result.imports.len(), 6); - - // import os - assert_eq!(result.imports[0].source, "os"); - assert!(result.imports[0].is_namespace); - assert_eq!(result.imports[0].names[0].name, "os"); - - // import json as j - assert_eq!(result.imports[1].source, "json"); - assert_eq!(result.imports[1].names[0].name, "json"); - assert_eq!(result.imports[1].names[0].alias, Some("j".to_string())); - - // from pathlib import Path - assert_eq!(result.imports[2].source, "pathlib"); - assert!(!result.imports[2].is_namespace); - assert_eq!(result.imports[2].names[0].name, "Path"); - - // from typing import List, Optional - assert_eq!(result.imports[3].source, "typing"); - assert_eq!(result.imports[3].names.len(), 2); - assert_eq!(result.imports[3].names[0].name, "List"); - assert_eq!(result.imports[3].names[1].name, "Optional"); - - // from . import utils (relative import) - assert_eq!(result.imports[4].source, "."); - assert_eq!(result.imports[4].names[0].name, "utils"); - - // from ..models import User as U - assert!(result.imports[5].source.contains("models")); - assert_eq!(result.imports[5].names[0].name, "User"); - assert_eq!(result.imports[5].names[0].alias, Some("U".to_string())); - } - - // === Python definitions === - - #[test] - fn test_parse_python_functions() { - let source = r#" -def greet(name: str) -> str: - return f"Hello {name}" - -class UserService: - def create_user(self, data: dict) -> User: - return User(**data) - - def delete_user(self, user_id: int) -> None: - pass -"#; - let result = parse_file("service.py", source).unwrap(); - - // Top-level function - let greet = result - .definitions - .iter() - .find(|d| d.name == "greet") - .unwrap(); - assert_eq!(greet.kind, SymbolKind::Function); - - // Class - let svc = result - .definitions - .iter() - .find(|d| d.name == "UserService") - .unwrap(); - assert_eq!(svc.kind, SymbolKind::Class); - - // Methods - let create = result - .definitions - .iter() - .find(|d| d.name == "create_user") - .unwrap(); - assert_eq!(create.kind, SymbolKind::Function); - - let delete = result - .definitions - .iter() - .find(|d| d.name == "delete_user") - .unwrap(); - assert_eq!(delete.kind, SymbolKind::Function); - } - - #[test] - fn test_parse_python_decorated_functions() { - let source = r#" -from flask import Flask -app = Flask(__name__) - -@app.route('/users', methods=['GET']) -def list_users(): - return get_all_users() - -@staticmethod -def helper(): - pass -"#; - let result = parse_file("routes.py", source).unwrap(); - - let list_users = result - .definitions - .iter() - .find(|d| d.name == "list_users") - .unwrap(); - assert_eq!(list_users.kind, SymbolKind::Function); - - let helper = result - .definitions - .iter() - .find(|d| d.name == "helper") - .unwrap(); - assert_eq!(helper.kind, SymbolKind::Function); - } - - // === Python class hierarchy === - - #[test] - fn test_parse_python_class_hierarchy() { - let source = r#" -class Animal: - pass - -class Dog(Animal): - def bark(self): - pass - -class GuideDog(Dog, ServiceAnimal): - pass -"#; - // Verify class definitions are detected - let result = parse_file("models.py", source).unwrap(); - let classes: Vec<&str> = result - .definitions - .iter() - .filter(|d| d.kind == SymbolKind::Class) - .map(|d| d.name.as_str()) - .collect(); - assert!(classes.contains(&"Animal")); - assert!(classes.contains(&"Dog")); - assert!(classes.contains(&"GuideDog")); - - // Verify base class extraction - let animal_bases = get_python_class_bases(source, "Animal").unwrap(); - assert!(animal_bases.is_empty()); - - let dog_bases = get_python_class_bases(source, "Dog").unwrap(); - assert_eq!(dog_bases, vec!["Animal"]); - - let guide_bases = get_python_class_bases(source, "GuideDog").unwrap(); - assert_eq!(guide_bases, vec!["Dog", "ServiceAnimal"]); - } - - // === Unknown language === - - #[test] - fn test_parse_unknown_language() { - let source = "some random content that is not code"; - let result = parse_file("main.xyz", source).unwrap(); - assert_eq!(result.language, Language::Unknown); - assert!(result.definitions.is_empty()); - assert!(result.imports.is_empty()); - assert!(result.exports.is_empty()); - assert!(result.call_sites.is_empty()); - } - - /// §13.3: Handles Rust `mod`, `use`, `pub` visibility. - /// - /// Rust is detected as Language::Rust but parsing currently falls through to the - /// generic handler (no tree-sitter queries for Rust yet). This test verifies: - /// 1. Rust files parse without error - /// 2. Language is correctly detected as Rust - /// 3. Graceful fallback produces empty definitions/imports/exports - #[test] - fn test_parse_rust_modules() { - let source = r#" -mod handlers; -mod models; - -use std::collections::HashMap; -use crate::models::User; - -pub fn create_user(name: &str) -> User { - User { name: name.to_string() } -} - -pub(crate) fn internal_helper() -> bool { - true -} -"#; - let result = parse_file("src/lib.rs", source).unwrap(); - - // Language should be correctly detected - assert_eq!(result.language, Language::Rust); - assert_eq!(result.path, "src/lib.rs"); - - // Rust parsing is not yet implemented via tree-sitter queries, - // so definitions/imports/exports are empty (graceful fallback). - // This documents the current state and will catch when Rust parsing is added. - assert!( - result.definitions.is_empty(), - "Rust definitions are not yet extracted (graceful fallback)" - ); - assert!( - result.imports.is_empty(), - "Rust imports are not yet extracted (graceful fallback)" - ); - assert!( - result.exports.is_empty(), - "Rust exports are not yet extracted (graceful fallback)" - ); - } - - // === Changed symbols detection === - - #[test] - fn test_changed_symbols_detection() { - let old_source = r#" -function foo() {} -function bar() {} -const VALUE = 42; -"#; - let new_source = r#" -function foo() { - return 1; -} -function baz() {} -const VALUE = 42; -"#; - let old = parse_file("lib.ts", old_source).unwrap(); - let new = parse_file("lib.ts", new_source).unwrap(); - let changes = detect_changed_symbols(&old, &new); - - let added: Vec<&str> = changes - .iter() - .filter_map(|c| match c { - SymbolChange::Added(d) => Some(d.name.as_str()), - _ => None, - }) - .collect(); - assert!(added.contains(&"baz"), "baz should be added"); - - let removed: Vec<&str> = changes - .iter() - .filter_map(|c| match c { - SymbolChange::Removed(d) => Some(d.name.as_str()), - _ => None, - }) - .collect(); - assert!(removed.contains(&"bar"), "bar should be removed"); - - let modified: Vec<&str> = changes - .iter() - .filter_map(|c| match c { - SymbolChange::Modified { old, .. } => Some(old.name.as_str()), - _ => None, - }) - .collect(); - assert!(modified.contains(&"foo"), "foo should be modified"); - - // VALUE unchanged - assert!( - !changes.iter().any(|c| match c { - SymbolChange::Added(d) | SymbolChange::Removed(d) => d.name == "VALUE", - SymbolChange::Modified { old, .. } => old.name == "VALUE", - }), - "VALUE should be unchanged" - ); - } - - #[test] - fn test_changed_symbols_no_changes() { - let source = "function foo() {}\n"; - let old = parse_file("lib.ts", source).unwrap(); - let new = parse_file("lib.ts", source).unwrap(); - let changes = detect_changed_symbols(&old, &new); - assert!(changes.is_empty()); - } - - // === Language detection === - - #[test] - fn test_language_from_path() { - assert_eq!(Language::from_path("app.ts"), Language::TypeScript); - assert_eq!(Language::from_path("app.tsx"), Language::TypeScript); - assert_eq!(Language::from_path("app.js"), Language::JavaScript); - assert_eq!(Language::from_path("app.jsx"), Language::JavaScript); - assert_eq!(Language::from_path("app.mjs"), Language::JavaScript); - assert_eq!(Language::from_path("app.cjs"), Language::JavaScript); - assert_eq!(Language::from_path("app.py"), Language::Python); - assert_eq!(Language::from_path("app.pyi"), Language::Python); - assert_eq!(Language::from_path("app.go"), Language::Go); - assert_eq!(Language::from_path("app.rs"), Language::Rust); - assert_eq!(Language::from_path("Makefile"), Language::Unknown); - } - - // === Line numbers === - - #[test] - fn test_definition_line_numbers() { - let source = "function foo() {\n return 1;\n}\n\nfunction bar() {\n return 2;\n}\n"; - let result = parse_file("lib.ts", source).unwrap(); - - let foo = result.definitions.iter().find(|d| d.name == "foo").unwrap(); - assert_eq!(foo.start_line, 1); - assert_eq!(foo.end_line, 3); - - let bar = result.definitions.iter().find(|d| d.name == "bar").unwrap(); - assert_eq!(bar.start_line, 5); - assert_eq!(bar.end_line, 7); - } - - // === Performance === - - #[test] - fn test_large_file_performance() { - // Generate a 10K+ line TypeScript file - let mut source = String::with_capacity(2_000_000); - for i in 0..3000 { - source.push_str(&format!( - "function func_{i}(x: number): number {{\n return x * {i};\n}}\n\n" - )); - } - for i in 0..500 { - source.push_str(&format!("const arrow_{i} = (x: number) => x + {i};\n")); - } - for i in 0..100 { - source.push_str(&format!( - "class Class_{i} {{\n method_a() {{ return func_{i}(1); }}\n method_b() {{ return arrow_{i}(2); }}\n}}\n\n" - )); - } - - let line_count = source.lines().count(); - assert!( - line_count > 10_000, - "generated file should have 10K+ lines, got {line_count}" - ); - - let start = std::time::Instant::now(); - let result = parse_file("large.ts", &source).unwrap(); - let elapsed = start.elapsed(); - - assert!( - elapsed.as_millis() < 500, - "parsing 10K+ line file took {}ms, should be < 500ms", - elapsed.as_millis() - ); - - // Sanity check: we extracted definitions - assert!(result.definitions.len() > 3000); - assert!(!result.call_sites.is_empty()); - } - - // === Python call sites === - - #[test] - fn test_parse_python_call_sites() { - let source = r#" -def process(data): - validated = validate(data) - result = db.save(validated) - return result -"#; - let result = parse_file("handler.py", source).unwrap(); - let call_names: Vec<&str> = result - .call_sites - .iter() - .map(|c| c.callee.as_str()) - .collect(); - assert!(call_names.contains(&"validate")); - assert!(call_names.contains(&"db.save")); - - for call in &result.call_sites { - assert_eq!(call.containing_function, Some("process".to_string())); - } - } - - // === Edge cases === - - #[test] - fn test_empty_source() { - let result = parse_file("empty.ts", "").unwrap(); - assert!(result.definitions.is_empty()); - assert!(result.imports.is_empty()); - assert!(result.exports.is_empty()); - assert!(result.call_sites.is_empty()); - } - - #[test] - fn test_parse_js_file_uses_typescript_parser() { - let source = "function hello() { console.log('hi'); }\n"; - let result = parse_file("app.js", source).unwrap(); - assert_eq!(result.language, Language::JavaScript); - assert_eq!(result.definitions.len(), 1); - assert_eq!(result.definitions[0].name, "hello"); - } - - #[test] - fn test_export_class_with_methods() { - let source = r#" -export class Router { - get(path: string) {} - post(path: string) {} -} -"#; - let result = parse_file("router.ts", source).unwrap(); - - let class_export = result.exports.iter().find(|e| e.name == "Router").unwrap(); - assert!(!class_export.is_default); - - let methods: Vec<&str> = result - .definitions - .iter() - .filter(|d| d.name == "get" || d.name == "post") - .map(|d| d.name.as_str()) - .collect(); - assert!(methods.contains(&"get")); - assert!(methods.contains(&"post")); - } - - // ======================================================================== - // Data flow extraction — TypeScript - // ======================================================================== - - #[test] - fn test_data_flow_ts_simple_assignment() { - let source = r#" -function handler(req: any) { - const data = parseBody(req); - return respond(data); -} -"#; - let info = extract_data_flow_info("handler.ts", source).unwrap(); - - // Should detect `const data = parseBody(req)` - assert_eq!(info.assignments.len(), 1); - assert_eq!(info.assignments[0].variable, "data"); - assert_eq!(info.assignments[0].callee, "parseBody"); - assert_eq!( - info.assignments[0].containing_function, - Some("handler".to_string()) - ); - - // Should detect both calls with their arguments - let parse_call = info - .calls_with_args - .iter() - .find(|c| c.callee == "parseBody") - .unwrap(); - assert!(parse_call.arguments.contains(&"req".to_string())); - - let respond_call = info - .calls_with_args - .iter() - .find(|c| c.callee == "respond") - .unwrap(); - assert!(respond_call.arguments.contains(&"data".to_string())); - } - - #[test] - fn test_data_flow_ts_method_call_assignment() { - let source = r#" -function process() { - const user = db.findOne(id); - return transform(user); -} -"#; - let info = extract_data_flow_info("service.ts", source).unwrap(); - - assert_eq!(info.assignments.len(), 1); - assert_eq!(info.assignments[0].variable, "user"); - assert_eq!(info.assignments[0].callee, "db.findOne"); - } - - #[test] - fn test_data_flow_ts_await_assignment() { - let source = r#" -async function handler(req: any) { - const data = await fetchData(req.id); - return process(data); -} -"#; - let info = extract_data_flow_info("handler.ts", source).unwrap(); - - // Should unwrap the await and capture the call - assert_eq!(info.assignments.len(), 1); - assert_eq!(info.assignments[0].variable, "data"); - assert_eq!(info.assignments[0].callee, "fetchData"); - } - - #[test] - fn test_data_flow_ts_chained_assignments() { - let source = r#" -function pipeline(input: any) { - const validated = validate(input); - const processed = transform(validated); - const result = save(processed); - return result; -} -"#; - let info = extract_data_flow_info("pipeline.ts", source).unwrap(); - - assert_eq!(info.assignments.len(), 3); - - let vars: Vec<&str> = info - .assignments - .iter() - .map(|a| a.variable.as_str()) - .collect(); - assert!(vars.contains(&"validated")); - assert!(vars.contains(&"processed")); - assert!(vars.contains(&"result")); - - let callees: Vec<&str> = info.assignments.iter().map(|a| a.callee.as_str()).collect(); - assert!(callees.contains(&"validate")); - assert!(callees.contains(&"transform")); - assert!(callees.contains(&"save")); - } - - #[test] - fn test_data_flow_ts_call_arguments_multiple() { - let source = r#" -function merge(a: any, b: any) { - const x = getFirst(); - const y = getSecond(); - return combine(x, y, 42); -} -"#; - let info = extract_data_flow_info("merge.ts", source).unwrap(); - - let combine_call = info - .calls_with_args - .iter() - .find(|c| c.callee == "combine") - .unwrap(); - assert!(combine_call.arguments.contains(&"x".to_string())); - assert!(combine_call.arguments.contains(&"y".to_string())); - // 42 is a literal, should also be captured as argument text - assert!(combine_call.arguments.contains(&"42".to_string())); - } - - #[test] - fn test_data_flow_ts_arrow_function() { - let source = r#" -const handler = (req: any) => { - const data = parseBody(req); - return respond(data); -}; -"#; - let info = extract_data_flow_info("handler.ts", source).unwrap(); - - assert_eq!(info.assignments.len(), 1); - assert_eq!(info.assignments[0].variable, "data"); - assert_eq!(info.assignments[0].callee, "parseBody"); - assert_eq!( - info.assignments[0].containing_function, - Some("handler".to_string()) - ); - } - - #[test] - fn test_data_flow_ts_no_assignments() { - let source = r#" -function simple() { - console.log("hello"); - return 42; -} -"#; - let info = extract_data_flow_info("simple.ts", source).unwrap(); - assert!(info.assignments.is_empty()); - } - - #[test] - fn test_data_flow_ts_module_level() { - let source = r#" -const config = loadConfig(); -startServer(config); -"#; - let info = extract_data_flow_info("main.ts", source).unwrap(); - - assert_eq!(info.assignments.len(), 1); - assert_eq!(info.assignments[0].variable, "config"); - assert_eq!(info.assignments[0].callee, "loadConfig"); - // Module-level has no containing function - assert_eq!(info.assignments[0].containing_function, None); - - let start_call = info - .calls_with_args - .iter() - .find(|c| c.callee == "startServer") - .unwrap(); - assert!(start_call.arguments.contains(&"config".to_string())); - } - - #[test] - fn test_data_flow_ts_nested_call_as_argument() { - let source = r#" -function process() { - return save(transform(input)); -} -"#; - let info = extract_data_flow_info("process.ts", source).unwrap(); - - // The inner call `transform(input)` should be captured - let save_call = info - .calls_with_args - .iter() - .find(|c| c.callee == "save") - .unwrap(); - // The argument to save is the full nested call text - assert_eq!(save_call.arguments.len(), 1); - assert!(save_call.arguments[0].contains("transform")); - } - - #[test] - fn test_data_flow_ts_non_call_value_ignored() { - let source = r#" -function process() { - const x = 42; - const y = "hello"; - const z = someVar; - return x; -} -"#; - let info = extract_data_flow_info("process.ts", source).unwrap(); - - // None of these are function call assignments - assert!( - info.assignments.is_empty(), - "literal and variable assignments should not be captured" - ); - } - - // ======================================================================== - // Data flow extraction — Python - // ======================================================================== - - #[test] - fn test_data_flow_python_simple_assignment() { - let source = r#" -def handler(req): - data = parse_body(req) - return respond(data) -"#; - let info = extract_data_flow_info("handler.py", source).unwrap(); - - assert_eq!(info.assignments.len(), 1); - assert_eq!(info.assignments[0].variable, "data"); - assert_eq!(info.assignments[0].callee, "parse_body"); - assert_eq!( - info.assignments[0].containing_function, - Some("handler".to_string()) - ); - - let respond_call = info - .calls_with_args - .iter() - .find(|c| c.callee == "respond") - .unwrap(); - assert!(respond_call.arguments.contains(&"data".to_string())); - } - - #[test] - fn test_data_flow_python_chained() { - let source = r#" -def pipeline(raw): - validated = validate(raw) - processed = transform(validated) - save(processed) -"#; - let info = extract_data_flow_info("pipeline.py", source).unwrap(); - - assert_eq!(info.assignments.len(), 2); - - let vars: Vec<&str> = info - .assignments - .iter() - .map(|a| a.variable.as_str()) - .collect(); - assert!(vars.contains(&"validated")); - assert!(vars.contains(&"processed")); - } - - #[test] - fn test_data_flow_python_method_call() { - let source = r#" -def get_user(user_id): - user = db.find_one(user_id) - return serialize(user) -"#; - let info = extract_data_flow_info("service.py", source).unwrap(); - - assert_eq!(info.assignments.len(), 1); - assert_eq!(info.assignments[0].callee, "db.find_one"); - } - - #[test] - fn test_data_flow_unknown_language() { - let info = extract_data_flow_info("main.rs", "fn main() {}").unwrap(); - assert!(info.assignments.is_empty()); - assert!(info.calls_with_args.is_empty()); - } - - #[test] - fn test_data_flow_empty_source() { - let info = extract_data_flow_info("empty.ts", "").unwrap(); - assert!(info.assignments.is_empty()); - assert!(info.calls_with_args.is_empty()); - } - - #[test] - fn test_data_flow_ts_multiple_consumers() { - let source = r#" -function process() { - const data = fetchData(); - validate(data); - transform(data); - save(data); -} -"#; - let info = extract_data_flow_info("process.ts", source).unwrap(); - - // One assignment, three consumers - assert_eq!(info.assignments.len(), 1); - assert_eq!(info.assignments[0].variable, "data"); - - let consumers_using_data: Vec<&str> = info - .calls_with_args - .iter() - .filter(|c| c.arguments.contains(&"data".to_string())) - .map(|c| c.callee.as_str()) - .collect(); - assert!(consumers_using_data.contains(&"validate")); - assert!(consumers_using_data.contains(&"transform")); - assert!(consumers_using_data.contains(&"save")); - } - - // ======================================================================== - // Phase 8 audit: edge case tests - // ======================================================================== - - #[test] - fn test_ts_enum_declaration_not_captured() { - // Known limitation: TS enums are not extracted as definitions. - // This test documents the behavior so it's visible. - let source = r#" -enum Color { - Red, - Green, - Blue, -} -"#; - let result = parse_file("types.ts", source).unwrap(); - // Enums are not captured — this documents the gap. - assert!( - result.definitions.iter().all(|d| d.name != "Color"), - "TS enums are not captured by the current parser" - ); - } - - #[test] - fn test_changed_symbols_same_span_different_body() { - // If a function changes body but keeps the same line count, - // detect_changed_symbols won't flag it as modified (by design — compares span size). - let old_source = "function foo() {\n return 1;\n}\n"; - let new_source = "function foo() {\n return 2;\n}\n"; - let old = parse_file("lib.ts", old_source).unwrap(); - let new = parse_file("lib.ts", new_source).unwrap(); - let changes = detect_changed_symbols(&old, &new); - // Same span size → not detected as modified (design limitation) - assert!( - changes.is_empty(), - "same-span changes are not detected by span comparison" - ); - } - - #[test] - fn test_ts_abstract_class() { - let source = r#" -abstract class BaseService { - abstract process(): void; - helper() { return 1; } -} -"#; - let result = parse_file("service.ts", source).unwrap(); - let base_svc = result.definitions.iter().find(|d| d.name == "BaseService"); - assert!(base_svc.is_some(), "abstract classes should be captured"); - assert_eq!(base_svc.unwrap().kind, SymbolKind::Class); - - // Method inside abstract class - assert!(result.definitions.iter().any(|d| d.name == "helper")); - } - - #[test] - fn test_ts_generator_function() { - let source = r#" -function* generate() { - yield 1; - yield 2; -} -"#; - let result = parse_file("gen.ts", source).unwrap(); - let gen = result.definitions.iter().find(|d| d.name == "generate"); - assert!(gen.is_some(), "generator functions should be captured"); - assert_eq!(gen.unwrap().kind, SymbolKind::Function); - } - - #[test] - fn test_ts_multiple_classes_with_methods() { - let source = r#" -class A { - foo() {} -} -class B { - foo() {} - bar() {} -} -"#; - let result = parse_file("classes.ts", source).unwrap(); - let classes: Vec<&str> = result - .definitions - .iter() - .filter(|d| d.kind == SymbolKind::Class) - .map(|d| d.name.as_str()) - .collect(); - assert!(classes.contains(&"A")); - assert!(classes.contains(&"B")); - - // Both classes have foo() methods — both should be captured - let foos: Vec<&Definition> = result - .definitions - .iter() - .filter(|d| d.name == "foo" && d.kind == SymbolKind::Function) - .collect(); - assert_eq!(foos.len(), 2, "both foo() methods should be captured"); - } - - #[test] - fn test_ts_unicode_identifiers() { - let source = r#" -function grüßen(名前: string): string { - return `Hello ${名前}`; -} -const αβγ = 42; -"#; - let result = parse_file("unicode.ts", source).unwrap(); - assert!(result.definitions.iter().any(|d| d.name == "grüßen")); - assert!(result.definitions.iter().any(|d| d.name == "αβγ")); - } - - #[test] - fn test_ts_syntax_error_partial_parse() { - // tree-sitter does partial parsing on syntax errors but recovery is - // not guaranteed for all subsequent definitions. - let source = r#" -function valid() { return 1; } -const x = {{{ -function alsoValid() { return 2; } -"#; - let result = parse_file("broken.ts", source).unwrap(); - // The definition before the error should be extracted - assert!(result.definitions.iter().any(|d| d.name == "valid")); - // Parsing doesn't fail — no panic, just potentially missing later defs - assert!(result.language == Language::TypeScript); - } - - #[test] - fn test_ts_export_default_class() { - let source = r#"export default class App { - render() {} -}"#; - let result = parse_file("app.ts", source).unwrap(); - let app_export = result.exports.iter().find(|e| e.name == "App"); - assert!( - app_export.is_some(), - "export default class should be captured" - ); - assert!(app_export.unwrap().is_default); - } - - #[test] - fn test_ts_export_interface_and_type() { - let source = r#" -export interface Config { - port: number; -} -export type ID = string; -"#; - let result = parse_file("types.ts", source).unwrap(); - assert!(result.exports.iter().any(|e| e.name == "Config")); - assert!(result.exports.iter().any(|e| e.name == "ID")); - assert!(result - .definitions - .iter() - .any(|d| d.name == "Config" && d.kind == SymbolKind::Interface)); - assert!(result - .definitions - .iter() - .any(|d| d.name == "ID" && d.kind == SymbolKind::TypeAlias)); - } - - #[test] - fn test_python_decorated_class_with_methods() { - let source = r#" -@dataclass -class User: - name: str - - def greet(self): - pass - - @staticmethod - def create(name): - pass -"#; - let result = parse_file("models.py", source).unwrap(); - assert!(result - .definitions - .iter() - .any(|d| d.name == "User" && d.kind == SymbolKind::Class)); - assert!(result.definitions.iter().any(|d| d.name == "greet")); - assert!(result.definitions.iter().any(|d| d.name == "create")); - } - - #[test] - fn test_python_wildcard_import() { - let source = "from os.path import *\n"; - let result = parse_file("app.py", source).unwrap(); - assert_eq!(result.imports.len(), 1); - assert!(result.imports[0].names.iter().any(|n| n.name == "*")); - } - - #[test] - fn test_python_relative_import_parent() { - let source = "from .. import utils\n"; - let result = parse_file("sub/mod.py", source).unwrap(); - assert_eq!(result.imports.len(), 1); - assert_eq!(result.imports[0].source, ".."); - } - - #[test] - fn test_ts_deeply_nested_calls() { - // Verify recursive call collection handles nesting - let source = r#" -function outer() { - function middle() { - function inner() { - deepCall(); - } - middleCall(); - } - outerCall(); -} -"#; - let result = parse_file("nested.ts", source).unwrap(); - let callees: Vec<&str> = result - .call_sites - .iter() - .map(|c| c.callee.as_str()) - .collect(); - assert!(callees.contains(&"deepCall")); - assert!(callees.contains(&"middleCall")); - assert!(callees.contains(&"outerCall")); - - // Containing function resolution - let deep = result - .call_sites - .iter() - .find(|c| c.callee == "deepCall") - .unwrap(); - assert_eq!(deep.containing_function, Some("inner".to_string())); - } - - #[test] - fn test_ts_module_level_calls_no_containing() { - let source = "init();\nconfigure();\n"; - let result = parse_file("init.ts", source).unwrap(); - for call in &result.call_sites { - assert_eq!( - call.containing_function, None, - "top-level calls should have no containing function" - ); - } - } - - #[test] - fn test_language_from_path_edge_cases() { - assert_eq!(Language::from_path(""), Language::Unknown); - assert_eq!(Language::from_path("noext"), Language::Unknown); - assert_eq!(Language::from_path(".ts"), Language::TypeScript); - assert_eq!(Language::from_path("a/b/c.py"), Language::Python); - assert_eq!(Language::from_path("my.module.ts"), Language::TypeScript); - } - - #[test] - fn test_ts_comments_only_file() { - let source = r#" -// This is a comment -/* block comment */ -/** JSDoc */ -"#; - let result = parse_file("comments.ts", source).unwrap(); - assert!(result.definitions.is_empty()); - assert!(result.imports.is_empty()); - assert!(result.exports.is_empty()); - assert!(result.call_sites.is_empty()); - } - - #[test] - fn test_data_flow_python_keyword_args_only() { - let source = r#" -def main(): - result = connect(host='localhost', port=5432) -"#; - let info = extract_data_flow_info("main.py", source).unwrap(); - - let connect_call = info - .calls_with_args - .iter() - .find(|c| c.callee == "connect") - .unwrap(); - // Keyword arg values should be captured - assert!(connect_call.arguments.contains(&"'localhost'".to_string())); - assert!(connect_call.arguments.contains(&"5432".to_string())); - } - - #[test] - fn test_ts_let_var_declarations() { - let source = r#" -let mutable = 42; -var legacy = "old"; -"#; - let result = parse_file("vars.ts", source).unwrap(); - assert!(result.definitions.iter().any(|d| d.name == "mutable")); - assert!(result.definitions.iter().any(|d| d.name == "legacy")); - } - - #[test] - fn test_ts_export_multiple_vars() { - let source = "export const A = 1, B = 2;\n"; - let result = parse_file("consts.ts", source).unwrap(); - assert!(result.exports.iter().any(|e| e.name == "A")); - assert!(result.exports.iter().any(|e| e.name == "B")); - } - - // ======================================================================== - // Go parsing tests - // ======================================================================== - - #[test] - fn test_go_language_detection() { - assert_eq!(Language::from_path("main.go"), Language::Go); - assert_eq!(Language::from_path("handlers/user.go"), Language::Go); - } - - #[test] - fn test_go_simple_imports() { - let source = r#" -package main - -import "fmt" -import "net/http" -"#; - let result = parse_file("main.go", source).unwrap(); - assert_eq!(result.language, Language::Go); - assert_eq!(result.imports.len(), 2); - - assert_eq!(result.imports[0].source, "fmt"); - assert!(result.imports[0].is_namespace); - assert_eq!(result.imports[0].names[0].name, "fmt"); - - assert_eq!(result.imports[1].source, "net/http"); - assert!(result.imports[1].is_namespace); - assert_eq!(result.imports[1].names[0].name, "http"); - } - - #[test] - fn test_go_grouped_imports() { - let source = r#" -package main - -import ( - "fmt" - "net/http" - "github.com/gin-gonic/gin" -) -"#; - let result = parse_file("main.go", source).unwrap(); - assert_eq!(result.imports.len(), 3); - assert_eq!(result.imports[0].source, "fmt"); - assert_eq!(result.imports[1].source, "net/http"); - assert_eq!(result.imports[2].source, "github.com/gin-gonic/gin"); - assert_eq!(result.imports[2].names[0].name, "gin"); - } - - #[test] - fn test_go_aliased_import() { - let source = r#" -package main - -import ( - myhttp "net/http" - _ "database/sql" -) -"#; - let result = parse_file("main.go", source).unwrap(); - assert_eq!(result.imports.len(), 2); - - // Aliased import - assert_eq!(result.imports[0].source, "net/http"); - assert_eq!(result.imports[0].names[0].name, "http"); - assert_eq!(result.imports[0].names[0].alias, Some("myhttp".to_string())); - - // Blank import (side-effect only) - assert_eq!(result.imports[1].source, "database/sql"); - assert!(result.imports[1].names.is_empty()); - } - - #[test] - fn test_go_function_definitions() { - let source = r#" -package main - -func main() { - fmt.Println("Hello") -} - -func greet(name string) string { - return "Hello " + name -} - -func add(a, b int) int { - return a + b -} -"#; - let result = parse_file("main.go", source).unwrap(); - assert_eq!(result.language, Language::Go); - - let fns: Vec<&str> = result - .definitions - .iter() - .filter(|d| d.kind == SymbolKind::Function) - .map(|d| d.name.as_str()) - .collect(); - assert!(fns.contains(&"main")); - assert!(fns.contains(&"greet")); - assert!(fns.contains(&"add")); - } - - #[test] - fn test_go_struct_definitions() { - let source = r#" -package models - -type User struct { - ID int - Name string - Email string -} - -type Config struct { - Port int - Host string -} -"#; - let result = parse_file("models.go", source).unwrap(); - - let user = result - .definitions - .iter() - .find(|d| d.name == "User") - .unwrap(); - assert_eq!(user.kind, SymbolKind::Class); // structs map to Class - - let config = result - .definitions - .iter() - .find(|d| d.name == "Config") - .unwrap(); - assert_eq!(config.kind, SymbolKind::Class); - } - - #[test] - fn test_go_interface_definitions() { - let source = r#" -package service - -type UserService interface { - GetUser(id int) (*User, error) - CreateUser(data UserInput) (*User, error) - DeleteUser(id int) error -} - -type Repository interface { - Find(id int) (interface{}, error) - Save(entity interface{}) error -} -"#; - let result = parse_file("service.go", source).unwrap(); - - let user_svc = result - .definitions - .iter() - .find(|d| d.name == "UserService") - .unwrap(); - assert_eq!(user_svc.kind, SymbolKind::Interface); - - let repo = result - .definitions - .iter() - .find(|d| d.name == "Repository") - .unwrap(); - assert_eq!(repo.kind, SymbolKind::Interface); - } - - #[test] - fn test_go_method_declarations() { - let source = r#" -package models - -type User struct { - Name string -} - -func (u *User) Greet() string { - return "Hello " + u.Name -} - -func (u User) String() string { - return u.Name -} -"#; - let result = parse_file("models.go", source).unwrap(); - - let methods: Vec<&str> = result - .definitions - .iter() - .filter(|d| d.kind == SymbolKind::Function) - .map(|d| d.name.as_str()) - .collect(); - assert!( - methods.contains(&"Greet"), - "method Greet should be detected" - ); - assert!( - methods.contains(&"String"), - "method String should be detected" - ); - } - - #[test] - fn test_go_constants() { - let source = r#" -package config - -const MaxRetries = 3 -const ( - DefaultPort = 8080 - DefaultHost = "localhost" -) -"#; - let result = parse_file("config.go", source).unwrap(); - - let consts: Vec<&str> = result - .definitions - .iter() - .filter(|d| d.kind == SymbolKind::Constant) - .map(|d| d.name.as_str()) - .collect(); - assert!(consts.contains(&"MaxRetries")); - assert!(consts.contains(&"DefaultPort")); - assert!(consts.contains(&"DefaultHost")); - } - - #[test] - fn test_go_type_alias() { - let source = r#" -package types - -type UserID int64 -type Handler func(w http.ResponseWriter, r *http.Request) -"#; - let result = parse_file("types.go", source).unwrap(); - - // UserID should be detected as TypeAlias (not struct/interface) - let user_id = result - .definitions - .iter() - .find(|d| d.name == "UserID") - .unwrap(); - assert_eq!(user_id.kind, SymbolKind::TypeAlias); - - let handler = result - .definitions - .iter() - .find(|d| d.name == "Handler") - .unwrap(); - assert_eq!(handler.kind, SymbolKind::TypeAlias); - } - - #[test] - fn test_go_call_sites() { - let source = r#" -package main - -import "fmt" - -func process(data string) { - validated := validate(data) - result := db.Save(validated) - fmt.Println(result) -} -"#; - let result = parse_file("handler.go", source).unwrap(); - - let callees: Vec<&str> = result - .call_sites - .iter() - .map(|c| c.callee.as_str()) - .collect(); - assert!(callees.contains(&"validate")); - assert!(callees.contains(&"db.Save")); - assert!(callees.contains(&"fmt.Println")); - - // All calls inside process function - for call in &result.call_sites { - assert_eq!(call.containing_function, Some("process".to_string())); - } - } - - #[test] - fn test_go_call_sites_in_method() { - let source = r#" -package service - -func (s *UserService) Create(data UserInput) (*User, error) { - validated := s.validate(data) - return s.repo.Save(validated) -} -"#; - let result = parse_file("service.go", source).unwrap(); - - let callees: Vec<&str> = result - .call_sites - .iter() - .map(|c| c.callee.as_str()) - .collect(); - assert!(callees.contains(&"s.validate")); - assert!(callees.contains(&"s.repo.Save")); - - for call in &result.call_sites { - assert_eq!(call.containing_function, Some("Create".to_string())); - } - } - - #[test] - fn test_go_exported_symbols() { - let source = r#" -package models - -type User struct { - Name string -} - -type internalState struct { - cache map[string]string -} - -func GetUser(id int) *User { - return nil -} - -func helper() { -} - -const MaxSize = 100 -const defaultTimeout = 30 -"#; - let result = parse_file("models.go", source).unwrap(); - - let export_names: Vec<&str> = result.exports.iter().map(|e| e.name.as_str()).collect(); - // Uppercase = exported - assert!(export_names.contains(&"User")); - assert!(export_names.contains(&"GetUser")); - assert!(export_names.contains(&"MaxSize")); - // Lowercase = not exported - assert!(!export_names.contains(&"internalState")); - assert!(!export_names.contains(&"helper")); - assert!(!export_names.contains(&"defaultTimeout")); - } - - #[test] - fn test_go_data_flow_short_var_decl() { - let source = r#" -package main - -func handler(req string) { - data := parseBody(req) - result := transform(data) - save(result) -} -"#; - let info = extract_data_flow_info("handler.go", source).unwrap(); - - assert_eq!(info.assignments.len(), 2); - - let vars: Vec<&str> = info - .assignments - .iter() - .map(|a| a.variable.as_str()) - .collect(); - assert!(vars.contains(&"data")); - assert!(vars.contains(&"result")); - - let callees: Vec<&str> = info.assignments.iter().map(|a| a.callee.as_str()).collect(); - assert!(callees.contains(&"parseBody")); - assert!(callees.contains(&"transform")); - } - - #[test] - fn test_go_data_flow_method_call() { - let source = r#" -package main - -func getUser(id int) { - user := db.FindOne(id) - save(user) -} -"#; - let info = extract_data_flow_info("service.go", source).unwrap(); - - assert_eq!(info.assignments.len(), 1); - assert_eq!(info.assignments[0].variable, "user"); - assert_eq!(info.assignments[0].callee, "db.FindOne"); - assert_eq!( - info.assignments[0].containing_function, - Some("getUser".to_string()) - ); - } - - #[test] - fn test_go_data_flow_call_with_args() { - let source = r#" -package main - -func process() { - x := getFirst() - y := getSecond() - combine(x, y, 42) -} -"#; - let info = extract_data_flow_info("process.go", source).unwrap(); - - let combine = info - .calls_with_args - .iter() - .find(|c| c.callee == "combine") - .unwrap(); - assert!(combine.arguments.contains(&"x".to_string())); - assert!(combine.arguments.contains(&"y".to_string())); - assert!(combine.arguments.contains(&"42".to_string())); - } - - #[test] - fn test_go_empty_source() { - let source = "package main\n"; - let result = parse_file("empty.go", source).unwrap(); - assert_eq!(result.language, Language::Go); - assert!(result.definitions.is_empty()); - assert!(result.imports.is_empty()); - assert!(result.call_sites.is_empty()); - } - - #[test] - fn test_go_goroutine_call() { - let source = r#" -package main - -func startWorker() { - go processQueue() - go handleMessages() -} -"#; - let result = parse_file("worker.go", source).unwrap(); - - // Goroutine calls should still be detected as call sites - let callees: Vec<&str> = result - .call_sites - .iter() - .map(|c| c.callee.as_str()) - .collect(); - assert!(callees.contains(&"processQueue")); - assert!(callees.contains(&"handleMessages")); - } - - #[test] - fn test_go_var_declaration() { - let source = r#" -package config - -var GlobalConfig Config -var ( - Logger *log.Logger - Verbose bool -) -"#; - let result = parse_file("config.go", source).unwrap(); - let vars: Vec<&str> = result.definitions.iter().map(|d| d.name.as_str()).collect(); - assert!(vars.contains(&"GlobalConfig")); - assert!(vars.contains(&"Logger")); - assert!(vars.contains(&"Verbose")); - } - - #[test] - fn test_go_http_handler_pattern() { - let source = r#" -package main - -import "net/http" - -func main() { - http.HandleFunc("/users", handleUsers) - http.ListenAndServe(":8080", nil) -} - -func handleUsers(w http.ResponseWriter, r *http.Request) { - fmt.Fprintln(w, "users") -} -"#; - let result = parse_file("main.go", source).unwrap(); - - let fns: Vec<&str> = result - .definitions - .iter() - .filter(|d| d.kind == SymbolKind::Function) - .map(|d| d.name.as_str()) - .collect(); - assert!(fns.contains(&"main")); - assert!(fns.contains(&"handleUsers")); - - // Verify http import - assert!(result.imports.iter().any(|i| i.source == "net/http")); - - // Verify call sites - let callees: Vec<&str> = result - .call_sites - .iter() - .map(|c| c.callee.as_str()) - .collect(); - assert!(callees.contains(&"http.HandleFunc")); - assert!(callees.contains(&"http.ListenAndServe")); - } -} +mod tests; diff --git a/crates/diffcore-core/src/ast/tests.rs b/crates/diffcore-core/src/ast/tests.rs new file mode 100644 index 0000000..5b77025 --- /dev/null +++ b/crates/diffcore-core/src/ast/tests.rs @@ -0,0 +1,1837 @@ + use super::*; + + // === TypeScript imports === + + #[test] + fn test_parse_ts_imports() { + let source = r#" +import React from 'react'; +import { useState, useEffect } from 'react'; +import * as path from 'path'; +import { foo as bar } from './utils'; +"#; + let result = parse_file("app.ts", source).unwrap(); + assert_eq!(result.imports.len(), 4); + + // Default import + assert_eq!(result.imports[0].source, "react"); + assert!(result.imports[0].is_default); + assert!(!result.imports[0].is_namespace); + assert_eq!(result.imports[0].names.len(), 1); + assert_eq!(result.imports[0].names[0].name, "React"); + + // Named imports + assert_eq!(result.imports[1].source, "react"); + assert!(!result.imports[1].is_default); + assert_eq!(result.imports[1].names.len(), 2); + assert_eq!(result.imports[1].names[0].name, "useState"); + assert_eq!(result.imports[1].names[1].name, "useEffect"); + + // Namespace import + assert_eq!(result.imports[2].source, "path"); + assert!(result.imports[2].is_namespace); + assert_eq!(result.imports[2].names[0].name, "path"); + + // Aliased import + assert_eq!(result.imports[3].source, "./utils"); + assert_eq!(result.imports[3].names[0].name, "foo"); + assert_eq!(result.imports[3].names[0].alias, Some("bar".to_string())); + } + + #[test] + fn test_parse_ts_default_and_named_import() { + let source = r#"import React, { useState } from 'react';"#; + let result = parse_file("app.ts", source).unwrap(); + assert_eq!(result.imports.len(), 1); + let imp = &result.imports[0]; + assert!(imp.is_default); + assert_eq!(imp.names.len(), 2); + assert_eq!(imp.names[0].name, "React"); + assert_eq!(imp.names[1].name, "useState"); + } + + #[test] + fn test_parse_ts_side_effect_import() { + let source = r#"import './polyfill';"#; + let result = parse_file("app.ts", source).unwrap(); + assert_eq!(result.imports.len(), 1); + assert_eq!(result.imports[0].source, "./polyfill"); + assert!(result.imports[0].names.is_empty()); + } + + // === TypeScript exports === + + #[test] + fn test_parse_ts_exports() { + let source = r#" +export function greet() {} +export default function main() {} +export { foo, bar }; +export { baz } from './other'; +export const VALUE = 42; +"#; + let result = parse_file("lib.ts", source).unwrap(); + + // export function greet + let greet_export = result.exports.iter().find(|e| e.name == "greet").unwrap(); + assert!(!greet_export.is_default); + assert!(!greet_export.is_reexport); + + // export default function main + let main_export = result.exports.iter().find(|e| e.name == "main").unwrap(); + assert!(main_export.is_default); + + // export { foo, bar } + let foo_export = result.exports.iter().find(|e| e.name == "foo").unwrap(); + assert!(!foo_export.is_default); + assert!(!foo_export.is_reexport); + + let bar_export = result.exports.iter().find(|e| e.name == "bar").unwrap(); + assert!(!bar_export.is_reexport); + + // export { baz } from './other' + let baz_export = result.exports.iter().find(|e| e.name == "baz").unwrap(); + assert!(baz_export.is_reexport); + assert_eq!(baz_export.source, Some("./other".to_string())); + + // export const VALUE + let val_export = result.exports.iter().find(|e| e.name == "VALUE").unwrap(); + assert!(!val_export.is_default); + } + + #[test] + fn test_parse_ts_wildcard_reexport() { + let source = r#"export * from './all';"#; + let result = parse_file("index.ts", source).unwrap(); + assert_eq!(result.exports.len(), 1); + assert_eq!(result.exports[0].name, "*"); + assert!(result.exports[0].is_reexport); + assert_eq!(result.exports[0].source, Some("./all".to_string())); + } + + #[test] + fn test_parse_ts_export_default_expression() { + let source = r#" +const app = createApp(); +export default app; +"#; + let result = parse_file("app.ts", source).unwrap(); + let default_export = result.exports.iter().find(|e| e.is_default).unwrap(); + assert_eq!(default_export.name, "app"); + } + + // === TypeScript definitions === + + #[test] + fn test_parse_ts_functions() { + let source = r#" +function greet(name: string): string { + return `Hello ${name}`; +} + +const double = (x: number) => x * 2; + +class Calculator { + add(a: number, b: number): number { + return a + b; + } + subtract(a: number, b: number): number { + return a - b; + } +} +"#; + let result = parse_file("math.ts", source).unwrap(); + + // function declaration + let greet = result + .definitions + .iter() + .find(|d| d.name == "greet") + .unwrap(); + assert_eq!(greet.kind, SymbolKind::Function); + + // arrow function + let double = result + .definitions + .iter() + .find(|d| d.name == "double") + .unwrap(); + assert_eq!(double.kind, SymbolKind::Function); + + // class + let calc = result + .definitions + .iter() + .find(|d| d.name == "Calculator") + .unwrap(); + assert_eq!(calc.kind, SymbolKind::Class); + + // methods + let add = result.definitions.iter().find(|d| d.name == "add").unwrap(); + assert_eq!(add.kind, SymbolKind::Function); + + let sub = result + .definitions + .iter() + .find(|d| d.name == "subtract") + .unwrap(); + assert_eq!(sub.kind, SymbolKind::Function); + } + + #[test] + fn test_parse_ts_interface_and_type() { + let source = r#" +interface User { + name: string; + age: number; +} + +type UserId = string; +"#; + let result = parse_file("types.ts", source).unwrap(); + + let user_iface = result + .definitions + .iter() + .find(|d| d.name == "User") + .unwrap(); + assert_eq!(user_iface.kind, SymbolKind::Interface); + + let user_id = result + .definitions + .iter() + .find(|d| d.name == "UserId") + .unwrap(); + assert_eq!(user_id.kind, SymbolKind::TypeAlias); + } + + #[test] + fn test_parse_ts_constants() { + let source = r#" +const MAX_RETRIES = 3; +const API_URL = "https://example.com"; +"#; + let result = parse_file("config.ts", source).unwrap(); + assert_eq!(result.definitions.len(), 2); + + let max = result + .definitions + .iter() + .find(|d| d.name == "MAX_RETRIES") + .unwrap(); + assert_eq!(max.kind, SymbolKind::Constant); + } + + // === TypeScript call sites === + + #[test] + fn test_parse_ts_call_sites() { + let source = r#" +function processUser(user: User) { + const validated = validateUser(user); + const saved = db.save(validated); + notifyAdmin(saved.id); +} +"#; + let result = parse_file("handler.ts", source).unwrap(); + + let call_names: Vec<&str> = result + .call_sites + .iter() + .map(|c| c.callee.as_str()) + .collect(); + assert!(call_names.contains(&"validateUser")); + assert!(call_names.contains(&"db.save")); + assert!(call_names.contains(&"notifyAdmin")); + + // All calls should be inside processUser + for call in &result.call_sites { + assert_eq!(call.containing_function, Some("processUser".to_string())); + } + } + + #[test] + fn test_parse_ts_call_sites_in_arrow() { + let source = r#" +const handler = (req: Request) => { + const data = parseBody(req); + return respond(data); +}; +"#; + let result = parse_file("handler.ts", source).unwrap(); + let call_names: Vec<&str> = result + .call_sites + .iter() + .map(|c| c.callee.as_str()) + .collect(); + assert!(call_names.contains(&"parseBody")); + assert!(call_names.contains(&"respond")); + + for call in &result.call_sites { + assert_eq!(call.containing_function, Some("handler".to_string())); + } + } + + // === Python imports === + + #[test] + fn test_parse_python_imports() { + let source = r#" +import os +import json as j +from pathlib import Path +from typing import List, Optional +from . import utils +from ..models import User as U +"#; + let result = parse_file("app.py", source).unwrap(); + assert_eq!(result.imports.len(), 6); + + // import os + assert_eq!(result.imports[0].source, "os"); + assert!(result.imports[0].is_namespace); + assert_eq!(result.imports[0].names[0].name, "os"); + + // import json as j + assert_eq!(result.imports[1].source, "json"); + assert_eq!(result.imports[1].names[0].name, "json"); + assert_eq!(result.imports[1].names[0].alias, Some("j".to_string())); + + // from pathlib import Path + assert_eq!(result.imports[2].source, "pathlib"); + assert!(!result.imports[2].is_namespace); + assert_eq!(result.imports[2].names[0].name, "Path"); + + // from typing import List, Optional + assert_eq!(result.imports[3].source, "typing"); + assert_eq!(result.imports[3].names.len(), 2); + assert_eq!(result.imports[3].names[0].name, "List"); + assert_eq!(result.imports[3].names[1].name, "Optional"); + + // from . import utils (relative import) + assert_eq!(result.imports[4].source, "."); + assert_eq!(result.imports[4].names[0].name, "utils"); + + // from ..models import User as U + assert!(result.imports[5].source.contains("models")); + assert_eq!(result.imports[5].names[0].name, "User"); + assert_eq!(result.imports[5].names[0].alias, Some("U".to_string())); + } + + // === Python definitions === + + #[test] + fn test_parse_python_functions() { + let source = r#" +def greet(name: str) -> str: + return f"Hello {name}" + +class UserService: + def create_user(self, data: dict) -> User: + return User(**data) + + def delete_user(self, user_id: int) -> None: + pass +"#; + let result = parse_file("service.py", source).unwrap(); + + // Top-level function + let greet = result + .definitions + .iter() + .find(|d| d.name == "greet") + .unwrap(); + assert_eq!(greet.kind, SymbolKind::Function); + + // Class + let svc = result + .definitions + .iter() + .find(|d| d.name == "UserService") + .unwrap(); + assert_eq!(svc.kind, SymbolKind::Class); + + // Methods + let create = result + .definitions + .iter() + .find(|d| d.name == "create_user") + .unwrap(); + assert_eq!(create.kind, SymbolKind::Function); + + let delete = result + .definitions + .iter() + .find(|d| d.name == "delete_user") + .unwrap(); + assert_eq!(delete.kind, SymbolKind::Function); + } + + #[test] + fn test_parse_python_decorated_functions() { + let source = r#" +from flask import Flask +app = Flask(__name__) + +@app.route('/users', methods=['GET']) +def list_users(): + return get_all_users() + +@staticmethod +def helper(): + pass +"#; + let result = parse_file("routes.py", source).unwrap(); + + let list_users = result + .definitions + .iter() + .find(|d| d.name == "list_users") + .unwrap(); + assert_eq!(list_users.kind, SymbolKind::Function); + + let helper = result + .definitions + .iter() + .find(|d| d.name == "helper") + .unwrap(); + assert_eq!(helper.kind, SymbolKind::Function); + } + + // === Python class hierarchy === + + #[test] + fn test_parse_python_class_hierarchy() { + let source = r#" +class Animal: + pass + +class Dog(Animal): + def bark(self): + pass + +class GuideDog(Dog, ServiceAnimal): + pass +"#; + // Verify class definitions are detected + let result = parse_file("models.py", source).unwrap(); + let classes: Vec<&str> = result + .definitions + .iter() + .filter(|d| d.kind == SymbolKind::Class) + .map(|d| d.name.as_str()) + .collect(); + assert!(classes.contains(&"Animal")); + assert!(classes.contains(&"Dog")); + assert!(classes.contains(&"GuideDog")); + + // Verify base class extraction + let animal_bases = get_python_class_bases(source, "Animal").unwrap(); + assert!(animal_bases.is_empty()); + + let dog_bases = get_python_class_bases(source, "Dog").unwrap(); + assert_eq!(dog_bases, vec!["Animal"]); + + let guide_bases = get_python_class_bases(source, "GuideDog").unwrap(); + assert_eq!(guide_bases, vec!["Dog", "ServiceAnimal"]); + } + + // === Unknown language === + + #[test] + fn test_parse_unknown_language() { + let source = "some random content that is not code"; + let result = parse_file("main.xyz", source).unwrap(); + assert_eq!(result.language, Language::Unknown); + assert!(result.definitions.is_empty()); + assert!(result.imports.is_empty()); + assert!(result.exports.is_empty()); + assert!(result.call_sites.is_empty()); + } + + /// §13.3: Handles Rust `mod`, `use`, `pub` visibility. + /// + /// Rust is detected as Language::Rust but parsing currently falls through to the + /// generic handler (no tree-sitter queries for Rust yet). This test verifies: + /// 1. Rust files parse without error + /// 2. Language is correctly detected as Rust + /// 3. Graceful fallback produces empty definitions/imports/exports + #[test] + fn test_parse_rust_modules() { + let source = r#" +mod handlers; +mod models; + +use std::collections::HashMap; +use crate::models::User; + +pub fn create_user(name: &str) -> User { + User { name: name.to_string() } +} + +pub(crate) fn internal_helper() -> bool { + true +} +"#; + let result = parse_file("src/lib.rs", source).unwrap(); + + // Language should be correctly detected + assert_eq!(result.language, Language::Rust); + assert_eq!(result.path, "src/lib.rs"); + + // Rust parsing is not yet implemented via tree-sitter queries, + // so definitions/imports/exports are empty (graceful fallback). + // This documents the current state and will catch when Rust parsing is added. + assert!( + result.definitions.is_empty(), + "Rust definitions are not yet extracted (graceful fallback)" + ); + assert!( + result.imports.is_empty(), + "Rust imports are not yet extracted (graceful fallback)" + ); + assert!( + result.exports.is_empty(), + "Rust exports are not yet extracted (graceful fallback)" + ); + } + + // === Changed symbols detection === + + #[test] + fn test_changed_symbols_detection() { + let old_source = r#" +function foo() {} +function bar() {} +const VALUE = 42; +"#; + let new_source = r#" +function foo() { + return 1; +} +function baz() {} +const VALUE = 42; +"#; + let old = parse_file("lib.ts", old_source).unwrap(); + let new = parse_file("lib.ts", new_source).unwrap(); + let changes = detect_changed_symbols(&old, &new); + + let added: Vec<&str> = changes + .iter() + .filter_map(|c| match c { + SymbolChange::Added(d) => Some(d.name.as_str()), + _ => None, + }) + .collect(); + assert!(added.contains(&"baz"), "baz should be added"); + + let removed: Vec<&str> = changes + .iter() + .filter_map(|c| match c { + SymbolChange::Removed(d) => Some(d.name.as_str()), + _ => None, + }) + .collect(); + assert!(removed.contains(&"bar"), "bar should be removed"); + + let modified: Vec<&str> = changes + .iter() + .filter_map(|c| match c { + SymbolChange::Modified { old, .. } => Some(old.name.as_str()), + _ => None, + }) + .collect(); + assert!(modified.contains(&"foo"), "foo should be modified"); + + // VALUE unchanged + assert!( + !changes.iter().any(|c| match c { + SymbolChange::Added(d) | SymbolChange::Removed(d) => d.name == "VALUE", + SymbolChange::Modified { old, .. } => old.name == "VALUE", + }), + "VALUE should be unchanged" + ); + } + + #[test] + fn test_changed_symbols_no_changes() { + let source = "function foo() {}\n"; + let old = parse_file("lib.ts", source).unwrap(); + let new = parse_file("lib.ts", source).unwrap(); + let changes = detect_changed_symbols(&old, &new); + assert!(changes.is_empty()); + } + + // === Language detection === + + #[test] + fn test_language_from_path() { + assert_eq!(Language::from_path("app.ts"), Language::TypeScript); + assert_eq!(Language::from_path("app.tsx"), Language::TypeScript); + assert_eq!(Language::from_path("app.js"), Language::JavaScript); + assert_eq!(Language::from_path("app.jsx"), Language::JavaScript); + assert_eq!(Language::from_path("app.mjs"), Language::JavaScript); + assert_eq!(Language::from_path("app.cjs"), Language::JavaScript); + assert_eq!(Language::from_path("app.py"), Language::Python); + assert_eq!(Language::from_path("app.pyi"), Language::Python); + assert_eq!(Language::from_path("app.go"), Language::Go); + assert_eq!(Language::from_path("app.rs"), Language::Rust); + assert_eq!(Language::from_path("Makefile"), Language::Unknown); + } + + // === Line numbers === + + #[test] + fn test_definition_line_numbers() { + let source = "function foo() {\n return 1;\n}\n\nfunction bar() {\n return 2;\n}\n"; + let result = parse_file("lib.ts", source).unwrap(); + + let foo = result.definitions.iter().find(|d| d.name == "foo").unwrap(); + assert_eq!(foo.start_line, 1); + assert_eq!(foo.end_line, 3); + + let bar = result.definitions.iter().find(|d| d.name == "bar").unwrap(); + assert_eq!(bar.start_line, 5); + assert_eq!(bar.end_line, 7); + } + + // === Performance === + + #[test] + fn test_large_file_performance() { + // Generate a 10K+ line TypeScript file + let mut source = String::with_capacity(2_000_000); + for i in 0..3000 { + source.push_str(&format!( + "function func_{i}(x: number): number {{\n return x * {i};\n}}\n\n" + )); + } + for i in 0..500 { + source.push_str(&format!("const arrow_{i} = (x: number) => x + {i};\n")); + } + for i in 0..100 { + source.push_str(&format!( + "class Class_{i} {{\n method_a() {{ return func_{i}(1); }}\n method_b() {{ return arrow_{i}(2); }}\n}}\n\n" + )); + } + + let line_count = source.lines().count(); + assert!( + line_count > 10_000, + "generated file should have 10K+ lines, got {line_count}" + ); + + let start = std::time::Instant::now(); + let result = parse_file("large.ts", &source).unwrap(); + let elapsed = start.elapsed(); + + assert!( + elapsed.as_millis() < 500, + "parsing 10K+ line file took {}ms, should be < 500ms", + elapsed.as_millis() + ); + + // Sanity check: we extracted definitions + assert!(result.definitions.len() > 3000); + assert!(!result.call_sites.is_empty()); + } + + // === Python call sites === + + #[test] + fn test_parse_python_call_sites() { + let source = r#" +def process(data): + validated = validate(data) + result = db.save(validated) + return result +"#; + let result = parse_file("handler.py", source).unwrap(); + let call_names: Vec<&str> = result + .call_sites + .iter() + .map(|c| c.callee.as_str()) + .collect(); + assert!(call_names.contains(&"validate")); + assert!(call_names.contains(&"db.save")); + + for call in &result.call_sites { + assert_eq!(call.containing_function, Some("process".to_string())); + } + } + + // === Edge cases === + + #[test] + fn test_empty_source() { + let result = parse_file("empty.ts", "").unwrap(); + assert!(result.definitions.is_empty()); + assert!(result.imports.is_empty()); + assert!(result.exports.is_empty()); + assert!(result.call_sites.is_empty()); + } + + #[test] + fn test_parse_js_file_uses_typescript_parser() { + let source = "function hello() { console.log('hi'); }\n"; + let result = parse_file("app.js", source).unwrap(); + assert_eq!(result.language, Language::JavaScript); + assert_eq!(result.definitions.len(), 1); + assert_eq!(result.definitions[0].name, "hello"); + } + + #[test] + fn test_export_class_with_methods() { + let source = r#" +export class Router { + get(path: string) {} + post(path: string) {} +} +"#; + let result = parse_file("router.ts", source).unwrap(); + + let class_export = result.exports.iter().find(|e| e.name == "Router").unwrap(); + assert!(!class_export.is_default); + + let methods: Vec<&str> = result + .definitions + .iter() + .filter(|d| d.name == "get" || d.name == "post") + .map(|d| d.name.as_str()) + .collect(); + assert!(methods.contains(&"get")); + assert!(methods.contains(&"post")); + } + + // ======================================================================== + // Data flow extraction — TypeScript + // ======================================================================== + + #[test] + fn test_data_flow_ts_simple_assignment() { + let source = r#" +function handler(req: any) { + const data = parseBody(req); + return respond(data); +} +"#; + let info = extract_data_flow_info("handler.ts", source).unwrap(); + + // Should detect `const data = parseBody(req)` + assert_eq!(info.assignments.len(), 1); + assert_eq!(info.assignments[0].variable, "data"); + assert_eq!(info.assignments[0].callee, "parseBody"); + assert_eq!( + info.assignments[0].containing_function, + Some("handler".to_string()) + ); + + // Should detect both calls with their arguments + let parse_call = info + .calls_with_args + .iter() + .find(|c| c.callee == "parseBody") + .unwrap(); + assert!(parse_call.arguments.contains(&"req".to_string())); + + let respond_call = info + .calls_with_args + .iter() + .find(|c| c.callee == "respond") + .unwrap(); + assert!(respond_call.arguments.contains(&"data".to_string())); + } + + #[test] + fn test_data_flow_ts_method_call_assignment() { + let source = r#" +function process() { + const user = db.findOne(id); + return transform(user); +} +"#; + let info = extract_data_flow_info("service.ts", source).unwrap(); + + assert_eq!(info.assignments.len(), 1); + assert_eq!(info.assignments[0].variable, "user"); + assert_eq!(info.assignments[0].callee, "db.findOne"); + } + + #[test] + fn test_data_flow_ts_await_assignment() { + let source = r#" +async function handler(req: any) { + const data = await fetchData(req.id); + return process(data); +} +"#; + let info = extract_data_flow_info("handler.ts", source).unwrap(); + + // Should unwrap the await and capture the call + assert_eq!(info.assignments.len(), 1); + assert_eq!(info.assignments[0].variable, "data"); + assert_eq!(info.assignments[0].callee, "fetchData"); + } + + #[test] + fn test_data_flow_ts_chained_assignments() { + let source = r#" +function pipeline(input: any) { + const validated = validate(input); + const processed = transform(validated); + const result = save(processed); + return result; +} +"#; + let info = extract_data_flow_info("pipeline.ts", source).unwrap(); + + assert_eq!(info.assignments.len(), 3); + + let vars: Vec<&str> = info + .assignments + .iter() + .map(|a| a.variable.as_str()) + .collect(); + assert!(vars.contains(&"validated")); + assert!(vars.contains(&"processed")); + assert!(vars.contains(&"result")); + + let callees: Vec<&str> = info.assignments.iter().map(|a| a.callee.as_str()).collect(); + assert!(callees.contains(&"validate")); + assert!(callees.contains(&"transform")); + assert!(callees.contains(&"save")); + } + + #[test] + fn test_data_flow_ts_call_arguments_multiple() { + let source = r#" +function merge(a: any, b: any) { + const x = getFirst(); + const y = getSecond(); + return combine(x, y, 42); +} +"#; + let info = extract_data_flow_info("merge.ts", source).unwrap(); + + let combine_call = info + .calls_with_args + .iter() + .find(|c| c.callee == "combine") + .unwrap(); + assert!(combine_call.arguments.contains(&"x".to_string())); + assert!(combine_call.arguments.contains(&"y".to_string())); + // 42 is a literal, should also be captured as argument text + assert!(combine_call.arguments.contains(&"42".to_string())); + } + + #[test] + fn test_data_flow_ts_arrow_function() { + let source = r#" +const handler = (req: any) => { + const data = parseBody(req); + return respond(data); +}; +"#; + let info = extract_data_flow_info("handler.ts", source).unwrap(); + + assert_eq!(info.assignments.len(), 1); + assert_eq!(info.assignments[0].variable, "data"); + assert_eq!(info.assignments[0].callee, "parseBody"); + assert_eq!( + info.assignments[0].containing_function, + Some("handler".to_string()) + ); + } + + #[test] + fn test_data_flow_ts_no_assignments() { + let source = r#" +function simple() { + console.log("hello"); + return 42; +} +"#; + let info = extract_data_flow_info("simple.ts", source).unwrap(); + assert!(info.assignments.is_empty()); + } + + #[test] + fn test_data_flow_ts_module_level() { + let source = r#" +const config = loadConfig(); +startServer(config); +"#; + let info = extract_data_flow_info("main.ts", source).unwrap(); + + assert_eq!(info.assignments.len(), 1); + assert_eq!(info.assignments[0].variable, "config"); + assert_eq!(info.assignments[0].callee, "loadConfig"); + // Module-level has no containing function + assert_eq!(info.assignments[0].containing_function, None); + + let start_call = info + .calls_with_args + .iter() + .find(|c| c.callee == "startServer") + .unwrap(); + assert!(start_call.arguments.contains(&"config".to_string())); + } + + #[test] + fn test_data_flow_ts_nested_call_as_argument() { + let source = r#" +function process() { + return save(transform(input)); +} +"#; + let info = extract_data_flow_info("process.ts", source).unwrap(); + + // The inner call `transform(input)` should be captured + let save_call = info + .calls_with_args + .iter() + .find(|c| c.callee == "save") + .unwrap(); + // The argument to save is the full nested call text + assert_eq!(save_call.arguments.len(), 1); + assert!(save_call.arguments[0].contains("transform")); + } + + #[test] + fn test_data_flow_ts_non_call_value_ignored() { + let source = r#" +function process() { + const x = 42; + const y = "hello"; + const z = someVar; + return x; +} +"#; + let info = extract_data_flow_info("process.ts", source).unwrap(); + + // None of these are function call assignments + assert!( + info.assignments.is_empty(), + "literal and variable assignments should not be captured" + ); + } + + // ======================================================================== + // Data flow extraction — Python + // ======================================================================== + + #[test] + fn test_data_flow_python_simple_assignment() { + let source = r#" +def handler(req): + data = parse_body(req) + return respond(data) +"#; + let info = extract_data_flow_info("handler.py", source).unwrap(); + + assert_eq!(info.assignments.len(), 1); + assert_eq!(info.assignments[0].variable, "data"); + assert_eq!(info.assignments[0].callee, "parse_body"); + assert_eq!( + info.assignments[0].containing_function, + Some("handler".to_string()) + ); + + let respond_call = info + .calls_with_args + .iter() + .find(|c| c.callee == "respond") + .unwrap(); + assert!(respond_call.arguments.contains(&"data".to_string())); + } + + #[test] + fn test_data_flow_python_chained() { + let source = r#" +def pipeline(raw): + validated = validate(raw) + processed = transform(validated) + save(processed) +"#; + let info = extract_data_flow_info("pipeline.py", source).unwrap(); + + assert_eq!(info.assignments.len(), 2); + + let vars: Vec<&str> = info + .assignments + .iter() + .map(|a| a.variable.as_str()) + .collect(); + assert!(vars.contains(&"validated")); + assert!(vars.contains(&"processed")); + } + + #[test] + fn test_data_flow_python_method_call() { + let source = r#" +def get_user(user_id): + user = db.find_one(user_id) + return serialize(user) +"#; + let info = extract_data_flow_info("service.py", source).unwrap(); + + assert_eq!(info.assignments.len(), 1); + assert_eq!(info.assignments[0].callee, "db.find_one"); + } + + #[test] + fn test_data_flow_unknown_language() { + let info = extract_data_flow_info("main.rs", "fn main() {}").unwrap(); + assert!(info.assignments.is_empty()); + assert!(info.calls_with_args.is_empty()); + } + + #[test] + fn test_data_flow_empty_source() { + let info = extract_data_flow_info("empty.ts", "").unwrap(); + assert!(info.assignments.is_empty()); + assert!(info.calls_with_args.is_empty()); + } + + #[test] + fn test_data_flow_ts_multiple_consumers() { + let source = r#" +function process() { + const data = fetchData(); + validate(data); + transform(data); + save(data); +} +"#; + let info = extract_data_flow_info("process.ts", source).unwrap(); + + // One assignment, three consumers + assert_eq!(info.assignments.len(), 1); + assert_eq!(info.assignments[0].variable, "data"); + + let consumers_using_data: Vec<&str> = info + .calls_with_args + .iter() + .filter(|c| c.arguments.contains(&"data".to_string())) + .map(|c| c.callee.as_str()) + .collect(); + assert!(consumers_using_data.contains(&"validate")); + assert!(consumers_using_data.contains(&"transform")); + assert!(consumers_using_data.contains(&"save")); + } + + // ======================================================================== + // Phase 8 audit: edge case tests + // ======================================================================== + + #[test] + fn test_ts_enum_declaration_not_captured() { + // Known limitation: TS enums are not extracted as definitions. + // This test documents the behavior so it's visible. + let source = r#" +enum Color { + Red, + Green, + Blue, +} +"#; + let result = parse_file("types.ts", source).unwrap(); + // Enums are not captured — this documents the gap. + assert!( + result.definitions.iter().all(|d| d.name != "Color"), + "TS enums are not captured by the current parser" + ); + } + + #[test] + fn test_changed_symbols_same_span_different_body() { + // If a function changes body but keeps the same line count, + // detect_changed_symbols won't flag it as modified (by design — compares span size). + let old_source = "function foo() {\n return 1;\n}\n"; + let new_source = "function foo() {\n return 2;\n}\n"; + let old = parse_file("lib.ts", old_source).unwrap(); + let new = parse_file("lib.ts", new_source).unwrap(); + let changes = detect_changed_symbols(&old, &new); + // Same span size → not detected as modified (design limitation) + assert!( + changes.is_empty(), + "same-span changes are not detected by span comparison" + ); + } + + #[test] + fn test_ts_abstract_class() { + let source = r#" +abstract class BaseService { + abstract process(): void; + helper() { return 1; } +} +"#; + let result = parse_file("service.ts", source).unwrap(); + let base_svc = result.definitions.iter().find(|d| d.name == "BaseService"); + assert!(base_svc.is_some(), "abstract classes should be captured"); + assert_eq!(base_svc.unwrap().kind, SymbolKind::Class); + + // Method inside abstract class + assert!(result.definitions.iter().any(|d| d.name == "helper")); + } + + #[test] + fn test_ts_generator_function() { + let source = r#" +function* generate() { + yield 1; + yield 2; +} +"#; + let result = parse_file("gen.ts", source).unwrap(); + let gen = result.definitions.iter().find(|d| d.name == "generate"); + assert!(gen.is_some(), "generator functions should be captured"); + assert_eq!(gen.unwrap().kind, SymbolKind::Function); + } + + #[test] + fn test_ts_multiple_classes_with_methods() { + let source = r#" +class A { + foo() {} +} +class B { + foo() {} + bar() {} +} +"#; + let result = parse_file("classes.ts", source).unwrap(); + let classes: Vec<&str> = result + .definitions + .iter() + .filter(|d| d.kind == SymbolKind::Class) + .map(|d| d.name.as_str()) + .collect(); + assert!(classes.contains(&"A")); + assert!(classes.contains(&"B")); + + // Both classes have foo() methods — both should be captured + let foos: Vec<&Definition> = result + .definitions + .iter() + .filter(|d| d.name == "foo" && d.kind == SymbolKind::Function) + .collect(); + assert_eq!(foos.len(), 2, "both foo() methods should be captured"); + } + + #[test] + fn test_ts_unicode_identifiers() { + let source = r#" +function grüßen(名前: string): string { + return `Hello ${名前}`; +} +const αβγ = 42; +"#; + let result = parse_file("unicode.ts", source).unwrap(); + assert!(result.definitions.iter().any(|d| d.name == "grüßen")); + assert!(result.definitions.iter().any(|d| d.name == "αβγ")); + } + + #[test] + fn test_ts_syntax_error_partial_parse() { + // tree-sitter does partial parsing on syntax errors but recovery is + // not guaranteed for all subsequent definitions. + let source = r#" +function valid() { return 1; } +const x = {{{ +function alsoValid() { return 2; } +"#; + let result = parse_file("broken.ts", source).unwrap(); + // The definition before the error should be extracted + assert!(result.definitions.iter().any(|d| d.name == "valid")); + // Parsing doesn't fail — no panic, just potentially missing later defs + assert!(result.language == Language::TypeScript); + } + + #[test] + fn test_ts_export_default_class() { + let source = r#"export default class App { + render() {} +}"#; + let result = parse_file("app.ts", source).unwrap(); + let app_export = result.exports.iter().find(|e| e.name == "App"); + assert!( + app_export.is_some(), + "export default class should be captured" + ); + assert!(app_export.unwrap().is_default); + } + + #[test] + fn test_ts_export_interface_and_type() { + let source = r#" +export interface Config { + port: number; +} +export type ID = string; +"#; + let result = parse_file("types.ts", source).unwrap(); + assert!(result.exports.iter().any(|e| e.name == "Config")); + assert!(result.exports.iter().any(|e| e.name == "ID")); + assert!(result + .definitions + .iter() + .any(|d| d.name == "Config" && d.kind == SymbolKind::Interface)); + assert!(result + .definitions + .iter() + .any(|d| d.name == "ID" && d.kind == SymbolKind::TypeAlias)); + } + + #[test] + fn test_python_decorated_class_with_methods() { + let source = r#" +@dataclass +class User: + name: str + + def greet(self): + pass + + @staticmethod + def create(name): + pass +"#; + let result = parse_file("models.py", source).unwrap(); + assert!(result + .definitions + .iter() + .any(|d| d.name == "User" && d.kind == SymbolKind::Class)); + assert!(result.definitions.iter().any(|d| d.name == "greet")); + assert!(result.definitions.iter().any(|d| d.name == "create")); + } + + #[test] + fn test_python_wildcard_import() { + let source = "from os.path import *\n"; + let result = parse_file("app.py", source).unwrap(); + assert_eq!(result.imports.len(), 1); + assert!(result.imports[0].names.iter().any(|n| n.name == "*")); + } + + #[test] + fn test_python_relative_import_parent() { + let source = "from .. import utils\n"; + let result = parse_file("sub/mod.py", source).unwrap(); + assert_eq!(result.imports.len(), 1); + assert_eq!(result.imports[0].source, ".."); + } + + #[test] + fn test_ts_deeply_nested_calls() { + // Verify recursive call collection handles nesting + let source = r#" +function outer() { + function middle() { + function inner() { + deepCall(); + } + middleCall(); + } + outerCall(); +} +"#; + let result = parse_file("nested.ts", source).unwrap(); + let callees: Vec<&str> = result + .call_sites + .iter() + .map(|c| c.callee.as_str()) + .collect(); + assert!(callees.contains(&"deepCall")); + assert!(callees.contains(&"middleCall")); + assert!(callees.contains(&"outerCall")); + + // Containing function resolution + let deep = result + .call_sites + .iter() + .find(|c| c.callee == "deepCall") + .unwrap(); + assert_eq!(deep.containing_function, Some("inner".to_string())); + } + + #[test] + fn test_ts_module_level_calls_no_containing() { + let source = "init();\nconfigure();\n"; + let result = parse_file("init.ts", source).unwrap(); + for call in &result.call_sites { + assert_eq!( + call.containing_function, None, + "top-level calls should have no containing function" + ); + } + } + + #[test] + fn test_language_from_path_edge_cases() { + assert_eq!(Language::from_path(""), Language::Unknown); + assert_eq!(Language::from_path("noext"), Language::Unknown); + assert_eq!(Language::from_path(".ts"), Language::TypeScript); + assert_eq!(Language::from_path("a/b/c.py"), Language::Python); + assert_eq!(Language::from_path("my.module.ts"), Language::TypeScript); + } + + #[test] + fn test_ts_comments_only_file() { + let source = r#" +// This is a comment +/* block comment */ +/** JSDoc */ +"#; + let result = parse_file("comments.ts", source).unwrap(); + assert!(result.definitions.is_empty()); + assert!(result.imports.is_empty()); + assert!(result.exports.is_empty()); + assert!(result.call_sites.is_empty()); + } + + #[test] + fn test_data_flow_python_keyword_args_only() { + let source = r#" +def main(): + result = connect(host='localhost', port=5432) +"#; + let info = extract_data_flow_info("main.py", source).unwrap(); + + let connect_call = info + .calls_with_args + .iter() + .find(|c| c.callee == "connect") + .unwrap(); + // Keyword arg values should be captured + assert!(connect_call.arguments.contains(&"'localhost'".to_string())); + assert!(connect_call.arguments.contains(&"5432".to_string())); + } + + #[test] + fn test_ts_let_var_declarations() { + let source = r#" +let mutable = 42; +var legacy = "old"; +"#; + let result = parse_file("vars.ts", source).unwrap(); + assert!(result.definitions.iter().any(|d| d.name == "mutable")); + assert!(result.definitions.iter().any(|d| d.name == "legacy")); + } + + #[test] + fn test_ts_export_multiple_vars() { + let source = "export const A = 1, B = 2;\n"; + let result = parse_file("consts.ts", source).unwrap(); + assert!(result.exports.iter().any(|e| e.name == "A")); + assert!(result.exports.iter().any(|e| e.name == "B")); + } + + // ======================================================================== + // Go parsing tests + // ======================================================================== + + #[test] + fn test_go_language_detection() { + assert_eq!(Language::from_path("main.go"), Language::Go); + assert_eq!(Language::from_path("handlers/user.go"), Language::Go); + } + + #[test] + fn test_go_simple_imports() { + let source = r#" +package main + +import "fmt" +import "net/http" +"#; + let result = parse_file("main.go", source).unwrap(); + assert_eq!(result.language, Language::Go); + assert_eq!(result.imports.len(), 2); + + assert_eq!(result.imports[0].source, "fmt"); + assert!(result.imports[0].is_namespace); + assert_eq!(result.imports[0].names[0].name, "fmt"); + + assert_eq!(result.imports[1].source, "net/http"); + assert!(result.imports[1].is_namespace); + assert_eq!(result.imports[1].names[0].name, "http"); + } + + #[test] + fn test_go_grouped_imports() { + let source = r#" +package main + +import ( + "fmt" + "net/http" + "github.com/gin-gonic/gin" +) +"#; + let result = parse_file("main.go", source).unwrap(); + assert_eq!(result.imports.len(), 3); + assert_eq!(result.imports[0].source, "fmt"); + assert_eq!(result.imports[1].source, "net/http"); + assert_eq!(result.imports[2].source, "github.com/gin-gonic/gin"); + assert_eq!(result.imports[2].names[0].name, "gin"); + } + + #[test] + fn test_go_aliased_import() { + let source = r#" +package main + +import ( + myhttp "net/http" + _ "database/sql" +) +"#; + let result = parse_file("main.go", source).unwrap(); + assert_eq!(result.imports.len(), 2); + + // Aliased import + assert_eq!(result.imports[0].source, "net/http"); + assert_eq!(result.imports[0].names[0].name, "http"); + assert_eq!(result.imports[0].names[0].alias, Some("myhttp".to_string())); + + // Blank import (side-effect only) + assert_eq!(result.imports[1].source, "database/sql"); + assert!(result.imports[1].names.is_empty()); + } + + #[test] + fn test_go_function_definitions() { + let source = r#" +package main + +func main() { + fmt.Println("Hello") +} + +func greet(name string) string { + return "Hello " + name +} + +func add(a, b int) int { + return a + b +} +"#; + let result = parse_file("main.go", source).unwrap(); + assert_eq!(result.language, Language::Go); + + let fns: Vec<&str> = result + .definitions + .iter() + .filter(|d| d.kind == SymbolKind::Function) + .map(|d| d.name.as_str()) + .collect(); + assert!(fns.contains(&"main")); + assert!(fns.contains(&"greet")); + assert!(fns.contains(&"add")); + } + + #[test] + fn test_go_struct_definitions() { + let source = r#" +package models + +type User struct { + ID int + Name string + Email string +} + +type Config struct { + Port int + Host string +} +"#; + let result = parse_file("models.go", source).unwrap(); + + let user = result + .definitions + .iter() + .find(|d| d.name == "User") + .unwrap(); + assert_eq!(user.kind, SymbolKind::Class); // structs map to Class + + let config = result + .definitions + .iter() + .find(|d| d.name == "Config") + .unwrap(); + assert_eq!(config.kind, SymbolKind::Class); + } + + #[test] + fn test_go_interface_definitions() { + let source = r#" +package service + +type UserService interface { + GetUser(id int) (*User, error) + CreateUser(data UserInput) (*User, error) + DeleteUser(id int) error +} + +type Repository interface { + Find(id int) (interface{}, error) + Save(entity interface{}) error +} +"#; + let result = parse_file("service.go", source).unwrap(); + + let user_svc = result + .definitions + .iter() + .find(|d| d.name == "UserService") + .unwrap(); + assert_eq!(user_svc.kind, SymbolKind::Interface); + + let repo = result + .definitions + .iter() + .find(|d| d.name == "Repository") + .unwrap(); + assert_eq!(repo.kind, SymbolKind::Interface); + } + + #[test] + fn test_go_method_declarations() { + let source = r#" +package models + +type User struct { + Name string +} + +func (u *User) Greet() string { + return "Hello " + u.Name +} + +func (u User) String() string { + return u.Name +} +"#; + let result = parse_file("models.go", source).unwrap(); + + let methods: Vec<&str> = result + .definitions + .iter() + .filter(|d| d.kind == SymbolKind::Function) + .map(|d| d.name.as_str()) + .collect(); + assert!( + methods.contains(&"Greet"), + "method Greet should be detected" + ); + assert!( + methods.contains(&"String"), + "method String should be detected" + ); + } + + #[test] + fn test_go_constants() { + let source = r#" +package config + +const MaxRetries = 3 +const ( + DefaultPort = 8080 + DefaultHost = "localhost" +) +"#; + let result = parse_file("config.go", source).unwrap(); + + let consts: Vec<&str> = result + .definitions + .iter() + .filter(|d| d.kind == SymbolKind::Constant) + .map(|d| d.name.as_str()) + .collect(); + assert!(consts.contains(&"MaxRetries")); + assert!(consts.contains(&"DefaultPort")); + assert!(consts.contains(&"DefaultHost")); + } + + #[test] + fn test_go_type_alias() { + let source = r#" +package types + +type UserID int64 +type Handler func(w http.ResponseWriter, r *http.Request) +"#; + let result = parse_file("types.go", source).unwrap(); + + // UserID should be detected as TypeAlias (not struct/interface) + let user_id = result + .definitions + .iter() + .find(|d| d.name == "UserID") + .unwrap(); + assert_eq!(user_id.kind, SymbolKind::TypeAlias); + + let handler = result + .definitions + .iter() + .find(|d| d.name == "Handler") + .unwrap(); + assert_eq!(handler.kind, SymbolKind::TypeAlias); + } + + #[test] + fn test_go_call_sites() { + let source = r#" +package main + +import "fmt" + +func process(data string) { + validated := validate(data) + result := db.Save(validated) + fmt.Println(result) +} +"#; + let result = parse_file("handler.go", source).unwrap(); + + let callees: Vec<&str> = result + .call_sites + .iter() + .map(|c| c.callee.as_str()) + .collect(); + assert!(callees.contains(&"validate")); + assert!(callees.contains(&"db.Save")); + assert!(callees.contains(&"fmt.Println")); + + // All calls inside process function + for call in &result.call_sites { + assert_eq!(call.containing_function, Some("process".to_string())); + } + } + + #[test] + fn test_go_call_sites_in_method() { + let source = r#" +package service + +func (s *UserService) Create(data UserInput) (*User, error) { + validated := s.validate(data) + return s.repo.Save(validated) +} +"#; + let result = parse_file("service.go", source).unwrap(); + + let callees: Vec<&str> = result + .call_sites + .iter() + .map(|c| c.callee.as_str()) + .collect(); + assert!(callees.contains(&"s.validate")); + assert!(callees.contains(&"s.repo.Save")); + + for call in &result.call_sites { + assert_eq!(call.containing_function, Some("Create".to_string())); + } + } + + #[test] + fn test_go_exported_symbols() { + let source = r#" +package models + +type User struct { + Name string +} + +type internalState struct { + cache map[string]string +} + +func GetUser(id int) *User { + return nil +} + +func helper() { +} + +const MaxSize = 100 +const defaultTimeout = 30 +"#; + let result = parse_file("models.go", source).unwrap(); + + let export_names: Vec<&str> = result.exports.iter().map(|e| e.name.as_str()).collect(); + // Uppercase = exported + assert!(export_names.contains(&"User")); + assert!(export_names.contains(&"GetUser")); + assert!(export_names.contains(&"MaxSize")); + // Lowercase = not exported + assert!(!export_names.contains(&"internalState")); + assert!(!export_names.contains(&"helper")); + assert!(!export_names.contains(&"defaultTimeout")); + } + + #[test] + fn test_go_data_flow_short_var_decl() { + let source = r#" +package main + +func handler(req string) { + data := parseBody(req) + result := transform(data) + save(result) +} +"#; + let info = extract_data_flow_info("handler.go", source).unwrap(); + + assert_eq!(info.assignments.len(), 2); + + let vars: Vec<&str> = info + .assignments + .iter() + .map(|a| a.variable.as_str()) + .collect(); + assert!(vars.contains(&"data")); + assert!(vars.contains(&"result")); + + let callees: Vec<&str> = info.assignments.iter().map(|a| a.callee.as_str()).collect(); + assert!(callees.contains(&"parseBody")); + assert!(callees.contains(&"transform")); + } + + #[test] + fn test_go_data_flow_method_call() { + let source = r#" +package main + +func getUser(id int) { + user := db.FindOne(id) + save(user) +} +"#; + let info = extract_data_flow_info("service.go", source).unwrap(); + + assert_eq!(info.assignments.len(), 1); + assert_eq!(info.assignments[0].variable, "user"); + assert_eq!(info.assignments[0].callee, "db.FindOne"); + assert_eq!( + info.assignments[0].containing_function, + Some("getUser".to_string()) + ); + } + + #[test] + fn test_go_data_flow_call_with_args() { + let source = r#" +package main + +func process() { + x := getFirst() + y := getSecond() + combine(x, y, 42) +} +"#; + let info = extract_data_flow_info("process.go", source).unwrap(); + + let combine = info + .calls_with_args + .iter() + .find(|c| c.callee == "combine") + .unwrap(); + assert!(combine.arguments.contains(&"x".to_string())); + assert!(combine.arguments.contains(&"y".to_string())); + assert!(combine.arguments.contains(&"42".to_string())); + } + + #[test] + fn test_go_empty_source() { + let source = "package main\n"; + let result = parse_file("empty.go", source).unwrap(); + assert_eq!(result.language, Language::Go); + assert!(result.definitions.is_empty()); + assert!(result.imports.is_empty()); + assert!(result.call_sites.is_empty()); + } + + #[test] + fn test_go_goroutine_call() { + let source = r#" +package main + +func startWorker() { + go processQueue() + go handleMessages() +} +"#; + let result = parse_file("worker.go", source).unwrap(); + + // Goroutine calls should still be detected as call sites + let callees: Vec<&str> = result + .call_sites + .iter() + .map(|c| c.callee.as_str()) + .collect(); + assert!(callees.contains(&"processQueue")); + assert!(callees.contains(&"handleMessages")); + } + + #[test] + fn test_go_var_declaration() { + let source = r#" +package config + +var GlobalConfig Config +var ( + Logger *log.Logger + Verbose bool +) +"#; + let result = parse_file("config.go", source).unwrap(); + let vars: Vec<&str> = result.definitions.iter().map(|d| d.name.as_str()).collect(); + assert!(vars.contains(&"GlobalConfig")); + assert!(vars.contains(&"Logger")); + assert!(vars.contains(&"Verbose")); + } + + #[test] + fn test_go_http_handler_pattern() { + let source = r#" +package main + +import "net/http" + +func main() { + http.HandleFunc("/users", handleUsers) + http.ListenAndServe(":8080", nil) +} + +func handleUsers(w http.ResponseWriter, r *http.Request) { + fmt.Fprintln(w, "users") +} +"#; + let result = parse_file("main.go", source).unwrap(); + + let fns: Vec<&str> = result + .definitions + .iter() + .filter(|d| d.kind == SymbolKind::Function) + .map(|d| d.name.as_str()) + .collect(); + assert!(fns.contains(&"main")); + assert!(fns.contains(&"handleUsers")); + + // Verify http import + assert!(result.imports.iter().any(|i| i.source == "net/http")); + + // Verify call sites + let callees: Vec<&str> = result + .call_sites + .iter() + .map(|c| c.callee.as_str()) + .collect(); + assert!(callees.contains(&"http.HandleFunc")); + assert!(callees.contains(&"http.ListenAndServe")); + } From 8a3f7388c10c5b2741f37c3569512df42ebbf49e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Apr 2026 17:22:58 +0000 Subject: [PATCH 03/15] refactor: split flow.rs into flow/mod.rs + flow/tests.rs Converts src/flow.rs (3999 lines) into a module directory to keep individual files below 3000 lines: - flow/mod.rs: production code (1433 lines) - flow/tests.rs: test module body (2566 lines) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: mikenrafter <88250914+mikenrafter@users.noreply.github.com> --- crates/diffcore-core/src/flow/mod.rs | 1433 +++++++++++++++++ .../src/{flow.rs => flow/tests.rs} | 1433 ----------------- 2 files changed, 1433 insertions(+), 1433 deletions(-) create mode 100644 crates/diffcore-core/src/flow/mod.rs rename crates/diffcore-core/src/{flow.rs => flow/tests.rs} (62%) diff --git a/crates/diffcore-core/src/flow/mod.rs b/crates/diffcore-core/src/flow/mod.rs new file mode 100644 index 0000000..3f685b5 --- /dev/null +++ b/crates/diffcore-core/src/flow/mod.rs @@ -0,0 +1,1433 @@ +//! Data flow tracing and heuristic inference module. +//! +//! Analyzes parsed files to infer additional data flow edges beyond what +//! static import/call analysis can determine. Uses pattern matching on +//! call sites and identifiers to detect: +//! +//! - Database persistence patterns (`.save()`, `.insert()`, `INSERT INTO`) +//! - Database read patterns (`.find()`, `.query()`, `SELECT`) +//! - Event emission (`.emit()`, `.publish()`, `.dispatch()`) +//! - Event handling (`.on()`, `.subscribe()`, `.listen()`) +//! - Configuration reads (`process.env`, `os.environ`) +//! - HTTP outbound calls (`fetch()`, `axios.get()`) +//! - Logging calls (`console.log`, `logger.info`) +//! +//! Also detects frameworks from import patterns. + +use std::collections::{HashMap, HashSet}; +use std::sync::OnceLock; + +use aho_corasick::AhoCorasick; + +use crate::ast::{CallSite, ParsedFile}; +use crate::graph::{GraphEdge, SymbolGraph}; +use crate::ir::IrFile; +use crate::types::EdgeType; + +/// A data flow pattern detected via heuristic matching. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum FlowPattern { + /// Database write: `.save()`, `.insert()`, `.create()`, `.update()`, `.delete()`, `INSERT INTO` + Persistence, + /// Database read: `.find()`, `.query()`, `.select()`, `.findOne()`, `SELECT` + DatabaseRead, + /// Event emission: `.emit()`, `.publish()`, `.send()`, `.dispatch()` + EventEmission, + /// Event handling: `.on()`, `.subscribe()`, `.listen()`, `.addEventListener()` + EventHandling, + /// Configuration read: `process.env`, `os.environ`, `config.get()` + ConfigRead, + /// HTTP outbound call: `fetch()`, `axios.get()`, `requests.get()` + HttpCall, + /// Logging: `console.log`, `logger.info`, `logging.debug` + Logging, +} + +/// A heuristic edge inferred from code patterns. +#[derive(Debug, Clone, PartialEq)] +pub struct HeuristicEdge { + /// Symbol id of the function containing the pattern (e.g. `file.ts::handler`) + pub from_symbol: String, + /// The file containing the pattern + pub file: String, + /// The detected flow pattern + pub pattern: FlowPattern, + /// Confidence score [0.0, 1.0] + pub confidence: f64, + /// The callee string that matched (evidence) + pub evidence: String, + /// Line number where the pattern was detected + pub line: usize, +} + +/// Result of data flow analysis across all files. +#[derive(Debug, Clone)] +pub struct FlowAnalysis { + /// Heuristic edges inferred from code patterns. + pub heuristic_edges: Vec, + /// Frameworks detected from import patterns. + pub frameworks_detected: Vec, +} + +/// A data flow edge connecting a producer function to a consumer function +/// through a shared variable within the same function scope. +/// +/// Example: `const x = funcA(); funcB(x)` creates an edge from funcA → funcB via "x". +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct DataFlowEdge { + /// Callee of the assignment (the function producing data). + pub producer: String, + /// Callee of the consuming call (the function receiving the data). + pub consumer: String, + /// Variable name connecting the producer to the consumer. + pub via: String, + /// Symbol ID of the function containing both calls. + pub containing_function: String, + /// File path. + pub file: String, + /// Line of the consumer call. + pub line: usize, +} + +/// Configuration for flow analysis. +#[derive(Debug, Clone)] +pub struct FlowConfig { + /// Maximum call chain depth to trace (prevents runaway on cycles). + pub max_depth: usize, +} + +impl Default for FlowConfig { + fn default() -> Self { + Self { max_depth: 10 } + } +} + +// --------------------------------------------------------------------------- +// Heuristic pattern matching rules +// --------------------------------------------------------------------------- + +/// Persistence (database write) patterns. +const DB_WRITE_METHODS: &[&str] = &[ + ".save", + ".insert", + ".create", + ".update", + ".delete", + ".remove", + ".upsert", + ".bulkCreate", + ".bulkInsert", + ".insertMany", + ".updateMany", + ".deleteMany", + ".findAndUpdate", + ".findOneAndUpdate", + ".findOneAndDelete", + ".findOneAndRemove", + ".persist", + ".flush", + ".execute", + ".run", +]; + +/// SQL write keywords (case-insensitive matching on string literals). +const SQL_WRITE_KEYWORDS: &[&str] = &[ + "INSERT INTO", + "UPDATE ", + "DELETE FROM", + "DROP TABLE", + "ALTER TABLE", + "CREATE TABLE", + "TRUNCATE", +]; + +/// Database read patterns. +const DB_READ_METHODS: &[&str] = &[ + ".find", + ".findOne", + ".findById", + ".findAll", + ".findMany", + ".findFirst", + ".findUnique", + ".query", + ".select", + ".get", + ".fetch", + ".count", + ".aggregate", + ".groupBy", + ".where", +]; + +/// SQL read keywords. +const SQL_READ_KEYWORDS: &[&str] = &["SELECT ", "SELECT\n"]; + +/// Event emission patterns. +const EVENT_EMIT_METHODS: &[&str] = &[ + ".emit", + ".publish", + ".send", + ".dispatch", + ".fire", + ".trigger", + ".broadcast", + ".notify", + ".produce", + ".enqueue", +]; + +/// Event handling patterns. +const EVENT_HANDLE_METHODS: &[&str] = &[ + ".on", + ".subscribe", + ".listen", + ".addEventListener", + ".addListener", + ".handle", + ".consume", + ".onMessage", + ".onEvent", +]; + +/// Config read patterns. +const CONFIG_PATTERNS: &[&str] = &[ + "process.env", + "os.environ", + "os.getenv", + "config.get", + "config.set", + "dotenv", + "Deno.env", +]; + +/// HTTP outbound call patterns. +const HTTP_CALL_PATTERNS: &[&str] = &[ + "fetch", + "axios.get", + "axios.post", + "axios.put", + "axios.delete", + "axios.patch", + "axios.request", + "requests.get", + "requests.post", + "requests.put", + "requests.delete", + "requests.patch", + "http.get", + "http.post", + "http.request", + "urllib.request", + "httpx.get", + "httpx.post", +]; + +/// Logging patterns. +const LOG_PATTERNS: &[&str] = &[ + "console.log", + "console.error", + "console.warn", + "console.info", + "console.debug", + "console.trace", + "logger.info", + "logger.error", + "logger.warn", + "logger.debug", + "logger.trace", + "logger.fatal", + "logging.info", + "logging.error", + "logging.warning", + "logging.debug", + "logging.critical", + "log.info", + "log.error", + "log.warn", + "log.debug", +]; + +// --------------------------------------------------------------------------- +// Framework detection +// --------------------------------------------------------------------------- + +/// Known framework import sources and their display names. +const FRAMEWORK_IMPORTS: &[(&str, &str)] = &[ + // JavaScript/TypeScript + ("express", "Express"), + ("fastify", "Fastify"), + ("next", "Next.js"), + ("next/", "Next.js"), + ("react", "React"), + ("react-dom", "React"), + ("vue", "Vue"), + ("@angular/core", "Angular"), + ("svelte", "Svelte"), + ("@nestjs/common", "NestJS"), + ("@nestjs/core", "NestJS"), + ("hono", "Hono"), + ("koa", "Koa"), + ("@effect/", "Effect.ts"), + ("effect", "Effect.ts"), + ("prisma", "Prisma"), + ("@prisma/client", "Prisma"), + ("typeorm", "TypeORM"), + ("sequelize", "Sequelize"), + ("mongoose", "Mongoose"), + ("drizzle-orm", "Drizzle"), + ("@trpc/server", "tRPC"), + ("@trpc/client", "tRPC"), + ("graphql", "GraphQL"), + ("@apollo/server", "Apollo"), + ("@apollo/client", "Apollo"), + ("tailwindcss", "Tailwind CSS"), + ("redux", "Redux"), + ("@reduxjs/toolkit", "Redux"), + ("zustand", "Zustand"), + ("zod", "Zod"), + ("vitest", "Vitest"), + ("jest", "Jest"), + ("@effect/vitest", "Effect.ts"), + // Python + ("fastapi", "FastAPI"), + ("flask", "Flask"), + ("django", "Django"), + ("sqlalchemy", "SQLAlchemy"), + ("pydantic", "Pydantic"), + ("celery", "Celery"), + ("pytest", "pytest"), + ("alembic", "Alembic"), + ("tortoise", "Tortoise ORM"), + ("starlette", "Starlette"), + ("aiohttp", "aiohttp"), + ("httpx", "httpx"), + ("uvicorn", "Uvicorn"), + // Go + ("net/http", "Go net/http"), + ("github.com/gin-gonic/gin", "Gin"), + ("github.com/labstack/echo", "Echo"), + ("github.com/go-chi/chi", "Chi"), + ("github.com/gofiber/fiber", "Fiber"), + ("github.com/gorilla/mux", "Gorilla Mux"), + ("google.golang.org/grpc", "gRPC"), + ("github.com/spf13/cobra", "Cobra"), + ("github.com/spf13/viper", "Viper"), + ("gorm.io/gorm", "GORM"), + ("github.com/jmoiron/sqlx", "sqlx"), + ("database/sql", "Go database/sql"), + ("github.com/go-playground/validator", "Go Validator"), + ("github.com/stretchr/testify", "Testify"), + // Rust + ("actix_web", "Actix-web"), + ("actix-web", "Actix-web"), + ("axum", "Axum"), + ("rocket", "Rocket"), + ("warp", "Warp"), + ("hyper", "Hyper"), + ("tokio", "Tokio"), + ("diesel", "Diesel"), + ("sqlx", "SQLx"), + ("sea_orm", "SeaORM"), + ("sea-orm", "SeaORM"), + ("clap", "Clap"), + ("tauri", "Tauri"), + ("serde", "Serde"), + ("tower", "Tower"), + ("tonic", "Tonic"), + ("tracing", "Tracing"), + // Java + ("org.springframework.boot", "Spring Boot"), + ("org.springframework.web", "Spring MVC"), + ("org.springframework.data", "Spring Data"), + ("org.springframework.stereotype", "Spring Boot"), + ("org.springframework.beans", "Spring Boot"), + ("org.springframework.context", "Spring Boot"), + ("org.springframework.security", "Spring Security"), + ("jakarta.persistence", "JPA"), + ("javax.persistence", "JPA"), + ("jakarta.ws.rs", "JAX-RS"), + ("javax.ws.rs", "JAX-RS"), + ("jakarta.servlet", "Servlet"), + ("javax.servlet", "Servlet"), + ("org.hibernate", "Hibernate"), + ("org.junit", "JUnit"), + ("org.junit.jupiter", "JUnit 5"), + ("org.mockito", "Mockito"), + ("com.google.inject", "Guice"), + ("io.micronaut", "Micronaut"), + ("io.quarkus", "Quarkus"), + ("org.apache.maven", "Maven"), + // C# + ("Microsoft.AspNetCore", "ASP.NET Core"), + ("Microsoft.AspNetCore.Mvc", "ASP.NET Core MVC"), + ("Microsoft.AspNetCore.Builder", "ASP.NET Core"), + ("Microsoft.AspNetCore.Http", "ASP.NET Core"), + ("Microsoft.AspNetCore.Routing", "ASP.NET Core"), + ("Microsoft.AspNetCore.Authorization", "ASP.NET Core"), + ("Microsoft.AspNetCore.Identity", "ASP.NET Identity"), + ("Microsoft.AspNetCore.SignalR", "SignalR"), + ("Microsoft.EntityFrameworkCore", "Entity Framework Core"), + ("Microsoft.Extensions.DependencyInjection", "ASP.NET Core"), + ("Microsoft.Extensions.Logging", "ASP.NET Core"), + ("Microsoft.Extensions.Configuration", "ASP.NET Core"), + ("System.Linq", "LINQ"), + ("Xunit", "xUnit"), + ("NUnit", "NUnit"), + ("Microsoft.VisualStudio.TestTools", "MSTest"), + ("Moq", "Moq"), + ("FluentAssertions", "FluentAssertions"), + ("MediatR", "MediatR"), + ("AutoMapper", "AutoMapper"), + ("Newtonsoft.Json", "Newtonsoft.Json"), + ("System.Text.Json", "System.Text.Json"), + ("Dapper", "Dapper"), + ("Microsoft.AspNetCore.Components", "Blazor"), + // PHP (use namespace segments without trailing backslash; + // the match logic adds \ as a separator) + ("Illuminate", "Laravel"), + ("Illuminate\\Http", "Laravel"), + ("Illuminate\\Routing", "Laravel"), + ("Illuminate\\Database", "Laravel Eloquent"), + ("Illuminate\\Queue", "Laravel Queue"), + ("Illuminate\\Console", "Laravel Artisan"), + ("Illuminate\\Support", "Laravel"), + ("Laravel", "Laravel"), + ("Symfony", "Symfony"), + ("Symfony\\Component\\HttpFoundation", "Symfony"), + ("Symfony\\Component\\Console", "Symfony Console"), + ("Symfony\\Component\\Routing", "Symfony"), + ("Doctrine\\ORM", "Doctrine ORM"), + ("Doctrine\\DBAL", "Doctrine DBAL"), + ("Slim", "Slim"), + ("GuzzleHttp", "Guzzle"), + ("Monolog", "Monolog"), + ("PHPUnit", "PHPUnit"), + ("Livewire", "Livewire"), + ("Inertia", "Inertia"), + // Ruby + ("rails", "Rails"), + ("action_controller", "Rails"), + ("active_record", "Rails ActiveRecord"), + ("active_support", "Rails"), + ("action_view", "Rails"), + ("action_mailer", "Rails"), + ("active_job", "Rails ActiveJob"), + ("active_storage", "Rails"), + ("action_cable", "Rails ActionCable"), + ("sinatra", "Sinatra"), + ("rack", "Rack"), + ("grape", "Grape"), + ("hanami", "Hanami"), + ("rspec", "RSpec"), + ("minitest", "Minitest"), + ("sidekiq", "Sidekiq"), + ("devise", "Devise"), + ("pundit", "Pundit"), + ("cancancan", "CanCanCan"), + ("sequel", "Sequel"), + ("mongoid", "Mongoid"), + ("dry-rb", "dry-rb"), + ("roda", "Roda"), + ("puma", "Puma"), + ("faraday", "Faraday"), + ("httparty", "HTTParty"), + ("factory_bot", "FactoryBot"), + ("rubocop", "RuboCop"), + // Kotlin + ("io.ktor", "Ktor"), + ("io.ktor.server", "Ktor"), + ("io.ktor.client", "Ktor Client"), + ("io.ktor.routing", "Ktor"), + ("org.springframework", "Spring Boot"), + ("org.springframework.boot", "Spring Boot"), + ("org.springframework.web", "Spring MVC"), + ("org.springframework.data", "Spring Data"), + ("org.jetbrains.exposed", "Exposed"), + ("org.jetbrains.compose", "Jetpack Compose"), + ("androidx.compose", "Jetpack Compose"), + ("kotlinx.coroutines", "Kotlin Coroutines"), + ("kotlinx.serialization", "Kotlin Serialization"), + ("org.junit", "JUnit"), + ("kotlin.test", "Kotlin Test"), + ("io.kotest", "Kotest"), + ("io.mockk", "MockK"), + ("org.koin", "Koin"), + ("com.squareup.retrofit2", "Retrofit"), + ("com.squareup.okhttp3", "OkHttp"), + ("io.arrow-kt", "Arrow"), + ("com.google.dagger", "Dagger/Hilt"), + // Swift + ("SwiftUI", "SwiftUI"), + ("UIKit", "UIKit"), + ("Foundation", "Foundation"), + ("Vapor", "Vapor"), + ("Fluent", "Fluent"), + ("FluentPostgresDriver", "Fluent"), + ("FluentSQLiteDriver", "Fluent"), + ("FluentMySQLDriver", "Fluent"), + ("XCTest", "XCTest"), + ("Combine", "Combine"), + ("CoreData", "Core Data"), + ("SwiftData", "SwiftData"), + ("Alamofire", "Alamofire"), + ("Kitura", "Kitura"), + ("Perfect", "Perfect"), + ("Hummingbird", "Hummingbird"), + ("Observation", "Observation"), + ("SwiftNIO", "SwiftNIO"), + ("GRDB", "GRDB"), + ("SnapKit", "SnapKit"), + ("Quick", "Quick"), + ("Nimble", "Nimble"), + // C + ("stdio.h", "C stdio"), + ("stdlib.h", "C stdlib"), + ("string.h", "C string"), + ("pthread.h", "POSIX threads"), + ("unistd.h", "POSIX"), + ("curl/curl.h", "libcurl"), + ("sqlite3.h", "SQLite3"), + ("mysql.h", "MySQL C API"), + ("libpq-fe.h", "PostgreSQL libpq"), + ("openssl/ssl.h", "OpenSSL"), + ("jansson.h", "Jansson"), + ("cjson/cJSON.h", "cJSON"), + ("check.h", "Check"), + ("cmocka.h", "CMocka"), + // C++ + ("iostream", "C++ STL"), + ("vector", "C++ STL"), + ("memory", "C++ STL"), + ("string", "C++ STL"), + ("algorithm", "C++ STL"), + ("thread", "C++ STL"), + ("mutex", "C++ STL"), + ("boost/asio.hpp", "Boost.Asio"), + ("boost/beast.hpp", "Boost.Beast"), + ("boost/", "Boost"), + ("crow.h", "Crow"), + ("crow/crow.h", "Crow"), + ("httplib.h", "cpp-httplib"), + ("pistache/endpoint.h", "Pistache"), + ("pistache/", "Pistache"), + ("drogon/drogon.h", "Drogon"), + ("drogon/", "Drogon"), + ("cpprest/", "C++ REST SDK"), + ("nlohmann/json.hpp", "nlohmann/json"), + ("sqlite3.h", "SQLite3"), + ("pqxx/pqxx", "libpqxx"), + ("mysql++.h", "MySQL++"), + ("gtest/gtest.h", "Google Test"), + ("gmock/gmock.h", "Google Mock"), + ("catch2/catch.hpp", "Catch2"), + ("catch2/", "Catch2"), + ("doctest/doctest.h", "doctest"), + ("fmt/format.h", "fmt"), + ("spdlog/spdlog.h", "spdlog"), + ("grpcpp/grpcpp.h", "gRPC C++"), + ("grpc++/", "gRPC C++"), + ("absl/", "Abseil"), + ("folly/", "Folly"), + ("Qt", "Qt"), + ("QApplication", "Qt"), + ("QWidget", "Qt"), + // Scala + ("play.api.mvc", "Play Framework"), + ("play.mvc", "Play Framework"), + ("akka.actor", "Akka"), + ("akka.stream", "Akka Streams"), + ("akka.http", "Akka HTTP"), + ("scala.concurrent", "Scala Concurrency"), + ("org.scalatest", "ScalaTest"), + ("org.specs2", "Specs2"), + ("org.scalatestplus", "ScalaTestPlus"), + ("org.mockito", "Mockito Scala"), + ("slick", "Slick"), + ("doobie", "Doobie"), + ("quill", "Quill"), + ("scalikejdbc", "ScalikeJDBC"), + ("circe", "Circe"), + ("spray", "Spray"), + ("org.http4s", "http4s"), + ("cats", "Cats"), + ("cats.effect", "Cats Effect"), + ("zio", "ZIO"), + ("monix", "Monix"), + ("fs2", "FS2"), + ("shapeless", "Shapeless"), + ("com.typesafe.config", "Typesafe Config"), + ("io.getquill", "Quill"), + ("sttp", "sttp"), + ("tapir", "Tapir"), +]; + +// --------------------------------------------------------------------------- +// Pre-compiled pattern matchers (built once, reused across all files) +// --------------------------------------------------------------------------- + +/// Suffix set for DB write methods (method name after last dot, e.g. "save"). +fn db_write_suffix_set() -> &'static HashSet<&'static str> { + static SET: OnceLock> = OnceLock::new(); + SET.get_or_init(|| { + DB_WRITE_METHODS + .iter() + .map(|s| s.trim_start_matches('.')) + .collect() + }) +} + +/// Suffix set for DB read methods. +fn db_read_suffix_set() -> &'static HashSet<&'static str> { + static SET: OnceLock> = OnceLock::new(); + SET.get_or_init(|| { + DB_READ_METHODS + .iter() + .map(|s| s.trim_start_matches('.')) + .collect() + }) +} + +/// Suffix set for event emission methods. +fn event_emit_suffix_set() -> &'static HashSet<&'static str> { + static SET: OnceLock> = OnceLock::new(); + SET.get_or_init(|| { + EVENT_EMIT_METHODS + .iter() + .map(|s| s.trim_start_matches('.')) + .collect() + }) +} + +/// Suffix set for event handling methods. +fn event_handle_suffix_set() -> &'static HashSet<&'static str> { + static SET: OnceLock> = OnceLock::new(); + SET.get_or_init(|| { + EVENT_HANDLE_METHODS + .iter() + .map(|s| s.trim_start_matches('.')) + .collect() + }) +} + +/// Exact-match set for logging patterns. +fn log_pattern_set() -> &'static HashSet<&'static str> { + static SET: OnceLock> = OnceLock::new(); + SET.get_or_init(|| LOG_PATTERNS.iter().copied().collect()) +} + +/// Exact-match set for known non-DB callees. +fn non_db_callee_set() -> &'static HashSet<&'static str> { + static SET: OnceLock> = OnceLock::new(); + SET.get_or_init(|| { + [ + "JSON.parse", + "JSON.stringify", + "Object.create", + "Object.assign", + "Array.from", + "Promise.resolve", + "Promise.reject", + "Date.now", + "Math.round", + "Math.floor", + "Math.ceil", + "Math.abs", + "Math.min", + "Math.max", + ] + .into_iter() + .collect() + }) +} + +/// Exact-match set for known non-DB receivers (lowercased). +fn non_db_receiver_set() -> &'static HashSet<&'static str> { + static SET: OnceLock> = OnceLock::new(); + SET.get_or_init(|| { + [ + "array", + "map", + "set", + "object", + "string", + "number", + "promise", + "json", + "math", + "date", + "regexp", + "cache", + "localstorage", + "sessionstorage", + "window", + "document", + "navigator", + "console", + "process", + "os", + "path", + "fs", + "http", + "https", + "url", + "buffer", + "stream", + "crypto", + "util", + "events", + "child_process", + "cluster", + "net", + "tls", + "dns", + "axios", + "requests", + "fetch", + "httpx", + "urllib", + "list", + "dict", + "tuple", + "frozenset", + "deque", + "defaultdict", + "items", + "result", + "results", + "data", + "response", + "request", + "config", + "env", + "settings", + "options", + "args", + "params", + "logger", + "log", + "logging", + "console", + ] + .into_iter() + .collect() + }) +} + +/// Aho-Corasick automaton for DB-keyword substring matching in receivers. +fn db_keyword_automaton() -> &'static AhoCorasick { + static AC: OnceLock = OnceLock::new(); + AC.get_or_init(|| { + AhoCorasick::new([ + "db", + "database", + "repo", + "repository", + "model", + "store", + "dao", + "collection", + "prisma", + "sequelize", + "typeorm", + "mongoose", + "drizzle", + "session", + "connection", + "pool", + "client", + "table", + "entity", + "schema", + "migration", + "knex", + "query", + "sql", + ]) + .expect("valid patterns") + }) +} + +/// Aho-Corasick automaton for ORM-specific names in confidence scoring. +fn orm_automaton() -> &'static AhoCorasick { + static AC: OnceLock = OnceLock::new(); + AC.get_or_init(|| { + AhoCorasick::new([ + "prisma", + "sequelize", + "typeorm", + "mongoose", + "sqlalchemy", + "drizzle", + ]) + .expect("valid patterns") + }) +} + +/// Aho-Corasick automaton for high-confidence receiver keywords in confidence scoring. +fn confidence_receiver_automaton() -> &'static AhoCorasick { + static AC: OnceLock = OnceLock::new(); + AC.get_or_init(|| { + AhoCorasick::new(["db", "repo", "model", "store", "dao", "collection"]) + .expect("valid patterns") + }) +} + +/// Aho-Corasick automaton for SQL write keywords (lowercased). +fn sql_write_automaton() -> &'static AhoCorasick { + static AC: OnceLock = OnceLock::new(); + AC.get_or_init(|| { + let patterns: Vec = SQL_WRITE_KEYWORDS + .iter() + .map(|k| k.to_lowercase()) + .collect(); + AhoCorasick::new(&patterns).expect("valid patterns") + }) +} + +/// Aho-Corasick automaton for SQL read keywords (lowercased). +fn sql_read_automaton() -> &'static AhoCorasick { + static AC: OnceLock = OnceLock::new(); + AC.get_or_init(|| { + let patterns: Vec = SQL_READ_KEYWORDS.iter().map(|k| k.to_lowercase()).collect(); + AhoCorasick::new(&patterns).expect("valid patterns") + }) +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/// Analyze data flow patterns across all parsed files. +/// +/// Scans call sites for heuristic patterns (DB writes, event emission, config reads, etc.) +/// and detects frameworks from import patterns. +pub fn analyze_data_flow(files: &[ParsedFile], _config: &FlowConfig) -> FlowAnalysis { + let mut heuristic_edges = Vec::new(); + + for file in files { + let file_edges = detect_heuristic_patterns(file); + heuristic_edges.extend(file_edges); + } + + let frameworks_detected = detect_frameworks(files); + + FlowAnalysis { + heuristic_edges, + frameworks_detected, + } +} + +/// Enrich an existing symbol graph with heuristic edges. +/// +/// For each heuristic edge, adds the appropriate edge type (Writes, Reads, Emits, Handles) +/// from the containing symbol to the file's module node (since the target is typically +/// an external resource like a database or event bus). +pub fn enrich_graph(graph: &mut SymbolGraph, analysis: &FlowAnalysis) { + for edge in &analysis.heuristic_edges { + let edge_type = match edge.pattern { + FlowPattern::Persistence => EdgeType::Writes, + FlowPattern::DatabaseRead => EdgeType::Reads, + FlowPattern::EventEmission => EdgeType::Emits, + FlowPattern::EventHandling => EdgeType::Handles, + FlowPattern::ConfigRead => EdgeType::Reads, + FlowPattern::HttpCall => EdgeType::Reads, + FlowPattern::Logging => continue, // Don't add graph edges for logging + }; + + let from_idx = match graph.get_node(&edge.from_symbol) { + Some(idx) => idx, + None => { + // Try the file-level module node as fallback + match graph.get_node(&edge.file) { + Some(idx) => idx, + None => continue, + } + } + }; + + // For heuristic edges, we connect to the file's module node since the actual + // target (database, event bus, etc.) is external and not in our graph. + let to_idx = match graph.get_node(&edge.file) { + Some(idx) => idx, + None => continue, + }; + + // Don't add self-edges + if from_idx == to_idx { + continue; + } + + graph.add_edge(from_idx, to_idx, GraphEdge { edge_type }); + } +} + +/// Detect frameworks from import patterns across all files. +pub fn detect_frameworks(files: &[ParsedFile]) -> Vec { + let mut frameworks: HashSet = HashSet::new(); + + for file in files { + for import in &file.imports { + let source = &import.source; + for &(pattern, name) in FRAMEWORK_IMPORTS { + // Match exact, or prefixed by separator: slash (JS/TS), + // dot (Python), :: (Rust), or backslash (PHP namespaces) + if source == pattern + || source.starts_with(pattern) + && source.as_bytes().get(pattern.len()).map_or(false, |&b| { + b == b'/' || b == b'.' || b == b':' || b == b'\\' + }) + { + frameworks.insert(name.to_string()); + } + } + } + } + + // Also detect Next.js from file structure conventions + for file in files { + let path = &file.path; + if path.contains("pages/") || path.contains("app/") { + if path.ends_with("page.tsx") + || path.ends_with("page.ts") + || path.ends_with("page.jsx") + || path.ends_with("page.js") + || path.ends_with("route.ts") + || path.ends_with("route.js") + || path.ends_with("layout.tsx") + || path.ends_with("layout.ts") + { + frameworks.insert("Next.js".to_string()); + } + } + } + + let mut result: Vec = frameworks.into_iter().collect(); + result.sort(); + result +} + +// --------------------------------------------------------------------------- +// Internal: heuristic pattern detection +// --------------------------------------------------------------------------- + +/// Detect heuristic data flow patterns in a single file's call sites. +fn detect_heuristic_patterns(file: &ParsedFile) -> Vec { + let mut edges = Vec::new(); + + for call in &file.call_sites { + if let Some(edge) = classify_call_site(call, &file.path) { + edges.push(edge); + } + } + + edges +} + +/// Classify a single call site into a flow pattern, if any. +/// +/// Pattern matching order is important: more specific patterns are checked first +/// to avoid false positives (e.g., `axios.get` is HTTP, not a DB read). +fn classify_call_site(call: &CallSite, file_path: &str) -> Option { + let callee = &call.callee; + let containing = call + .containing_function + .as_ref() + .map(|f| format!("{}::{}", file_path, f)) + .unwrap_or_else(|| file_path.to_string()); + + let make_edge = |pattern: FlowPattern, confidence: f64| HeuristicEdge { + from_symbol: containing.clone(), + file: file_path.to_string(), + pattern, + confidence, + evidence: callee.clone(), + line: call.line, + }; + + // 1. Logging — most specific, check first to prevent console.log matching elsewhere + if let Some(pattern) = match_logging(callee) { + return Some(make_edge(pattern, 0.95)); + } + + // 2. Config reads — specific patterns like process.env, os.environ + if let Some(pattern) = match_config_read(callee) { + return Some(make_edge(pattern, 0.9)); + } + + // 3. HTTP calls — check before DB reads so axios.get/requests.get match HTTP + if let Some(pattern) = match_http_call(callee) { + return Some(make_edge(pattern, 0.85)); + } + + // 4. Check for collection/stdlib false positives before DB patterns + if is_collection_method(callee) { + return None; + } + + // 5. Event emission + if let Some(pattern) = match_event_emission(callee) { + return Some(make_edge(pattern, 0.8)); + } + + // 6. Event handling + if let Some(pattern) = match_event_handling(callee) { + return Some(make_edge(pattern, 0.8)); + } + + // 7. Persistence (DB writes) — with DB-like receiver guard + if let Some(pattern) = match_persistence(callee) { + return Some(make_edge(pattern, confidence_for_db_pattern(callee))); + } + + // 8. Database reads — with DB-like receiver guard + if let Some(pattern) = match_db_read(callee) { + return Some(make_edge(pattern, confidence_for_db_pattern(callee))); + } + + None +} + +/// Check if a callee is a standard library collection/utility method (not a DB operation). +fn is_collection_method(callee: &str) -> bool { + if let Some(method) = callee.split('.').last() { + match method { + // Array/list methods + "push" | "pop" | "shift" | "unshift" | "splice" | "slice" | "concat" | "join" + | "reverse" | "sort" | "fill" | "copyWithin" | "flat" | "flatMap" | "map" + | "filter" | "reduce" | "forEach" | "some" | "every" | "includes" | "indexOf" + | "find" | "findIndex" + // Python list/set + | "append" | "extend" | "clear" | "copy" | "items" | "len" + // Object/Map/Set methods + | "keys" | "values" | "entries" | "toString" | "toLocaleString" | "has" | "add" + // JSON/utility + | "parse" | "stringify" | "assign" | "from" | "resolve" | "reject" + | "now" | "round" | "floor" | "ceil" | "abs" | "min" | "max" + | "charAt" | "charCodeAt" | "trim" | "split" | "replace" | "match" + | "startsWith" | "endsWith" | "padStart" | "padEnd" | "repeat" + | "toLowerCase" | "toUpperCase" => { + return true; + } + _ => {} + } + } + + // Known non-DB full callee patterns (HashSet lookup) + if non_db_callee_set().contains(callee) { + return true; + } + + false +} + +fn match_persistence(callee: &str) -> Option { + // Must be a method call (has a dot) with a DB-like receiver + if let Some(method) = callee.rsplit('.').next() { + if callee.contains('.') + && db_write_suffix_set().contains(method) + && has_db_like_receiver(callee) + { + return Some(FlowPattern::Persistence); + } + } + + // SQL keywords in the callee string (single-pass Aho-Corasick) + let lower = callee.to_lowercase(); + if sql_write_automaton().is_match(&lower) { + return Some(FlowPattern::Persistence); + } + + None +} + +fn match_db_read(callee: &str) -> Option { + // Must be a method call with a DB-like receiver + if let Some(method) = callee.rsplit('.').next() { + if callee.contains('.') + && db_read_suffix_set().contains(method) + && has_db_like_receiver(callee) + { + return Some(FlowPattern::DatabaseRead); + } + } + + // SQL keywords (single-pass Aho-Corasick) + let lower = callee.to_lowercase(); + if sql_read_automaton().is_match(&lower) { + return Some(FlowPattern::DatabaseRead); + } + + None +} + +/// Check if the receiver (part before the last dot) looks like a database/ORM object. +/// +/// Returns true for receivers containing DB-related keywords like "db", "repo", +/// "model", "store", "collection", "prisma", "session", etc. +/// Returns false for single-letter variables, known non-DB names, and stdlib objects. +fn has_db_like_receiver(callee: &str) -> bool { + // Get the receiver (everything before the last method) + let parts: Vec<&str> = callee.rsplitn(2, '.').collect(); + let receiver = if parts.len() == 2 { + parts[1] + } else { + return false; + }; + let lower = receiver.to_lowercase(); + + // Skip single-letter variable names (too ambiguous) + if receiver.len() <= 1 { + return false; + } + + // Skip known non-DB receivers (HashSet lookup) + if non_db_receiver_set().contains(lower.as_str()) { + return false; + } + + // Positive signal: receiver contains DB-related keywords (Aho-Corasick single-pass) + if db_keyword_automaton().is_match(&lower) { + return true; + } + + // Also match if it looks like a specific ORM method chain (e.g., prisma.user) + let first_part = callee.split('.').next().unwrap_or(""); + let first_lower = first_part.to_lowercase(); + if db_keyword_automaton().is_match(&first_lower) { + return true; + } + + // For multi-part receivers like "prisma.user", check the first part + if receiver.contains('.') { + let root = receiver.split('.').next().unwrap_or(""); + let root_lower = root.to_lowercase(); + if db_keyword_automaton().is_match(&root_lower) { + return true; + } + } + + false +} + +fn match_event_emission(callee: &str) -> Option { + if let Some(method) = callee.rsplit('.').next() { + if callee.contains('.') && event_emit_suffix_set().contains(method) { + return Some(FlowPattern::EventEmission); + } + } + None +} + +fn match_event_handling(callee: &str) -> Option { + if let Some(method) = callee.rsplit('.').next() { + if callee.contains('.') && event_handle_suffix_set().contains(method) { + return Some(FlowPattern::EventHandling); + } + } + None +} + +fn match_config_read(callee: &str) -> Option { + // Fast check: process.env and os.environ are the most common config patterns + if callee.starts_with("process.env") || callee.starts_with("os.environ") { + return Some(FlowPattern::ConfigRead); + } + + for &pattern in CONFIG_PATTERNS { + // Match exact, dot-prefix (member access), or bracket-prefix + if callee == pattern + || callee.starts_with(pattern) + && callee + .as_bytes() + .get(pattern.len()) + .map_or(true, |&b| b == b'.' || b == b'[') + { + return Some(FlowPattern::ConfigRead); + } + } + + None +} + +fn match_http_call(callee: &str) -> Option { + for &pattern in HTTP_CALL_PATTERNS { + if callee == pattern + || callee.starts_with(pattern) && callee.as_bytes().get(pattern.len()) == Some(&b'.') + { + return Some(FlowPattern::HttpCall); + } + } + None +} + +fn match_logging(callee: &str) -> Option { + if log_pattern_set().contains(callee) { + Some(FlowPattern::Logging) + } else { + None + } +} + +/// Assign confidence based on how specific the DB pattern is. +fn confidence_for_db_pattern(callee: &str) -> f64 { + let lower = callee.to_lowercase(); + + // ORM-specific method chains are high confidence (Aho-Corasick single-pass) + if orm_automaton().is_match(&lower) { + return 0.95; + } + + // Methods with "db" or "repo" or "repository" in the receiver are high confidence + let receiver = callee.split('.').next().unwrap_or(""); + let lower_receiver = receiver.to_lowercase(); + if confidence_receiver_automaton().is_match(&lower_receiver) { + return 0.9; + } + + // SQL keywords are high confidence (Aho-Corasick single-pass) + if sql_write_automaton().is_match(&lower) || sql_read_automaton().is_match(&lower) { + return 0.95; + } + + // Generic methods like `.save()` on unknown receivers are medium confidence + 0.7 +} + +/// Trace call chains to a configurable depth, collecting all reachable symbols. +/// +/// Given a starting symbol, follows call edges in the graph up to `max_depth` hops. +/// Returns the list of symbol IDs reachable from the start, in BFS order. +pub fn trace_call_chain(graph: &SymbolGraph, start: &str, max_depth: usize) -> Vec { + use std::collections::VecDeque; + + let start_idx = match graph.get_node(start) { + Some(idx) => idx, + None => return vec![], + }; + + let mut visited: HashSet = HashSet::new(); + let mut queue: VecDeque<(petgraph::graph::NodeIndex, usize)> = VecDeque::new(); + let mut result = Vec::new(); + + visited.insert(start_idx); + queue.push_back((start_idx, 0)); + + while let Some((current, depth)) = queue.pop_front() { + if depth > 0 { + result.push(graph.graph[current].id.clone()); + } + + if depth >= max_depth { + continue; + } + + // Follow outgoing Calls edges + for neighbor in graph + .graph + .neighbors_directed(current, petgraph::Direction::Outgoing) + { + if visited.insert(neighbor) { + // Check if the edge is a Calls edge + if let Some(edge) = graph.graph.find_edge(current, neighbor) { + if graph.graph[edge].edge_type == EdgeType::Calls { + queue.push_back((neighbor, depth + 1)); + } + } + } + } + } + + result +} + +// --------------------------------------------------------------------------- +// Full data flow tracing +// --------------------------------------------------------------------------- + +/// Build data flow edges from extracted data flow info for a single file. +/// +/// Connects variable assignments from calls to subsequent calls that use those +/// variables as arguments within the same function scope. +/// +/// Example: in `function f() { const x = funcA(); funcB(x); }`, +/// produces `DataFlowEdge { producer: "funcA", consumer: "funcB", via: "x" }`. +pub fn build_data_flow_edges( + info: &crate::ast::DataFlowInfo, + file_path: &str, +) -> Vec { + use std::collections::HashMap; + + let mut edges = Vec::new(); + + // Group assignments by containing function for scope-aware matching. + let mut assignments_by_scope: HashMap, Vec<&crate::ast::VarCallAssignment>> = + HashMap::new(); + for assignment in &info.assignments { + let key = assignment.containing_function.as_deref(); + assignments_by_scope + .entry(key) + .or_default() + .push(assignment); + } + + // For each call, check if any argument matches a variable assigned from another call. + for call in &info.calls_with_args { + let scope_key = call.containing_function.as_deref(); + if let Some(scope_assignments) = assignments_by_scope.get(&scope_key) { + for arg in &call.arguments { + for assignment in scope_assignments { + if assignment.variable == *arg && assignment.callee != call.callee { + let containing = match &call.containing_function { + Some(f) => format!("{}::{}", file_path, f), + None => file_path.to_string(), + }; + edges.push(DataFlowEdge { + producer: assignment.callee.clone(), + consumer: call.callee.clone(), + via: arg.clone(), + containing_function: containing, + file: file_path.to_string(), + line: call.line, + }); + } + } + } + } + } + + edges +} + +/// Trace data flow across all files, producing edges that show how data moves +/// through variable assignments and function calls. +/// +/// Requires source code for each file (re-parses with tree-sitter for finer-grained +/// extraction of variable assignments and call arguments). +pub fn trace_data_flow(files_with_source: &[(&str, &str)]) -> Vec { + let mut all_edges = Vec::new(); + + for &(path, source) in files_with_source { + match crate::ast::extract_data_flow_info(path, source) { + Ok(info) => { + let edges = build_data_flow_edges(&info, path); + all_edges.extend(edges); + } + Err(_) => continue, + } + } + + all_edges +} + +// --------------------------------------------------------------------------- +// IR-based public API +// --------------------------------------------------------------------------- + +/// Analyze data flow patterns from IR files (declarative query engine / IR path). +/// +/// Delegates to the existing heuristic analysis via ParsedFile conversion. +/// The heuristic pattern matching operates on the same call site data available +/// in both representations. +pub fn analyze_data_flow_ir(files: &[IrFile], config: &FlowConfig) -> FlowAnalysis { + let parsed: Vec = files.iter().map(|f| f.to_parsed_file()).collect(); + analyze_data_flow(&parsed, config) +} + +/// Detect frameworks from IR files' import patterns. +pub fn detect_frameworks_ir(files: &[IrFile]) -> Vec { + let parsed: Vec = files.iter().map(|f| f.to_parsed_file()).collect(); + detect_frameworks(&parsed) +} + +/// Build data flow edges directly from an IR file, without re-parsing source code. +/// +/// This is the key improvement over the ParsedFile path: `IrFile` already contains +/// `assignments` (variable = call()) and `call_expressions` with arguments, so we +/// can trace producer → consumer edges without needing the original source text. +/// +/// Example: given `const x = funcA(); funcB(x);` in the IR: +/// - `assignments` contains: pattern=x, value=Call(funcA), scope=f +/// - `call_expressions` contains: callee=funcB, args=["x"], scope=f +/// - Produces: `DataFlowEdge { producer: "funcA", consumer: "funcB", via: "x" }` +pub fn build_data_flow_edges_from_ir(file: &IrFile) -> Vec { + let mut edges = Vec::new(); + + // Group assignments by containing function for scope-aware matching. + // Each entry: (variable_name, callee_name, line) + let mut assignments_by_scope: HashMap, Vec<(&str, &str, usize)>> = HashMap::new(); + + for assignment in &file.assignments { + if let (Some(var), Some(callee)) = ( + assignment.pattern.as_identifier(), + assignment.value.callee_name(), + ) { + let scope = assignment.containing_function.as_deref(); + assignments_by_scope.entry(scope).or_default().push(( + var, + callee, + assignment.span.start_line, + )); + } + } + + // For each call with arguments, check if any argument matches a variable + // assigned from another call within the same scope. + for call in &file.call_expressions { + if call.arguments.is_empty() { + continue; + } + let scope = call.containing_function.as_deref(); + if let Some(scope_assignments) = assignments_by_scope.get(&scope) { + for arg in &call.arguments { + for &(var, producer_callee, _line) in scope_assignments { + if var == arg.as_str() && producer_callee != call.callee { + let containing = match &call.containing_function { + Some(f) => format!("{}::{}", file.path, f), + None => file.path.to_string(), + }; + edges.push(DataFlowEdge { + producer: producer_callee.to_string(), + consumer: call.callee.clone(), + via: arg.clone(), + containing_function: containing, + file: file.path.clone(), + line: call.span.start_line, + }); + } + } + } + } + } + + edges +} + +/// Trace data flow across all IR files, producing edges that show how data moves +/// through variable assignments and function calls. +/// +/// Unlike `trace_data_flow` which requires source code and re-parses with tree-sitter, +/// this version works directly from the IR which already has assignments and call arguments. +pub fn trace_data_flow_ir(files: &[IrFile]) -> Vec { + let mut all_edges = Vec::new(); + for file in files { + let edges = build_data_flow_edges_from_ir(file); + all_edges.extend(edges); + } + all_edges +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + + +#[cfg(test)] +#[allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::print_stdout, + clippy::print_stderr +)] +mod tests; diff --git a/crates/diffcore-core/src/flow.rs b/crates/diffcore-core/src/flow/tests.rs similarity index 62% rename from crates/diffcore-core/src/flow.rs rename to crates/diffcore-core/src/flow/tests.rs index a114f1d..deae858 100644 --- a/crates/diffcore-core/src/flow.rs +++ b/crates/diffcore-core/src/flow/tests.rs @@ -1,1435 +1,3 @@ -//! Data flow tracing and heuristic inference module. -//! -//! Analyzes parsed files to infer additional data flow edges beyond what -//! static import/call analysis can determine. Uses pattern matching on -//! call sites and identifiers to detect: -//! -//! - Database persistence patterns (`.save()`, `.insert()`, `INSERT INTO`) -//! - Database read patterns (`.find()`, `.query()`, `SELECT`) -//! - Event emission (`.emit()`, `.publish()`, `.dispatch()`) -//! - Event handling (`.on()`, `.subscribe()`, `.listen()`) -//! - Configuration reads (`process.env`, `os.environ`) -//! - HTTP outbound calls (`fetch()`, `axios.get()`) -//! - Logging calls (`console.log`, `logger.info`) -//! -//! Also detects frameworks from import patterns. - -use std::collections::{HashMap, HashSet}; -use std::sync::OnceLock; - -use aho_corasick::AhoCorasick; - -use crate::ast::{CallSite, ParsedFile}; -use crate::graph::{GraphEdge, SymbolGraph}; -use crate::ir::IrFile; -use crate::types::EdgeType; - -/// A data flow pattern detected via heuristic matching. -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub enum FlowPattern { - /// Database write: `.save()`, `.insert()`, `.create()`, `.update()`, `.delete()`, `INSERT INTO` - Persistence, - /// Database read: `.find()`, `.query()`, `.select()`, `.findOne()`, `SELECT` - DatabaseRead, - /// Event emission: `.emit()`, `.publish()`, `.send()`, `.dispatch()` - EventEmission, - /// Event handling: `.on()`, `.subscribe()`, `.listen()`, `.addEventListener()` - EventHandling, - /// Configuration read: `process.env`, `os.environ`, `config.get()` - ConfigRead, - /// HTTP outbound call: `fetch()`, `axios.get()`, `requests.get()` - HttpCall, - /// Logging: `console.log`, `logger.info`, `logging.debug` - Logging, -} - -/// A heuristic edge inferred from code patterns. -#[derive(Debug, Clone, PartialEq)] -pub struct HeuristicEdge { - /// Symbol id of the function containing the pattern (e.g. `file.ts::handler`) - pub from_symbol: String, - /// The file containing the pattern - pub file: String, - /// The detected flow pattern - pub pattern: FlowPattern, - /// Confidence score [0.0, 1.0] - pub confidence: f64, - /// The callee string that matched (evidence) - pub evidence: String, - /// Line number where the pattern was detected - pub line: usize, -} - -/// Result of data flow analysis across all files. -#[derive(Debug, Clone)] -pub struct FlowAnalysis { - /// Heuristic edges inferred from code patterns. - pub heuristic_edges: Vec, - /// Frameworks detected from import patterns. - pub frameworks_detected: Vec, -} - -/// A data flow edge connecting a producer function to a consumer function -/// through a shared variable within the same function scope. -/// -/// Example: `const x = funcA(); funcB(x)` creates an edge from funcA → funcB via "x". -#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -pub struct DataFlowEdge { - /// Callee of the assignment (the function producing data). - pub producer: String, - /// Callee of the consuming call (the function receiving the data). - pub consumer: String, - /// Variable name connecting the producer to the consumer. - pub via: String, - /// Symbol ID of the function containing both calls. - pub containing_function: String, - /// File path. - pub file: String, - /// Line of the consumer call. - pub line: usize, -} - -/// Configuration for flow analysis. -#[derive(Debug, Clone)] -pub struct FlowConfig { - /// Maximum call chain depth to trace (prevents runaway on cycles). - pub max_depth: usize, -} - -impl Default for FlowConfig { - fn default() -> Self { - Self { max_depth: 10 } - } -} - -// --------------------------------------------------------------------------- -// Heuristic pattern matching rules -// --------------------------------------------------------------------------- - -/// Persistence (database write) patterns. -const DB_WRITE_METHODS: &[&str] = &[ - ".save", - ".insert", - ".create", - ".update", - ".delete", - ".remove", - ".upsert", - ".bulkCreate", - ".bulkInsert", - ".insertMany", - ".updateMany", - ".deleteMany", - ".findAndUpdate", - ".findOneAndUpdate", - ".findOneAndDelete", - ".findOneAndRemove", - ".persist", - ".flush", - ".execute", - ".run", -]; - -/// SQL write keywords (case-insensitive matching on string literals). -const SQL_WRITE_KEYWORDS: &[&str] = &[ - "INSERT INTO", - "UPDATE ", - "DELETE FROM", - "DROP TABLE", - "ALTER TABLE", - "CREATE TABLE", - "TRUNCATE", -]; - -/// Database read patterns. -const DB_READ_METHODS: &[&str] = &[ - ".find", - ".findOne", - ".findById", - ".findAll", - ".findMany", - ".findFirst", - ".findUnique", - ".query", - ".select", - ".get", - ".fetch", - ".count", - ".aggregate", - ".groupBy", - ".where", -]; - -/// SQL read keywords. -const SQL_READ_KEYWORDS: &[&str] = &["SELECT ", "SELECT\n"]; - -/// Event emission patterns. -const EVENT_EMIT_METHODS: &[&str] = &[ - ".emit", - ".publish", - ".send", - ".dispatch", - ".fire", - ".trigger", - ".broadcast", - ".notify", - ".produce", - ".enqueue", -]; - -/// Event handling patterns. -const EVENT_HANDLE_METHODS: &[&str] = &[ - ".on", - ".subscribe", - ".listen", - ".addEventListener", - ".addListener", - ".handle", - ".consume", - ".onMessage", - ".onEvent", -]; - -/// Config read patterns. -const CONFIG_PATTERNS: &[&str] = &[ - "process.env", - "os.environ", - "os.getenv", - "config.get", - "config.set", - "dotenv", - "Deno.env", -]; - -/// HTTP outbound call patterns. -const HTTP_CALL_PATTERNS: &[&str] = &[ - "fetch", - "axios.get", - "axios.post", - "axios.put", - "axios.delete", - "axios.patch", - "axios.request", - "requests.get", - "requests.post", - "requests.put", - "requests.delete", - "requests.patch", - "http.get", - "http.post", - "http.request", - "urllib.request", - "httpx.get", - "httpx.post", -]; - -/// Logging patterns. -const LOG_PATTERNS: &[&str] = &[ - "console.log", - "console.error", - "console.warn", - "console.info", - "console.debug", - "console.trace", - "logger.info", - "logger.error", - "logger.warn", - "logger.debug", - "logger.trace", - "logger.fatal", - "logging.info", - "logging.error", - "logging.warning", - "logging.debug", - "logging.critical", - "log.info", - "log.error", - "log.warn", - "log.debug", -]; - -// --------------------------------------------------------------------------- -// Framework detection -// --------------------------------------------------------------------------- - -/// Known framework import sources and their display names. -const FRAMEWORK_IMPORTS: &[(&str, &str)] = &[ - // JavaScript/TypeScript - ("express", "Express"), - ("fastify", "Fastify"), - ("next", "Next.js"), - ("next/", "Next.js"), - ("react", "React"), - ("react-dom", "React"), - ("vue", "Vue"), - ("@angular/core", "Angular"), - ("svelte", "Svelte"), - ("@nestjs/common", "NestJS"), - ("@nestjs/core", "NestJS"), - ("hono", "Hono"), - ("koa", "Koa"), - ("@effect/", "Effect.ts"), - ("effect", "Effect.ts"), - ("prisma", "Prisma"), - ("@prisma/client", "Prisma"), - ("typeorm", "TypeORM"), - ("sequelize", "Sequelize"), - ("mongoose", "Mongoose"), - ("drizzle-orm", "Drizzle"), - ("@trpc/server", "tRPC"), - ("@trpc/client", "tRPC"), - ("graphql", "GraphQL"), - ("@apollo/server", "Apollo"), - ("@apollo/client", "Apollo"), - ("tailwindcss", "Tailwind CSS"), - ("redux", "Redux"), - ("@reduxjs/toolkit", "Redux"), - ("zustand", "Zustand"), - ("zod", "Zod"), - ("vitest", "Vitest"), - ("jest", "Jest"), - ("@effect/vitest", "Effect.ts"), - // Python - ("fastapi", "FastAPI"), - ("flask", "Flask"), - ("django", "Django"), - ("sqlalchemy", "SQLAlchemy"), - ("pydantic", "Pydantic"), - ("celery", "Celery"), - ("pytest", "pytest"), - ("alembic", "Alembic"), - ("tortoise", "Tortoise ORM"), - ("starlette", "Starlette"), - ("aiohttp", "aiohttp"), - ("httpx", "httpx"), - ("uvicorn", "Uvicorn"), - // Go - ("net/http", "Go net/http"), - ("github.com/gin-gonic/gin", "Gin"), - ("github.com/labstack/echo", "Echo"), - ("github.com/go-chi/chi", "Chi"), - ("github.com/gofiber/fiber", "Fiber"), - ("github.com/gorilla/mux", "Gorilla Mux"), - ("google.golang.org/grpc", "gRPC"), - ("github.com/spf13/cobra", "Cobra"), - ("github.com/spf13/viper", "Viper"), - ("gorm.io/gorm", "GORM"), - ("github.com/jmoiron/sqlx", "sqlx"), - ("database/sql", "Go database/sql"), - ("github.com/go-playground/validator", "Go Validator"), - ("github.com/stretchr/testify", "Testify"), - // Rust - ("actix_web", "Actix-web"), - ("actix-web", "Actix-web"), - ("axum", "Axum"), - ("rocket", "Rocket"), - ("warp", "Warp"), - ("hyper", "Hyper"), - ("tokio", "Tokio"), - ("diesel", "Diesel"), - ("sqlx", "SQLx"), - ("sea_orm", "SeaORM"), - ("sea-orm", "SeaORM"), - ("clap", "Clap"), - ("tauri", "Tauri"), - ("serde", "Serde"), - ("tower", "Tower"), - ("tonic", "Tonic"), - ("tracing", "Tracing"), - // Java - ("org.springframework.boot", "Spring Boot"), - ("org.springframework.web", "Spring MVC"), - ("org.springframework.data", "Spring Data"), - ("org.springframework.stereotype", "Spring Boot"), - ("org.springframework.beans", "Spring Boot"), - ("org.springframework.context", "Spring Boot"), - ("org.springframework.security", "Spring Security"), - ("jakarta.persistence", "JPA"), - ("javax.persistence", "JPA"), - ("jakarta.ws.rs", "JAX-RS"), - ("javax.ws.rs", "JAX-RS"), - ("jakarta.servlet", "Servlet"), - ("javax.servlet", "Servlet"), - ("org.hibernate", "Hibernate"), - ("org.junit", "JUnit"), - ("org.junit.jupiter", "JUnit 5"), - ("org.mockito", "Mockito"), - ("com.google.inject", "Guice"), - ("io.micronaut", "Micronaut"), - ("io.quarkus", "Quarkus"), - ("org.apache.maven", "Maven"), - // C# - ("Microsoft.AspNetCore", "ASP.NET Core"), - ("Microsoft.AspNetCore.Mvc", "ASP.NET Core MVC"), - ("Microsoft.AspNetCore.Builder", "ASP.NET Core"), - ("Microsoft.AspNetCore.Http", "ASP.NET Core"), - ("Microsoft.AspNetCore.Routing", "ASP.NET Core"), - ("Microsoft.AspNetCore.Authorization", "ASP.NET Core"), - ("Microsoft.AspNetCore.Identity", "ASP.NET Identity"), - ("Microsoft.AspNetCore.SignalR", "SignalR"), - ("Microsoft.EntityFrameworkCore", "Entity Framework Core"), - ("Microsoft.Extensions.DependencyInjection", "ASP.NET Core"), - ("Microsoft.Extensions.Logging", "ASP.NET Core"), - ("Microsoft.Extensions.Configuration", "ASP.NET Core"), - ("System.Linq", "LINQ"), - ("Xunit", "xUnit"), - ("NUnit", "NUnit"), - ("Microsoft.VisualStudio.TestTools", "MSTest"), - ("Moq", "Moq"), - ("FluentAssertions", "FluentAssertions"), - ("MediatR", "MediatR"), - ("AutoMapper", "AutoMapper"), - ("Newtonsoft.Json", "Newtonsoft.Json"), - ("System.Text.Json", "System.Text.Json"), - ("Dapper", "Dapper"), - ("Microsoft.AspNetCore.Components", "Blazor"), - // PHP (use namespace segments without trailing backslash; - // the match logic adds \ as a separator) - ("Illuminate", "Laravel"), - ("Illuminate\\Http", "Laravel"), - ("Illuminate\\Routing", "Laravel"), - ("Illuminate\\Database", "Laravel Eloquent"), - ("Illuminate\\Queue", "Laravel Queue"), - ("Illuminate\\Console", "Laravel Artisan"), - ("Illuminate\\Support", "Laravel"), - ("Laravel", "Laravel"), - ("Symfony", "Symfony"), - ("Symfony\\Component\\HttpFoundation", "Symfony"), - ("Symfony\\Component\\Console", "Symfony Console"), - ("Symfony\\Component\\Routing", "Symfony"), - ("Doctrine\\ORM", "Doctrine ORM"), - ("Doctrine\\DBAL", "Doctrine DBAL"), - ("Slim", "Slim"), - ("GuzzleHttp", "Guzzle"), - ("Monolog", "Monolog"), - ("PHPUnit", "PHPUnit"), - ("Livewire", "Livewire"), - ("Inertia", "Inertia"), - // Ruby - ("rails", "Rails"), - ("action_controller", "Rails"), - ("active_record", "Rails ActiveRecord"), - ("active_support", "Rails"), - ("action_view", "Rails"), - ("action_mailer", "Rails"), - ("active_job", "Rails ActiveJob"), - ("active_storage", "Rails"), - ("action_cable", "Rails ActionCable"), - ("sinatra", "Sinatra"), - ("rack", "Rack"), - ("grape", "Grape"), - ("hanami", "Hanami"), - ("rspec", "RSpec"), - ("minitest", "Minitest"), - ("sidekiq", "Sidekiq"), - ("devise", "Devise"), - ("pundit", "Pundit"), - ("cancancan", "CanCanCan"), - ("sequel", "Sequel"), - ("mongoid", "Mongoid"), - ("dry-rb", "dry-rb"), - ("roda", "Roda"), - ("puma", "Puma"), - ("faraday", "Faraday"), - ("httparty", "HTTParty"), - ("factory_bot", "FactoryBot"), - ("rubocop", "RuboCop"), - // Kotlin - ("io.ktor", "Ktor"), - ("io.ktor.server", "Ktor"), - ("io.ktor.client", "Ktor Client"), - ("io.ktor.routing", "Ktor"), - ("org.springframework", "Spring Boot"), - ("org.springframework.boot", "Spring Boot"), - ("org.springframework.web", "Spring MVC"), - ("org.springframework.data", "Spring Data"), - ("org.jetbrains.exposed", "Exposed"), - ("org.jetbrains.compose", "Jetpack Compose"), - ("androidx.compose", "Jetpack Compose"), - ("kotlinx.coroutines", "Kotlin Coroutines"), - ("kotlinx.serialization", "Kotlin Serialization"), - ("org.junit", "JUnit"), - ("kotlin.test", "Kotlin Test"), - ("io.kotest", "Kotest"), - ("io.mockk", "MockK"), - ("org.koin", "Koin"), - ("com.squareup.retrofit2", "Retrofit"), - ("com.squareup.okhttp3", "OkHttp"), - ("io.arrow-kt", "Arrow"), - ("com.google.dagger", "Dagger/Hilt"), - // Swift - ("SwiftUI", "SwiftUI"), - ("UIKit", "UIKit"), - ("Foundation", "Foundation"), - ("Vapor", "Vapor"), - ("Fluent", "Fluent"), - ("FluentPostgresDriver", "Fluent"), - ("FluentSQLiteDriver", "Fluent"), - ("FluentMySQLDriver", "Fluent"), - ("XCTest", "XCTest"), - ("Combine", "Combine"), - ("CoreData", "Core Data"), - ("SwiftData", "SwiftData"), - ("Alamofire", "Alamofire"), - ("Kitura", "Kitura"), - ("Perfect", "Perfect"), - ("Hummingbird", "Hummingbird"), - ("Observation", "Observation"), - ("SwiftNIO", "SwiftNIO"), - ("GRDB", "GRDB"), - ("SnapKit", "SnapKit"), - ("Quick", "Quick"), - ("Nimble", "Nimble"), - // C - ("stdio.h", "C stdio"), - ("stdlib.h", "C stdlib"), - ("string.h", "C string"), - ("pthread.h", "POSIX threads"), - ("unistd.h", "POSIX"), - ("curl/curl.h", "libcurl"), - ("sqlite3.h", "SQLite3"), - ("mysql.h", "MySQL C API"), - ("libpq-fe.h", "PostgreSQL libpq"), - ("openssl/ssl.h", "OpenSSL"), - ("jansson.h", "Jansson"), - ("cjson/cJSON.h", "cJSON"), - ("check.h", "Check"), - ("cmocka.h", "CMocka"), - // C++ - ("iostream", "C++ STL"), - ("vector", "C++ STL"), - ("memory", "C++ STL"), - ("string", "C++ STL"), - ("algorithm", "C++ STL"), - ("thread", "C++ STL"), - ("mutex", "C++ STL"), - ("boost/asio.hpp", "Boost.Asio"), - ("boost/beast.hpp", "Boost.Beast"), - ("boost/", "Boost"), - ("crow.h", "Crow"), - ("crow/crow.h", "Crow"), - ("httplib.h", "cpp-httplib"), - ("pistache/endpoint.h", "Pistache"), - ("pistache/", "Pistache"), - ("drogon/drogon.h", "Drogon"), - ("drogon/", "Drogon"), - ("cpprest/", "C++ REST SDK"), - ("nlohmann/json.hpp", "nlohmann/json"), - ("sqlite3.h", "SQLite3"), - ("pqxx/pqxx", "libpqxx"), - ("mysql++.h", "MySQL++"), - ("gtest/gtest.h", "Google Test"), - ("gmock/gmock.h", "Google Mock"), - ("catch2/catch.hpp", "Catch2"), - ("catch2/", "Catch2"), - ("doctest/doctest.h", "doctest"), - ("fmt/format.h", "fmt"), - ("spdlog/spdlog.h", "spdlog"), - ("grpcpp/grpcpp.h", "gRPC C++"), - ("grpc++/", "gRPC C++"), - ("absl/", "Abseil"), - ("folly/", "Folly"), - ("Qt", "Qt"), - ("QApplication", "Qt"), - ("QWidget", "Qt"), - // Scala - ("play.api.mvc", "Play Framework"), - ("play.mvc", "Play Framework"), - ("akka.actor", "Akka"), - ("akka.stream", "Akka Streams"), - ("akka.http", "Akka HTTP"), - ("scala.concurrent", "Scala Concurrency"), - ("org.scalatest", "ScalaTest"), - ("org.specs2", "Specs2"), - ("org.scalatestplus", "ScalaTestPlus"), - ("org.mockito", "Mockito Scala"), - ("slick", "Slick"), - ("doobie", "Doobie"), - ("quill", "Quill"), - ("scalikejdbc", "ScalikeJDBC"), - ("circe", "Circe"), - ("spray", "Spray"), - ("org.http4s", "http4s"), - ("cats", "Cats"), - ("cats.effect", "Cats Effect"), - ("zio", "ZIO"), - ("monix", "Monix"), - ("fs2", "FS2"), - ("shapeless", "Shapeless"), - ("com.typesafe.config", "Typesafe Config"), - ("io.getquill", "Quill"), - ("sttp", "sttp"), - ("tapir", "Tapir"), -]; - -// --------------------------------------------------------------------------- -// Pre-compiled pattern matchers (built once, reused across all files) -// --------------------------------------------------------------------------- - -/// Suffix set for DB write methods (method name after last dot, e.g. "save"). -fn db_write_suffix_set() -> &'static HashSet<&'static str> { - static SET: OnceLock> = OnceLock::new(); - SET.get_or_init(|| { - DB_WRITE_METHODS - .iter() - .map(|s| s.trim_start_matches('.')) - .collect() - }) -} - -/// Suffix set for DB read methods. -fn db_read_suffix_set() -> &'static HashSet<&'static str> { - static SET: OnceLock> = OnceLock::new(); - SET.get_or_init(|| { - DB_READ_METHODS - .iter() - .map(|s| s.trim_start_matches('.')) - .collect() - }) -} - -/// Suffix set for event emission methods. -fn event_emit_suffix_set() -> &'static HashSet<&'static str> { - static SET: OnceLock> = OnceLock::new(); - SET.get_or_init(|| { - EVENT_EMIT_METHODS - .iter() - .map(|s| s.trim_start_matches('.')) - .collect() - }) -} - -/// Suffix set for event handling methods. -fn event_handle_suffix_set() -> &'static HashSet<&'static str> { - static SET: OnceLock> = OnceLock::new(); - SET.get_or_init(|| { - EVENT_HANDLE_METHODS - .iter() - .map(|s| s.trim_start_matches('.')) - .collect() - }) -} - -/// Exact-match set for logging patterns. -fn log_pattern_set() -> &'static HashSet<&'static str> { - static SET: OnceLock> = OnceLock::new(); - SET.get_or_init(|| LOG_PATTERNS.iter().copied().collect()) -} - -/// Exact-match set for known non-DB callees. -fn non_db_callee_set() -> &'static HashSet<&'static str> { - static SET: OnceLock> = OnceLock::new(); - SET.get_or_init(|| { - [ - "JSON.parse", - "JSON.stringify", - "Object.create", - "Object.assign", - "Array.from", - "Promise.resolve", - "Promise.reject", - "Date.now", - "Math.round", - "Math.floor", - "Math.ceil", - "Math.abs", - "Math.min", - "Math.max", - ] - .into_iter() - .collect() - }) -} - -/// Exact-match set for known non-DB receivers (lowercased). -fn non_db_receiver_set() -> &'static HashSet<&'static str> { - static SET: OnceLock> = OnceLock::new(); - SET.get_or_init(|| { - [ - "array", - "map", - "set", - "object", - "string", - "number", - "promise", - "json", - "math", - "date", - "regexp", - "cache", - "localstorage", - "sessionstorage", - "window", - "document", - "navigator", - "console", - "process", - "os", - "path", - "fs", - "http", - "https", - "url", - "buffer", - "stream", - "crypto", - "util", - "events", - "child_process", - "cluster", - "net", - "tls", - "dns", - "axios", - "requests", - "fetch", - "httpx", - "urllib", - "list", - "dict", - "tuple", - "frozenset", - "deque", - "defaultdict", - "items", - "result", - "results", - "data", - "response", - "request", - "config", - "env", - "settings", - "options", - "args", - "params", - "logger", - "log", - "logging", - "console", - ] - .into_iter() - .collect() - }) -} - -/// Aho-Corasick automaton for DB-keyword substring matching in receivers. -fn db_keyword_automaton() -> &'static AhoCorasick { - static AC: OnceLock = OnceLock::new(); - AC.get_or_init(|| { - AhoCorasick::new([ - "db", - "database", - "repo", - "repository", - "model", - "store", - "dao", - "collection", - "prisma", - "sequelize", - "typeorm", - "mongoose", - "drizzle", - "session", - "connection", - "pool", - "client", - "table", - "entity", - "schema", - "migration", - "knex", - "query", - "sql", - ]) - .expect("valid patterns") - }) -} - -/// Aho-Corasick automaton for ORM-specific names in confidence scoring. -fn orm_automaton() -> &'static AhoCorasick { - static AC: OnceLock = OnceLock::new(); - AC.get_or_init(|| { - AhoCorasick::new([ - "prisma", - "sequelize", - "typeorm", - "mongoose", - "sqlalchemy", - "drizzle", - ]) - .expect("valid patterns") - }) -} - -/// Aho-Corasick automaton for high-confidence receiver keywords in confidence scoring. -fn confidence_receiver_automaton() -> &'static AhoCorasick { - static AC: OnceLock = OnceLock::new(); - AC.get_or_init(|| { - AhoCorasick::new(["db", "repo", "model", "store", "dao", "collection"]) - .expect("valid patterns") - }) -} - -/// Aho-Corasick automaton for SQL write keywords (lowercased). -fn sql_write_automaton() -> &'static AhoCorasick { - static AC: OnceLock = OnceLock::new(); - AC.get_or_init(|| { - let patterns: Vec = SQL_WRITE_KEYWORDS - .iter() - .map(|k| k.to_lowercase()) - .collect(); - AhoCorasick::new(&patterns).expect("valid patterns") - }) -} - -/// Aho-Corasick automaton for SQL read keywords (lowercased). -fn sql_read_automaton() -> &'static AhoCorasick { - static AC: OnceLock = OnceLock::new(); - AC.get_or_init(|| { - let patterns: Vec = SQL_READ_KEYWORDS.iter().map(|k| k.to_lowercase()).collect(); - AhoCorasick::new(&patterns).expect("valid patterns") - }) -} - -// --------------------------------------------------------------------------- -// Public API -// --------------------------------------------------------------------------- - -/// Analyze data flow patterns across all parsed files. -/// -/// Scans call sites for heuristic patterns (DB writes, event emission, config reads, etc.) -/// and detects frameworks from import patterns. -pub fn analyze_data_flow(files: &[ParsedFile], _config: &FlowConfig) -> FlowAnalysis { - let mut heuristic_edges = Vec::new(); - - for file in files { - let file_edges = detect_heuristic_patterns(file); - heuristic_edges.extend(file_edges); - } - - let frameworks_detected = detect_frameworks(files); - - FlowAnalysis { - heuristic_edges, - frameworks_detected, - } -} - -/// Enrich an existing symbol graph with heuristic edges. -/// -/// For each heuristic edge, adds the appropriate edge type (Writes, Reads, Emits, Handles) -/// from the containing symbol to the file's module node (since the target is typically -/// an external resource like a database or event bus). -pub fn enrich_graph(graph: &mut SymbolGraph, analysis: &FlowAnalysis) { - for edge in &analysis.heuristic_edges { - let edge_type = match edge.pattern { - FlowPattern::Persistence => EdgeType::Writes, - FlowPattern::DatabaseRead => EdgeType::Reads, - FlowPattern::EventEmission => EdgeType::Emits, - FlowPattern::EventHandling => EdgeType::Handles, - FlowPattern::ConfigRead => EdgeType::Reads, - FlowPattern::HttpCall => EdgeType::Reads, - FlowPattern::Logging => continue, // Don't add graph edges for logging - }; - - let from_idx = match graph.get_node(&edge.from_symbol) { - Some(idx) => idx, - None => { - // Try the file-level module node as fallback - match graph.get_node(&edge.file) { - Some(idx) => idx, - None => continue, - } - } - }; - - // For heuristic edges, we connect to the file's module node since the actual - // target (database, event bus, etc.) is external and not in our graph. - let to_idx = match graph.get_node(&edge.file) { - Some(idx) => idx, - None => continue, - }; - - // Don't add self-edges - if from_idx == to_idx { - continue; - } - - graph.add_edge(from_idx, to_idx, GraphEdge { edge_type }); - } -} - -/// Detect frameworks from import patterns across all files. -pub fn detect_frameworks(files: &[ParsedFile]) -> Vec { - let mut frameworks: HashSet = HashSet::new(); - - for file in files { - for import in &file.imports { - let source = &import.source; - for &(pattern, name) in FRAMEWORK_IMPORTS { - // Match exact, or prefixed by separator: slash (JS/TS), - // dot (Python), :: (Rust), or backslash (PHP namespaces) - if source == pattern - || source.starts_with(pattern) - && source.as_bytes().get(pattern.len()).map_or(false, |&b| { - b == b'/' || b == b'.' || b == b':' || b == b'\\' - }) - { - frameworks.insert(name.to_string()); - } - } - } - } - - // Also detect Next.js from file structure conventions - for file in files { - let path = &file.path; - if path.contains("pages/") || path.contains("app/") { - if path.ends_with("page.tsx") - || path.ends_with("page.ts") - || path.ends_with("page.jsx") - || path.ends_with("page.js") - || path.ends_with("route.ts") - || path.ends_with("route.js") - || path.ends_with("layout.tsx") - || path.ends_with("layout.ts") - { - frameworks.insert("Next.js".to_string()); - } - } - } - - let mut result: Vec = frameworks.into_iter().collect(); - result.sort(); - result -} - -// --------------------------------------------------------------------------- -// Internal: heuristic pattern detection -// --------------------------------------------------------------------------- - -/// Detect heuristic data flow patterns in a single file's call sites. -fn detect_heuristic_patterns(file: &ParsedFile) -> Vec { - let mut edges = Vec::new(); - - for call in &file.call_sites { - if let Some(edge) = classify_call_site(call, &file.path) { - edges.push(edge); - } - } - - edges -} - -/// Classify a single call site into a flow pattern, if any. -/// -/// Pattern matching order is important: more specific patterns are checked first -/// to avoid false positives (e.g., `axios.get` is HTTP, not a DB read). -fn classify_call_site(call: &CallSite, file_path: &str) -> Option { - let callee = &call.callee; - let containing = call - .containing_function - .as_ref() - .map(|f| format!("{}::{}", file_path, f)) - .unwrap_or_else(|| file_path.to_string()); - - let make_edge = |pattern: FlowPattern, confidence: f64| HeuristicEdge { - from_symbol: containing.clone(), - file: file_path.to_string(), - pattern, - confidence, - evidence: callee.clone(), - line: call.line, - }; - - // 1. Logging — most specific, check first to prevent console.log matching elsewhere - if let Some(pattern) = match_logging(callee) { - return Some(make_edge(pattern, 0.95)); - } - - // 2. Config reads — specific patterns like process.env, os.environ - if let Some(pattern) = match_config_read(callee) { - return Some(make_edge(pattern, 0.9)); - } - - // 3. HTTP calls — check before DB reads so axios.get/requests.get match HTTP - if let Some(pattern) = match_http_call(callee) { - return Some(make_edge(pattern, 0.85)); - } - - // 4. Check for collection/stdlib false positives before DB patterns - if is_collection_method(callee) { - return None; - } - - // 5. Event emission - if let Some(pattern) = match_event_emission(callee) { - return Some(make_edge(pattern, 0.8)); - } - - // 6. Event handling - if let Some(pattern) = match_event_handling(callee) { - return Some(make_edge(pattern, 0.8)); - } - - // 7. Persistence (DB writes) — with DB-like receiver guard - if let Some(pattern) = match_persistence(callee) { - return Some(make_edge(pattern, confidence_for_db_pattern(callee))); - } - - // 8. Database reads — with DB-like receiver guard - if let Some(pattern) = match_db_read(callee) { - return Some(make_edge(pattern, confidence_for_db_pattern(callee))); - } - - None -} - -/// Check if a callee is a standard library collection/utility method (not a DB operation). -fn is_collection_method(callee: &str) -> bool { - if let Some(method) = callee.split('.').last() { - match method { - // Array/list methods - "push" | "pop" | "shift" | "unshift" | "splice" | "slice" | "concat" | "join" - | "reverse" | "sort" | "fill" | "copyWithin" | "flat" | "flatMap" | "map" - | "filter" | "reduce" | "forEach" | "some" | "every" | "includes" | "indexOf" - | "find" | "findIndex" - // Python list/set - | "append" | "extend" | "clear" | "copy" | "items" | "len" - // Object/Map/Set methods - | "keys" | "values" | "entries" | "toString" | "toLocaleString" | "has" | "add" - // JSON/utility - | "parse" | "stringify" | "assign" | "from" | "resolve" | "reject" - | "now" | "round" | "floor" | "ceil" | "abs" | "min" | "max" - | "charAt" | "charCodeAt" | "trim" | "split" | "replace" | "match" - | "startsWith" | "endsWith" | "padStart" | "padEnd" | "repeat" - | "toLowerCase" | "toUpperCase" => { - return true; - } - _ => {} - } - } - - // Known non-DB full callee patterns (HashSet lookup) - if non_db_callee_set().contains(callee) { - return true; - } - - false -} - -fn match_persistence(callee: &str) -> Option { - // Must be a method call (has a dot) with a DB-like receiver - if let Some(method) = callee.rsplit('.').next() { - if callee.contains('.') - && db_write_suffix_set().contains(method) - && has_db_like_receiver(callee) - { - return Some(FlowPattern::Persistence); - } - } - - // SQL keywords in the callee string (single-pass Aho-Corasick) - let lower = callee.to_lowercase(); - if sql_write_automaton().is_match(&lower) { - return Some(FlowPattern::Persistence); - } - - None -} - -fn match_db_read(callee: &str) -> Option { - // Must be a method call with a DB-like receiver - if let Some(method) = callee.rsplit('.').next() { - if callee.contains('.') - && db_read_suffix_set().contains(method) - && has_db_like_receiver(callee) - { - return Some(FlowPattern::DatabaseRead); - } - } - - // SQL keywords (single-pass Aho-Corasick) - let lower = callee.to_lowercase(); - if sql_read_automaton().is_match(&lower) { - return Some(FlowPattern::DatabaseRead); - } - - None -} - -/// Check if the receiver (part before the last dot) looks like a database/ORM object. -/// -/// Returns true for receivers containing DB-related keywords like "db", "repo", -/// "model", "store", "collection", "prisma", "session", etc. -/// Returns false for single-letter variables, known non-DB names, and stdlib objects. -fn has_db_like_receiver(callee: &str) -> bool { - // Get the receiver (everything before the last method) - let parts: Vec<&str> = callee.rsplitn(2, '.').collect(); - let receiver = if parts.len() == 2 { - parts[1] - } else { - return false; - }; - let lower = receiver.to_lowercase(); - - // Skip single-letter variable names (too ambiguous) - if receiver.len() <= 1 { - return false; - } - - // Skip known non-DB receivers (HashSet lookup) - if non_db_receiver_set().contains(lower.as_str()) { - return false; - } - - // Positive signal: receiver contains DB-related keywords (Aho-Corasick single-pass) - if db_keyword_automaton().is_match(&lower) { - return true; - } - - // Also match if it looks like a specific ORM method chain (e.g., prisma.user) - let first_part = callee.split('.').next().unwrap_or(""); - let first_lower = first_part.to_lowercase(); - if db_keyword_automaton().is_match(&first_lower) { - return true; - } - - // For multi-part receivers like "prisma.user", check the first part - if receiver.contains('.') { - let root = receiver.split('.').next().unwrap_or(""); - let root_lower = root.to_lowercase(); - if db_keyword_automaton().is_match(&root_lower) { - return true; - } - } - - false -} - -fn match_event_emission(callee: &str) -> Option { - if let Some(method) = callee.rsplit('.').next() { - if callee.contains('.') && event_emit_suffix_set().contains(method) { - return Some(FlowPattern::EventEmission); - } - } - None -} - -fn match_event_handling(callee: &str) -> Option { - if let Some(method) = callee.rsplit('.').next() { - if callee.contains('.') && event_handle_suffix_set().contains(method) { - return Some(FlowPattern::EventHandling); - } - } - None -} - -fn match_config_read(callee: &str) -> Option { - // Fast check: process.env and os.environ are the most common config patterns - if callee.starts_with("process.env") || callee.starts_with("os.environ") { - return Some(FlowPattern::ConfigRead); - } - - for &pattern in CONFIG_PATTERNS { - // Match exact, dot-prefix (member access), or bracket-prefix - if callee == pattern - || callee.starts_with(pattern) - && callee - .as_bytes() - .get(pattern.len()) - .map_or(true, |&b| b == b'.' || b == b'[') - { - return Some(FlowPattern::ConfigRead); - } - } - - None -} - -fn match_http_call(callee: &str) -> Option { - for &pattern in HTTP_CALL_PATTERNS { - if callee == pattern - || callee.starts_with(pattern) && callee.as_bytes().get(pattern.len()) == Some(&b'.') - { - return Some(FlowPattern::HttpCall); - } - } - None -} - -fn match_logging(callee: &str) -> Option { - if log_pattern_set().contains(callee) { - Some(FlowPattern::Logging) - } else { - None - } -} - -/// Assign confidence based on how specific the DB pattern is. -fn confidence_for_db_pattern(callee: &str) -> f64 { - let lower = callee.to_lowercase(); - - // ORM-specific method chains are high confidence (Aho-Corasick single-pass) - if orm_automaton().is_match(&lower) { - return 0.95; - } - - // Methods with "db" or "repo" or "repository" in the receiver are high confidence - let receiver = callee.split('.').next().unwrap_or(""); - let lower_receiver = receiver.to_lowercase(); - if confidence_receiver_automaton().is_match(&lower_receiver) { - return 0.9; - } - - // SQL keywords are high confidence (Aho-Corasick single-pass) - if sql_write_automaton().is_match(&lower) || sql_read_automaton().is_match(&lower) { - return 0.95; - } - - // Generic methods like `.save()` on unknown receivers are medium confidence - 0.7 -} - -/// Trace call chains to a configurable depth, collecting all reachable symbols. -/// -/// Given a starting symbol, follows call edges in the graph up to `max_depth` hops. -/// Returns the list of symbol IDs reachable from the start, in BFS order. -pub fn trace_call_chain(graph: &SymbolGraph, start: &str, max_depth: usize) -> Vec { - use std::collections::VecDeque; - - let start_idx = match graph.get_node(start) { - Some(idx) => idx, - None => return vec![], - }; - - let mut visited: HashSet = HashSet::new(); - let mut queue: VecDeque<(petgraph::graph::NodeIndex, usize)> = VecDeque::new(); - let mut result = Vec::new(); - - visited.insert(start_idx); - queue.push_back((start_idx, 0)); - - while let Some((current, depth)) = queue.pop_front() { - if depth > 0 { - result.push(graph.graph[current].id.clone()); - } - - if depth >= max_depth { - continue; - } - - // Follow outgoing Calls edges - for neighbor in graph - .graph - .neighbors_directed(current, petgraph::Direction::Outgoing) - { - if visited.insert(neighbor) { - // Check if the edge is a Calls edge - if let Some(edge) = graph.graph.find_edge(current, neighbor) { - if graph.graph[edge].edge_type == EdgeType::Calls { - queue.push_back((neighbor, depth + 1)); - } - } - } - } - } - - result -} - -// --------------------------------------------------------------------------- -// Full data flow tracing -// --------------------------------------------------------------------------- - -/// Build data flow edges from extracted data flow info for a single file. -/// -/// Connects variable assignments from calls to subsequent calls that use those -/// variables as arguments within the same function scope. -/// -/// Example: in `function f() { const x = funcA(); funcB(x); }`, -/// produces `DataFlowEdge { producer: "funcA", consumer: "funcB", via: "x" }`. -pub fn build_data_flow_edges( - info: &crate::ast::DataFlowInfo, - file_path: &str, -) -> Vec { - use std::collections::HashMap; - - let mut edges = Vec::new(); - - // Group assignments by containing function for scope-aware matching. - let mut assignments_by_scope: HashMap, Vec<&crate::ast::VarCallAssignment>> = - HashMap::new(); - for assignment in &info.assignments { - let key = assignment.containing_function.as_deref(); - assignments_by_scope - .entry(key) - .or_default() - .push(assignment); - } - - // For each call, check if any argument matches a variable assigned from another call. - for call in &info.calls_with_args { - let scope_key = call.containing_function.as_deref(); - if let Some(scope_assignments) = assignments_by_scope.get(&scope_key) { - for arg in &call.arguments { - for assignment in scope_assignments { - if assignment.variable == *arg && assignment.callee != call.callee { - let containing = match &call.containing_function { - Some(f) => format!("{}::{}", file_path, f), - None => file_path.to_string(), - }; - edges.push(DataFlowEdge { - producer: assignment.callee.clone(), - consumer: call.callee.clone(), - via: arg.clone(), - containing_function: containing, - file: file_path.to_string(), - line: call.line, - }); - } - } - } - } - } - - edges -} - -/// Trace data flow across all files, producing edges that show how data moves -/// through variable assignments and function calls. -/// -/// Requires source code for each file (re-parses with tree-sitter for finer-grained -/// extraction of variable assignments and call arguments). -pub fn trace_data_flow(files_with_source: &[(&str, &str)]) -> Vec { - let mut all_edges = Vec::new(); - - for &(path, source) in files_with_source { - match crate::ast::extract_data_flow_info(path, source) { - Ok(info) => { - let edges = build_data_flow_edges(&info, path); - all_edges.extend(edges); - } - Err(_) => continue, - } - } - - all_edges -} - -// --------------------------------------------------------------------------- -// IR-based public API -// --------------------------------------------------------------------------- - -/// Analyze data flow patterns from IR files (declarative query engine / IR path). -/// -/// Delegates to the existing heuristic analysis via ParsedFile conversion. -/// The heuristic pattern matching operates on the same call site data available -/// in both representations. -pub fn analyze_data_flow_ir(files: &[IrFile], config: &FlowConfig) -> FlowAnalysis { - let parsed: Vec = files.iter().map(|f| f.to_parsed_file()).collect(); - analyze_data_flow(&parsed, config) -} - -/// Detect frameworks from IR files' import patterns. -pub fn detect_frameworks_ir(files: &[IrFile]) -> Vec { - let parsed: Vec = files.iter().map(|f| f.to_parsed_file()).collect(); - detect_frameworks(&parsed) -} - -/// Build data flow edges directly from an IR file, without re-parsing source code. -/// -/// This is the key improvement over the ParsedFile path: `IrFile` already contains -/// `assignments` (variable = call()) and `call_expressions` with arguments, so we -/// can trace producer → consumer edges without needing the original source text. -/// -/// Example: given `const x = funcA(); funcB(x);` in the IR: -/// - `assignments` contains: pattern=x, value=Call(funcA), scope=f -/// - `call_expressions` contains: callee=funcB, args=["x"], scope=f -/// - Produces: `DataFlowEdge { producer: "funcA", consumer: "funcB", via: "x" }` -pub fn build_data_flow_edges_from_ir(file: &IrFile) -> Vec { - let mut edges = Vec::new(); - - // Group assignments by containing function for scope-aware matching. - // Each entry: (variable_name, callee_name, line) - let mut assignments_by_scope: HashMap, Vec<(&str, &str, usize)>> = HashMap::new(); - - for assignment in &file.assignments { - if let (Some(var), Some(callee)) = ( - assignment.pattern.as_identifier(), - assignment.value.callee_name(), - ) { - let scope = assignment.containing_function.as_deref(); - assignments_by_scope.entry(scope).or_default().push(( - var, - callee, - assignment.span.start_line, - )); - } - } - - // For each call with arguments, check if any argument matches a variable - // assigned from another call within the same scope. - for call in &file.call_expressions { - if call.arguments.is_empty() { - continue; - } - let scope = call.containing_function.as_deref(); - if let Some(scope_assignments) = assignments_by_scope.get(&scope) { - for arg in &call.arguments { - for &(var, producer_callee, _line) in scope_assignments { - if var == arg.as_str() && producer_callee != call.callee { - let containing = match &call.containing_function { - Some(f) => format!("{}::{}", file.path, f), - None => file.path.to_string(), - }; - edges.push(DataFlowEdge { - producer: producer_callee.to_string(), - consumer: call.callee.clone(), - via: arg.clone(), - containing_function: containing, - file: file.path.clone(), - line: call.span.start_line, - }); - } - } - } - } - } - - edges -} - -/// Trace data flow across all IR files, producing edges that show how data moves -/// through variable assignments and function calls. -/// -/// Unlike `trace_data_flow` which requires source code and re-parses with tree-sitter, -/// this version works directly from the IR which already has assignments and call arguments. -pub fn trace_data_flow_ir(files: &[IrFile]) -> Vec { - let mut all_edges = Vec::new(); - for file in files { - let edges = build_data_flow_edges_from_ir(file); - all_edges.extend(edges); - } - all_edges -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -#[cfg(test)] -#[allow( - clippy::unwrap_used, - clippy::expect_used, - clippy::panic, - clippy::print_stdout, - clippy::print_stderr -)] -mod tests { use super::*; use crate::ast::{self, ParsedFile}; @@ -3996,4 +2564,3 @@ function processB() { } } } -} From ba2e402d76c071efb92c71e45354f9d2243f317b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Apr 2026 17:24:01 +0000 Subject: [PATCH 04/15] refactor: split entrypoint.rs into entrypoint/mod.rs + entrypoint/tests.rs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: mikenrafter <88250914+mikenrafter@users.noreply.github.com> --- .../src/{entrypoint.rs => entrypoint/mod.rs} | 1623 +---------------- crates/diffcore-core/src/entrypoint/tests.rs | 1620 ++++++++++++++++ 2 files changed, 1621 insertions(+), 1622 deletions(-) rename crates/diffcore-core/src/{entrypoint.rs => entrypoint/mod.rs} (56%) create mode 100644 crates/diffcore-core/src/entrypoint/tests.rs diff --git a/crates/diffcore-core/src/entrypoint.rs b/crates/diffcore-core/src/entrypoint/mod.rs similarity index 56% rename from crates/diffcore-core/src/entrypoint.rs rename to crates/diffcore-core/src/entrypoint/mod.rs index 2a819f8..3589ff9 100644 --- a/crates/diffcore-core/src/entrypoint.rs +++ b/crates/diffcore-core/src/entrypoint/mod.rs @@ -2332,1625 +2332,4 @@ fn file_stem(path: &str) -> String { clippy::print_stdout, clippy::print_stderr )] -mod tests { - use super::*; - use crate::ast::{CallSite, Definition, ExportInfo, ImportInfo, ImportedName}; - use crate::types::SymbolKind; - - fn make_file(path: &str, lang: Language) -> ParsedFile { - ParsedFile { - path: path.to_string(), - language: lang, - definitions: vec![], - imports: vec![], - exports: vec![], - call_sites: vec![], - } - } - - fn make_def(name: &str, kind: SymbolKind) -> Definition { - Definition { - name: name.to_string(), - kind, - start_line: 1, - end_line: 5, - } - } - - fn make_import(source: &str) -> ImportInfo { - ImportInfo { - source: source.to_string(), - names: vec![], - is_default: false, - is_namespace: false, - line: 1, - } - } - - fn make_import_with_names(source: &str, names: Vec<&str>) -> ImportInfo { - ImportInfo { - source: source.to_string(), - names: names - .into_iter() - .map(|n| ImportedName { - name: n.to_string(), - alias: None, - }) - .collect(), - is_default: false, - is_namespace: false, - line: 1, - } - } - - fn make_export(name: &str, is_default: bool) -> ExportInfo { - ExportInfo { - name: name.to_string(), - is_default, - is_reexport: false, - source: None, - line: 1, - } - } - - fn make_call(callee: &str, containing: Option<&str>) -> CallSite { - CallSite { - callee: callee.to_string(), - line: 1, - containing_function: containing.map(|s| s.to_string()), - } - } - - // ======================================================================== - // Test file detection - // ======================================================================== - - #[test] - fn test_detect_test_file_by_path_dot_test() { - let file = make_file("src/utils.test.ts", Language::TypeScript); - let result = detect_entrypoints(&[file]); - assert_eq!(result.len(), 1); - assert_eq!(result[0].entrypoint_type, EntrypointType::TestFile); - assert_eq!(result[0].symbol, "utils"); - } - - #[test] - fn test_detect_test_file_by_path_dot_spec() { - let file = make_file("src/utils.spec.js", Language::JavaScript); - let result = detect_entrypoints(&[file]); - assert_eq!(result.len(), 1); - assert_eq!(result[0].entrypoint_type, EntrypointType::TestFile); - } - - #[test] - fn test_detect_test_file_python_prefix() { - let file = make_file("tests/test_utils.py", Language::Python); - let result = detect_entrypoints(&[file]); - assert_eq!(result.len(), 1); - assert_eq!(result[0].entrypoint_type, EntrypointType::TestFile); - } - - #[test] - fn test_detect_test_file_tests_directory() { - let file = make_file("__tests__/App.test.tsx", Language::TypeScript); - let result = detect_entrypoints(&[file]); - assert_eq!(result.len(), 1); - assert_eq!(result[0].entrypoint_type, EntrypointType::TestFile); - } - - #[test] - fn test_detect_test_file_with_test_functions() { - let mut file = make_file("tests/test_auth.py", Language::Python); - file.definitions = vec![ - make_def("test_login", SymbolKind::Function), - make_def("test_logout", SymbolKind::Function), - make_def("helper_setup", SymbolKind::Function), - ]; - let result = detect_entrypoints(&[file]); - // Should detect test_login and test_logout but not helper_setup - assert_eq!(result.len(), 2); - assert!(result.iter().any(|e| e.symbol == "test_login")); - assert!(result.iter().any(|e| e.symbol == "test_logout")); - } - - #[test] - fn test_non_test_file_not_detected() { - let file = make_file("src/utils.ts", Language::TypeScript); - let result = detect_entrypoints(&[file]); - assert!(result.is_empty()); - } - - // ======================================================================== - // HTTP route detection — JS/TS - // ======================================================================== - - #[test] - fn test_detect_express_route() { - let mut file = make_file("src/routes/users.ts", Language::TypeScript); - file.call_sites = vec![ - make_call("app.get", Some("setupRoutes")), - make_call("app.post", Some("setupRoutes")), - ]; - let result = detect_entrypoints(&[file]); - assert!(!result.is_empty()); - assert!(result - .iter() - .all(|e| e.entrypoint_type == EntrypointType::HttpRoute)); - } - - #[test] - fn test_detect_router_route() { - let mut file = make_file("src/routes/api.ts", Language::TypeScript); - file.call_sites = vec![make_call("router.get", Some("getUsers"))]; - let result = detect_entrypoints(&[file]); - assert_eq!(result.len(), 1); - assert_eq!(result[0].symbol, "getUsers"); - assert_eq!(result[0].entrypoint_type, EntrypointType::HttpRoute); - } - - #[test] - fn test_detect_nextjs_app_router_route() { - let mut file = make_file("src/app/api/users/route.ts", Language::TypeScript); - file.exports = vec![make_export("GET", false), make_export("POST", false)]; - let result = detect_entrypoints(&[file]); - assert_eq!(result.len(), 2); - assert!(result.iter().any(|e| e.symbol == "GET")); - assert!(result.iter().any(|e| e.symbol == "POST")); - assert!(result - .iter() - .all(|e| e.entrypoint_type == EntrypointType::HttpRoute)); - } - - #[test] - fn test_detect_nextjs_pages_router() { - let mut file = make_file("pages/about.tsx", Language::TypeScript); - file.exports = vec![make_export("AboutPage", true)]; - let result = detect_entrypoints(&[file]); - assert_eq!(result.len(), 1); - assert_eq!(result[0].symbol, "AboutPage"); - assert_eq!(result[0].entrypoint_type, EntrypointType::HttpRoute); - } - - #[test] - fn test_nextjs_pages_skip_internal_files() { - let mut file = make_file("pages/_app.tsx", Language::TypeScript); - file.exports = vec![make_export("App", true)]; - let result = detect_entrypoints(&[file]); - // _app.tsx should NOT be detected as a page route - assert!(result - .iter() - .all(|e| e.entrypoint_type != EntrypointType::HttpRoute)); - } - - #[test] - fn test_non_route_call_not_detected() { - let mut file = make_file("src/utils.ts", Language::TypeScript); - file.call_sites = vec![make_call("console.log", Some("debug"))]; - let result = detect_entrypoints(&[file]); - assert!(result.is_empty()); - } - - // ======================================================================== - // HTTP route detection — Python - // ======================================================================== - - #[test] - fn test_detect_flask_route() { - let mut file = make_file("src/routes.py", Language::Python); - file.imports = vec![make_import_with_names("flask", vec!["Flask"])]; - file.call_sites = vec![make_call("app.route", Some("list_users"))]; - let result = detect_entrypoints(&[file]); - assert!(result - .iter() - .any(|e| e.symbol == "list_users" && e.entrypoint_type == EntrypointType::HttpRoute)); - } - - #[test] - fn test_detect_fastapi_route() { - let mut file = make_file("src/routes.py", Language::Python); - file.imports = vec![make_import_with_names("fastapi", vec!["FastAPI"])]; - file.call_sites = vec![make_call("app.get", Some("get_users"))]; - let result = detect_entrypoints(&[file]); - assert!(result - .iter() - .any(|e| e.symbol == "get_users" && e.entrypoint_type == EntrypointType::HttpRoute)); - } - - #[test] - fn test_detect_python_views_module() { - let mut file = make_file("myapp/views.py", Language::Python); - file.imports = vec![make_import("django.http")]; - file.definitions = vec![ - make_def("index", SymbolKind::Function), - make_def("detail", SymbolKind::Function), - make_def("__init__", SymbolKind::Function), - make_def("_helper", SymbolKind::Function), - ]; - let result = detect_entrypoints(&[file]); - // Should detect index and detail, but not __init__ or _helper - let http_routes: Vec<_> = result - .iter() - .filter(|e| e.entrypoint_type == EntrypointType::HttpRoute) - .collect(); - assert_eq!(http_routes.len(), 2); - assert!(http_routes.iter().any(|e| e.symbol == "index")); - assert!(http_routes.iter().any(|e| e.symbol == "detail")); - } - - // ======================================================================== - // CLI command detection - // ======================================================================== - - #[test] - fn test_detect_python_main() { - let mut file = make_file("src/cli.py", Language::Python); - file.definitions = vec![make_def("main", SymbolKind::Function)]; - let result = detect_entrypoints(&[file]); - assert!(result - .iter() - .any(|e| e.symbol == "main" && e.entrypoint_type == EntrypointType::CliCommand)); - } - - #[test] - fn test_detect_ts_main_in_cli_path() { - let mut file = make_file("src/cli/main.ts", Language::TypeScript); - file.definitions = vec![make_def("main", SymbolKind::Function)]; - let result = detect_entrypoints(&[file]); - assert!(result - .iter() - .any(|e| e.symbol == "main" && e.entrypoint_type == EntrypointType::CliCommand)); - } - - #[test] - fn test_detect_commander_cli() { - let mut file = make_file("src/cli.ts", Language::TypeScript); - file.imports = vec![make_import("commander")]; - let result = detect_entrypoints(&[file]); - assert!(result - .iter() - .any(|e| e.entrypoint_type == EntrypointType::CliCommand)); - } - - #[test] - fn test_detect_click_cli() { - let mut file = make_file("src/main.py", Language::Python); - file.imports = vec![make_import("click")]; - file.definitions = vec![make_def("main", SymbolKind::Function)]; - file.call_sites = vec![make_call("click.command", Some("main"))]; - let result = detect_entrypoints(&[file]); - let cli_entries: Vec<_> = result - .iter() - .filter(|e| e.entrypoint_type == EntrypointType::CliCommand) - .collect(); - assert!(!cli_entries.is_empty()); - } - - #[test] - fn test_detect_bin_path_as_cli() { - let file = make_file("bin/run.js", Language::JavaScript); - let result = detect_entrypoints(&[file]); - assert!(result - .iter() - .any(|e| e.entrypoint_type == EntrypointType::CliCommand)); - } - - #[test] - fn test_main_in_non_cli_path_not_cli_for_ts() { - // A main() in a random TS file shouldn't be CLI - let mut file = make_file("src/components/Widget.ts", Language::TypeScript); - file.definitions = vec![make_def("main", SymbolKind::Function)]; - let result = detect_entrypoints(&[file]); - assert!(result - .iter() - .all(|e| e.entrypoint_type != EntrypointType::CliCommand)); - } - - // ======================================================================== - // Queue consumer detection - // ======================================================================== - - #[test] - fn test_detect_bull_queue_consumer() { - let mut file = make_file("src/workers/email.ts", Language::TypeScript); - file.imports = vec![make_import("bullmq")]; - file.call_sites = vec![make_call("queue.process", Some("processEmail"))]; - let result = detect_entrypoints(&[file]); - assert!(result - .iter() - .any(|e| e.entrypoint_type == EntrypointType::QueueConsumer)); - } - - #[test] - fn test_detect_celery_consumer() { - let mut file = make_file("src/tasks/send_email.py", Language::Python); - file.imports = vec![make_import("celery")]; - file.definitions = vec![make_def("process_email", SymbolKind::Function)]; - // Worker path + celery import → queue consumer for process-like functions - let result = detect_entrypoints(&[file]); - assert!(result - .iter() - .any(|e| e.entrypoint_type == EntrypointType::QueueConsumer)); - } - - #[test] - fn test_no_queue_without_import() { - let mut file = make_file("src/workers/email.ts", Language::TypeScript); - file.call_sites = vec![make_call("queue.process", Some("processEmail"))]; - // No queue import → no detection - let result = detect_entrypoints(&[file]); - assert!(result - .iter() - .all(|e| e.entrypoint_type != EntrypointType::QueueConsumer)); - } - - // ======================================================================== - // Cron job detection - // ======================================================================== - - #[test] - fn test_detect_node_cron() { - let mut file = make_file("src/cron/cleanup.ts", Language::TypeScript); - file.imports = vec![make_import("node-cron")]; - file.call_sites = vec![make_call("cron.schedule", Some("scheduleCleanup"))]; - let result = detect_entrypoints(&[file]); - assert!(result - .iter() - .any(|e| e.entrypoint_type == EntrypointType::CronJob)); - } - - #[test] - fn test_detect_apscheduler() { - let mut file = make_file("src/scheduler/jobs.py", Language::Python); - file.imports = vec![make_import("apscheduler")]; - file.call_sites = vec![make_call("scheduler.add_job", Some("daily_report"))]; - let result = detect_entrypoints(&[file]); - assert!(result - .iter() - .any(|e| e.entrypoint_type == EntrypointType::CronJob)); - } - - // ======================================================================== - // React page detection - // ======================================================================== - - #[test] - fn test_detect_nextjs_page_tsx() { - let mut file = make_file("src/app/dashboard/page.tsx", Language::TypeScript); - file.exports = vec![make_export("DashboardPage", true)]; - let result = detect_entrypoints(&[file]); - assert!( - result - .iter() - .any(|e| e.symbol == "DashboardPage" - && e.entrypoint_type == EntrypointType::ReactPage) - ); - } - - #[test] - fn test_detect_pages_dir_page() { - let mut file = make_file("pages/dashboard.tsx", Language::TypeScript); - file.exports = vec![make_export("Dashboard", true)]; - let result = detect_entrypoints(&[file]); - // Should be detected as either HttpRoute (from pages router detection) or ReactPage - assert!(!result.is_empty()); - } - - #[test] - fn test_python_file_not_react_page() { - let mut file = make_file("pages/admin.py", Language::Python); - file.exports = vec![]; - let result = detect_entrypoints(&[file]); - assert!(result - .iter() - .all(|e| e.entrypoint_type != EntrypointType::ReactPage)); - } - - // ======================================================================== - // Event handler detection - // ======================================================================== - - #[test] - fn test_detect_socket_event_handler() { - let mut file = make_file("src/socket/handler.ts", Language::TypeScript); - file.imports = vec![make_import("socket.io")]; - file.call_sites = vec![make_call("socket.on", Some("handleConnection"))]; - let result = detect_entrypoints(&[file]); - assert!(result - .iter() - .any(|e| e.entrypoint_type == EntrypointType::EventHandler)); - } - - #[test] - fn test_detect_eventemitter_handler() { - let mut file = make_file("src/events/listener.ts", Language::TypeScript); - file.imports = vec![make_import("events")]; - file.call_sites = vec![make_call("emitter.addListener", Some("onUserCreated"))]; - let result = detect_entrypoints(&[file]); - assert!(result - .iter() - .any(|e| e.entrypoint_type == EntrypointType::EventHandler)); - } - - #[test] - fn test_no_event_handler_without_import() { - let mut file = make_file("src/events/listener.ts", Language::TypeScript); - file.call_sites = vec![make_call("emitter.on", Some("handler"))]; - // No event import → no detection - let result = detect_entrypoints(&[file]); - assert!(result - .iter() - .all(|e| e.entrypoint_type != EntrypointType::EventHandler)); - } - - // ======================================================================== - // Multi-entrypoint and deduplication - // ======================================================================== - - #[test] - fn test_multiple_files_multiple_entrypoints() { - let mut route_file = make_file("src/routes/users.ts", Language::TypeScript); - route_file.call_sites = vec![ - make_call("router.get", Some("getUsers")), - make_call("router.post", Some("createUser")), - ]; - - let test_file = make_file("src/routes/users.test.ts", Language::TypeScript); - - let mut cli_file = make_file("src/cli/main.ts", Language::TypeScript); - cli_file.definitions = vec![make_def("main", SymbolKind::Function)]; - - let result = detect_entrypoints(&[route_file, test_file, cli_file]); - - let types: Vec<_> = result.iter().map(|e| &e.entrypoint_type).collect(); - assert!(types.contains(&&EntrypointType::HttpRoute)); - assert!(types.contains(&&EntrypointType::TestFile)); - assert!(types.contains(&&EntrypointType::CliCommand)); - } - - #[test] - fn test_deduplication() { - // A file that could trigger the same entrypoint via multiple detection paths - let mut file = make_file("src/app/api/users/route.ts", Language::TypeScript); - file.exports = vec![make_export("GET", false)]; - - let result = detect_entrypoints(&[file]); - // Should not have duplicates - let get_entries: Vec<_> = result - .iter() - .filter(|e| e.symbol == "GET" && e.file == "src/app/api/users/route.ts") - .collect(); - assert_eq!(get_entries.len(), 1); - } - - #[test] - fn test_empty_input() { - let result = detect_entrypoints(&[]); - assert!(result.is_empty()); - } - - #[test] - fn test_no_entrypoints_in_plain_utility() { - let mut file = make_file("src/utils/format.ts", Language::TypeScript); - file.definitions = vec![ - make_def("formatDate", SymbolKind::Function), - make_def("formatCurrency", SymbolKind::Function), - ]; - file.imports = vec![make_import("date-fns")]; - let result = detect_entrypoints(&[file]); - assert!(result.is_empty()); - } - - // ======================================================================== - // Edge cases - // ======================================================================== - - // ======================================================================== - // Effect.ts HTTP route detection - // ======================================================================== - - #[test] - fn test_detect_effect_http_api_endpoint() { - let mut file = make_file("src/api/users.ts", Language::TypeScript); - file.imports = vec![make_import_with_names( - "@effect/platform", - vec!["HttpApiEndpoint", "HttpApi"], - )]; - file.call_sites = vec![ - make_call("HttpApiEndpoint.get", Some("getUserEndpoint")), - make_call("HttpApiEndpoint.post", Some("createUserEndpoint")), - ]; - let result = detect_entrypoints(&[file]); - let http: Vec<_> = result - .iter() - .filter(|e| e.entrypoint_type == EntrypointType::HttpRoute) - .collect(); - assert_eq!(http.len(), 2); - assert!(http.iter().any(|e| e.symbol == "getUserEndpoint")); - assert!(http.iter().any(|e| e.symbol == "createUserEndpoint")); - } - - #[test] - fn test_detect_effect_http_api_make() { - let mut file = make_file("src/api/index.ts", Language::TypeScript); - file.imports = vec![make_import("@effect/platform/HttpApi")]; - file.call_sites = vec![make_call("HttpApi.make", Some("makeApi"))]; - let result = detect_entrypoints(&[file]); - assert!(result - .iter() - .any(|e| e.symbol == "makeApi" && e.entrypoint_type == EntrypointType::HttpRoute)); - } - - #[test] - fn test_detect_effect_http_api_group() { - let mut file = make_file("src/api/group.ts", Language::TypeScript); - file.imports = vec![make_import("@effect/platform/HttpApiGroup")]; - file.call_sites = vec![make_call("HttpApiGroup.make", Some("usersGroup"))]; - let result = detect_entrypoints(&[file]); - assert!(result - .iter() - .any(|e| e.symbol == "usersGroup" && e.entrypoint_type == EntrypointType::HttpRoute)); - } - - #[test] - fn test_detect_effect_http_router() { - let mut file = make_file("src/router.ts", Language::TypeScript); - file.imports = vec![make_import_with_names( - "@effect/platform", - vec!["HttpRouter"], - )]; - file.call_sites = vec![ - make_call("HttpRouter.get", Some("getHandler")), - make_call("HttpRouter.post", Some("postHandler")), - ]; - let result = detect_entrypoints(&[file]); - let http: Vec<_> = result - .iter() - .filter(|e| e.entrypoint_type == EntrypointType::HttpRoute) - .collect(); - assert_eq!(http.len(), 2); - assert!(http.iter().any(|e| e.symbol == "getHandler")); - assert!(http.iter().any(|e| e.symbol == "postHandler")); - } - - #[test] - fn test_detect_effect_http_subpath_import() { - let mut file = make_file("src/api/endpoint.ts", Language::TypeScript); - file.imports = vec![make_import("@effect/platform/HttpApiEndpoint")]; - file.call_sites = vec![make_call("HttpApiEndpoint.put", Some("updateUser"))]; - let result = detect_entrypoints(&[file]); - assert!(result - .iter() - .any(|e| e.symbol == "updateUser" && e.entrypoint_type == EntrypointType::HttpRoute)); - } - - #[test] - fn test_no_effect_http_without_import() { - let mut file = make_file("src/api/users.ts", Language::TypeScript); - file.call_sites = vec![make_call("HttpApiEndpoint.get", Some("getUser"))]; - let result = detect_entrypoints(&[file]); - assert!(result - .iter() - .all(|e| e.entrypoint_type != EntrypointType::HttpRoute)); - } - - // ======================================================================== - // Effect.ts CLI command detection - // ======================================================================== - - #[test] - fn test_detect_effect_cli_command_make() { - let mut file = make_file("src/cli/main.ts", Language::TypeScript); - file.imports = vec![make_import_with_names( - "@effect/cli", - vec!["Command", "Args"], - )]; - file.call_sites = vec![make_call("Command.make", Some("myCommand"))]; - let result = detect_entrypoints(&[file]); - assert!(result - .iter() - .any(|e| e.symbol == "myCommand" && e.entrypoint_type == EntrypointType::CliCommand)); - } - - #[test] - fn test_detect_effect_cli_command_run() { - let mut file = make_file("src/cli.ts", Language::TypeScript); - file.imports = vec![make_import("@effect/cli/Command")]; - file.call_sites = vec![make_call("Command.run", Some("runCli"))]; - let result = detect_entrypoints(&[file]); - assert!(result - .iter() - .any(|e| e.symbol == "runCli" && e.entrypoint_type == EntrypointType::CliCommand)); - } - - #[test] - fn test_no_effect_cli_without_import() { - let mut file = make_file("src/cli.ts", Language::TypeScript); - file.call_sites = vec![make_call("Command.make", Some("myCmd"))]; - let result = detect_entrypoints(&[file]); - // Without @effect/cli import, should not detect via Effect.ts CLI path - // (may detect via other paths if path matches cli patterns) - assert!(result - .iter() - .all(|e| e.symbol != "myCmd" || e.entrypoint_type != EntrypointType::CliCommand)); - } - - // ======================================================================== - // Effect.ts queue consumer detection - // ======================================================================== - - #[test] - fn test_detect_effect_queue_take() { - let mut file = make_file("src/workers/processor.ts", Language::TypeScript); - file.imports = vec![make_import_with_names("effect", vec!["Queue"])]; - file.call_sites = vec![make_call("Queue.take", Some("processMessages"))]; - let result = detect_entrypoints(&[file]); - assert!(result - .iter() - .any(|e| e.symbol == "processMessages" - && e.entrypoint_type == EntrypointType::QueueConsumer)); - } - - #[test] - fn test_detect_effect_pubsub_subscribe() { - let mut file = make_file("src/events/subscriber.ts", Language::TypeScript); - file.imports = vec![make_import_with_names("effect", vec!["PubSub"])]; - file.call_sites = vec![make_call("PubSub.subscribe", Some("handleEvents"))]; - let result = detect_entrypoints(&[file]); - assert!(result - .iter() - .any(|e| e.symbol == "handleEvents" - && e.entrypoint_type == EntrypointType::QueueConsumer)); - } - - #[test] - fn test_detect_effect_queue_subpath_import() { - let mut file = make_file("src/worker.ts", Language::TypeScript); - file.imports = vec![make_import("effect/Queue")]; - file.call_sites = vec![make_call("Queue.dequeue", Some("drain"))]; - let result = detect_entrypoints(&[file]); - assert!(result - .iter() - .any(|e| e.symbol == "drain" && e.entrypoint_type == EntrypointType::QueueConsumer)); - } - - // ======================================================================== - // Effect.ts cron job detection - // ======================================================================== - - #[test] - fn test_detect_effect_schedule_cron() { - let mut file = make_file("src/cron/cleanup.ts", Language::TypeScript); - file.imports = vec![make_import_with_names("effect", vec!["Schedule"])]; - file.call_sites = vec![make_call("Schedule.cron", Some("dailyCleanup"))]; - let result = detect_entrypoints(&[file]); - assert!(result - .iter() - .any(|e| e.symbol == "dailyCleanup" && e.entrypoint_type == EntrypointType::CronJob)); - } - - #[test] - fn test_detect_effect_schedule_spaced() { - let mut file = make_file("src/scheduler.ts", Language::TypeScript); - file.imports = vec![make_import("effect/Schedule")]; - file.call_sites = vec![make_call("Schedule.spaced", Some("heartbeat"))]; - let result = detect_entrypoints(&[file]); - assert!(result - .iter() - .any(|e| e.symbol == "heartbeat" && e.entrypoint_type == EntrypointType::CronJob)); - } - - #[test] - fn test_detect_effect_cron_make() { - let mut file = make_file("src/cron.ts", Language::TypeScript); - file.imports = vec![make_import("@effect/cron")]; - file.call_sites = vec![make_call("Cron.make", Some("setupCron"))]; - let result = detect_entrypoints(&[file]); - assert!(result - .iter() - .any(|e| e.symbol == "setupCron" && e.entrypoint_type == EntrypointType::CronJob)); - } - - #[test] - fn test_no_effect_cron_without_import() { - let mut file = make_file("src/utils.ts", Language::TypeScript); - file.call_sites = vec![make_call("Schedule.cron", Some("nope"))]; - let result = detect_entrypoints(&[file]); - assert!(result - .iter() - .all(|e| e.entrypoint_type != EntrypointType::CronJob)); - } - - // ======================================================================== - // Effect.ts test file detection - // ======================================================================== - - #[test] - fn test_detect_effect_vitest_it_effect() { - let mut file = make_file("src/services/auth.test.ts", Language::TypeScript); - file.imports = vec![make_import("@effect/vitest")]; - file.call_sites = vec![make_call("it.effect", Some("describe"))]; - let result = detect_entrypoints(&[file]); - // Should be detected via both test path and Effect.ts vitest - assert!(result - .iter() - .any(|e| e.entrypoint_type == EntrypointType::TestFile)); - } - - #[test] - fn test_detect_effect_vitest_it_scoped() { - let mut file = make_file("src/services/db.test.ts", Language::TypeScript); - file.imports = vec![make_import("@effect/vitest")]; - file.call_sites = vec![make_call("it.scoped", Some("dbTests"))]; - let result = detect_entrypoints(&[file]); - assert!(result - .iter() - .any(|e| e.entrypoint_type == EntrypointType::TestFile)); - } - - #[test] - fn test_detect_effect_vitest_it_live() { - let mut file = make_file("test/integration.test.ts", Language::TypeScript); - file.imports = vec![make_import("@effect/vitest")]; - file.call_sites = vec![make_call("it.live", Some("liveTest"))]; - let result = detect_entrypoints(&[file]); - assert!(result - .iter() - .any(|e| e.entrypoint_type == EntrypointType::TestFile)); - } - - // ======================================================================== - // Effect.ts event handler detection - // ======================================================================== - - #[test] - fn test_detect_effect_stream_run() { - let mut file = make_file("src/streams/processor.ts", Language::TypeScript); - file.imports = vec![make_import_with_names("effect", vec!["Stream"])]; - file.call_sites = vec![make_call("Stream.runForEach", Some("processStream"))]; - let result = detect_entrypoints(&[file]); - assert!(result - .iter() - .any(|e| e.symbol == "processStream" - && e.entrypoint_type == EntrypointType::EventHandler)); - } - - #[test] - fn test_detect_effect_hub_subscribe() { - let mut file = make_file("src/events/hub.ts", Language::TypeScript); - file.imports = vec![make_import_with_names("effect", vec!["Hub"])]; - file.call_sites = vec![make_call("Hub.subscribe", Some("listenForEvents"))]; - let result = detect_entrypoints(&[file]); - assert!(result - .iter() - .any(|e| e.symbol == "listenForEvents" - && e.entrypoint_type == EntrypointType::EventHandler)); - } - - #[test] - fn test_detect_effect_stream_subpath_import() { - let mut file = make_file("src/stream.ts", Language::TypeScript); - file.imports = vec![make_import("effect/Stream")]; - file.call_sites = vec![make_call("Stream.runDrain", Some("drainEvents"))]; - let result = detect_entrypoints(&[file]); - assert!(result.iter().any( - |e| e.symbol == "drainEvents" && e.entrypoint_type == EntrypointType::EventHandler - )); - } - - #[test] - fn test_no_effect_event_without_import() { - let mut file = make_file("src/stream.ts", Language::TypeScript); - file.call_sites = vec![make_call("Stream.run", Some("noImport"))]; - let result = detect_entrypoints(&[file]); - assert!(result - .iter() - .all(|e| e.entrypoint_type != EntrypointType::EventHandler)); - } - - // ======================================================================== - // Effect.ts service detection - // ======================================================================== - - #[test] - fn test_detect_effect_service() { - let mut file = make_file("src/services/user.ts", Language::TypeScript); - file.imports = vec![make_import_with_names("effect", vec!["Effect", "Context"])]; - file.call_sites = vec![make_call("Effect.Service", Some("UserService"))]; - let result = detect_entrypoints(&[file]); - assert!(result.iter().any( - |e| e.symbol == "UserService" && e.entrypoint_type == EntrypointType::EffectService - )); - } - - #[test] - fn test_detect_context_tag() { - let mut file = make_file("src/services/db.ts", Language::TypeScript); - file.imports = vec![make_import_with_names("effect", vec!["Context"])]; - file.call_sites = vec![make_call("Context.Tag", Some("DatabaseService"))]; - let result = detect_entrypoints(&[file]); - assert!(result - .iter() - .any(|e| e.symbol == "DatabaseService" - && e.entrypoint_type == EntrypointType::EffectService)); - } - - #[test] - fn test_detect_context_generic_tag() { - let mut file = make_file("src/services/config.ts", Language::TypeScript); - file.imports = vec![make_import("effect/Context")]; - file.call_sites = vec![make_call("Context.GenericTag", Some("ConfigService"))]; - let result = detect_entrypoints(&[file]); - assert!(result - .iter() - .any(|e| e.symbol == "ConfigService" - && e.entrypoint_type == EntrypointType::EffectService)); - } - - #[test] - fn test_detect_layer_succeed() { - let mut file = make_file("src/layers/live.ts", Language::TypeScript); - file.imports = vec![make_import_with_names("effect", vec!["Layer"])]; - file.call_sites = vec![make_call("Layer.succeed", Some("LiveLayer"))]; - let result = detect_entrypoints(&[file]); - assert!( - result - .iter() - .any(|e| e.symbol == "LiveLayer" - && e.entrypoint_type == EntrypointType::EffectService) - ); - } - - #[test] - fn test_detect_layer_effect() { - let mut file = make_file("src/layers/db.ts", Language::TypeScript); - file.imports = vec![make_import("effect/Layer")]; - file.call_sites = vec![make_call("Layer.effect", Some("DbLayer"))]; - let result = detect_entrypoints(&[file]); - assert!(result - .iter() - .any(|e| e.symbol == "DbLayer" && e.entrypoint_type == EntrypointType::EffectService)); - } - - #[test] - fn test_detect_layer_scoped() { - let mut file = make_file("src/layers/connection.ts", Language::TypeScript); - file.imports = vec![make_import_with_names("effect", vec!["Layer", "Effect"])]; - file.call_sites = vec![make_call("Layer.scoped", Some("ConnectionLayer"))]; - let result = detect_entrypoints(&[file]); - assert!(result - .iter() - .any(|e| e.symbol == "ConnectionLayer" - && e.entrypoint_type == EntrypointType::EffectService)); - } - - #[test] - fn test_no_effect_service_without_import() { - let mut file = make_file("src/services/user.ts", Language::TypeScript); - file.call_sites = vec![make_call("Effect.Service", Some("UserService"))]; - let result = detect_entrypoints(&[file]); - assert!(result - .iter() - .all(|e| e.entrypoint_type != EntrypointType::EffectService)); - } - - // ======================================================================== - // Effect.ts edge cases - // ======================================================================== - - #[test] - fn test_effect_ts_python_file_ignored() { - let mut file = make_file("src/services/user.py", Language::Python); - file.imports = vec![make_import_with_names("effect", vec!["Effect"])]; - file.call_sites = vec![make_call("Effect.Service", Some("UserService"))]; - let result = detect_entrypoints(&[file]); - assert!(result - .iter() - .all(|e| e.entrypoint_type != EntrypointType::EffectService)); - } - - #[test] - fn test_effect_multiple_entrypoint_types() { - let mut file = make_file("src/api/server.ts", Language::TypeScript); - file.imports = vec![ - make_import_with_names("@effect/platform", vec!["HttpApiEndpoint"]), - make_import_with_names("effect", vec!["Effect", "Layer"]), - ]; - file.call_sites = vec![ - make_call("HttpApiEndpoint.get", Some("getEndpoint")), - make_call("Layer.succeed", Some("ApiLayer")), - ]; - let result = detect_entrypoints(&[file]); - assert!(result - .iter() - .any(|e| e.entrypoint_type == EntrypointType::HttpRoute)); - assert!(result - .iter() - .any(|e| e.entrypoint_type == EntrypointType::EffectService)); - } - - #[test] - fn test_effect_platform_node_import() { - let mut file = make_file("src/server.ts", Language::TypeScript); - file.imports = vec![make_import_with_names( - "@effect/platform-node", - vec!["HttpServer"], - )]; - file.call_sites = vec![make_call("HttpRouter.get", Some("serveApp"))]; - let result = detect_entrypoints(&[file]); - assert!(result - .iter() - .any(|e| e.symbol == "serveApp" && e.entrypoint_type == EntrypointType::HttpRoute)); - } - - #[test] - fn test_effect_deduplication_with_regular_detection() { - // A file detected as test by both path-based and Effect.ts vitest detection - let mut file = make_file("src/auth.test.ts", Language::TypeScript); - file.imports = vec![make_import("@effect/vitest")]; - file.call_sites = vec![make_call("it.effect", Some("describe"))]; - let result = detect_entrypoints(&[file]); - // Should deduplicate — same (file, symbol) pair - let test_entries: Vec<_> = result - .iter() - .filter(|e| e.file == "src/auth.test.ts" && e.symbol == "describe") - .collect(); - assert_eq!(test_entries.len(), 1); - } - - // ======================================================================== - // Edge cases (existing + extended) - // ======================================================================== - - #[test] - fn test_unknown_language_no_entrypoints() { - let file = make_file("main.go", Language::Unknown); - let result = detect_entrypoints(&[file]); - assert!(result.is_empty()); - } - - #[test] - fn test_file_stem_extraction() { - assert_eq!(file_stem("src/utils/format.ts"), "format"); - assert_eq!(file_stem("main.py"), "main"); - assert_eq!(file_stem("Makefile"), "Makefile"); - } - - // ======================================================================== - // Path detection helpers - // ======================================================================== - - #[test] - fn test_is_test_path_variants() { - assert!(is_test_path("src/utils.test.ts")); - assert!(is_test_path("src/utils.spec.js")); - assert!(is_test_path("__tests__/App.test.tsx")); - assert!(is_test_path("tests/test_utils.py")); - assert!(is_test_path("test/integration.py")); - assert!(is_test_path("src/auth_test.py")); - assert!(!is_test_path("src/utils.ts")); - assert!(!is_test_path("src/testing-utils.ts")); - } - - #[test] - fn test_is_nextjs_route_file() { - assert!(is_nextjs_route_file("src/app/api/users/route.ts")); - assert!(is_nextjs_route_file("app/api/route.ts")); - assert!(!is_nextjs_route_file("src/app/api/users/page.ts")); - assert!(!is_nextjs_route_file("src/routes/users.ts")); - } - - #[test] - fn test_is_worker_path() { - assert!(is_worker_path("src/workers/email.ts")); - assert!(is_worker_path("src/jobs/cleanup.py")); - assert!(is_worker_path("src/email_worker.ts")); - assert!(!is_worker_path("src/services/email.ts")); - } - - // ======================================================================= - // IR-based entrypoint parity tests - // ======================================================================= - - mod ir_parity { - use super::*; - use crate::ast; - use crate::ir::IrFile; - - /// Helper: detect entrypoints via both paths and compare. - fn detect_both(files: &[(&str, &str)]) -> (Vec, Vec) { - let parsed: Vec = files - .iter() - .map(|(path, source)| ast::parse_file(path, source).unwrap()) - .collect(); - let ir_files: Vec = parsed.iter().map(IrFile::from_parsed_file).collect(); - - let from_parsed = detect_entrypoints(&parsed); - let from_ir = detect_entrypoints_ir(&ir_files); - (from_parsed, from_ir) - } - - #[test] - fn test_ir_parity_test_file() { - let (ep, ei) = detect_both(&[( - "src/utils.test.ts", - r#" -function test_validate() {} -function test_sanitize() {} -"#, - )]); - - assert_eq!(ep.len(), ei.len(), "entrypoint count should match"); - for (a, b) in ep.iter().zip(ei.iter()) { - assert_eq!(a.file, b.file); - assert_eq!(a.symbol, b.symbol); - assert_eq!(a.entrypoint_type, b.entrypoint_type); - } - } - - #[test] - fn test_ir_parity_express_route() { - let (ep, ei) = detect_both(&[( - "src/routes/users.ts", - r#" -import express from 'express'; -const router = express.Router(); -function getUsers() {} -router.get('/users', getUsers); -"#, - )]); - - assert_eq!( - ep.len(), - ei.len(), - "Express route entrypoint count should match" - ); - for (a, b) in ep.iter().zip(ei.iter()) { - assert_eq!(a.file, b.file); - assert_eq!(a.entrypoint_type, b.entrypoint_type); - } - } - - #[test] - fn test_ir_parity_flask_route() { - let (ep, ei) = detect_both(&[( - "app/views.py", - r#" -from flask import Flask -app = Flask(__name__) - -def list_users(): - pass -app.route('/users')(list_users) -"#, - )]); - - assert_eq!(ep.len(), ei.len()); - } - - #[test] - fn test_ir_parity_nextjs_route() { - let (ep, ei) = detect_both(&[( - "src/app/api/users/route.ts", - r#" -export function GET(request: Request) { - return Response.json({}); -} -export function POST(request: Request) { - return Response.json({}); -} -"#, - )]); - - assert_eq!( - ep.len(), - ei.len(), - "Next.js route entrypoint count should match" - ); - for (a, b) in ep.iter().zip(ei.iter()) { - assert_eq!(a.file, b.file); - assert_eq!(a.symbol, b.symbol); - } - } - - #[test] - fn test_ir_parity_cli_command() { - let (ep, ei) = detect_both(&[( - "src/cli.py", - r#" -import click - -def main(): - pass - -click.command()(main) -"#, - )]); - - assert_eq!(ep.len(), ei.len()); - } - - #[test] - fn test_ir_parity_no_entrypoints() { - let (ep, ei) = detect_both(&[( - "src/utils.ts", - r#" -export function validate(data: any) { return data; } -export function sanitize(data: any) { return data; } -"#, - )]); - - assert_eq!(ep.len(), ei.len(), "no entrypoints should be found"); - assert!(ep.is_empty()); - } - - #[test] - fn test_ir_parity_multiple_files() { - let (ep, ei) = detect_both(&[ - ( - "src/app/api/users/route.ts", - r#" -export function GET() { return Response.json([]); } -"#, - ), - ( - "src/utils.test.ts", - r#" -function test_something() {} -"#, - ), - ( - "src/services/user.ts", - r#" -export function createUser(data: any) {} -"#, - ), - ]); - - assert_eq!( - ep.len(), - ei.len(), - "multi-file entrypoint count should match" - ); - for (a, b) in ep.iter().zip(ei.iter()) { - assert_eq!(a.file, b.file); - assert_eq!(a.symbol, b.symbol); - assert_eq!(a.entrypoint_type, b.entrypoint_type); - } - } - - #[test] - fn test_ir_parity_empty() { - let from_parsed = detect_entrypoints(&[]); - let from_ir = detect_entrypoints_ir(&[]); - assert_eq!(from_parsed.len(), from_ir.len()); - assert!(from_ir.is_empty()); - } - - #[test] - fn test_ir_parity_effect_ts_service() { - let (ep, ei) = detect_both(&[( - "src/services/user.ts", - r#" -import { Effect, Context } from 'effect'; -function UserService() {} -Effect.Service(UserService); -"#, - )]); - - assert_eq!(ep.len(), ei.len()); - } - - #[test] - fn test_ir_parity_queue_consumer() { - let (ep, ei) = detect_both(&[( - "src/workers/email.ts", - r#" -import { Queue } from 'bullmq'; -function processEmail() {} -queue.process(processEmail); -"#, - )]); - - assert_eq!(ep.len(), ei.len()); - } - } - - // ======================================================================== - // Phase 3: Path-based entrypoint detection (spec §1.4) - // ======================================================================== - - #[test] - fn test_ts_routes_dir_with_express() { - let mut file = make_file("src/routes/users.ts", Language::TypeScript); - file.imports = vec![make_import("express")]; - file.definitions = vec![make_def("getUsers", SymbolKind::Function)]; - let result = detect_entrypoints(&[file]); - assert!( - result - .iter() - .any(|e| e.entrypoint_type == EntrypointType::HttpRoute), - "TS file in /routes/ with express import should be detected as HTTP entrypoint" - ); - } - - #[test] - fn test_ts_routes_dir_no_import_strong_path() { - let mut file = make_file("src/routes/users.ts", Language::TypeScript); - // No framework import — but /routes/ is a strong path signal - file.definitions = vec![make_def("getUsers", SymbolKind::Function)]; - file.exports = vec![make_export("getUsers", false)]; - let result = detect_entrypoints(&[file]); - assert!( - result - .iter() - .any(|e| e.entrypoint_type == EntrypointType::HttpRoute), - "TS file in /routes/ should be detected as entrypoint (strong path)" - ); - } - - #[test] - fn test_ts_controller_suffix_nestjs() { - let mut file = make_file("src/billing.controller.ts", Language::TypeScript); - file.imports = vec![make_import("@nestjs/common")]; - file.definitions = vec![make_def("create", SymbolKind::Function)]; - let result = detect_entrypoints(&[file]); - assert!( - result - .iter() - .any(|e| e.entrypoint_type == EntrypointType::HttpRoute), - "*.controller.ts with @nestjs/common import should be detected" - ); - } - - #[test] - fn test_ts_controller_suffix_strong_path() { - let mut file = make_file("src/billing.controller.ts", Language::TypeScript); - // No import — strong path (controller suffix) - file.definitions = vec![make_def("create", SymbolKind::Function)]; - file.exports = vec![make_export("create", false)]; - let result = detect_entrypoints(&[file]); - assert!( - result - .iter() - .any(|e| e.entrypoint_type == EntrypointType::HttpRoute), - "*.controller.ts should be detected as entrypoint (strong path)" - ); - } - - #[test] - fn test_ts_entrypoints_in_name() { - let mut file = make_file("src/command-entrypoints.ts", Language::TypeScript); - file.definitions = vec![make_def("deploy", SymbolKind::Function)]; - file.exports = vec![make_export("deploy", false)]; - let result = detect_entrypoints(&[file]); - assert!( - !result.is_empty(), - "File with 'entrypoints' in name should be detected (strong path)" - ); - } - - #[test] - fn test_go_handlers_dir() { - let mut file = make_file("internal/handlers/auth.go", Language::Go); - file.imports = vec![make_import("net/http")]; - file.definitions = vec![make_def("HandleAuth", SymbolKind::Function)]; - let result = detect_entrypoints(&[file]); - assert!( - result - .iter() - .any(|e| e.entrypoint_type == EntrypointType::HttpRoute), - "Go file in /handlers/ with net/http import should be detected" - ); - } - - #[test] - fn test_python_views_flask() { - let mut file = make_file("app/views/dashboard.py", Language::Python); - file.imports = vec![make_import("flask")]; - file.definitions = vec![make_def("index", SymbolKind::Function)]; - let result = detect_entrypoints(&[file]); - assert!( - result - .iter() - .any(|e| e.entrypoint_type == EntrypointType::HttpRoute), - "Python file in /views/ with flask import should be detected" - ); - } - - #[test] - fn test_java_controller_spring() { - let mut file = make_file("com/api/controllers/UserController.java", Language::Java); - file.imports = vec![make_import( - "org.springframework.web.bind.annotation.RestController", - )]; - file.definitions = vec![make_def("getUser", SymbolKind::Function)]; - let result = detect_entrypoints(&[file]); - assert!( - result - .iter() - .any(|e| e.entrypoint_type == EntrypointType::HttpRoute), - "Java controller in /controllers/ with Spring import should be detected" - ); - } - - #[test] - fn test_rust_handlers_axum() { - let mut file = make_file("src/handlers/auth.rs", Language::Rust); - file.imports = vec![make_import("axum")]; - file.definitions = vec![make_def("login", SymbolKind::Function)]; - let result = detect_entrypoints(&[file]); - assert!( - result - .iter() - .any(|e| e.entrypoint_type == EntrypointType::HttpRoute), - "Rust file in /handlers/ with axum import should be detected" - ); - } - - #[test] - fn test_no_false_positive_utils() { - let mut file = make_file("src/utils/helpers.ts", Language::TypeScript); - file.definitions = vec![make_def("formatDate", SymbolKind::Function)]; - let result = detect_entrypoints(&[file]); - assert!( - result.is_empty(), - "src/utils/helpers.ts should NOT be detected as entrypoint" - ); - } - - #[test] - fn test_no_false_positive_api_types() { - let mut file = make_file("src/api/types.ts", Language::TypeScript); - // No framework import, not a strong path - file.definitions = vec![make_def("UserType", SymbolKind::TypeAlias)]; - let result = detect_entrypoints(&[file]); - assert!( - result.is_empty(), - "src/api/types.ts without framework import should NOT be detected" - ); - } - - #[test] - fn test_cli_commands_dir_with_commander() { - let mut file = make_file("src/commands/deploy.ts", Language::TypeScript); - file.imports = vec![make_import("commander")]; - file.definitions = vec![make_def("deploy", SymbolKind::Function)]; - let result = detect_entrypoints(&[file]); - assert!( - result - .iter() - .any(|e| e.entrypoint_type == EntrypointType::CliCommand), - "TS file in /commands/ with commander import should be detected as CLI" - ); - } - - #[test] - fn test_cli_commands_dir_strong_path() { - let mut file = make_file("src/commands/migrate.ts", Language::TypeScript); - file.definitions = vec![make_def("migrate", SymbolKind::Function)]; - file.exports = vec![make_export("migrate", false)]; - let result = detect_entrypoints(&[file]); - assert!( - !result.is_empty(), - "/commands/ dir should detect entrypoints (strong path)" - ); - } - - // ======================================================================== - // Path helper unit tests - // ======================================================================== - - #[test] - fn test_is_route_handler_path() { - assert!(is_route_handler_path("src/routes/users.ts")); - assert!(is_route_handler_path("src/handlers/auth.go")); - assert!(is_route_handler_path("src/controllers/billing.ts")); - assert!(is_route_handler_path("src/endpoints/api.ts")); - assert!(is_route_handler_path("src/billing.controller.ts")); - assert!(is_route_handler_path("src/users.route.ts")); - assert!(!is_route_handler_path("src/utils/helpers.ts")); - assert!(!is_route_handler_path("src/models/user.ts")); - } - - #[test] - fn test_is_strong_route_handler_path() { - assert!(is_strong_route_handler_path("src/routes/users.ts")); - assert!(is_strong_route_handler_path("src/handlers/auth.go")); - assert!(is_strong_route_handler_path("src/controllers/billing.ts")); - assert!(is_strong_route_handler_path("src/billing.controller.ts")); - assert!(is_strong_route_handler_path("src/command-entrypoints.ts")); - assert!(!is_strong_route_handler_path("src/utils/helpers.ts")); - assert!(!is_strong_route_handler_path("src/api/types.ts")); - } - - #[test] - fn test_is_cli_command_path() { - assert!(is_cli_command_path("src/commands/deploy.ts")); - assert!(is_cli_command_path("src/cmd/run.go")); - assert!(is_cli_command_path("src/cli/main.ts")); - assert!(is_cli_command_path("src/deploy.command.ts")); - assert!(!is_cli_command_path("src/utils/helpers.ts")); - } - - #[test] - fn test_framework_import_helpers() { - // JS web - assert!(is_js_web_framework_import(&make_import("express"))); - assert!(is_js_web_framework_import(&make_import("@nestjs/common"))); - assert!(is_js_web_framework_import(&make_import("hono"))); - assert!(!is_js_web_framework_import(&make_import("lodash"))); - - // Go web - assert!(is_go_web_framework_import(&make_import("net/http"))); - assert!(is_go_web_framework_import(&make_import( - "github.com/gin-gonic/gin" - ))); - assert!(!is_go_web_framework_import(&make_import("fmt"))); - - // Rust web - assert!(is_rust_web_framework_import(&make_import("axum"))); - assert!(is_rust_web_framework_import(&make_import("actix_web"))); - assert!(!is_rust_web_framework_import(&make_import("serde"))); - - // Java web - assert!(is_java_web_framework_import(&make_import( - "org.springframework.web.bind.annotation.RestController" - ))); - assert!(!is_java_web_framework_import(&make_import( - "java.util.List" - ))); - - // JS CLI - assert!(is_js_cli_framework_import(&make_import("commander"))); - assert!(is_js_cli_framework_import(&make_import("yargs"))); - assert!(is_js_cli_framework_import(&make_import("@effect/cli"))); - assert!(!is_js_cli_framework_import(&make_import("express"))); - } - - // =================================================================== - // Property-based tests for path detection helpers (spec §1) - // =================================================================== - - mod proptests_path { - use super::*; - use proptest::prelude::*; - - proptest! { - /// Strong route handler paths are always also regular route handler paths. - #[test] - fn prop_strong_route_implies_regular( - dir in prop_oneof![ - Just("routes"), - Just("handlers"), - Just("controllers"), - Just("endpoints"), - ], - name in "[a-z]{3,10}", - ext in prop_oneof![Just(".ts"), Just(".js"), Just(".py"), Just(".go")], - ) { - let path = format!("src/{}/{}{}", dir, name, ext); - if is_strong_route_handler_path(&path) { - prop_assert!( - is_route_handler_path(&path), - "strong path '{}' must also be a regular route path", path, - ); - } - } - - /// Route handler path detection is case-insensitive. - #[test] - fn prop_route_handler_case_insensitive( - dir in prop_oneof![ - Just("routes"), - Just("handlers"), - Just("controllers"), - ], - name in "[a-z]{3,10}", - ) { - let lower = format!("src/{}/{}.ts", dir, name); - let upper = format!("SRC/{}/{}.TS", dir.to_uppercase(), name.to_uppercase()); - prop_assert_eq!( - is_route_handler_path(&lower), - is_route_handler_path(&upper), - "detection should be case-insensitive: '{}' vs '{}'", lower, upper, - ); - } - - /// Files in plain src/ directories (no route/handler/controller pattern) are not - /// detected as route handlers. - #[test] - fn prop_plain_src_not_route(name in "[a-z]{3,15}") { - let path = format!("src/{}.ts", name); - // Only assert when name doesn't accidentally contain a pattern keyword - prop_assume!( - !name.contains("route") && !name.contains("handler") - && !name.contains("controller") && !name.contains("endpoint") - && !name.contains("entrypoint") - ); - prop_assert!( - !is_route_handler_path(&path), - "'{}' should not be a route handler path", path, - ); - } - - /// CLI command path detection: files in /commands/ are always detected. - #[test] - fn prop_cli_commands_dir_always_detected( - name in "[a-z]{3,10}", - ext in prop_oneof![Just(".ts"), Just(".go"), Just(".py"), Just(".rs")], - ) { - let path = format!("src/commands/{}{}", name, ext); - prop_assert!( - is_cli_command_path(&path), - "'{}' should be a CLI command path", path, - ); - } - - /// CLI and route directory patterns don't overlap (commands/ is CLI, routes/ is HTTP). - #[test] - fn prop_cli_route_dirs_disjoint(name in "[a-z]{3,10}") { - let cli_path = format!("src/commands/{}.ts", name); - let route_path = format!("src/routes/{}.ts", name); - - // Guard: name doesn't contain keywords from the other domain - prop_assume!( - !name.contains("route") && !name.contains("handler") - && !name.contains("controller") && !name.contains("endpoint") - ); - prop_assume!( - !name.contains("command") && !name.contains("cli") - ); - - prop_assert!(is_cli_command_path(&cli_path), "commands/ should be CLI"); - prop_assert!(!is_route_handler_path(&cli_path), "commands/ should NOT be HTTP"); - prop_assert!(is_route_handler_path(&route_path), "routes/ should be HTTP"); - prop_assert!(!is_cli_command_path(&route_path), "routes/ should NOT be CLI"); - } - - /// has_filename_pattern requires dot delimiters — partial substring matches don't count. - #[test] - fn prop_filename_pattern_requires_dots( - prefix in "[a-z]{2,8}", - pattern in prop_oneof![ - Just("controller"), - Just("route"), - Just("handler"), - ], - ext in prop_oneof![Just(".ts"), Just(".js")], - ) { - // With dots: "prefix.pattern.ext" → should match - let dotted = format!("src/{}.{}{}", prefix, pattern, ext); - prop_assert!( - has_filename_pattern(&dotted.to_lowercase(), pattern), - "'{}' with dot-delimited pattern should match", dotted, - ); - - // Without dots: "prefixpatternext" → should NOT match - let no_dots = format!("src/{}{}{}", prefix, pattern, ext); - // Only assert if the concatenation doesn't accidentally create a dot pattern - if !no_dots.to_lowercase().contains(&format!(".{}.", pattern)) { - prop_assert!( - !has_filename_pattern(&no_dots.to_lowercase(), pattern), - "'{}' without dot delimiters should not match", no_dots, - ); - } - } - - /// is_route_handler_path is deterministic. - #[test] - fn prop_route_handler_deterministic(path in "[a-z/._]{1,50}") { - let r1 = is_route_handler_path(&path); - let r2 = is_route_handler_path(&path); - prop_assert_eq!(r1, r2); - } - - /// is_cli_command_path is deterministic. - #[test] - fn prop_cli_command_deterministic(path in "[a-z/._]{1,50}") { - let r1 = is_cli_command_path(&path); - let r2 = is_cli_command_path(&path); - prop_assert_eq!(r1, r2); - } - } - } -} +mod tests; diff --git a/crates/diffcore-core/src/entrypoint/tests.rs b/crates/diffcore-core/src/entrypoint/tests.rs new file mode 100644 index 0000000..e9576b9 --- /dev/null +++ b/crates/diffcore-core/src/entrypoint/tests.rs @@ -0,0 +1,1620 @@ + use super::*; + use crate::ast::{CallSite, Definition, ExportInfo, ImportInfo, ImportedName}; + use crate::types::SymbolKind; + + fn make_file(path: &str, lang: Language) -> ParsedFile { + ParsedFile { + path: path.to_string(), + language: lang, + definitions: vec![], + imports: vec![], + exports: vec![], + call_sites: vec![], + } + } + + fn make_def(name: &str, kind: SymbolKind) -> Definition { + Definition { + name: name.to_string(), + kind, + start_line: 1, + end_line: 5, + } + } + + fn make_import(source: &str) -> ImportInfo { + ImportInfo { + source: source.to_string(), + names: vec![], + is_default: false, + is_namespace: false, + line: 1, + } + } + + fn make_import_with_names(source: &str, names: Vec<&str>) -> ImportInfo { + ImportInfo { + source: source.to_string(), + names: names + .into_iter() + .map(|n| ImportedName { + name: n.to_string(), + alias: None, + }) + .collect(), + is_default: false, + is_namespace: false, + line: 1, + } + } + + fn make_export(name: &str, is_default: bool) -> ExportInfo { + ExportInfo { + name: name.to_string(), + is_default, + is_reexport: false, + source: None, + line: 1, + } + } + + fn make_call(callee: &str, containing: Option<&str>) -> CallSite { + CallSite { + callee: callee.to_string(), + line: 1, + containing_function: containing.map(|s| s.to_string()), + } + } + + // ======================================================================== + // Test file detection + // ======================================================================== + + #[test] + fn test_detect_test_file_by_path_dot_test() { + let file = make_file("src/utils.test.ts", Language::TypeScript); + let result = detect_entrypoints(&[file]); + assert_eq!(result.len(), 1); + assert_eq!(result[0].entrypoint_type, EntrypointType::TestFile); + assert_eq!(result[0].symbol, "utils"); + } + + #[test] + fn test_detect_test_file_by_path_dot_spec() { + let file = make_file("src/utils.spec.js", Language::JavaScript); + let result = detect_entrypoints(&[file]); + assert_eq!(result.len(), 1); + assert_eq!(result[0].entrypoint_type, EntrypointType::TestFile); + } + + #[test] + fn test_detect_test_file_python_prefix() { + let file = make_file("tests/test_utils.py", Language::Python); + let result = detect_entrypoints(&[file]); + assert_eq!(result.len(), 1); + assert_eq!(result[0].entrypoint_type, EntrypointType::TestFile); + } + + #[test] + fn test_detect_test_file_tests_directory() { + let file = make_file("__tests__/App.test.tsx", Language::TypeScript); + let result = detect_entrypoints(&[file]); + assert_eq!(result.len(), 1); + assert_eq!(result[0].entrypoint_type, EntrypointType::TestFile); + } + + #[test] + fn test_detect_test_file_with_test_functions() { + let mut file = make_file("tests/test_auth.py", Language::Python); + file.definitions = vec![ + make_def("test_login", SymbolKind::Function), + make_def("test_logout", SymbolKind::Function), + make_def("helper_setup", SymbolKind::Function), + ]; + let result = detect_entrypoints(&[file]); + // Should detect test_login and test_logout but not helper_setup + assert_eq!(result.len(), 2); + assert!(result.iter().any(|e| e.symbol == "test_login")); + assert!(result.iter().any(|e| e.symbol == "test_logout")); + } + + #[test] + fn test_non_test_file_not_detected() { + let file = make_file("src/utils.ts", Language::TypeScript); + let result = detect_entrypoints(&[file]); + assert!(result.is_empty()); + } + + // ======================================================================== + // HTTP route detection — JS/TS + // ======================================================================== + + #[test] + fn test_detect_express_route() { + let mut file = make_file("src/routes/users.ts", Language::TypeScript); + file.call_sites = vec![ + make_call("app.get", Some("setupRoutes")), + make_call("app.post", Some("setupRoutes")), + ]; + let result = detect_entrypoints(&[file]); + assert!(!result.is_empty()); + assert!(result + .iter() + .all(|e| e.entrypoint_type == EntrypointType::HttpRoute)); + } + + #[test] + fn test_detect_router_route() { + let mut file = make_file("src/routes/api.ts", Language::TypeScript); + file.call_sites = vec![make_call("router.get", Some("getUsers"))]; + let result = detect_entrypoints(&[file]); + assert_eq!(result.len(), 1); + assert_eq!(result[0].symbol, "getUsers"); + assert_eq!(result[0].entrypoint_type, EntrypointType::HttpRoute); + } + + #[test] + fn test_detect_nextjs_app_router_route() { + let mut file = make_file("src/app/api/users/route.ts", Language::TypeScript); + file.exports = vec![make_export("GET", false), make_export("POST", false)]; + let result = detect_entrypoints(&[file]); + assert_eq!(result.len(), 2); + assert!(result.iter().any(|e| e.symbol == "GET")); + assert!(result.iter().any(|e| e.symbol == "POST")); + assert!(result + .iter() + .all(|e| e.entrypoint_type == EntrypointType::HttpRoute)); + } + + #[test] + fn test_detect_nextjs_pages_router() { + let mut file = make_file("pages/about.tsx", Language::TypeScript); + file.exports = vec![make_export("AboutPage", true)]; + let result = detect_entrypoints(&[file]); + assert_eq!(result.len(), 1); + assert_eq!(result[0].symbol, "AboutPage"); + assert_eq!(result[0].entrypoint_type, EntrypointType::HttpRoute); + } + + #[test] + fn test_nextjs_pages_skip_internal_files() { + let mut file = make_file("pages/_app.tsx", Language::TypeScript); + file.exports = vec![make_export("App", true)]; + let result = detect_entrypoints(&[file]); + // _app.tsx should NOT be detected as a page route + assert!(result + .iter() + .all(|e| e.entrypoint_type != EntrypointType::HttpRoute)); + } + + #[test] + fn test_non_route_call_not_detected() { + let mut file = make_file("src/utils.ts", Language::TypeScript); + file.call_sites = vec![make_call("console.log", Some("debug"))]; + let result = detect_entrypoints(&[file]); + assert!(result.is_empty()); + } + + // ======================================================================== + // HTTP route detection — Python + // ======================================================================== + + #[test] + fn test_detect_flask_route() { + let mut file = make_file("src/routes.py", Language::Python); + file.imports = vec![make_import_with_names("flask", vec!["Flask"])]; + file.call_sites = vec![make_call("app.route", Some("list_users"))]; + let result = detect_entrypoints(&[file]); + assert!(result + .iter() + .any(|e| e.symbol == "list_users" && e.entrypoint_type == EntrypointType::HttpRoute)); + } + + #[test] + fn test_detect_fastapi_route() { + let mut file = make_file("src/routes.py", Language::Python); + file.imports = vec![make_import_with_names("fastapi", vec!["FastAPI"])]; + file.call_sites = vec![make_call("app.get", Some("get_users"))]; + let result = detect_entrypoints(&[file]); + assert!(result + .iter() + .any(|e| e.symbol == "get_users" && e.entrypoint_type == EntrypointType::HttpRoute)); + } + + #[test] + fn test_detect_python_views_module() { + let mut file = make_file("myapp/views.py", Language::Python); + file.imports = vec![make_import("django.http")]; + file.definitions = vec![ + make_def("index", SymbolKind::Function), + make_def("detail", SymbolKind::Function), + make_def("__init__", SymbolKind::Function), + make_def("_helper", SymbolKind::Function), + ]; + let result = detect_entrypoints(&[file]); + // Should detect index and detail, but not __init__ or _helper + let http_routes: Vec<_> = result + .iter() + .filter(|e| e.entrypoint_type == EntrypointType::HttpRoute) + .collect(); + assert_eq!(http_routes.len(), 2); + assert!(http_routes.iter().any(|e| e.symbol == "index")); + assert!(http_routes.iter().any(|e| e.symbol == "detail")); + } + + // ======================================================================== + // CLI command detection + // ======================================================================== + + #[test] + fn test_detect_python_main() { + let mut file = make_file("src/cli.py", Language::Python); + file.definitions = vec![make_def("main", SymbolKind::Function)]; + let result = detect_entrypoints(&[file]); + assert!(result + .iter() + .any(|e| e.symbol == "main" && e.entrypoint_type == EntrypointType::CliCommand)); + } + + #[test] + fn test_detect_ts_main_in_cli_path() { + let mut file = make_file("src/cli/main.ts", Language::TypeScript); + file.definitions = vec![make_def("main", SymbolKind::Function)]; + let result = detect_entrypoints(&[file]); + assert!(result + .iter() + .any(|e| e.symbol == "main" && e.entrypoint_type == EntrypointType::CliCommand)); + } + + #[test] + fn test_detect_commander_cli() { + let mut file = make_file("src/cli.ts", Language::TypeScript); + file.imports = vec![make_import("commander")]; + let result = detect_entrypoints(&[file]); + assert!(result + .iter() + .any(|e| e.entrypoint_type == EntrypointType::CliCommand)); + } + + #[test] + fn test_detect_click_cli() { + let mut file = make_file("src/main.py", Language::Python); + file.imports = vec![make_import("click")]; + file.definitions = vec![make_def("main", SymbolKind::Function)]; + file.call_sites = vec![make_call("click.command", Some("main"))]; + let result = detect_entrypoints(&[file]); + let cli_entries: Vec<_> = result + .iter() + .filter(|e| e.entrypoint_type == EntrypointType::CliCommand) + .collect(); + assert!(!cli_entries.is_empty()); + } + + #[test] + fn test_detect_bin_path_as_cli() { + let file = make_file("bin/run.js", Language::JavaScript); + let result = detect_entrypoints(&[file]); + assert!(result + .iter() + .any(|e| e.entrypoint_type == EntrypointType::CliCommand)); + } + + #[test] + fn test_main_in_non_cli_path_not_cli_for_ts() { + // A main() in a random TS file shouldn't be CLI + let mut file = make_file("src/components/Widget.ts", Language::TypeScript); + file.definitions = vec![make_def("main", SymbolKind::Function)]; + let result = detect_entrypoints(&[file]); + assert!(result + .iter() + .all(|e| e.entrypoint_type != EntrypointType::CliCommand)); + } + + // ======================================================================== + // Queue consumer detection + // ======================================================================== + + #[test] + fn test_detect_bull_queue_consumer() { + let mut file = make_file("src/workers/email.ts", Language::TypeScript); + file.imports = vec![make_import("bullmq")]; + file.call_sites = vec![make_call("queue.process", Some("processEmail"))]; + let result = detect_entrypoints(&[file]); + assert!(result + .iter() + .any(|e| e.entrypoint_type == EntrypointType::QueueConsumer)); + } + + #[test] + fn test_detect_celery_consumer() { + let mut file = make_file("src/tasks/send_email.py", Language::Python); + file.imports = vec![make_import("celery")]; + file.definitions = vec![make_def("process_email", SymbolKind::Function)]; + // Worker path + celery import → queue consumer for process-like functions + let result = detect_entrypoints(&[file]); + assert!(result + .iter() + .any(|e| e.entrypoint_type == EntrypointType::QueueConsumer)); + } + + #[test] + fn test_no_queue_without_import() { + let mut file = make_file("src/workers/email.ts", Language::TypeScript); + file.call_sites = vec![make_call("queue.process", Some("processEmail"))]; + // No queue import → no detection + let result = detect_entrypoints(&[file]); + assert!(result + .iter() + .all(|e| e.entrypoint_type != EntrypointType::QueueConsumer)); + } + + // ======================================================================== + // Cron job detection + // ======================================================================== + + #[test] + fn test_detect_node_cron() { + let mut file = make_file("src/cron/cleanup.ts", Language::TypeScript); + file.imports = vec![make_import("node-cron")]; + file.call_sites = vec![make_call("cron.schedule", Some("scheduleCleanup"))]; + let result = detect_entrypoints(&[file]); + assert!(result + .iter() + .any(|e| e.entrypoint_type == EntrypointType::CronJob)); + } + + #[test] + fn test_detect_apscheduler() { + let mut file = make_file("src/scheduler/jobs.py", Language::Python); + file.imports = vec![make_import("apscheduler")]; + file.call_sites = vec![make_call("scheduler.add_job", Some("daily_report"))]; + let result = detect_entrypoints(&[file]); + assert!(result + .iter() + .any(|e| e.entrypoint_type == EntrypointType::CronJob)); + } + + // ======================================================================== + // React page detection + // ======================================================================== + + #[test] + fn test_detect_nextjs_page_tsx() { + let mut file = make_file("src/app/dashboard/page.tsx", Language::TypeScript); + file.exports = vec![make_export("DashboardPage", true)]; + let result = detect_entrypoints(&[file]); + assert!( + result + .iter() + .any(|e| e.symbol == "DashboardPage" + && e.entrypoint_type == EntrypointType::ReactPage) + ); + } + + #[test] + fn test_detect_pages_dir_page() { + let mut file = make_file("pages/dashboard.tsx", Language::TypeScript); + file.exports = vec![make_export("Dashboard", true)]; + let result = detect_entrypoints(&[file]); + // Should be detected as either HttpRoute (from pages router detection) or ReactPage + assert!(!result.is_empty()); + } + + #[test] + fn test_python_file_not_react_page() { + let mut file = make_file("pages/admin.py", Language::Python); + file.exports = vec![]; + let result = detect_entrypoints(&[file]); + assert!(result + .iter() + .all(|e| e.entrypoint_type != EntrypointType::ReactPage)); + } + + // ======================================================================== + // Event handler detection + // ======================================================================== + + #[test] + fn test_detect_socket_event_handler() { + let mut file = make_file("src/socket/handler.ts", Language::TypeScript); + file.imports = vec![make_import("socket.io")]; + file.call_sites = vec![make_call("socket.on", Some("handleConnection"))]; + let result = detect_entrypoints(&[file]); + assert!(result + .iter() + .any(|e| e.entrypoint_type == EntrypointType::EventHandler)); + } + + #[test] + fn test_detect_eventemitter_handler() { + let mut file = make_file("src/events/listener.ts", Language::TypeScript); + file.imports = vec![make_import("events")]; + file.call_sites = vec![make_call("emitter.addListener", Some("onUserCreated"))]; + let result = detect_entrypoints(&[file]); + assert!(result + .iter() + .any(|e| e.entrypoint_type == EntrypointType::EventHandler)); + } + + #[test] + fn test_no_event_handler_without_import() { + let mut file = make_file("src/events/listener.ts", Language::TypeScript); + file.call_sites = vec![make_call("emitter.on", Some("handler"))]; + // No event import → no detection + let result = detect_entrypoints(&[file]); + assert!(result + .iter() + .all(|e| e.entrypoint_type != EntrypointType::EventHandler)); + } + + // ======================================================================== + // Multi-entrypoint and deduplication + // ======================================================================== + + #[test] + fn test_multiple_files_multiple_entrypoints() { + let mut route_file = make_file("src/routes/users.ts", Language::TypeScript); + route_file.call_sites = vec![ + make_call("router.get", Some("getUsers")), + make_call("router.post", Some("createUser")), + ]; + + let test_file = make_file("src/routes/users.test.ts", Language::TypeScript); + + let mut cli_file = make_file("src/cli/main.ts", Language::TypeScript); + cli_file.definitions = vec![make_def("main", SymbolKind::Function)]; + + let result = detect_entrypoints(&[route_file, test_file, cli_file]); + + let types: Vec<_> = result.iter().map(|e| &e.entrypoint_type).collect(); + assert!(types.contains(&&EntrypointType::HttpRoute)); + assert!(types.contains(&&EntrypointType::TestFile)); + assert!(types.contains(&&EntrypointType::CliCommand)); + } + + #[test] + fn test_deduplication() { + // A file that could trigger the same entrypoint via multiple detection paths + let mut file = make_file("src/app/api/users/route.ts", Language::TypeScript); + file.exports = vec![make_export("GET", false)]; + + let result = detect_entrypoints(&[file]); + // Should not have duplicates + let get_entries: Vec<_> = result + .iter() + .filter(|e| e.symbol == "GET" && e.file == "src/app/api/users/route.ts") + .collect(); + assert_eq!(get_entries.len(), 1); + } + + #[test] + fn test_empty_input() { + let result = detect_entrypoints(&[]); + assert!(result.is_empty()); + } + + #[test] + fn test_no_entrypoints_in_plain_utility() { + let mut file = make_file("src/utils/format.ts", Language::TypeScript); + file.definitions = vec![ + make_def("formatDate", SymbolKind::Function), + make_def("formatCurrency", SymbolKind::Function), + ]; + file.imports = vec![make_import("date-fns")]; + let result = detect_entrypoints(&[file]); + assert!(result.is_empty()); + } + + // ======================================================================== + // Edge cases + // ======================================================================== + + // ======================================================================== + // Effect.ts HTTP route detection + // ======================================================================== + + #[test] + fn test_detect_effect_http_api_endpoint() { + let mut file = make_file("src/api/users.ts", Language::TypeScript); + file.imports = vec![make_import_with_names( + "@effect/platform", + vec!["HttpApiEndpoint", "HttpApi"], + )]; + file.call_sites = vec![ + make_call("HttpApiEndpoint.get", Some("getUserEndpoint")), + make_call("HttpApiEndpoint.post", Some("createUserEndpoint")), + ]; + let result = detect_entrypoints(&[file]); + let http: Vec<_> = result + .iter() + .filter(|e| e.entrypoint_type == EntrypointType::HttpRoute) + .collect(); + assert_eq!(http.len(), 2); + assert!(http.iter().any(|e| e.symbol == "getUserEndpoint")); + assert!(http.iter().any(|e| e.symbol == "createUserEndpoint")); + } + + #[test] + fn test_detect_effect_http_api_make() { + let mut file = make_file("src/api/index.ts", Language::TypeScript); + file.imports = vec![make_import("@effect/platform/HttpApi")]; + file.call_sites = vec![make_call("HttpApi.make", Some("makeApi"))]; + let result = detect_entrypoints(&[file]); + assert!(result + .iter() + .any(|e| e.symbol == "makeApi" && e.entrypoint_type == EntrypointType::HttpRoute)); + } + + #[test] + fn test_detect_effect_http_api_group() { + let mut file = make_file("src/api/group.ts", Language::TypeScript); + file.imports = vec![make_import("@effect/platform/HttpApiGroup")]; + file.call_sites = vec![make_call("HttpApiGroup.make", Some("usersGroup"))]; + let result = detect_entrypoints(&[file]); + assert!(result + .iter() + .any(|e| e.symbol == "usersGroup" && e.entrypoint_type == EntrypointType::HttpRoute)); + } + + #[test] + fn test_detect_effect_http_router() { + let mut file = make_file("src/router.ts", Language::TypeScript); + file.imports = vec![make_import_with_names( + "@effect/platform", + vec!["HttpRouter"], + )]; + file.call_sites = vec![ + make_call("HttpRouter.get", Some("getHandler")), + make_call("HttpRouter.post", Some("postHandler")), + ]; + let result = detect_entrypoints(&[file]); + let http: Vec<_> = result + .iter() + .filter(|e| e.entrypoint_type == EntrypointType::HttpRoute) + .collect(); + assert_eq!(http.len(), 2); + assert!(http.iter().any(|e| e.symbol == "getHandler")); + assert!(http.iter().any(|e| e.symbol == "postHandler")); + } + + #[test] + fn test_detect_effect_http_subpath_import() { + let mut file = make_file("src/api/endpoint.ts", Language::TypeScript); + file.imports = vec![make_import("@effect/platform/HttpApiEndpoint")]; + file.call_sites = vec![make_call("HttpApiEndpoint.put", Some("updateUser"))]; + let result = detect_entrypoints(&[file]); + assert!(result + .iter() + .any(|e| e.symbol == "updateUser" && e.entrypoint_type == EntrypointType::HttpRoute)); + } + + #[test] + fn test_no_effect_http_without_import() { + let mut file = make_file("src/api/users.ts", Language::TypeScript); + file.call_sites = vec![make_call("HttpApiEndpoint.get", Some("getUser"))]; + let result = detect_entrypoints(&[file]); + assert!(result + .iter() + .all(|e| e.entrypoint_type != EntrypointType::HttpRoute)); + } + + // ======================================================================== + // Effect.ts CLI command detection + // ======================================================================== + + #[test] + fn test_detect_effect_cli_command_make() { + let mut file = make_file("src/cli/main.ts", Language::TypeScript); + file.imports = vec![make_import_with_names( + "@effect/cli", + vec!["Command", "Args"], + )]; + file.call_sites = vec![make_call("Command.make", Some("myCommand"))]; + let result = detect_entrypoints(&[file]); + assert!(result + .iter() + .any(|e| e.symbol == "myCommand" && e.entrypoint_type == EntrypointType::CliCommand)); + } + + #[test] + fn test_detect_effect_cli_command_run() { + let mut file = make_file("src/cli.ts", Language::TypeScript); + file.imports = vec![make_import("@effect/cli/Command")]; + file.call_sites = vec![make_call("Command.run", Some("runCli"))]; + let result = detect_entrypoints(&[file]); + assert!(result + .iter() + .any(|e| e.symbol == "runCli" && e.entrypoint_type == EntrypointType::CliCommand)); + } + + #[test] + fn test_no_effect_cli_without_import() { + let mut file = make_file("src/cli.ts", Language::TypeScript); + file.call_sites = vec![make_call("Command.make", Some("myCmd"))]; + let result = detect_entrypoints(&[file]); + // Without @effect/cli import, should not detect via Effect.ts CLI path + // (may detect via other paths if path matches cli patterns) + assert!(result + .iter() + .all(|e| e.symbol != "myCmd" || e.entrypoint_type != EntrypointType::CliCommand)); + } + + // ======================================================================== + // Effect.ts queue consumer detection + // ======================================================================== + + #[test] + fn test_detect_effect_queue_take() { + let mut file = make_file("src/workers/processor.ts", Language::TypeScript); + file.imports = vec![make_import_with_names("effect", vec!["Queue"])]; + file.call_sites = vec![make_call("Queue.take", Some("processMessages"))]; + let result = detect_entrypoints(&[file]); + assert!(result + .iter() + .any(|e| e.symbol == "processMessages" + && e.entrypoint_type == EntrypointType::QueueConsumer)); + } + + #[test] + fn test_detect_effect_pubsub_subscribe() { + let mut file = make_file("src/events/subscriber.ts", Language::TypeScript); + file.imports = vec![make_import_with_names("effect", vec!["PubSub"])]; + file.call_sites = vec![make_call("PubSub.subscribe", Some("handleEvents"))]; + let result = detect_entrypoints(&[file]); + assert!(result + .iter() + .any(|e| e.symbol == "handleEvents" + && e.entrypoint_type == EntrypointType::QueueConsumer)); + } + + #[test] + fn test_detect_effect_queue_subpath_import() { + let mut file = make_file("src/worker.ts", Language::TypeScript); + file.imports = vec![make_import("effect/Queue")]; + file.call_sites = vec![make_call("Queue.dequeue", Some("drain"))]; + let result = detect_entrypoints(&[file]); + assert!(result + .iter() + .any(|e| e.symbol == "drain" && e.entrypoint_type == EntrypointType::QueueConsumer)); + } + + // ======================================================================== + // Effect.ts cron job detection + // ======================================================================== + + #[test] + fn test_detect_effect_schedule_cron() { + let mut file = make_file("src/cron/cleanup.ts", Language::TypeScript); + file.imports = vec![make_import_with_names("effect", vec!["Schedule"])]; + file.call_sites = vec![make_call("Schedule.cron", Some("dailyCleanup"))]; + let result = detect_entrypoints(&[file]); + assert!(result + .iter() + .any(|e| e.symbol == "dailyCleanup" && e.entrypoint_type == EntrypointType::CronJob)); + } + + #[test] + fn test_detect_effect_schedule_spaced() { + let mut file = make_file("src/scheduler.ts", Language::TypeScript); + file.imports = vec![make_import("effect/Schedule")]; + file.call_sites = vec![make_call("Schedule.spaced", Some("heartbeat"))]; + let result = detect_entrypoints(&[file]); + assert!(result + .iter() + .any(|e| e.symbol == "heartbeat" && e.entrypoint_type == EntrypointType::CronJob)); + } + + #[test] + fn test_detect_effect_cron_make() { + let mut file = make_file("src/cron.ts", Language::TypeScript); + file.imports = vec![make_import("@effect/cron")]; + file.call_sites = vec![make_call("Cron.make", Some("setupCron"))]; + let result = detect_entrypoints(&[file]); + assert!(result + .iter() + .any(|e| e.symbol == "setupCron" && e.entrypoint_type == EntrypointType::CronJob)); + } + + #[test] + fn test_no_effect_cron_without_import() { + let mut file = make_file("src/utils.ts", Language::TypeScript); + file.call_sites = vec![make_call("Schedule.cron", Some("nope"))]; + let result = detect_entrypoints(&[file]); + assert!(result + .iter() + .all(|e| e.entrypoint_type != EntrypointType::CronJob)); + } + + // ======================================================================== + // Effect.ts test file detection + // ======================================================================== + + #[test] + fn test_detect_effect_vitest_it_effect() { + let mut file = make_file("src/services/auth.test.ts", Language::TypeScript); + file.imports = vec![make_import("@effect/vitest")]; + file.call_sites = vec![make_call("it.effect", Some("describe"))]; + let result = detect_entrypoints(&[file]); + // Should be detected via both test path and Effect.ts vitest + assert!(result + .iter() + .any(|e| e.entrypoint_type == EntrypointType::TestFile)); + } + + #[test] + fn test_detect_effect_vitest_it_scoped() { + let mut file = make_file("src/services/db.test.ts", Language::TypeScript); + file.imports = vec![make_import("@effect/vitest")]; + file.call_sites = vec![make_call("it.scoped", Some("dbTests"))]; + let result = detect_entrypoints(&[file]); + assert!(result + .iter() + .any(|e| e.entrypoint_type == EntrypointType::TestFile)); + } + + #[test] + fn test_detect_effect_vitest_it_live() { + let mut file = make_file("test/integration.test.ts", Language::TypeScript); + file.imports = vec![make_import("@effect/vitest")]; + file.call_sites = vec![make_call("it.live", Some("liveTest"))]; + let result = detect_entrypoints(&[file]); + assert!(result + .iter() + .any(|e| e.entrypoint_type == EntrypointType::TestFile)); + } + + // ======================================================================== + // Effect.ts event handler detection + // ======================================================================== + + #[test] + fn test_detect_effect_stream_run() { + let mut file = make_file("src/streams/processor.ts", Language::TypeScript); + file.imports = vec![make_import_with_names("effect", vec!["Stream"])]; + file.call_sites = vec![make_call("Stream.runForEach", Some("processStream"))]; + let result = detect_entrypoints(&[file]); + assert!(result + .iter() + .any(|e| e.symbol == "processStream" + && e.entrypoint_type == EntrypointType::EventHandler)); + } + + #[test] + fn test_detect_effect_hub_subscribe() { + let mut file = make_file("src/events/hub.ts", Language::TypeScript); + file.imports = vec![make_import_with_names("effect", vec!["Hub"])]; + file.call_sites = vec![make_call("Hub.subscribe", Some("listenForEvents"))]; + let result = detect_entrypoints(&[file]); + assert!(result + .iter() + .any(|e| e.symbol == "listenForEvents" + && e.entrypoint_type == EntrypointType::EventHandler)); + } + + #[test] + fn test_detect_effect_stream_subpath_import() { + let mut file = make_file("src/stream.ts", Language::TypeScript); + file.imports = vec![make_import("effect/Stream")]; + file.call_sites = vec![make_call("Stream.runDrain", Some("drainEvents"))]; + let result = detect_entrypoints(&[file]); + assert!(result.iter().any( + |e| e.symbol == "drainEvents" && e.entrypoint_type == EntrypointType::EventHandler + )); + } + + #[test] + fn test_no_effect_event_without_import() { + let mut file = make_file("src/stream.ts", Language::TypeScript); + file.call_sites = vec![make_call("Stream.run", Some("noImport"))]; + let result = detect_entrypoints(&[file]); + assert!(result + .iter() + .all(|e| e.entrypoint_type != EntrypointType::EventHandler)); + } + + // ======================================================================== + // Effect.ts service detection + // ======================================================================== + + #[test] + fn test_detect_effect_service() { + let mut file = make_file("src/services/user.ts", Language::TypeScript); + file.imports = vec![make_import_with_names("effect", vec!["Effect", "Context"])]; + file.call_sites = vec![make_call("Effect.Service", Some("UserService"))]; + let result = detect_entrypoints(&[file]); + assert!(result.iter().any( + |e| e.symbol == "UserService" && e.entrypoint_type == EntrypointType::EffectService + )); + } + + #[test] + fn test_detect_context_tag() { + let mut file = make_file("src/services/db.ts", Language::TypeScript); + file.imports = vec![make_import_with_names("effect", vec!["Context"])]; + file.call_sites = vec![make_call("Context.Tag", Some("DatabaseService"))]; + let result = detect_entrypoints(&[file]); + assert!(result + .iter() + .any(|e| e.symbol == "DatabaseService" + && e.entrypoint_type == EntrypointType::EffectService)); + } + + #[test] + fn test_detect_context_generic_tag() { + let mut file = make_file("src/services/config.ts", Language::TypeScript); + file.imports = vec![make_import("effect/Context")]; + file.call_sites = vec![make_call("Context.GenericTag", Some("ConfigService"))]; + let result = detect_entrypoints(&[file]); + assert!(result + .iter() + .any(|e| e.symbol == "ConfigService" + && e.entrypoint_type == EntrypointType::EffectService)); + } + + #[test] + fn test_detect_layer_succeed() { + let mut file = make_file("src/layers/live.ts", Language::TypeScript); + file.imports = vec![make_import_with_names("effect", vec!["Layer"])]; + file.call_sites = vec![make_call("Layer.succeed", Some("LiveLayer"))]; + let result = detect_entrypoints(&[file]); + assert!( + result + .iter() + .any(|e| e.symbol == "LiveLayer" + && e.entrypoint_type == EntrypointType::EffectService) + ); + } + + #[test] + fn test_detect_layer_effect() { + let mut file = make_file("src/layers/db.ts", Language::TypeScript); + file.imports = vec![make_import("effect/Layer")]; + file.call_sites = vec![make_call("Layer.effect", Some("DbLayer"))]; + let result = detect_entrypoints(&[file]); + assert!(result + .iter() + .any(|e| e.symbol == "DbLayer" && e.entrypoint_type == EntrypointType::EffectService)); + } + + #[test] + fn test_detect_layer_scoped() { + let mut file = make_file("src/layers/connection.ts", Language::TypeScript); + file.imports = vec![make_import_with_names("effect", vec!["Layer", "Effect"])]; + file.call_sites = vec![make_call("Layer.scoped", Some("ConnectionLayer"))]; + let result = detect_entrypoints(&[file]); + assert!(result + .iter() + .any(|e| e.symbol == "ConnectionLayer" + && e.entrypoint_type == EntrypointType::EffectService)); + } + + #[test] + fn test_no_effect_service_without_import() { + let mut file = make_file("src/services/user.ts", Language::TypeScript); + file.call_sites = vec![make_call("Effect.Service", Some("UserService"))]; + let result = detect_entrypoints(&[file]); + assert!(result + .iter() + .all(|e| e.entrypoint_type != EntrypointType::EffectService)); + } + + // ======================================================================== + // Effect.ts edge cases + // ======================================================================== + + #[test] + fn test_effect_ts_python_file_ignored() { + let mut file = make_file("src/services/user.py", Language::Python); + file.imports = vec![make_import_with_names("effect", vec!["Effect"])]; + file.call_sites = vec![make_call("Effect.Service", Some("UserService"))]; + let result = detect_entrypoints(&[file]); + assert!(result + .iter() + .all(|e| e.entrypoint_type != EntrypointType::EffectService)); + } + + #[test] + fn test_effect_multiple_entrypoint_types() { + let mut file = make_file("src/api/server.ts", Language::TypeScript); + file.imports = vec![ + make_import_with_names("@effect/platform", vec!["HttpApiEndpoint"]), + make_import_with_names("effect", vec!["Effect", "Layer"]), + ]; + file.call_sites = vec![ + make_call("HttpApiEndpoint.get", Some("getEndpoint")), + make_call("Layer.succeed", Some("ApiLayer")), + ]; + let result = detect_entrypoints(&[file]); + assert!(result + .iter() + .any(|e| e.entrypoint_type == EntrypointType::HttpRoute)); + assert!(result + .iter() + .any(|e| e.entrypoint_type == EntrypointType::EffectService)); + } + + #[test] + fn test_effect_platform_node_import() { + let mut file = make_file("src/server.ts", Language::TypeScript); + file.imports = vec![make_import_with_names( + "@effect/platform-node", + vec!["HttpServer"], + )]; + file.call_sites = vec![make_call("HttpRouter.get", Some("serveApp"))]; + let result = detect_entrypoints(&[file]); + assert!(result + .iter() + .any(|e| e.symbol == "serveApp" && e.entrypoint_type == EntrypointType::HttpRoute)); + } + + #[test] + fn test_effect_deduplication_with_regular_detection() { + // A file detected as test by both path-based and Effect.ts vitest detection + let mut file = make_file("src/auth.test.ts", Language::TypeScript); + file.imports = vec![make_import("@effect/vitest")]; + file.call_sites = vec![make_call("it.effect", Some("describe"))]; + let result = detect_entrypoints(&[file]); + // Should deduplicate — same (file, symbol) pair + let test_entries: Vec<_> = result + .iter() + .filter(|e| e.file == "src/auth.test.ts" && e.symbol == "describe") + .collect(); + assert_eq!(test_entries.len(), 1); + } + + // ======================================================================== + // Edge cases (existing + extended) + // ======================================================================== + + #[test] + fn test_unknown_language_no_entrypoints() { + let file = make_file("main.go", Language::Unknown); + let result = detect_entrypoints(&[file]); + assert!(result.is_empty()); + } + + #[test] + fn test_file_stem_extraction() { + assert_eq!(file_stem("src/utils/format.ts"), "format"); + assert_eq!(file_stem("main.py"), "main"); + assert_eq!(file_stem("Makefile"), "Makefile"); + } + + // ======================================================================== + // Path detection helpers + // ======================================================================== + + #[test] + fn test_is_test_path_variants() { + assert!(is_test_path("src/utils.test.ts")); + assert!(is_test_path("src/utils.spec.js")); + assert!(is_test_path("__tests__/App.test.tsx")); + assert!(is_test_path("tests/test_utils.py")); + assert!(is_test_path("test/integration.py")); + assert!(is_test_path("src/auth_test.py")); + assert!(!is_test_path("src/utils.ts")); + assert!(!is_test_path("src/testing-utils.ts")); + } + + #[test] + fn test_is_nextjs_route_file() { + assert!(is_nextjs_route_file("src/app/api/users/route.ts")); + assert!(is_nextjs_route_file("app/api/route.ts")); + assert!(!is_nextjs_route_file("src/app/api/users/page.ts")); + assert!(!is_nextjs_route_file("src/routes/users.ts")); + } + + #[test] + fn test_is_worker_path() { + assert!(is_worker_path("src/workers/email.ts")); + assert!(is_worker_path("src/jobs/cleanup.py")); + assert!(is_worker_path("src/email_worker.ts")); + assert!(!is_worker_path("src/services/email.ts")); + } + + // ======================================================================= + // IR-based entrypoint parity tests + // ======================================================================= + + mod ir_parity { + use super::*; + use crate::ast; + use crate::ir::IrFile; + + /// Helper: detect entrypoints via both paths and compare. + fn detect_both(files: &[(&str, &str)]) -> (Vec, Vec) { + let parsed: Vec = files + .iter() + .map(|(path, source)| ast::parse_file(path, source).unwrap()) + .collect(); + let ir_files: Vec = parsed.iter().map(IrFile::from_parsed_file).collect(); + + let from_parsed = detect_entrypoints(&parsed); + let from_ir = detect_entrypoints_ir(&ir_files); + (from_parsed, from_ir) + } + + #[test] + fn test_ir_parity_test_file() { + let (ep, ei) = detect_both(&[( + "src/utils.test.ts", + r#" +function test_validate() {} +function test_sanitize() {} +"#, + )]); + + assert_eq!(ep.len(), ei.len(), "entrypoint count should match"); + for (a, b) in ep.iter().zip(ei.iter()) { + assert_eq!(a.file, b.file); + assert_eq!(a.symbol, b.symbol); + assert_eq!(a.entrypoint_type, b.entrypoint_type); + } + } + + #[test] + fn test_ir_parity_express_route() { + let (ep, ei) = detect_both(&[( + "src/routes/users.ts", + r#" +import express from 'express'; +const router = express.Router(); +function getUsers() {} +router.get('/users', getUsers); +"#, + )]); + + assert_eq!( + ep.len(), + ei.len(), + "Express route entrypoint count should match" + ); + for (a, b) in ep.iter().zip(ei.iter()) { + assert_eq!(a.file, b.file); + assert_eq!(a.entrypoint_type, b.entrypoint_type); + } + } + + #[test] + fn test_ir_parity_flask_route() { + let (ep, ei) = detect_both(&[( + "app/views.py", + r#" +from flask import Flask +app = Flask(__name__) + +def list_users(): + pass +app.route('/users')(list_users) +"#, + )]); + + assert_eq!(ep.len(), ei.len()); + } + + #[test] + fn test_ir_parity_nextjs_route() { + let (ep, ei) = detect_both(&[( + "src/app/api/users/route.ts", + r#" +export function GET(request: Request) { + return Response.json({}); +} +export function POST(request: Request) { + return Response.json({}); +} +"#, + )]); + + assert_eq!( + ep.len(), + ei.len(), + "Next.js route entrypoint count should match" + ); + for (a, b) in ep.iter().zip(ei.iter()) { + assert_eq!(a.file, b.file); + assert_eq!(a.symbol, b.symbol); + } + } + + #[test] + fn test_ir_parity_cli_command() { + let (ep, ei) = detect_both(&[( + "src/cli.py", + r#" +import click + +def main(): + pass + +click.command()(main) +"#, + )]); + + assert_eq!(ep.len(), ei.len()); + } + + #[test] + fn test_ir_parity_no_entrypoints() { + let (ep, ei) = detect_both(&[( + "src/utils.ts", + r#" +export function validate(data: any) { return data; } +export function sanitize(data: any) { return data; } +"#, + )]); + + assert_eq!(ep.len(), ei.len(), "no entrypoints should be found"); + assert!(ep.is_empty()); + } + + #[test] + fn test_ir_parity_multiple_files() { + let (ep, ei) = detect_both(&[ + ( + "src/app/api/users/route.ts", + r#" +export function GET() { return Response.json([]); } +"#, + ), + ( + "src/utils.test.ts", + r#" +function test_something() {} +"#, + ), + ( + "src/services/user.ts", + r#" +export function createUser(data: any) {} +"#, + ), + ]); + + assert_eq!( + ep.len(), + ei.len(), + "multi-file entrypoint count should match" + ); + for (a, b) in ep.iter().zip(ei.iter()) { + assert_eq!(a.file, b.file); + assert_eq!(a.symbol, b.symbol); + assert_eq!(a.entrypoint_type, b.entrypoint_type); + } + } + + #[test] + fn test_ir_parity_empty() { + let from_parsed = detect_entrypoints(&[]); + let from_ir = detect_entrypoints_ir(&[]); + assert_eq!(from_parsed.len(), from_ir.len()); + assert!(from_ir.is_empty()); + } + + #[test] + fn test_ir_parity_effect_ts_service() { + let (ep, ei) = detect_both(&[( + "src/services/user.ts", + r#" +import { Effect, Context } from 'effect'; +function UserService() {} +Effect.Service(UserService); +"#, + )]); + + assert_eq!(ep.len(), ei.len()); + } + + #[test] + fn test_ir_parity_queue_consumer() { + let (ep, ei) = detect_both(&[( + "src/workers/email.ts", + r#" +import { Queue } from 'bullmq'; +function processEmail() {} +queue.process(processEmail); +"#, + )]); + + assert_eq!(ep.len(), ei.len()); + } + } + + // ======================================================================== + // Phase 3: Path-based entrypoint detection (spec §1.4) + // ======================================================================== + + #[test] + fn test_ts_routes_dir_with_express() { + let mut file = make_file("src/routes/users.ts", Language::TypeScript); + file.imports = vec![make_import("express")]; + file.definitions = vec![make_def("getUsers", SymbolKind::Function)]; + let result = detect_entrypoints(&[file]); + assert!( + result + .iter() + .any(|e| e.entrypoint_type == EntrypointType::HttpRoute), + "TS file in /routes/ with express import should be detected as HTTP entrypoint" + ); + } + + #[test] + fn test_ts_routes_dir_no_import_strong_path() { + let mut file = make_file("src/routes/users.ts", Language::TypeScript); + // No framework import — but /routes/ is a strong path signal + file.definitions = vec![make_def("getUsers", SymbolKind::Function)]; + file.exports = vec![make_export("getUsers", false)]; + let result = detect_entrypoints(&[file]); + assert!( + result + .iter() + .any(|e| e.entrypoint_type == EntrypointType::HttpRoute), + "TS file in /routes/ should be detected as entrypoint (strong path)" + ); + } + + #[test] + fn test_ts_controller_suffix_nestjs() { + let mut file = make_file("src/billing.controller.ts", Language::TypeScript); + file.imports = vec![make_import("@nestjs/common")]; + file.definitions = vec![make_def("create", SymbolKind::Function)]; + let result = detect_entrypoints(&[file]); + assert!( + result + .iter() + .any(|e| e.entrypoint_type == EntrypointType::HttpRoute), + "*.controller.ts with @nestjs/common import should be detected" + ); + } + + #[test] + fn test_ts_controller_suffix_strong_path() { + let mut file = make_file("src/billing.controller.ts", Language::TypeScript); + // No import — strong path (controller suffix) + file.definitions = vec![make_def("create", SymbolKind::Function)]; + file.exports = vec![make_export("create", false)]; + let result = detect_entrypoints(&[file]); + assert!( + result + .iter() + .any(|e| e.entrypoint_type == EntrypointType::HttpRoute), + "*.controller.ts should be detected as entrypoint (strong path)" + ); + } + + #[test] + fn test_ts_entrypoints_in_name() { + let mut file = make_file("src/command-entrypoints.ts", Language::TypeScript); + file.definitions = vec![make_def("deploy", SymbolKind::Function)]; + file.exports = vec![make_export("deploy", false)]; + let result = detect_entrypoints(&[file]); + assert!( + !result.is_empty(), + "File with 'entrypoints' in name should be detected (strong path)" + ); + } + + #[test] + fn test_go_handlers_dir() { + let mut file = make_file("internal/handlers/auth.go", Language::Go); + file.imports = vec![make_import("net/http")]; + file.definitions = vec![make_def("HandleAuth", SymbolKind::Function)]; + let result = detect_entrypoints(&[file]); + assert!( + result + .iter() + .any(|e| e.entrypoint_type == EntrypointType::HttpRoute), + "Go file in /handlers/ with net/http import should be detected" + ); + } + + #[test] + fn test_python_views_flask() { + let mut file = make_file("app/views/dashboard.py", Language::Python); + file.imports = vec![make_import("flask")]; + file.definitions = vec![make_def("index", SymbolKind::Function)]; + let result = detect_entrypoints(&[file]); + assert!( + result + .iter() + .any(|e| e.entrypoint_type == EntrypointType::HttpRoute), + "Python file in /views/ with flask import should be detected" + ); + } + + #[test] + fn test_java_controller_spring() { + let mut file = make_file("com/api/controllers/UserController.java", Language::Java); + file.imports = vec![make_import( + "org.springframework.web.bind.annotation.RestController", + )]; + file.definitions = vec![make_def("getUser", SymbolKind::Function)]; + let result = detect_entrypoints(&[file]); + assert!( + result + .iter() + .any(|e| e.entrypoint_type == EntrypointType::HttpRoute), + "Java controller in /controllers/ with Spring import should be detected" + ); + } + + #[test] + fn test_rust_handlers_axum() { + let mut file = make_file("src/handlers/auth.rs", Language::Rust); + file.imports = vec![make_import("axum")]; + file.definitions = vec![make_def("login", SymbolKind::Function)]; + let result = detect_entrypoints(&[file]); + assert!( + result + .iter() + .any(|e| e.entrypoint_type == EntrypointType::HttpRoute), + "Rust file in /handlers/ with axum import should be detected" + ); + } + + #[test] + fn test_no_false_positive_utils() { + let mut file = make_file("src/utils/helpers.ts", Language::TypeScript); + file.definitions = vec![make_def("formatDate", SymbolKind::Function)]; + let result = detect_entrypoints(&[file]); + assert!( + result.is_empty(), + "src/utils/helpers.ts should NOT be detected as entrypoint" + ); + } + + #[test] + fn test_no_false_positive_api_types() { + let mut file = make_file("src/api/types.ts", Language::TypeScript); + // No framework import, not a strong path + file.definitions = vec![make_def("UserType", SymbolKind::TypeAlias)]; + let result = detect_entrypoints(&[file]); + assert!( + result.is_empty(), + "src/api/types.ts without framework import should NOT be detected" + ); + } + + #[test] + fn test_cli_commands_dir_with_commander() { + let mut file = make_file("src/commands/deploy.ts", Language::TypeScript); + file.imports = vec![make_import("commander")]; + file.definitions = vec![make_def("deploy", SymbolKind::Function)]; + let result = detect_entrypoints(&[file]); + assert!( + result + .iter() + .any(|e| e.entrypoint_type == EntrypointType::CliCommand), + "TS file in /commands/ with commander import should be detected as CLI" + ); + } + + #[test] + fn test_cli_commands_dir_strong_path() { + let mut file = make_file("src/commands/migrate.ts", Language::TypeScript); + file.definitions = vec![make_def("migrate", SymbolKind::Function)]; + file.exports = vec![make_export("migrate", false)]; + let result = detect_entrypoints(&[file]); + assert!( + !result.is_empty(), + "/commands/ dir should detect entrypoints (strong path)" + ); + } + + // ======================================================================== + // Path helper unit tests + // ======================================================================== + + #[test] + fn test_is_route_handler_path() { + assert!(is_route_handler_path("src/routes/users.ts")); + assert!(is_route_handler_path("src/handlers/auth.go")); + assert!(is_route_handler_path("src/controllers/billing.ts")); + assert!(is_route_handler_path("src/endpoints/api.ts")); + assert!(is_route_handler_path("src/billing.controller.ts")); + assert!(is_route_handler_path("src/users.route.ts")); + assert!(!is_route_handler_path("src/utils/helpers.ts")); + assert!(!is_route_handler_path("src/models/user.ts")); + } + + #[test] + fn test_is_strong_route_handler_path() { + assert!(is_strong_route_handler_path("src/routes/users.ts")); + assert!(is_strong_route_handler_path("src/handlers/auth.go")); + assert!(is_strong_route_handler_path("src/controllers/billing.ts")); + assert!(is_strong_route_handler_path("src/billing.controller.ts")); + assert!(is_strong_route_handler_path("src/command-entrypoints.ts")); + assert!(!is_strong_route_handler_path("src/utils/helpers.ts")); + assert!(!is_strong_route_handler_path("src/api/types.ts")); + } + + #[test] + fn test_is_cli_command_path() { + assert!(is_cli_command_path("src/commands/deploy.ts")); + assert!(is_cli_command_path("src/cmd/run.go")); + assert!(is_cli_command_path("src/cli/main.ts")); + assert!(is_cli_command_path("src/deploy.command.ts")); + assert!(!is_cli_command_path("src/utils/helpers.ts")); + } + + #[test] + fn test_framework_import_helpers() { + // JS web + assert!(is_js_web_framework_import(&make_import("express"))); + assert!(is_js_web_framework_import(&make_import("@nestjs/common"))); + assert!(is_js_web_framework_import(&make_import("hono"))); + assert!(!is_js_web_framework_import(&make_import("lodash"))); + + // Go web + assert!(is_go_web_framework_import(&make_import("net/http"))); + assert!(is_go_web_framework_import(&make_import( + "github.com/gin-gonic/gin" + ))); + assert!(!is_go_web_framework_import(&make_import("fmt"))); + + // Rust web + assert!(is_rust_web_framework_import(&make_import("axum"))); + assert!(is_rust_web_framework_import(&make_import("actix_web"))); + assert!(!is_rust_web_framework_import(&make_import("serde"))); + + // Java web + assert!(is_java_web_framework_import(&make_import( + "org.springframework.web.bind.annotation.RestController" + ))); + assert!(!is_java_web_framework_import(&make_import( + "java.util.List" + ))); + + // JS CLI + assert!(is_js_cli_framework_import(&make_import("commander"))); + assert!(is_js_cli_framework_import(&make_import("yargs"))); + assert!(is_js_cli_framework_import(&make_import("@effect/cli"))); + assert!(!is_js_cli_framework_import(&make_import("express"))); + } + + // =================================================================== + // Property-based tests for path detection helpers (spec §1) + // =================================================================== + + mod proptests_path { + use super::*; + use proptest::prelude::*; + + proptest! { + /// Strong route handler paths are always also regular route handler paths. + #[test] + fn prop_strong_route_implies_regular( + dir in prop_oneof![ + Just("routes"), + Just("handlers"), + Just("controllers"), + Just("endpoints"), + ], + name in "[a-z]{3,10}", + ext in prop_oneof![Just(".ts"), Just(".js"), Just(".py"), Just(".go")], + ) { + let path = format!("src/{}/{}{}", dir, name, ext); + if is_strong_route_handler_path(&path) { + prop_assert!( + is_route_handler_path(&path), + "strong path '{}' must also be a regular route path", path, + ); + } + } + + /// Route handler path detection is case-insensitive. + #[test] + fn prop_route_handler_case_insensitive( + dir in prop_oneof![ + Just("routes"), + Just("handlers"), + Just("controllers"), + ], + name in "[a-z]{3,10}", + ) { + let lower = format!("src/{}/{}.ts", dir, name); + let upper = format!("SRC/{}/{}.TS", dir.to_uppercase(), name.to_uppercase()); + prop_assert_eq!( + is_route_handler_path(&lower), + is_route_handler_path(&upper), + "detection should be case-insensitive: '{}' vs '{}'", lower, upper, + ); + } + + /// Files in plain src/ directories (no route/handler/controller pattern) are not + /// detected as route handlers. + #[test] + fn prop_plain_src_not_route(name in "[a-z]{3,15}") { + let path = format!("src/{}.ts", name); + // Only assert when name doesn't accidentally contain a pattern keyword + prop_assume!( + !name.contains("route") && !name.contains("handler") + && !name.contains("controller") && !name.contains("endpoint") + && !name.contains("entrypoint") + ); + prop_assert!( + !is_route_handler_path(&path), + "'{}' should not be a route handler path", path, + ); + } + + /// CLI command path detection: files in /commands/ are always detected. + #[test] + fn prop_cli_commands_dir_always_detected( + name in "[a-z]{3,10}", + ext in prop_oneof![Just(".ts"), Just(".go"), Just(".py"), Just(".rs")], + ) { + let path = format!("src/commands/{}{}", name, ext); + prop_assert!( + is_cli_command_path(&path), + "'{}' should be a CLI command path", path, + ); + } + + /// CLI and route directory patterns don't overlap (commands/ is CLI, routes/ is HTTP). + #[test] + fn prop_cli_route_dirs_disjoint(name in "[a-z]{3,10}") { + let cli_path = format!("src/commands/{}.ts", name); + let route_path = format!("src/routes/{}.ts", name); + + // Guard: name doesn't contain keywords from the other domain + prop_assume!( + !name.contains("route") && !name.contains("handler") + && !name.contains("controller") && !name.contains("endpoint") + ); + prop_assume!( + !name.contains("command") && !name.contains("cli") + ); + + prop_assert!(is_cli_command_path(&cli_path), "commands/ should be CLI"); + prop_assert!(!is_route_handler_path(&cli_path), "commands/ should NOT be HTTP"); + prop_assert!(is_route_handler_path(&route_path), "routes/ should be HTTP"); + prop_assert!(!is_cli_command_path(&route_path), "routes/ should NOT be CLI"); + } + + /// has_filename_pattern requires dot delimiters — partial substring matches don't count. + #[test] + fn prop_filename_pattern_requires_dots( + prefix in "[a-z]{2,8}", + pattern in prop_oneof![ + Just("controller"), + Just("route"), + Just("handler"), + ], + ext in prop_oneof![Just(".ts"), Just(".js")], + ) { + // With dots: "prefix.pattern.ext" → should match + let dotted = format!("src/{}.{}{}", prefix, pattern, ext); + prop_assert!( + has_filename_pattern(&dotted.to_lowercase(), pattern), + "'{}' with dot-delimited pattern should match", dotted, + ); + + // Without dots: "prefixpatternext" → should NOT match + let no_dots = format!("src/{}{}{}", prefix, pattern, ext); + // Only assert if the concatenation doesn't accidentally create a dot pattern + if !no_dots.to_lowercase().contains(&format!(".{}.", pattern)) { + prop_assert!( + !has_filename_pattern(&no_dots.to_lowercase(), pattern), + "'{}' without dot delimiters should not match", no_dots, + ); + } + } + + /// is_route_handler_path is deterministic. + #[test] + fn prop_route_handler_deterministic(path in "[a-z/._]{1,50}") { + let r1 = is_route_handler_path(&path); + let r2 = is_route_handler_path(&path); + prop_assert_eq!(r1, r2); + } + + /// is_cli_command_path is deterministic. + #[test] + fn prop_cli_command_deterministic(path in "[a-z/._]{1,50}") { + let r1 = is_cli_command_path(&path); + let r2 = is_cli_command_path(&path); + prop_assert_eq!(r1, r2); + } + } + } From 04891429569b31448766e4f75af8935362fc40d8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Apr 2026 17:30:37 +0000 Subject: [PATCH 05/15] refactor: split graph.rs into graph/mod.rs + graph/tests.rs + graph/tests_ir.rs Split 4341-line graph.rs into a module directory: - graph/mod.rs: ~1290 lines of production code + test module declarations - graph/tests.rs: ~1425 lines (first half of tests, through mod ir_proptest) - graph/tests_ir.rs: ~1666 lines (second half with IR-related tests, helper functions duplicated) Both test files are well under the 3000-line limit. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: mikenrafter <88250914+mikenrafter@users.noreply.github.com> --- crates/diffcore-core/src/graph.rs | 4341 -------------------- crates/diffcore-core/src/graph/mod.rs | 1290 ++++++ crates/diffcore-core/src/graph/tests.rs | 1425 +++++++ crates/diffcore-core/src/graph/tests_ir.rs | 1666 ++++++++ 4 files changed, 4381 insertions(+), 4341 deletions(-) delete mode 100644 crates/diffcore-core/src/graph.rs create mode 100644 crates/diffcore-core/src/graph/mod.rs create mode 100644 crates/diffcore-core/src/graph/tests.rs create mode 100644 crates/diffcore-core/src/graph/tests_ir.rs diff --git a/crates/diffcore-core/src/graph.rs b/crates/diffcore-core/src/graph.rs deleted file mode 100644 index b7c563c..0000000 --- a/crates/diffcore-core/src/graph.rs +++ /dev/null @@ -1,4341 +0,0 @@ -//! Symbol graph construction using petgraph. -//! -//! Builds a directed graph `G = (V, E)` from parsed AST data where: -//! - Vertices are symbols (functions, classes, types, modules) -//! - Edges represent relationships (imports, calls, extends) - -use std::collections::HashMap; - -use petgraph::graph::{DiGraph, NodeIndex}; -use rayon::prelude::*; -use serde::{Deserialize, Serialize}; - -use crate::ast::{Definition, ExportInfo, Language, ParsedFile}; -use crate::ir::{IrExport, IrFile, IrImportSpecifier, TypeDefKind}; -use crate::types::{EdgeType, SymbolKind}; - -/// A node in the symbol graph. -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct SymbolNode { - /// Unique identifier: `file_path::symbol_name` - pub id: String, - /// The symbol name. - pub name: String, - /// The file this symbol belongs to. - pub file: String, - /// The kind of symbol. - pub kind: SymbolKind, -} - -/// An edge in the symbol graph. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct GraphEdge { - pub edge_type: EdgeType, -} - -/// The complete symbol graph built from parsed files. -#[derive(Debug)] -pub struct SymbolGraph { - pub graph: DiGraph, - /// Map from symbol id (`file::name`) to node index for fast lookup. - id_to_index: HashMap, -} - -/// Errors from graph construction. -#[derive(Debug, thiserror::Error)] -pub enum GraphError { - #[error("graph serialization error: {0}")] - SerializationError(String), -} - -/// Serializable representation for roundtrip testing. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct SerializableGraph { - pub nodes: Vec, - pub edges: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct SerializableEdge { - pub from: String, - pub to: String, - pub edge_type: EdgeType, -} - -impl SymbolGraph { - /// Build a symbol graph from a collection of parsed files. - pub fn build(files: &[ParsedFile]) -> Self { - Self::build_with_workspace(files, &WorkspaceMap::new()) - } - - /// Build a symbol graph with workspace package resolution for monorepos. - /// - /// The `workspace_map` maps package names (e.g. `@scope/pkg`) to their - /// entry file paths (e.g. `packages/pkg/src/index.ts`), enabling cross-package - /// import edges in monorepo workspaces. - pub fn build_with_workspace(files: &[ParsedFile], workspace_map: &WorkspaceMap) -> Self { - let mut graph = DiGraph::new(); - let mut id_to_index: HashMap = HashMap::new(); - - // Phase 1: Collect node data per file in parallel, then merge single-threaded. - let node_batches: Vec> = files - .par_iter() - .map(|file| { - let mut nodes = Vec::new(); - // Module node. - let module_id = file.path.clone(); - nodes.push(( - module_id, - SymbolNode { - id: file.path.clone(), - name: file_stem(&file.path), - file: file.path.clone(), - kind: SymbolKind::Module, - }, - )); - // Definition nodes. - for def in &file.definitions { - let sym_id = format!("{}::{}", file.path, def.name); - nodes.push(( - sym_id.clone(), - SymbolNode { - id: sym_id, - name: def.name.clone(), - file: file.path.clone(), - kind: def.kind.clone(), - }, - )); - } - nodes - }) - .collect(); - - for batch in node_batches { - for (sym_id, node) in batch { - if id_to_index.contains_key(&sym_id) { - continue; // skip duplicates - } - let idx = graph.add_node(node); - id_to_index.insert(sym_id, idx); - } - } - - // Build lookup structures for import resolution. - let file_exports = build_export_map(files); - let file_defs = build_definition_map(files); - - // Phase 2: Compute edges per file in parallel, then add single-threaded. - let edge_batches: Vec> = files - .par_iter() - .map(|file| { - let mut edges = Vec::new(); - collect_import_edges( - file, - files, - &file_exports, - &file_defs, - &id_to_index, - workspace_map, - &mut edges, - ); - collect_call_edges( - file, - files, - &file_exports, - &file_defs, - &id_to_index, - workspace_map, - &mut edges, - ); - collect_extends_edges(file, files, &file_defs, &id_to_index, &mut edges); - edges - }) - .collect(); - - for batch in edge_batches { - for (from_id, to_id, edge_type) in batch { - if let (Some(&from_idx), Some(&to_idx)) = - (id_to_index.get(&from_id), id_to_index.get(&to_id)) - { - graph.add_edge(from_idx, to_idx, GraphEdge { edge_type }); - } - } - } - - SymbolGraph { graph, id_to_index } - } - - /// Build a symbol graph from IR files (declarative query engine / IR path). - /// - /// This is the primary entry point for graph construction from the IR pipeline. - /// It consumes `IrFile` types directly, enabling richer edge construction - /// (e.g., class extends edges from `IrTypeDef.bases`). - pub fn build_from_ir(files: &[IrFile]) -> Self { - Self::build_from_ir_with_workspace(files, &WorkspaceMap::new()) - } - - /// Build a symbol graph from IR files with workspace package resolution. - pub fn build_from_ir_with_workspace(files: &[IrFile], workspace_map: &WorkspaceMap) -> Self { - let mut graph = DiGraph::new(); - let mut id_to_index: HashMap = HashMap::new(); - - // Phase 1: Collect node data per file in parallel, then merge single-threaded. - let node_batches: Vec> = files - .par_iter() - .map(|file| { - let mut nodes = Vec::new(); - // Module node. - nodes.push(( - file.path.clone(), - SymbolNode { - id: file.path.clone(), - name: file_stem(&file.path), - file: file.path.clone(), - kind: SymbolKind::Module, - }, - )); - // Function nodes. - for f in &file.functions { - let sym_id = format!("{}::{}", file.path, f.name); - nodes.push(( - sym_id.clone(), - SymbolNode { - id: sym_id, - name: f.name.clone(), - file: file.path.clone(), - kind: SymbolKind::Function, - }, - )); - } - // Type definition nodes. - for t in &file.type_defs { - let sym_id = format!("{}::{}", file.path, t.name); - let kind = match t.kind { - TypeDefKind::Class => SymbolKind::Class, - TypeDefKind::Struct => SymbolKind::Struct, - TypeDefKind::Interface => SymbolKind::Interface, - TypeDefKind::TypeAlias => SymbolKind::TypeAlias, - TypeDefKind::Enum => SymbolKind::Class, - }; - nodes.push(( - sym_id.clone(), - SymbolNode { - id: sym_id, - name: t.name.clone(), - file: file.path.clone(), - kind, - }, - )); - } - // Constant nodes. - for c in &file.constants { - let sym_id = format!("{}::{}", file.path, c.name); - nodes.push(( - sym_id.clone(), - SymbolNode { - id: sym_id, - name: c.name.clone(), - file: file.path.clone(), - kind: SymbolKind::Constant, - }, - )); - } - nodes - }) - .collect(); - - for batch in node_batches { - for (sym_id, node) in batch { - if id_to_index.contains_key(&sym_id) { - continue; // skip duplicates - } - let idx = graph.add_node(node); - id_to_index.insert(sym_id, idx); - } - } - - // Build lookup structures. - let file_exports = build_ir_export_map(files); - let file_def_names = build_ir_def_names_map(files); - let known_paths: Vec<&str> = files.iter().map(|f| f.path.as_str()).collect(); - - // Phase 2: Compute edges per file in parallel, then add single-threaded. - let edge_batches: Vec> = files - .par_iter() - .map(|file| { - let mut edges = Vec::new(); - collect_ir_import_edges( - file, - &file_exports, - &file_def_names, - &id_to_index, - &known_paths, - workspace_map, - &mut edges, - ); - collect_ir_call_edges( - file, - files, - &file_def_names, - &id_to_index, - &known_paths, - workspace_map, - &mut edges, - ); - collect_ir_extends_edges(file, files, &id_to_index, &known_paths, &mut edges); - edges - }) - .collect(); - - for batch in edge_batches { - for (from_id, to_id, edge_type) in batch { - if let (Some(&from_idx), Some(&to_idx)) = - (id_to_index.get(&from_id), id_to_index.get(&to_id)) - { - graph.add_edge(from_idx, to_idx, GraphEdge { edge_type }); - } - } - } - - SymbolGraph { graph, id_to_index } - } - - /// Number of nodes in the graph. - pub fn node_count(&self) -> usize { - self.graph.node_count() - } - - /// Number of edges in the graph. - pub fn edge_count(&self) -> usize { - self.graph.edge_count() - } - - /// Look up a node index by symbol id. - pub fn get_node(&self, id: &str) -> Option { - self.id_to_index.get(id).copied() - } - - /// Get the symbol node data for a given id. - pub fn get_symbol(&self, id: &str) -> Option<&SymbolNode> { - self.id_to_index.get(id).map(|idx| &self.graph[*idx]) - } - - /// Get all node ids in the graph. - pub fn node_ids(&self) -> Vec<&str> { - self.id_to_index.keys().map(|s| s.as_str()).collect() - } - - /// Add an edge between two nodes by their indices. - pub fn add_edge(&mut self, from: NodeIndex, to: NodeIndex, edge: GraphEdge) { - self.graph.add_edge(from, to, edge); - } - - /// Get all edges as (from_id, to_id, edge_type) tuples. - pub fn edges(&self) -> Vec<(&str, &str, &EdgeType)> { - self.graph - .edge_indices() - .filter_map(|e| { - let (src, tgt) = self.graph.edge_endpoints(e)?; - let edge = &self.graph[e]; - Some(( - self.graph[src].id.as_str(), - self.graph[tgt].id.as_str(), - &edge.edge_type, - )) - }) - .collect() - } - - /// Serialize the graph to a JSON-friendly structure. - pub fn to_serializable(&self) -> SerializableGraph { - let nodes: Vec = self - .graph - .node_indices() - .map(|i| self.graph[i].clone()) - .collect(); - - let edges: Vec = self - .graph - .edge_indices() - .filter_map(|e| { - let (src, tgt) = self.graph.edge_endpoints(e)?; - Some(SerializableEdge { - from: self.graph[src].id.clone(), - to: self.graph[tgt].id.clone(), - edge_type: self.graph[e].edge_type.clone(), - }) - }) - .collect(); - - SerializableGraph { nodes, edges } - } - - /// Deserialize from a serializable graph back into a SymbolGraph. - pub fn from_serializable(sg: &SerializableGraph) -> Self { - let mut graph = DiGraph::new(); - let mut id_to_index: HashMap = HashMap::new(); - - for node in &sg.nodes { - let idx = graph.add_node(node.clone()); - id_to_index.insert(node.id.clone(), idx); - } - - for edge in &sg.edges { - if let (Some(&src), Some(&tgt)) = - (id_to_index.get(&edge.from), id_to_index.get(&edge.to)) - { - graph.add_edge( - src, - tgt, - GraphEdge { - edge_type: edge.edge_type.clone(), - }, - ); - } - } - - SymbolGraph { graph, id_to_index } - } -} - -// --------------------------------------------------------------------------- -// Import resolution helpers -// --------------------------------------------------------------------------- - -/// Map from file path to its exported symbol names. -fn build_export_map(files: &[ParsedFile]) -> HashMap> { - files - .iter() - .map(|f| (f.path.clone(), f.exports.clone())) - .collect() -} - -/// Map from file path to its definitions. -fn build_definition_map(files: &[ParsedFile]) -> HashMap> { - files - .iter() - .map(|f| (f.path.clone(), f.definitions.clone())) - .collect() -} - -/// Resolve an import source path (e.g. `./utils`, `../models/user`) relative to the -/// importing file, returning the resolved file path if it exists in our file set. -/// -/// Handles both JS/TS-style (`./utils`, `../models/user`) and Python-style -/// (`.models`, `..models`, `.models.user`) relative imports. -fn resolve_import_path( - import_source: &str, - importer_path: &str, - known_files: &[&str], -) -> Option { - // Only resolve relative imports - if !import_source.starts_with('.') { - return None; - } - - // Convert Python-style dot imports to path-style. - // `.models` → `./models`, `..models` → `../models`, `.models.user` → `./models/user` - let normalized_source = normalize_python_import(import_source); - - let importer_dir = parent_dir(importer_path); - let resolved = normalize_path(&format!("{}/{}", importer_dir, normalized_source)); - - // Try exact match first, then with common extensions. - let candidates = [ - resolved.clone(), - format!("{}.ts", resolved), - format!("{}.tsx", resolved), - format!("{}.js", resolved), - format!("{}.jsx", resolved), - format!("{}.py", resolved), - format!("{}/index.ts", resolved), - format!("{}/index.js", resolved), - format!("{}/index.tsx", resolved), - ]; - - for candidate in &candidates { - if known_files.contains(&candidate.as_str()) { - return Some(candidate.clone()); - } - } - - None -} - -/// A map from workspace package name (e.g. `@monorepo/shared-types`) to its -/// entry file path relative to the repo root (e.g. `packages/shared-types/src/index.ts`). -pub type WorkspaceMap = HashMap; - -/// Resolve a non-relative import through a workspace package map. -/// -/// When `import_source` is a bare specifier (e.g. `@monorepo/shared-types` or -/// `@monorepo/shared-types/utils`), look it up in the workspace map. If the -/// exact name matches, return its entry file. If only a prefix matches (e.g. -/// `@scope/pkg/sub`), try to resolve the sub-path relative to the package root. -fn resolve_workspace_import( - import_source: &str, - known_files: &[&str], - workspace_map: &WorkspaceMap, -) -> Option { - // Skip relative imports (already handled by resolve_import_path). - if import_source.starts_with('.') { - return None; - } - - // Try exact match first. - if let Some(entry) = workspace_map.get(import_source) { - if known_files.contains(&entry.as_str()) { - return Some(entry.clone()); - } - } - - // Try prefix match for deep imports like `@scope/pkg/sub/path`. - // Find the longest matching package name. - let mut best_match: Option<(&str, &str)> = None; - for (pkg_name, entry_file) in workspace_map { - if import_source.starts_with(pkg_name.as_str()) - && import_source[pkg_name.len()..].starts_with('/') - { - if best_match.map_or(true, |(prev, _)| pkg_name.len() > prev.len()) { - best_match = Some((pkg_name.as_str(), entry_file.as_str())); - } - } - } - - if let Some((pkg_name, entry_file)) = best_match { - // Get package root directory from entry file path. - let pkg_dir = parent_dir(parent_dir(entry_file).as_str()); - let sub_path = &import_source[pkg_name.len() + 1..]; // skip the '/' - let resolved = format!("{}/{}", pkg_dir, sub_path); - - // Try with common extensions. - let candidates = [ - resolved.clone(), - format!("{}.ts", resolved), - format!("{}.tsx", resolved), - format!("{}.js", resolved), - format!("{}.jsx", resolved), - format!("{}/index.ts", resolved), - format!("{}/index.js", resolved), - ]; - - for candidate in &candidates { - if known_files.contains(&candidate.as_str()) { - return Some(candidate.clone()); - } - } - } - - None -} - -/// Try to resolve an import path, falling back to workspace resolution. -fn resolve_import_or_workspace( - import_source: &str, - importer_path: &str, - known_files: &[&str], - workspace_map: &WorkspaceMap, -) -> Option { - resolve_import_path(import_source, importer_path, known_files) - .or_else(|| resolve_workspace_import(import_source, known_files, workspace_map)) -} - -/// Build a workspace package map by scanning `package.json` files in a directory. -/// -/// Reads the root `package.json` for `workspaces` globs, then reads each -/// matched package's `package.json` for its `name` and `main` fields. -/// Returns a map from package name → entry file path (relative to repo root). -pub fn build_workspace_map(repo_root: &std::path::Path) -> WorkspaceMap { - let mut map = WorkspaceMap::new(); - - // Read root package.json for workspaces. - let root_pkg = repo_root.join("package.json"); - let root_content = match std::fs::read_to_string(&root_pkg) { - Ok(c) => c, - Err(_) => return map, - }; - let root_json: serde_json::Value = match serde_json::from_str(&root_content) { - Ok(v) => v, - Err(_) => return map, - }; - - // Extract workspace patterns. - let workspace_patterns: Vec = match root_json.get("workspaces") { - Some(serde_json::Value::Array(arr)) => arr - .iter() - .filter_map(|v| v.as_str().map(String::from)) - .collect(), - // pnpm-style: { packages: [...] } - Some(serde_json::Value::Object(obj)) => obj - .get("packages") - .and_then(|v| v.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|v| v.as_str().map(String::from)) - .collect() - }) - .unwrap_or_default(), - _ => return map, - }; - - // Expand glob patterns to find package directories. - for pattern in &workspace_patterns { - let full_pattern = repo_root.join(pattern).join("package.json"); - if let Some(pattern_str) = full_pattern.to_str() { - if let Ok(entries) = glob::glob(pattern_str) { - for entry in entries.flatten() { - if let Ok(content) = std::fs::read_to_string(&entry) { - if let Ok(pkg_json) = serde_json::from_str::(&content) { - let name = pkg_json.get("name").and_then(|v| v.as_str()); - let main_field = pkg_json.get("main").and_then(|v| v.as_str()); - - if let Some(name) = name { - // Determine entry file path relative to repo root. - let pkg_dir = entry.parent().unwrap_or(repo_root.as_ref()); - let entry_file = if let Some(main_path) = main_field { - pkg_dir.join(main_path) - } else { - // Default: try src/index.ts, then index.ts - let src_index = pkg_dir.join("src/index.ts"); - if src_index.exists() { - src_index - } else { - pkg_dir.join("index.ts") - } - }; - - if let Ok(relative) = entry_file.strip_prefix(repo_root) { - if let Some(rel_str) = relative.to_str() { - map.insert(name.to_string(), rel_str.to_string()); - } - } - } - } - } - } - } - } - } - - map -} - -/// Get the parent directory of a file path. -fn parent_dir(path: &str) -> String { - match path.rfind('/') { - Some(pos) => path[..pos].to_string(), - None => ".".to_string(), - } -} - -/// Get the file stem (filename without extension). -fn file_stem(path: &str) -> String { - let filename = path.rsplit('/').next().unwrap_or(path); - match filename.find('.') { - Some(pos) => filename[..pos].to_string(), - None => filename.to_string(), - } -} - -/// Normalize a path by resolving `.` and `..` segments. -fn normalize_path(path: &str) -> String { - let mut parts: Vec<&str> = Vec::new(); - for segment in path.split('/') { - match segment { - "." | "" => {} - ".." => { - parts.pop(); - } - s => parts.push(s), - } - } - parts.join("/") -} - -/// Convert Python-style dot imports to path-style relative imports. -/// -/// - `.models` → `./models` -/// - `..models` → `../models` -/// - `.models.user` → `./models/user` -/// - `.` → `.` -/// - `...utils.helpers` → `../../utils/helpers` -fn normalize_python_import(source: &str) -> String { - // Count leading dots. - let dot_count = source.chars().take_while(|c| *c == '.').count(); - let remainder = &source[dot_count..]; - - if dot_count == 0 { - return source.to_string(); - } - - // Build the relative prefix: `.` → `./`, `..` → `../`, `...` → `../../` - let prefix = if dot_count == 1 { - ".".to_string() - } else { - let mut p = String::new(); - for i in 0..dot_count - 1 { - if i > 0 { - p.push('/'); - } - p.push_str(".."); - } - p - }; - - if remainder.is_empty() { - return prefix; - } - - // Convert remaining dots (module separators) to slashes. - let path_part = remainder.replace('.', "/"); - format!("{}/{}", prefix, path_part) -} - -// --------------------------------------------------------------------------- -// Edge construction -// --------------------------------------------------------------------------- - -/// Collect import edge descriptors: file A imports symbol from file B. -/// Pushes `(from_id, to_id, EdgeType)` tuples for later insertion. -fn collect_import_edges( - file: &ParsedFile, - all_files: &[ParsedFile], - file_exports: &HashMap>, - file_defs: &HashMap>, - id_to_index: &HashMap, - workspace_map: &WorkspaceMap, - edges: &mut Vec<(String, String, EdgeType)>, -) { - let known_paths: Vec<&str> = all_files.iter().map(|f| f.path.as_str()).collect(); - - for import in &file.imports { - let resolved = match resolve_import_or_workspace( - &import.source, - &file.path, - &known_paths, - workspace_map, - ) { - Some(p) => p, - None => continue, - }; - - let from_module_id = file.path.clone(); - if !id_to_index.contains_key(&from_module_id) { - continue; - } - - // For each imported name, find matching export or definition in target file. - if import.names.is_empty() { - // Side-effect import: create module-to-module edge. - if id_to_index.contains_key(&resolved) { - edges.push((from_module_id.clone(), resolved.clone(), EdgeType::Imports)); - } - continue; - } - - for imported_name in &import.names { - let target_name = &imported_name.name; - - // Try to find the symbol in the target file's definitions. - let target_sym_id = format!("{}::{}", resolved, target_name); - if id_to_index.contains_key(&target_sym_id) { - edges.push((from_module_id.clone(), target_sym_id, EdgeType::Imports)); - continue; - } - - // If importing a default, check if target has a matching export/def. - if import.is_default || import.is_namespace { - // Link to the module node itself. - if id_to_index.contains_key(&resolved) { - edges.push((from_module_id.clone(), resolved.clone(), EdgeType::Imports)); - } - continue; - } - - // Check re-exports: target file may re-export from another file. - if let Some(exports) = file_exports.get(&resolved) { - for export in exports { - if export.name == *target_name && export.is_reexport { - if let Some(ref reexport_source) = export.source { - if let Some(reexport_resolved) = resolve_import_or_workspace( - reexport_source, - &resolved, - &known_paths, - workspace_map, - ) { - let reexport_sym_id = - format!("{}::{}", reexport_resolved, target_name); - if id_to_index.contains_key(&reexport_sym_id) { - edges.push(( - from_module_id.clone(), - reexport_sym_id, - EdgeType::Imports, - )); - } - } - } - } - } - } - - // Fallback: Python-style — definition name matches directly. - if let Some(defs) = file_defs.get(&resolved) { - if defs.iter().any(|d| d.name == *target_name) { - let sym_id = format!("{}::{}", resolved, target_name); - if id_to_index.contains_key(&sym_id) { - edges.push((from_module_id.clone(), sym_id, EdgeType::Imports)); - } - } - } - } - } -} - -/// Collect call edge descriptors: function A calls function B. -fn collect_call_edges( - file: &ParsedFile, - all_files: &[ParsedFile], - file_exports: &HashMap>, - file_defs: &HashMap>, - id_to_index: &HashMap, - workspace_map: &WorkspaceMap, - edges: &mut Vec<(String, String, EdgeType)>, -) { - let known_paths: Vec<&str> = all_files.iter().map(|f| f.path.as_str()).collect(); - - // Build a map of imported names → resolved symbol ids for this file. - let import_map = build_import_resolution_map( - file, - all_files, - file_exports, - file_defs, - &known_paths, - workspace_map, - ); - - for call in &file.call_sites { - // Determine the calling symbol. - let caller_id = match &call.containing_function { - Some(func_name) => format!("{}::{}", file.path, func_name), - None => file.path.clone(), // module-level call - }; - - // Resolve caller: try exact id, then module node. - let resolved_caller_id = if id_to_index.contains_key(&caller_id) { - caller_id - } else if id_to_index.contains_key(&file.path) { - file.path.clone() - } else { - continue; - }; - - // Resolve the callee. - let callee_name = &call.callee; - - // Simple name (e.g., `validateUser`) — look up in import map or local defs. - if let Some(target_id) = import_map.get(callee_name.as_str()) { - if id_to_index.contains_key(target_id.as_str()) && resolved_caller_id != *target_id { - edges.push(( - resolved_caller_id.clone(), - target_id.clone(), - EdgeType::Calls, - )); - } - continue; - } - - // Method call (e.g., `db.save`) — check if `db` is an imported name. - if let Some(dot_pos) = callee_name.find('.') { - let receiver = &callee_name[..dot_pos]; - if let Some(target_module) = import_map.get(receiver) { - let method = &callee_name[dot_pos + 1..]; - let method_id = format!("{}::{}", target_module.trim_end_matches("::*"), method); - if id_to_index.contains_key(&method_id) && resolved_caller_id != method_id { - edges.push((resolved_caller_id.clone(), method_id, EdgeType::Calls)); - continue; - } - if id_to_index.contains_key(target_module.as_str()) - && resolved_caller_id != *target_module - { - edges.push(( - resolved_caller_id.clone(), - target_module.clone(), - EdgeType::Calls, - )); - continue; - } - } - } - - // Local function call — same file. - let local_id = format!("{}::{}", file.path, callee_name); - if id_to_index.contains_key(&local_id) && resolved_caller_id != local_id { - edges.push((resolved_caller_id.clone(), local_id, EdgeType::Calls)); - } - } -} - -/// Build a map from imported name → resolved symbol id for a given file. -fn build_import_resolution_map( - file: &ParsedFile, - all_files: &[ParsedFile], - _file_exports: &HashMap>, - _file_defs: &HashMap>, - known_paths: &[&str], - workspace_map: &WorkspaceMap, -) -> HashMap { - let mut map = HashMap::new(); - - for import in &file.imports { - let resolved = match resolve_import_or_workspace( - &import.source, - &file.path, - known_paths, - workspace_map, - ) { - Some(p) => p, - None => continue, - }; - - if import.is_namespace { - // `import * as X from './mod'` or Python `import X` - // Map X → resolved module path. - for name in &import.names { - let local_name = name.alias.as_ref().unwrap_or(&name.name); - map.insert(local_name.clone(), resolved.clone()); - } - continue; - } - - for name in &import.names { - let local_name = name.alias.as_ref().unwrap_or(&name.name); - // Try to resolve to a specific symbol in the target file. - let target_sym_id = format!("{}::{}", resolved, name.name); - - // Check if this symbol exists in the target file's definitions. - let target_file = all_files.iter().find(|f| f.path == resolved); - if let Some(tf) = target_file { - if tf.definitions.iter().any(|d| d.name == name.name) { - map.insert(local_name.clone(), target_sym_id); - continue; - } - } - - // Default import — map to module. - if import.is_default { - map.insert(local_name.clone(), resolved.clone()); - } else { - // Map to the symbol id even if we can't verify it exists. - map.insert(local_name.clone(), target_sym_id); - } - } - } - - map -} - -/// Collect extends edge descriptors for class inheritance (Python). -/// Currently a stub — ParsedFile lacks class base info, so no edges are emitted. -fn collect_extends_edges( - file: &ParsedFile, - _all_files: &[ParsedFile], - _file_defs: &HashMap>, - _id_to_index: &HashMap, - _edges: &mut Vec<(String, String, EdgeType)>, -) { - if file.language != Language::Python { - return; - } - // ParsedFile doesn't store class base info, so no extends edges can be produced. - // The IR path (build_from_ir → collect_ir_extends_edges) handles this via IrTypeDef.bases. -} - -// --------------------------------------------------------------------------- -// IR-based lookup helpers -// --------------------------------------------------------------------------- - -/// Map from file path to its IR exports. -fn build_ir_export_map(files: &[IrFile]) -> HashMap> { - files - .iter() - .map(|f| (f.path.clone(), f.exports.clone())) - .collect() -} - -/// Map from file path to (name, kind) pairs for all definitions. -fn build_ir_def_names_map(files: &[IrFile]) -> HashMap> { - files - .iter() - .map(|f| { - let mut defs = Vec::new(); - for func in &f.functions { - defs.push((func.name.clone(), SymbolKind::Function)); - } - for td in &f.type_defs { - let kind = match td.kind { - TypeDefKind::Class => SymbolKind::Class, - TypeDefKind::Struct => SymbolKind::Struct, - TypeDefKind::Interface => SymbolKind::Interface, - TypeDefKind::TypeAlias => SymbolKind::TypeAlias, - TypeDefKind::Enum => SymbolKind::Class, - }; - defs.push((td.name.clone(), kind)); - } - for c in &f.constants { - defs.push((c.name.clone(), SymbolKind::Constant)); - } - (f.path.clone(), defs) - }) - .collect() -} - -/// Collect import edge descriptors from IR imports. -fn collect_ir_import_edges( - file: &IrFile, - file_exports: &HashMap>, - file_defs: &HashMap>, - id_to_index: &HashMap, - known_paths: &[&str], - workspace_map: &WorkspaceMap, - edges: &mut Vec<(String, String, EdgeType)>, -) { - if !id_to_index.contains_key(&file.path) { - return; - } - let from_id = file.path.clone(); - - for import in &file.imports { - let resolved = match resolve_import_or_workspace( - &import.source, - &file.path, - known_paths, - workspace_map, - ) { - Some(p) => p, - None => continue, - }; - - // Check if this import is side-effect only. - let is_side_effect = import.specifiers.is_empty() - || import - .specifiers - .iter() - .all(|s| matches!(s, IrImportSpecifier::SideEffect)); - - if is_side_effect { - if id_to_index.contains_key(&resolved) { - edges.push((from_id.clone(), resolved.clone(), EdgeType::Imports)); - } - continue; - } - - for spec in &import.specifiers { - match spec { - IrImportSpecifier::Named { name, .. } => { - let target_sym_id = format!("{}::{}", resolved, name); - if id_to_index.contains_key(&target_sym_id) { - edges.push((from_id.clone(), target_sym_id, EdgeType::Imports)); - continue; - } - - // Check re-exports. - if let Some(exports) = file_exports.get(&resolved) { - for export in exports { - if export.name == *name && export.is_reexport { - if let Some(ref reexport_source) = export.source { - if let Some(reexport_resolved) = resolve_import_or_workspace( - reexport_source, - &resolved, - known_paths, - workspace_map, - ) { - let reexport_sym_id = - format!("{}::{}", reexport_resolved, name); - if id_to_index.contains_key(&reexport_sym_id) { - edges.push(( - from_id.clone(), - reexport_sym_id, - EdgeType::Imports, - )); - } - } - } - } - } - } - - // Fallback: definition name matches directly. - if let Some(defs) = file_defs.get(&resolved) { - if defs.iter().any(|(n, _): &(String, SymbolKind)| n == name) { - let sym_id = format!("{}::{}", resolved, name); - if id_to_index.contains_key(&sym_id) { - edges.push((from_id.clone(), sym_id, EdgeType::Imports)); - } - } - } - } - IrImportSpecifier::Default(_) | IrImportSpecifier::Namespace(_) => { - if id_to_index.contains_key(&resolved) { - edges.push((from_id.clone(), resolved.clone(), EdgeType::Imports)); - } - } - IrImportSpecifier::SideEffect => { - // Already handled above. - } - } - } - } -} - -/// Build import resolution map from IR imports for call edge resolution. -fn build_ir_import_resolution_map( - file: &IrFile, - all_files: &[IrFile], - _file_defs: &HashMap>, - known_paths: &[&str], - workspace_map: &WorkspaceMap, -) -> HashMap { - let mut map = HashMap::new(); - - for import in &file.imports { - let resolved = match resolve_import_or_workspace( - &import.source, - &file.path, - known_paths, - workspace_map, - ) { - Some(p) => p, - None => continue, - }; - - for spec in &import.specifiers { - match spec { - IrImportSpecifier::Namespace(local) => { - map.insert(local.clone(), resolved.clone()); - } - IrImportSpecifier::Named { name, alias } => { - let local_name = alias.as_deref().unwrap_or(name.as_str()); - let target_sym_id = format!("{}::{}", resolved, name); - - // Check if this symbol exists in the target file. - let target_file = all_files.iter().find(|f| f.path == resolved); - if let Some(tf) = target_file { - let has_def = tf.functions.iter().any(|d| d.name == *name) - || tf.type_defs.iter().any(|d| d.name == *name) - || tf.constants.iter().any(|d| d.name == *name); - if has_def { - map.insert(local_name.to_string(), target_sym_id); - continue; - } - } - - // Map to the symbol id even if we can't verify. - map.insert(local_name.to_string(), target_sym_id); - } - IrImportSpecifier::Default(local) => { - map.insert(local.clone(), resolved.clone()); - } - IrImportSpecifier::SideEffect => {} - } - } - } - - map -} - -/// Collect call edge descriptors from IR call expressions. -fn collect_ir_call_edges( - file: &IrFile, - all_files: &[IrFile], - file_defs: &HashMap>, - id_to_index: &HashMap, - known_paths: &[&str], - workspace_map: &WorkspaceMap, - edges: &mut Vec<(String, String, EdgeType)>, -) { - let import_map = - build_ir_import_resolution_map(file, all_files, file_defs, known_paths, workspace_map); - - for call in &file.call_expressions { - let caller_id = match &call.containing_function { - Some(func_name) => format!("{}::{}", file.path, func_name), - None => file.path.clone(), - }; - - // Resolve caller: try exact id, then module node. - let resolved_caller_id = if id_to_index.contains_key(&caller_id) { - caller_id - } else if id_to_index.contains_key(&file.path) { - file.path.clone() - } else { - continue; - }; - - let callee_name = &call.callee; - - // Simple name — look up in import map or local defs. - if let Some(target_id) = import_map.get(callee_name.as_str()) { - if id_to_index.contains_key(target_id.as_str()) && resolved_caller_id != *target_id { - edges.push(( - resolved_caller_id.clone(), - target_id.clone(), - EdgeType::Calls, - )); - } - continue; - } - - // Method call (e.g., `db.save`). - if let Some(dot_pos) = callee_name.find('.') { - let receiver = &callee_name[..dot_pos]; - if let Some(target_module) = import_map.get(receiver) { - let method = &callee_name[dot_pos + 1..]; - let method_id = format!("{}::{}", target_module.trim_end_matches("::*"), method); - if id_to_index.contains_key(&method_id) && resolved_caller_id != method_id { - edges.push((resolved_caller_id.clone(), method_id, EdgeType::Calls)); - continue; - } - if id_to_index.contains_key(target_module.as_str()) - && resolved_caller_id != *target_module - { - edges.push(( - resolved_caller_id.clone(), - target_module.clone(), - EdgeType::Calls, - )); - continue; - } - } - } - - // Local function call. - let local_id = format!("{}::{}", file.path, callee_name); - if id_to_index.contains_key(&local_id) && resolved_caller_id != local_id { - edges.push((resolved_caller_id.clone(), local_id, EdgeType::Calls)); - } - } -} - -/// Collect extends edge descriptors from IR type definitions with bases. -/// -/// Unlike the ParsedFile-based version which cannot determine class bases, -/// the IR path has `IrTypeDef.bases` populated from the query engine, enabling -/// real extends edge construction. -fn collect_ir_extends_edges( - file: &IrFile, - all_files: &[IrFile], - id_to_index: &HashMap, - known_paths: &[&str], - edges: &mut Vec<(String, String, EdgeType)>, -) { - let import_map = build_ir_import_resolution_map( - file, - all_files, - &HashMap::new(), - known_paths, - &WorkspaceMap::new(), - ); - - for td in &file.type_defs { - if td.bases.is_empty() { - continue; - } - - let child_id = format!("{}::{}", file.path, td.name); - if !id_to_index.contains_key(&child_id) { - continue; - } - - for base in &td.bases { - // Try imported name first. - if let Some(target_id) = import_map.get(base.as_str()) { - if id_to_index.contains_key(target_id.as_str()) && child_id != *target_id { - edges.push((child_id.clone(), target_id.clone(), EdgeType::Extends)); - continue; - } - } - - // Try local definition. - let local_id = format!("{}::{}", file.path, base); - if id_to_index.contains_key(&local_id) && child_id != local_id { - edges.push((child_id.clone(), local_id, EdgeType::Extends)); - } - } - } -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -#[cfg(test)] -#[allow( - clippy::unwrap_used, - clippy::expect_used, - clippy::panic, - clippy::print_stdout, - clippy::print_stderr -)] -mod tests { - use super::*; - use crate::ast::{self, ParsedFile}; - use crate::types::SymbolKind; - - /// Helper: parse multiple files and build a graph. - fn build_graph_from_sources(files: &[(&str, &str)]) -> SymbolGraph { - let parsed: Vec = files - .iter() - .map(|(path, source)| ast::parse_file(path, source).unwrap()) - .collect(); - SymbolGraph::build(&parsed) - } - - /// Helper: check if an edge exists between two symbol ids with a given type. - fn has_edge(graph: &SymbolGraph, from: &str, to: &str, edge_type: &EdgeType) -> bool { - graph - .edges() - .iter() - .any(|(f, t, et)| *f == from && *t == to && *et == edge_type) - } - - /// Helper: count edges of a specific type. - fn count_edges_of_type(graph: &SymbolGraph, edge_type: &EdgeType) -> usize { - graph - .edges() - .iter() - .filter(|(_, _, et)| *et == edge_type) - .count() - } - - // === Import edge tests === - - #[test] - fn test_build_import_edges() { - let graph = build_graph_from_sources(&[ - ( - "src/utils.ts", - r#" -export function validate(data: any) { return data; } -export function sanitize(data: any) { return data; } -"#, - ), - ( - "src/handler.ts", - r#" -import { validate, sanitize } from './utils'; -function handle() { validate({}); } -"#, - ), - ]); - - // handler.ts module should import validate and sanitize from utils.ts - assert!( - has_edge( - &graph, - "src/handler.ts", - "src/utils.ts::validate", - &EdgeType::Imports - ), - "should have import edge to validate" - ); - assert!( - has_edge( - &graph, - "src/handler.ts", - "src/utils.ts::sanitize", - &EdgeType::Imports - ), - "should have import edge to sanitize" - ); - } - - #[test] - fn test_build_import_edges_default() { - let graph = build_graph_from_sources(&[ - ( - "src/app.ts", - r#" -const app = createApp(); -export default app; -"#, - ), - ( - "src/main.ts", - r#" -import App from './app'; -"#, - ), - ]); - - // Default import should link to the module node. - assert!( - has_edge(&graph, "src/main.ts", "src/app.ts", &EdgeType::Imports), - "should have import edge for default import" - ); - } - - #[test] - fn test_build_import_edges_namespace() { - let graph = build_graph_from_sources(&[ - ( - "src/utils.ts", - r#" -export function foo() {} -export function bar() {} -"#, - ), - ( - "src/main.ts", - r#" -import * as utils from './utils'; -"#, - ), - ]); - - assert!( - has_edge(&graph, "src/main.ts", "src/utils.ts", &EdgeType::Imports), - "namespace import should link to module node" - ); - } - - #[test] - fn test_side_effect_import() { - let graph = build_graph_from_sources(&[ - ("src/polyfill.ts", "// polyfill code"), - ( - "src/main.ts", - r#" -import './polyfill'; -"#, - ), - ]); - - assert!( - has_edge(&graph, "src/main.ts", "src/polyfill.ts", &EdgeType::Imports), - "side-effect import should create module-to-module edge" - ); - } - - // === Call edge tests === - - #[test] - fn test_build_call_edges() { - let graph = build_graph_from_sources(&[ - ( - "src/utils.ts", - r#" -export function validate(data: any) { return data; } -"#, - ), - ( - "src/handler.ts", - r#" -import { validate } from './utils'; -function processRequest(req: any) { - const v = validate(req.body); - return v; -} -"#, - ), - ]); - - assert!( - has_edge( - &graph, - "src/handler.ts::processRequest", - "src/utils.ts::validate", - &EdgeType::Calls - ), - "processRequest should have call edge to validate" - ); - } - - #[test] - fn test_build_call_edges_local() { - let graph = build_graph_from_sources(&[( - "src/service.ts", - r#" -function helper() { return 42; } -function main() { - const x = helper(); - return x; -} -"#, - )]); - - assert!( - has_edge( - &graph, - "src/service.ts::main", - "src/service.ts::helper", - &EdgeType::Calls - ), - "main should have call edge to local helper" - ); - } - - #[test] - fn test_build_call_edges_method_on_import() { - let graph = build_graph_from_sources(&[ - ( - "src/db.ts", - r#" -export function save(data: any) { return data; } -export function find(id: string) { return {}; } -"#, - ), - ( - "src/service.ts", - r#" -import * as db from './db'; -function createUser(data: any) { - return db.save(data); -} -"#, - ), - ]); - - assert!( - has_edge( - &graph, - "src/service.ts::createUser", - "src/db.ts::save", - &EdgeType::Calls - ), - "should resolve method call on namespace import" - ); - } - - #[test] - fn test_no_self_call_edge() { - let graph = build_graph_from_sources(&[( - "src/lib.ts", - r#" -function recurse(n: number): number { - if (n <= 0) return 0; - return recurse(n - 1); -} -"#, - )]); - - // Recursive calls should not create self-edges. - let self_edges: Vec<_> = graph - .edges() - .into_iter() - .filter(|(f, t, _)| f == t) - .collect(); - assert!( - self_edges.is_empty(), - "recursive function should not create self-edges" - ); - } - - // === Graph structure tests === - - #[test] - fn test_graph_node_count() { - let graph = build_graph_from_sources(&[ - ( - "src/a.ts", - r#" -export function foo() {} -export function bar() {} -"#, - ), - ( - "src/b.ts", - r#" -export class Baz {} -"#, - ), - ]); - - // 2 module nodes + 2 functions + 1 class = 5 - assert_eq!(graph.node_count(), 5); - } - - #[test] - fn test_graph_edge_count() { - let graph = build_graph_from_sources(&[ - ( - "src/utils.ts", - r#" -export function validate(x: any) { return x; } -"#, - ), - ( - "src/handler.ts", - r#" -import { validate } from './utils'; -function handle() { validate({}); } -"#, - ), - ]); - - // 1 import edge + 1 call edge = 2 - let import_count = count_edges_of_type(&graph, &EdgeType::Imports); - let call_count = count_edges_of_type(&graph, &EdgeType::Calls); - assert_eq!(import_count, 1, "should have 1 import edge"); - assert_eq!(call_count, 1, "should have 1 call edge"); - } - - #[test] - fn test_cyclic_imports() { - let graph = build_graph_from_sources(&[ - ( - "src/a.ts", - r#" -import { funcB } from './b'; -export function funcA() { funcB(); } -"#, - ), - ( - "src/b.ts", - r#" -import { funcA } from './a'; -export function funcB() { funcA(); } -"#, - ), - ]); - - // Should handle cycles without panic/infinite loop. - assert!(graph.node_count() > 0); - - // Both import edges should exist. - assert!(has_edge( - &graph, - "src/a.ts", - "src/b.ts::funcB", - &EdgeType::Imports - )); - assert!(has_edge( - &graph, - "src/b.ts", - "src/a.ts::funcA", - &EdgeType::Imports - )); - - // Both call edges should exist. - assert!(has_edge( - &graph, - "src/a.ts::funcA", - "src/b.ts::funcB", - &EdgeType::Calls - )); - assert!(has_edge( - &graph, - "src/b.ts::funcB", - "src/a.ts::funcA", - &EdgeType::Calls - )); - } - - #[test] - fn test_reexport_chains() { - let graph = build_graph_from_sources(&[ - ( - "src/core/validate.ts", - r#" -export function validate(data: any) { return data; } -"#, - ), - ( - "src/core/index.ts", - r#" -export { validate } from './validate'; -"#, - ), - ( - "src/handler.ts", - r#" -import { validate } from './core/index'; -function handle() { validate({}); } -"#, - ), - ]); - - // The import from handler should resolve through the barrel file to the actual definition. - assert!( - has_edge( - &graph, - "src/handler.ts", - "src/core/validate.ts::validate", - &EdgeType::Imports - ), - "should resolve re-export chain through barrel file" - ); - } - - #[test] - fn test_graph_serialization_roundtrip() { - let original = build_graph_from_sources(&[ - ( - "src/a.ts", - r#" -export function foo() {} -"#, - ), - ( - "src/b.ts", - r#" -import { foo } from './a'; -function bar() { foo(); } -"#, - ), - ]); - - let serialized = original.to_serializable(); - let json = serde_json::to_string(&serialized).unwrap(); - let deserialized_data: SerializableGraph = serde_json::from_str(&json).unwrap(); - let restored = SymbolGraph::from_serializable(&deserialized_data); - - assert_eq!(original.node_count(), restored.node_count()); - assert_eq!(original.edge_count(), restored.edge_count()); - - // Verify all nodes match. - let orig_serialized = original.to_serializable(); - assert_eq!(orig_serialized, deserialized_data); - } - - #[test] - fn test_empty_files() { - let graph = build_graph_from_sources(&[]); - assert_eq!(graph.node_count(), 0); - assert_eq!(graph.edge_count(), 0); - } - - #[test] - fn test_single_file_no_edges() { - let graph = build_graph_from_sources(&[( - "src/lib.ts", - r#" -function hello() { console.log('hi'); } -"#, - )]); - - // 1 module node + 1 function node = 2 - assert_eq!(graph.node_count(), 2); - // console.log is external, no edge should be created. - assert_eq!( - count_edges_of_type(&graph, &EdgeType::Calls), - 0, - "external calls should not create edges" - ); - } - - #[test] - fn test_python_import_edges() { - let graph = build_graph_from_sources(&[ - ( - "src/models.py", - r#" -class User: - def __init__(self, name): - self.name = name -"#, - ), - ( - "src/service.py", - r#" -from .models import User - -def create_user(name): - return User(name) -"#, - ), - ]); - - assert!( - has_edge( - &graph, - "src/service.py", - "src/models.py::User", - &EdgeType::Imports - ), - "Python from-import should create import edge" - ); - } - - #[test] - fn test_python_call_edges() { - let graph = build_graph_from_sources(&[ - ( - "src/utils.py", - r#" -def validate(data): - return data -"#, - ), - ( - "src/handler.py", - r#" -from .utils import validate - -def process(data): - return validate(data) -"#, - ), - ]); - - assert!( - has_edge( - &graph, - "src/handler.py::process", - "src/utils.py::validate", - &EdgeType::Calls - ), - "Python call should create call edge" - ); - } - - #[test] - fn test_cross_directory_imports() { - let graph = build_graph_from_sources(&[ - ( - "src/models/user.ts", - r#" -export interface User { name: string; } -"#, - ), - ( - "src/handlers/auth.ts", - r#" -import { User } from '../models/user'; -function login(user: User) {} -"#, - ), - ]); - - assert!( - has_edge( - &graph, - "src/handlers/auth.ts", - "src/models/user.ts::User", - &EdgeType::Imports - ), - "should resolve cross-directory relative import with .." - ); - } - - #[test] - fn test_unknown_language_no_crash() { - let graph = - build_graph_from_sources(&[("src/main.rs", r#"fn main() { println!("hello"); }"#)]); - - // Should have module node only, no definitions from unknown language. - assert_eq!(graph.node_count(), 1); - assert_eq!(graph.edge_count(), 0); - } - - #[test] - fn test_multiple_call_targets() { - let graph = build_graph_from_sources(&[ - ( - "src/a.ts", - r#" -export function alpha() { return 1; } -"#, - ), - ( - "src/b.ts", - r#" -export function beta() { return 2; } -"#, - ), - ( - "src/c.ts", - r#" -import { alpha } from './a'; -import { beta } from './b'; -function gamma() { - alpha(); - beta(); -} -"#, - ), - ]); - - assert!(has_edge( - &graph, - "src/c.ts::gamma", - "src/a.ts::alpha", - &EdgeType::Calls - )); - assert!(has_edge( - &graph, - "src/c.ts::gamma", - "src/b.ts::beta", - &EdgeType::Calls - )); - } - - #[test] - fn test_aliased_import_call() { - let graph = build_graph_from_sources(&[ - ( - "src/utils.ts", - r#" -export function validate(data: any) { return data; } -"#, - ), - ( - "src/handler.ts", - r#" -import { validate as check } from './utils'; -function handle() { check({}); } -"#, - ), - ]); - - assert!( - has_edge( - &graph, - "src/handler.ts::handle", - "src/utils.ts::validate", - &EdgeType::Calls - ), - "aliased import should resolve calls through the alias" - ); - } - - #[test] - fn test_index_file_resolution() { - let graph = build_graph_from_sources(&[ - ( - "src/lib/index.ts", - r#" -export function helper() { return 42; } -"#, - ), - ( - "src/main.ts", - r#" -import { helper } from './lib'; -function run() { helper(); } -"#, - ), - ]); - - // `./lib` should resolve to `src/lib/index.ts` - assert!( - has_edge( - &graph, - "src/main.ts", - "src/lib/index.ts::helper", - &EdgeType::Imports - ), - "should resolve ./lib to ./lib/index.ts" - ); - } - - #[test] - fn test_node_lookup() { - let graph = build_graph_from_sources(&[( - "src/app.ts", - r#" -export function start() {} -export class Server {} -"#, - )]); - - assert!(graph.get_node("src/app.ts").is_some()); - assert!(graph.get_node("src/app.ts::start").is_some()); - assert!(graph.get_node("src/app.ts::Server").is_some()); - assert!(graph.get_node("src/nonexistent.ts").is_none()); - - let start = graph.get_symbol("src/app.ts::start").unwrap(); - assert_eq!(start.name, "start"); - assert_eq!(start.kind, SymbolKind::Function); - } - - #[test] - fn test_external_imports_no_edges() { - let graph = build_graph_from_sources(&[( - "src/app.ts", - r#" -import express from 'express'; -import { Router } from 'express'; -const app = express(); -"#, - )]); - - // External packages (non-relative imports) should not create edges. - assert_eq!( - count_edges_of_type(&graph, &EdgeType::Imports), - 0, - "external imports should not create edges" - ); - } - - #[test] - fn test_deterministic_output() { - let files = &[ - ( - "src/a.ts", - r#" -export function foo() {} -export function bar() {} -"#, - ), - ( - "src/b.ts", - r#" -import { foo, bar } from './a'; -function baz() { foo(); bar(); } -"#, - ), - ]; - - let g1 = build_graph_from_sources(files); - let g2 = build_graph_from_sources(files); - - assert_eq!(g1.node_count(), g2.node_count()); - assert_eq!(g1.edge_count(), g2.edge_count()); - assert_eq!(g1.to_serializable(), g2.to_serializable()); - } - - // === §13.3 spec-required tests === - - /// §13.3: Creates `extends` edges from class inheritance. - #[test] - fn test_build_extends_edges() { - // TypeScript class inheritance via AST path. - // Note: the AST path's `collect_extends_edges` is a stub — extends edges - // come from the IR path. Verify IR-based extends edges work correctly. - let graph = build_graph_from_sources(&[ - ( - "src/base.ts", - r#" -export class BaseEntity { - id: string; -} -"#, - ), - ( - "src/user.ts", - r#" -import { BaseEntity } from './base'; -export class User extends BaseEntity { - name: string; -} -"#, - ), - ]); - - // Via AST path, extends edges are not yet produced (stub). - // Verify the graph builds without error and has the expected nodes. - assert!(graph.node_count() >= 4, "should have module + class nodes"); - - // Now test via IR path which DOES produce extends edges. - use crate::ir::{IrFile, IrImport, IrImportSpecifier, IrTypeDef, Span, TypeDefKind}; - - let empty_span = || Span { - start_line: 0, - end_line: 0, - }; - - let base_file = IrFile { - path: "src/base.ts".to_string(), - language: crate::ast::Language::TypeScript, - functions: vec![], - type_defs: vec![IrTypeDef { - name: "BaseEntity".to_string(), - kind: TypeDefKind::Class, - span: empty_span(), - bases: vec![], - is_exported: true, - decorators: vec![], - }], - constants: vec![], - imports: vec![], - exports: vec![], - call_expressions: vec![], - assignments: vec![], - }; - - let user_file = IrFile { - path: "src/user.ts".to_string(), - language: crate::ast::Language::TypeScript, - functions: vec![], - type_defs: vec![IrTypeDef { - name: "User".to_string(), - kind: TypeDefKind::Class, - span: empty_span(), - bases: vec!["BaseEntity".to_string()], - is_exported: true, - decorators: vec![], - }], - constants: vec![], - imports: vec![IrImport { - source: "./base".to_string(), - specifiers: vec![IrImportSpecifier::Named { - name: "BaseEntity".to_string(), - alias: None, - }], - span: empty_span(), - }], - exports: vec![], - call_expressions: vec![], - assignments: vec![], - }; - - let ir_graph = SymbolGraph::build_from_ir(&[base_file, user_file]); - assert!( - has_edge( - &ir_graph, - "src/user.ts::User", - "src/base.ts::BaseEntity", - &EdgeType::Extends - ), - "should have Extends edge from User to BaseEntity via IR path" - ); - } - - /// §13.3: Resolves imports across monorepo package boundaries. - #[test] - fn test_cross_package_edges() { - let files = vec![ - ( - "packages/shared/src/index.ts", - r#" -export function formatDate(d: Date): string { return d.toISOString(); } -"#, - ), - ( - "packages/api/src/handler.ts", - r#" -import { formatDate } from "@acme/shared"; -export function handle() { return formatDate(new Date()); } -"#, - ), - ]; - - let parsed: Vec = files - .iter() - .map(|(path, source)| ast::parse_file(path, source).unwrap()) - .collect(); - - let mut ws = WorkspaceMap::new(); - ws.insert( - "@acme/shared".to_string(), - "packages/shared/src/index.ts".to_string(), - ); - let graph = SymbolGraph::build_with_workspace(&parsed, &ws); - - // Should have cross-package import edge - assert!( - has_edge( - &graph, - "packages/api/src/handler.ts", - "packages/shared/src/index.ts::formatDate", - &EdgeType::Imports - ), - "should resolve import across monorepo package boundary" - ); - - // Should have cross-package call edge - assert!( - has_edge( - &graph, - "packages/api/src/handler.ts::handle", - "packages/shared/src/index.ts::formatDate", - &EdgeType::Calls - ), - "should resolve call across monorepo package boundary" - ); - } - - /// §13.3: Handles `import()` / `require()` dynamic imports. - #[test] - fn test_dynamic_imports() { - // Dynamic imports (import() and require()) should not crash the graph builder. - // Whether edges are created depends on whether the callee can be resolved. - let graph = build_graph_from_sources(&[ - ( - "src/utils.ts", - r#" -export function lazyLoad() { return 42; } -"#, - ), - ( - "src/main.ts", - r#" -async function loadModule() { - const mod = await import('./utils'); - return mod.lazyLoad(); -} -function loadSync() { - const mod = require('./utils'); -} -"#, - ), - ]); - - // Graph should build without crashing on dynamic imports. - assert!(graph.node_count() >= 2, "should have nodes for both files"); - - // Dynamic import() and require() are call expressions; they may or may not - // create edges depending on resolution. The key property is no panic. - // Check that the graph is well-formed. - let serialized = graph.to_serializable(); - let json = serde_json::to_string(&serialized).unwrap(); - let _: SerializableGraph = serde_json::from_str(&json).unwrap(); - } - - // === Property-based tests === - - mod proptests { - use super::*; - use proptest::prelude::*; - - /// Generate a random function name. - fn func_name_strategy() -> impl Strategy { - "[a-z][a-zA-Z0-9]{0,15}".prop_map(|s| s) - } - - /// Generate a ParsedFile with random definitions. - fn parsed_file_strategy() -> impl Strategy { - ( - "[a-z]{1,8}".prop_map(|s| format!("src/{}.ts", s)), - prop::collection::vec(func_name_strategy(), 0..10), - ) - .prop_map(|(path, func_names)| { - let definitions: Vec = func_names - .iter() - .enumerate() - .map(|(i, name)| Definition { - name: name.clone(), - kind: SymbolKind::Function, - start_line: i + 1, - end_line: i + 3, - }) - .collect(); - - ParsedFile { - path, - language: Language::TypeScript, - definitions, - imports: vec![], - exports: vec![], - call_sites: vec![], - } - }) - } - - proptest! { - #[test] - fn prop_every_definition_has_node(files in prop::collection::vec(parsed_file_strategy(), 1..5)) { - let graph = SymbolGraph::build(&files); - - for file in &files { - // Module node exists. - prop_assert!(graph.get_node(&file.path).is_some(), - "module node should exist for {}", file.path); - - // Each unique definition has a node. - let mut seen = std::collections::HashSet::new(); - for def in &file.definitions { - let sym_id = format!("{}::{}", file.path, def.name); - if seen.insert(sym_id.clone()) { - prop_assert!(graph.get_node(&sym_id).is_some(), - "node should exist for {}", sym_id); - } - } - } - } - - #[test] - fn prop_node_count_at_least_file_count(files in prop::collection::vec(parsed_file_strategy(), 0..5)) { - let graph = SymbolGraph::build(&files); - // At minimum, one module node per file. - prop_assert!(graph.node_count() >= files.len()); - } - - #[test] - fn prop_no_self_edges(files in prop::collection::vec(parsed_file_strategy(), 0..5)) { - let graph = SymbolGraph::build(&files); - for (from, to, _) in graph.edges() { - prop_assert!(from != to, "self-edge found: {} -> {}", from, to); - } - } - - #[test] - fn prop_serialization_roundtrip(files in prop::collection::vec(parsed_file_strategy(), 0..5)) { - let graph = SymbolGraph::build(&files); - let serialized = graph.to_serializable(); - let json = serde_json::to_string(&serialized).unwrap(); - let deserialized: SerializableGraph = serde_json::from_str(&json).unwrap(); - let restored = SymbolGraph::from_serializable(&deserialized); - - prop_assert_eq!(graph.node_count(), restored.node_count()); - prop_assert_eq!(graph.edge_count(), restored.edge_count()); - } - - #[test] - fn prop_deterministic(files in prop::collection::vec(parsed_file_strategy(), 0..5)) { - let g1 = SymbolGraph::build(&files); - let g2 = SymbolGraph::build(&files); - prop_assert_eq!(g1.node_count(), g2.node_count()); - prop_assert_eq!(g1.edge_count(), g2.edge_count()); - } - - #[test] - fn prop_empty_input_empty_graph(_dummy in 0u32..1) { - let graph = SymbolGraph::build(&[]); - prop_assert_eq!(graph.node_count(), 0); - prop_assert_eq!(graph.edge_count(), 0); - } - } - } - - // ======================================================================= - // IR-based graph parity tests - // ======================================================================= - - mod ir_parity { - use super::*; - use crate::ir::IrFile; - - /// Helper: parse files and build graph via both paths, return both. - fn build_both(files: &[(&str, &str)]) -> (SymbolGraph, SymbolGraph) { - let parsed: Vec = files - .iter() - .map(|(path, source)| ast::parse_file(path, source).unwrap()) - .collect(); - let ir_files: Vec = parsed.iter().map(IrFile::from_parsed_file).collect(); - - let graph_parsed = SymbolGraph::build(&parsed); - let graph_ir = SymbolGraph::build_from_ir(&ir_files); - (graph_parsed, graph_ir) - } - - #[test] - fn test_ir_parity_simple_import() { - let (gp, gi) = build_both(&[ - ( - "src/utils.ts", - r#" -export function validate(data: any) { return data; } -export function sanitize(data: any) { return data; } -"#, - ), - ( - "src/handler.ts", - r#" -import { validate, sanitize } from './utils'; -function handle() { validate({}); } -"#, - ), - ]); - - assert_eq!(gp.node_count(), gi.node_count(), "node counts should match"); - assert_eq!(gp.edge_count(), gi.edge_count(), "edge counts should match"); - } - - #[test] - fn test_ir_parity_call_edges() { - let (gp, gi) = build_both(&[ - ( - "src/utils.ts", - r#" -export function validate(data: any) { return data; } -"#, - ), - ( - "src/handler.ts", - r#" -import { validate } from './utils'; -function processRequest(req: any) { - const v = validate(req.body); - return v; -} -"#, - ), - ]); - - assert_eq!(gp.node_count(), gi.node_count()); - assert_eq!(gp.edge_count(), gi.edge_count()); - - // Verify specific edge exists in IR graph. - assert!( - has_edge( - &gi, - "src/handler.ts::processRequest", - "src/utils.ts::validate", - &EdgeType::Calls - ), - "IR graph should have call edge" - ); - } - - #[test] - fn test_ir_parity_namespace_import() { - let (gp, gi) = build_both(&[ - ( - "src/utils.ts", - r#" -export function foo() {} -export function bar() {} -"#, - ), - ( - "src/main.ts", - r#" -import * as utils from './utils'; -function main() { - utils.foo(); -} -"#, - ), - ]); - - assert_eq!(gp.node_count(), gi.node_count()); - assert_eq!(gp.edge_count(), gi.edge_count()); - } - - #[test] - fn test_ir_parity_default_import() { - let (gp, gi) = build_both(&[ - ( - "src/utils.ts", - r#" -export default function doStuff() {} -"#, - ), - ( - "src/main.ts", - r#" -import doStuff from './utils'; -doStuff(); -"#, - ), - ]); - - assert_eq!(gp.node_count(), gi.node_count()); - assert_eq!(gp.edge_count(), gi.edge_count()); - } - - #[test] - fn test_ir_parity_python_imports() { - let (gp, gi) = build_both(&[ - ( - "models.py", - r#" -class User: - pass - -def create_user(): - pass -"#, - ), - ( - "views.py", - r#" -from .models import User, create_user - -def list_users(): - return create_user() -"#, - ), - ]); - - assert_eq!(gp.node_count(), gi.node_count()); - assert_eq!(gp.edge_count(), gi.edge_count()); - } - - #[test] - fn test_ir_parity_reexport_chain() { - let (gp, gi) = build_both(&[ - ( - "src/core.ts", - r#" -export function coreFunc() {} -"#, - ), - ( - "src/index.ts", - r#" -export { coreFunc } from './core'; -"#, - ), - ( - "src/consumer.ts", - r#" -import { coreFunc } from './index'; -function use() { coreFunc(); } -"#, - ), - ]); - - assert_eq!(gp.node_count(), gi.node_count()); - assert_eq!(gp.edge_count(), gi.edge_count()); - } - - #[test] - fn test_ir_parity_side_effect_import() { - let (gp, gi) = build_both(&[ - ( - "src/polyfill.ts", - r#" -export function polyfill() {} -"#, - ), - ("src/main.ts", r#"import './polyfill';"#), - ]); - - assert_eq!(gp.node_count(), gi.node_count()); - assert_eq!(gp.edge_count(), gi.edge_count()); - } - - #[test] - fn test_ir_parity_empty_input() { - let gi = SymbolGraph::build_from_ir(&[]); - assert_eq!(gi.node_count(), 0); - assert_eq!(gi.edge_count(), 0); - } - - #[test] - fn test_ir_parity_local_call() { - let (gp, gi) = build_both(&[( - "src/app.ts", - r#" -function helper() { return 42; } -function main() { helper(); } -"#, - )]); - - assert_eq!(gp.node_count(), gi.node_count()); - assert_eq!(gp.edge_count(), gi.edge_count()); - - assert!( - has_edge( - &gi, - "src/app.ts::main", - "src/app.ts::helper", - &EdgeType::Calls - ), - "IR graph should have local call edge" - ); - } - - #[test] - fn test_ir_parity_aliased_import() { - let (gp, gi) = build_both(&[ - ( - "src/utils.ts", - r#" -export function validate() {} -"#, - ), - ( - "src/main.ts", - r#" -import { validate as check } from './utils'; -function run() { check(); } -"#, - ), - ]); - - assert_eq!(gp.node_count(), gi.node_count()); - assert_eq!(gp.edge_count(), gi.edge_count()); - } - - #[test] - fn test_ir_parity_multiple_files() { - let (gp, gi) = build_both(&[ - ( - "src/db.ts", - r#" -export function query(sql: string) { return []; } -export function insert(data: any) { } -"#, - ), - ( - "src/service.ts", - r#" -import { query, insert } from './db'; -export function getUsers() { return query('SELECT * FROM users'); } -export function createUser(data: any) { insert(data); } -"#, - ), - ( - "src/handler.ts", - r#" -import { getUsers, createUser } from './service'; -function handleGet(req: any) { return getUsers(); } -function handlePost(req: any) { createUser(req.body); } -"#, - ), - ]); - - assert_eq!( - gp.node_count(), - gi.node_count(), - "3-file graph node count should match" - ); - assert_eq!( - gp.edge_count(), - gi.edge_count(), - "3-file graph edge count should match" - ); - } - } - - // ======================================================================= - // IR-based graph property-based tests - // ======================================================================= - - mod ir_proptest { - use super::*; - use crate::ast::{Definition, Language, ParsedFile}; - use crate::ir::IrFile; - use proptest::prelude::*; - - fn func_name_strategy() -> impl Strategy { - "[a-z][a-zA-Z0-9]{0,15}".prop_map(|s| s) - } - - fn parsed_file_strategy() -> impl Strategy { - ( - "[a-z]{1,8}".prop_map(|s| format!("src/{}.ts", s)), - prop::collection::vec(func_name_strategy(), 0..10), - ) - .prop_map(|(path, func_names)| { - let definitions: Vec = func_names - .iter() - .enumerate() - .map(|(i, name)| Definition { - name: name.clone(), - kind: SymbolKind::Function, - start_line: i + 1, - end_line: i + 3, - }) - .collect(); - - ParsedFile { - path, - language: Language::TypeScript, - definitions, - imports: vec![], - exports: vec![], - call_sites: vec![], - } - }) - } - - proptest! { - #[test] - fn prop_ir_node_count_matches_parsed(files in prop::collection::vec(parsed_file_strategy(), 0..5)) { - let ir_files: Vec = files.iter().map(|f| IrFile::from_parsed_file(f)).collect(); - let g_parsed = SymbolGraph::build(&files); - let g_ir = SymbolGraph::build_from_ir(&ir_files); - prop_assert_eq!(g_parsed.node_count(), g_ir.node_count(), - "node count mismatch: parsed={}, ir={}", g_parsed.node_count(), g_ir.node_count()); - } - - #[test] - fn prop_ir_edge_count_matches_parsed(files in prop::collection::vec(parsed_file_strategy(), 0..5)) { - let ir_files: Vec = files.iter().map(|f| IrFile::from_parsed_file(f)).collect(); - let g_parsed = SymbolGraph::build(&files); - let g_ir = SymbolGraph::build_from_ir(&ir_files); - prop_assert_eq!(g_parsed.edge_count(), g_ir.edge_count()); - } - - #[test] - fn prop_ir_no_self_edges(files in prop::collection::vec(parsed_file_strategy(), 0..5)) { - let ir_files: Vec = files.iter().map(|f| IrFile::from_parsed_file(f)).collect(); - let graph = SymbolGraph::build_from_ir(&ir_files); - for (from, to, _) in graph.edges() { - prop_assert!(from != to, "self-edge found in IR graph: {} -> {}", from, to); - } - } - - #[test] - fn prop_ir_deterministic(files in prop::collection::vec(parsed_file_strategy(), 0..5)) { - let ir_files: Vec = files.iter().map(|f| IrFile::from_parsed_file(f)).collect(); - let g1 = SymbolGraph::build_from_ir(&ir_files); - let g2 = SymbolGraph::build_from_ir(&ir_files); - prop_assert_eq!(g1.node_count(), g2.node_count()); - prop_assert_eq!(g1.edge_count(), g2.edge_count()); - } - - #[test] - fn prop_ir_empty_input_empty_graph(_dummy in 0u32..1) { - let graph = SymbolGraph::build_from_ir(&[]); - prop_assert_eq!(graph.node_count(), 0); - prop_assert_eq!(graph.edge_count(), 0); - } - - #[test] - fn prop_ir_every_definition_has_node(files in prop::collection::vec(parsed_file_strategy(), 1..5)) { - let ir_files: Vec = files.iter().map(|f| IrFile::from_parsed_file(f)).collect(); - let graph = SymbolGraph::build_from_ir(&ir_files); - - for ir_file in &ir_files { - prop_assert!(graph.get_node(&ir_file.path).is_some(), - "module node should exist for {}", ir_file.path); - - let mut seen = std::collections::HashSet::new(); - for func in &ir_file.functions { - let sym_id = format!("{}::{}", ir_file.path, func.name); - if seen.insert(sym_id.clone()) { - prop_assert!(graph.get_node(&sym_id).is_some(), - "node should exist for function {}", sym_id); - } - } - for td in &ir_file.type_defs { - let sym_id = format!("{}::{}", ir_file.path, td.name); - if seen.insert(sym_id.clone()) { - prop_assert!(graph.get_node(&sym_id).is_some(), - "node should exist for type def {}", sym_id); - } - } - for c in &ir_file.constants { - let sym_id = format!("{}::{}", ir_file.path, c.name); - if seen.insert(sym_id.clone()) { - prop_assert!(graph.get_node(&sym_id).is_some(), - "node should exist for constant {}", sym_id); - } - } - } - } - } - } - - // ======================================================================= - // Helper function unit tests - // ======================================================================= - - mod helper_tests { - use super::*; - - // --- normalize_path --- - - #[test] - fn test_normalize_path_simple() { - assert_eq!(normalize_path("src/utils.ts"), "src/utils.ts"); - } - - #[test] - fn test_normalize_path_dot_segments() { - assert_eq!(normalize_path("src/./utils.ts"), "src/utils.ts"); - } - - #[test] - fn test_normalize_path_dotdot_segments() { - assert_eq!(normalize_path("src/handlers/../utils.ts"), "src/utils.ts"); - } - - #[test] - fn test_normalize_path_multiple_dotdot() { - assert_eq!(normalize_path("src/a/b/../../utils.ts"), "src/utils.ts"); - } - - #[test] - fn test_normalize_path_leading_dotdot() { - // More `..` than components — pops everything available. - assert_eq!(normalize_path("../utils.ts"), "utils.ts"); - } - - #[test] - fn test_normalize_path_empty_segments() { - assert_eq!(normalize_path("src//utils.ts"), "src/utils.ts"); - } - - #[test] - fn test_normalize_path_only_dot() { - assert_eq!(normalize_path("."), ""); - } - - #[test] - fn test_normalize_path_trailing_slash() { - assert_eq!(normalize_path("src/lib/"), "src/lib"); - } - - // --- normalize_python_import --- - - #[test] - fn test_python_import_single_dot() { - assert_eq!(normalize_python_import(".models"), "./models"); - } - - #[test] - fn test_python_import_double_dot() { - assert_eq!(normalize_python_import("..models"), "../models"); - } - - #[test] - fn test_python_import_triple_dot() { - assert_eq!( - normalize_python_import("...utils.helpers"), - "../../utils/helpers" - ); - } - - #[test] - fn test_python_import_dot_only() { - assert_eq!(normalize_python_import("."), "."); - } - - #[test] - fn test_python_import_dotdot_only() { - assert_eq!(normalize_python_import(".."), ".."); - } - - #[test] - fn test_python_import_no_dots() { - assert_eq!(normalize_python_import("os.path"), "os.path"); - } - - #[test] - fn test_python_import_dotted_remainder() { - assert_eq!( - normalize_python_import(".models.user.schema"), - "./models/user/schema" - ); - } - - // --- parent_dir --- - - #[test] - fn test_parent_dir_nested() { - assert_eq!(parent_dir("src/handlers/auth.ts"), "src/handlers"); - } - - #[test] - fn test_parent_dir_single_level() { - assert_eq!(parent_dir("src/app.ts"), "src"); - } - - #[test] - fn test_parent_dir_no_slash() { - assert_eq!(parent_dir("app.ts"), "."); - } - - // --- file_stem --- - - #[test] - fn test_file_stem_simple() { - assert_eq!(file_stem("src/utils.ts"), "utils"); - } - - #[test] - fn test_file_stem_no_extension() { - assert_eq!(file_stem("src/Makefile"), "Makefile"); - } - - #[test] - fn test_file_stem_multiple_dots() { - assert_eq!(file_stem("src/utils.test.ts"), "utils"); - } - - #[test] - fn test_file_stem_no_directory() { - assert_eq!(file_stem("app.ts"), "app"); - } - - // --- resolve_import_path --- - - #[test] - fn test_resolve_import_exact_match() { - // Note: resolve_import_path normalizes Python-style dots, so - // explicit extensions like `./utils.ts` get mangled. Use - // extension-less import sources (the normal JS/TS convention). - let known = vec!["src/utils.ts"]; - let result = resolve_import_path("./utils", "src/handler.ts", &known); - assert_eq!(result, Some("src/utils.ts".to_string())); - } - - #[test] - fn test_resolve_import_ts_extension() { - let known = vec!["src/utils.ts"]; - let result = resolve_import_path("./utils", "src/handler.ts", &known); - assert_eq!(result, Some("src/utils.ts".to_string())); - } - - #[test] - fn test_resolve_import_tsx_extension() { - let known = vec!["src/Button.tsx"]; - let result = resolve_import_path("./Button", "src/App.tsx", &known); - assert_eq!(result, Some("src/Button.tsx".to_string())); - } - - #[test] - fn test_resolve_import_index_file() { - let known = vec!["src/lib/index.ts"]; - let result = resolve_import_path("./lib", "src/main.ts", &known); - assert_eq!(result, Some("src/lib/index.ts".to_string())); - } - - #[test] - fn test_resolve_import_parent_dir() { - let known = vec!["src/utils.ts"]; - let result = resolve_import_path("../utils", "src/handlers/auth.ts", &known); - assert_eq!(result, Some("src/utils.ts".to_string())); - } - - #[test] - fn test_resolve_import_nonrelative_ignored() { - let known = vec!["node_modules/express/index.js"]; - let result = resolve_import_path("express", "src/app.ts", &known); - assert_eq!(result, None); - } - - #[test] - fn test_resolve_import_not_found() { - let known = vec!["src/app.ts"]; - let result = resolve_import_path("./nonexistent", "src/main.ts", &known); - assert_eq!(result, None); - } - - #[test] - fn test_resolve_import_python_style() { - let known = vec!["models.py"]; - let result = resolve_import_path(".models", "views.py", &known); - assert_eq!(result, Some("models.py".to_string())); - } - - #[test] - fn test_resolve_import_js_extension() { - let known = vec!["src/helper.js"]; - let result = resolve_import_path("./helper", "src/main.ts", &known); - assert_eq!(result, Some("src/helper.js".to_string())); - } - - #[test] - fn test_resolve_import_priority_exact_over_extension() { - // If both exact match and .ts exist, exact match wins. - let known = vec!["src/utils", "src/utils.ts"]; - let result = resolve_import_path("./utils", "src/main.ts", &known); - assert_eq!(result, Some("src/utils".to_string())); - } - - // --- resolve_workspace_import --- - - #[test] - fn test_workspace_exact_package_match() { - let known = vec!["packages/shared/src/index.ts"]; - let mut ws = WorkspaceMap::new(); - ws.insert( - "@mono/shared".to_string(), - "packages/shared/src/index.ts".to_string(), - ); - let result = resolve_workspace_import("@mono/shared", &known, &ws); - assert_eq!(result, Some("packages/shared/src/index.ts".to_string())); - } - - #[test] - fn test_workspace_package_not_in_known_files() { - let known: Vec<&str> = vec!["src/app.ts"]; - let mut ws = WorkspaceMap::new(); - ws.insert( - "@mono/shared".to_string(), - "packages/shared/src/index.ts".to_string(), - ); - let result = resolve_workspace_import("@mono/shared", &known, &ws); - assert_eq!(result, None); - } - - #[test] - fn test_workspace_deep_import() { - // @mono/shared/utils → packages/shared/utils.ts - let known = vec!["packages/shared/utils.ts"]; - let mut ws = WorkspaceMap::new(); - ws.insert( - "@mono/shared".to_string(), - "packages/shared/src/index.ts".to_string(), - ); - let result = resolve_workspace_import("@mono/shared/utils", &known, &ws); - assert_eq!(result, Some("packages/shared/utils.ts".to_string())); - } - - #[test] - fn test_workspace_deep_import_with_extension() { - let known = vec!["packages/shared/models/user.ts"]; - let mut ws = WorkspaceMap::new(); - ws.insert( - "@mono/shared".to_string(), - "packages/shared/src/index.ts".to_string(), - ); - let result = resolve_workspace_import("@mono/shared/models/user", &known, &ws); - assert_eq!(result, Some("packages/shared/models/user.ts".to_string())); - } - - #[test] - fn test_workspace_relative_import_skipped() { - let known = vec!["packages/shared/src/index.ts"]; - let mut ws = WorkspaceMap::new(); - ws.insert( - "@mono/shared".to_string(), - "packages/shared/src/index.ts".to_string(), - ); - let result = resolve_workspace_import("./utils", &known, &ws); - assert_eq!(result, None); - } - - #[test] - fn test_workspace_no_match() { - let known = vec!["src/app.ts"]; - let ws = WorkspaceMap::new(); - let result = resolve_workspace_import("@mono/shared", &known, &ws); - assert_eq!(result, None); - } - - #[test] - fn test_workspace_longest_prefix_match() { - // @mono/shared/sub should match @mono/shared, not @mono - let known = vec!["packages/shared/sub.ts"]; - let mut ws = WorkspaceMap::new(); - ws.insert( - "@mono".to_string(), - "packages/mono/src/index.ts".to_string(), - ); - ws.insert( - "@mono/shared".to_string(), - "packages/shared/src/index.ts".to_string(), - ); - let result = resolve_workspace_import("@mono/shared/sub", &known, &ws); - assert_eq!(result, Some("packages/shared/sub.ts".to_string())); - } - - // --- resolve_import_or_workspace --- - - #[test] - fn test_resolve_or_workspace_prefers_relative() { - // Relative import should still work, even with workspace map. - let known = vec!["src/utils.ts", "packages/utils/src/index.ts"]; - let mut ws = WorkspaceMap::new(); - ws.insert( - "utils".to_string(), - "packages/utils/src/index.ts".to_string(), - ); - let result = resolve_import_or_workspace("./utils", "src/handler.ts", &known, &ws); - assert_eq!(result, Some("src/utils.ts".to_string())); - } - - #[test] - fn test_resolve_or_workspace_falls_back_to_workspace() { - let known = vec!["packages/types/src/index.ts"]; - let mut ws = WorkspaceMap::new(); - ws.insert( - "@app/types".to_string(), - "packages/types/src/index.ts".to_string(), - ); - let result = resolve_import_or_workspace("@app/types", "src/handler.ts", &known, &ws); - assert_eq!(result, Some("packages/types/src/index.ts".to_string())); - } - } - - // ======================================================================= - // IR extends edge tests - // ======================================================================= - - mod ir_extends_tests { - use super::*; - use crate::ast::Language; - use crate::ir::{IrFile, IrImport, IrImportSpecifier, IrTypeDef, Span, TypeDefKind}; - - fn empty_span() -> Span { - Span::new(1, 1) - } - - fn make_ir_file(path: &str, language: Language) -> IrFile { - IrFile { - path: path.to_string(), - language, - functions: vec![], - type_defs: vec![], - constants: vec![], - imports: vec![], - exports: vec![], - call_expressions: vec![], - assignments: vec![], - } - } - - #[test] - fn test_ir_extends_local_class() { - let mut file = make_ir_file("src/models.ts", Language::TypeScript); - file.type_defs.push(IrTypeDef { - name: "BaseModel".to_string(), - kind: TypeDefKind::Class, - span: empty_span(), - bases: vec![], - is_exported: true, - decorators: vec![], - }); - file.type_defs.push(IrTypeDef { - name: "User".to_string(), - kind: TypeDefKind::Class, - span: empty_span(), - bases: vec!["BaseModel".to_string()], - is_exported: true, - decorators: vec![], - }); - - let graph = SymbolGraph::build_from_ir(&[file]); - - assert!( - has_edge( - &graph, - "src/models.ts::User", - "src/models.ts::BaseModel", - &EdgeType::Extends - ), - "should have extends edge from User to BaseModel" - ); - } - - #[test] - fn test_ir_extends_imported_class() { - let mut base_file = make_ir_file("src/base.ts", Language::TypeScript); - base_file.type_defs.push(IrTypeDef { - name: "Entity".to_string(), - kind: TypeDefKind::Class, - span: empty_span(), - bases: vec![], - is_exported: true, - decorators: vec![], - }); - - let mut child_file = make_ir_file("src/user.ts", Language::TypeScript); - child_file.imports.push(IrImport { - source: "./base".to_string(), - specifiers: vec![IrImportSpecifier::Named { - name: "Entity".to_string(), - alias: None, - }], - span: empty_span(), - }); - child_file.type_defs.push(IrTypeDef { - name: "User".to_string(), - kind: TypeDefKind::Class, - span: empty_span(), - bases: vec!["Entity".to_string()], - is_exported: true, - decorators: vec![], - }); - - let graph = SymbolGraph::build_from_ir(&[base_file, child_file]); - - assert!( - has_edge( - &graph, - "src/user.ts::User", - "src/base.ts::Entity", - &EdgeType::Extends - ), - "should have extends edge to imported base class" - ); - } - - #[test] - fn test_ir_extends_multiple_bases() { - let mut file = make_ir_file("src/mixin.ts", Language::TypeScript); - file.type_defs.push(IrTypeDef { - name: "Serializable".to_string(), - kind: TypeDefKind::Interface, - span: empty_span(), - bases: vec![], - is_exported: false, - decorators: vec![], - }); - file.type_defs.push(IrTypeDef { - name: "Loggable".to_string(), - kind: TypeDefKind::Interface, - span: empty_span(), - bases: vec![], - is_exported: false, - decorators: vec![], - }); - file.type_defs.push(IrTypeDef { - name: "UserService".to_string(), - kind: TypeDefKind::Class, - span: empty_span(), - bases: vec!["Serializable".to_string(), "Loggable".to_string()], - is_exported: true, - decorators: vec![], - }); - - let graph = SymbolGraph::build_from_ir(&[file]); - - assert!( - has_edge( - &graph, - "src/mixin.ts::UserService", - "src/mixin.ts::Serializable", - &EdgeType::Extends - ), - "should have extends edge to Serializable" - ); - assert!( - has_edge( - &graph, - "src/mixin.ts::UserService", - "src/mixin.ts::Loggable", - &EdgeType::Extends - ), - "should have extends edge to Loggable" - ); - } - - #[test] - fn test_ir_extends_no_self_edge() { - let mut file = make_ir_file("src/app.ts", Language::TypeScript); - file.type_defs.push(IrTypeDef { - name: "App".to_string(), - kind: TypeDefKind::Class, - span: empty_span(), - bases: vec!["App".to_string()], - is_exported: false, - decorators: vec![], - }); - - let graph = SymbolGraph::build_from_ir(&[file]); - - let self_edges: Vec<_> = graph - .edges() - .into_iter() - .filter(|(f, t, _)| f == t) - .collect(); - assert!( - self_edges.is_empty(), - "self-referencing base should not create self-edge" - ); - } - - #[test] - fn test_ir_extends_missing_base_no_panic() { - let mut file = make_ir_file("src/app.ts", Language::TypeScript); - file.type_defs.push(IrTypeDef { - name: "App".to_string(), - kind: TypeDefKind::Class, - span: empty_span(), - bases: vec!["NonExistent".to_string()], - is_exported: false, - decorators: vec![], - }); - - let graph = SymbolGraph::build_from_ir(&[file]); - - assert!(graph.get_node("src/app.ts::App").is_some()); - assert_eq!( - count_edges_of_type(&graph, &EdgeType::Extends), - 0, - "missing base should not create extends edge" - ); - } - - #[test] - fn test_ir_extends_empty_bases() { - let mut file = make_ir_file("src/app.ts", Language::TypeScript); - file.type_defs.push(IrTypeDef { - name: "PlainClass".to_string(), - kind: TypeDefKind::Class, - span: empty_span(), - bases: vec![], - is_exported: false, - decorators: vec![], - }); - - let graph = SymbolGraph::build_from_ir(&[file]); - assert_eq!(count_edges_of_type(&graph, &EdgeType::Extends), 0); - } - - #[test] - fn test_ir_extends_cross_file_chain() { - let mut file_a = make_ir_file("src/a.ts", Language::TypeScript); - file_a.type_defs.push(IrTypeDef { - name: "GrandParent".to_string(), - kind: TypeDefKind::Class, - span: empty_span(), - bases: vec![], - is_exported: true, - decorators: vec![], - }); - - let mut file_b = make_ir_file("src/b.ts", Language::TypeScript); - file_b.imports.push(IrImport { - source: "./a".to_string(), - specifiers: vec![IrImportSpecifier::Named { - name: "GrandParent".to_string(), - alias: None, - }], - span: empty_span(), - }); - file_b.type_defs.push(IrTypeDef { - name: "Parent".to_string(), - kind: TypeDefKind::Class, - span: empty_span(), - bases: vec!["GrandParent".to_string()], - is_exported: true, - decorators: vec![], - }); - - let mut file_c = make_ir_file("src/c.ts", Language::TypeScript); - file_c.imports.push(IrImport { - source: "./b".to_string(), - specifiers: vec![IrImportSpecifier::Named { - name: "Parent".to_string(), - alias: None, - }], - span: empty_span(), - }); - file_c.type_defs.push(IrTypeDef { - name: "Child".to_string(), - kind: TypeDefKind::Class, - span: empty_span(), - bases: vec!["Parent".to_string()], - is_exported: false, - decorators: vec![], - }); - - let graph = SymbolGraph::build_from_ir(&[file_a, file_b, file_c]); - - assert!(has_edge( - &graph, - "src/b.ts::Parent", - "src/a.ts::GrandParent", - &EdgeType::Extends - )); - assert!(has_edge( - &graph, - "src/c.ts::Child", - "src/b.ts::Parent", - &EdgeType::Extends - )); - } - } - - // ======================================================================= - // IR-specific node type tests - // ======================================================================= - - mod ir_node_type_tests { - use super::*; - use crate::ast::Language; - use crate::ir::FunctionKind; - use crate::ir::{ - IrConstant, IrFile, IrFunctionDef, IrImport, IrImportSpecifier, IrTypeDef, Span, - TypeDefKind, - }; - - fn empty_span() -> Span { - Span::new(1, 1) - } - - fn make_ir_file(path: &str) -> IrFile { - IrFile { - path: path.to_string(), - language: Language::TypeScript, - functions: vec![], - type_defs: vec![], - constants: vec![], - imports: vec![], - exports: vec![], - call_expressions: vec![], - assignments: vec![], - } - } - - #[test] - fn test_ir_class_node_kind() { - let mut file = make_ir_file("src/app.ts"); - file.type_defs.push(IrTypeDef { - name: "AppServer".to_string(), - kind: TypeDefKind::Class, - span: empty_span(), - bases: vec![], - is_exported: false, - decorators: vec![], - }); - - let graph = SymbolGraph::build_from_ir(&[file]); - let sym = graph.get_symbol("src/app.ts::AppServer").unwrap(); - assert_eq!(sym.kind, SymbolKind::Class); - } - - #[test] - fn test_ir_struct_node_kind() { - let mut file = make_ir_file("src/data.ts"); - file.type_defs.push(IrTypeDef { - name: "Point".to_string(), - kind: TypeDefKind::Struct, - span: empty_span(), - bases: vec![], - is_exported: false, - decorators: vec![], - }); - - let graph = SymbolGraph::build_from_ir(&[file]); - let sym = graph.get_symbol("src/data.ts::Point").unwrap(); - assert_eq!(sym.kind, SymbolKind::Struct); - } - - #[test] - fn test_ir_interface_node_kind() { - let mut file = make_ir_file("src/types.ts"); - file.type_defs.push(IrTypeDef { - name: "Serializable".to_string(), - kind: TypeDefKind::Interface, - span: empty_span(), - bases: vec![], - is_exported: false, - decorators: vec![], - }); - - let graph = SymbolGraph::build_from_ir(&[file]); - let sym = graph.get_symbol("src/types.ts::Serializable").unwrap(); - assert_eq!(sym.kind, SymbolKind::Interface); - } - - #[test] - fn test_ir_type_alias_node_kind() { - let mut file = make_ir_file("src/types.ts"); - file.type_defs.push(IrTypeDef { - name: "UserId".to_string(), - kind: TypeDefKind::TypeAlias, - span: empty_span(), - bases: vec![], - is_exported: false, - decorators: vec![], - }); - - let graph = SymbolGraph::build_from_ir(&[file]); - let sym = graph.get_symbol("src/types.ts::UserId").unwrap(); - assert_eq!(sym.kind, SymbolKind::TypeAlias); - } - - #[test] - fn test_ir_enum_node_kind() { - let mut file = make_ir_file("src/status.ts"); - file.type_defs.push(IrTypeDef { - name: "Status".to_string(), - kind: TypeDefKind::Enum, - span: empty_span(), - bases: vec![], - is_exported: false, - decorators: vec![], - }); - - let graph = SymbolGraph::build_from_ir(&[file]); - let sym = graph.get_symbol("src/status.ts::Status").unwrap(); - assert_eq!(sym.kind, SymbolKind::Class); - } - - #[test] - fn test_ir_constant_node() { - let mut file = make_ir_file("src/config.ts"); - file.constants.push(IrConstant { - name: "MAX_RETRIES".to_string(), - span: empty_span(), - is_exported: true, - }); - - let graph = SymbolGraph::build_from_ir(&[file]); - let sym = graph.get_symbol("src/config.ts::MAX_RETRIES").unwrap(); - assert_eq!(sym.kind, SymbolKind::Constant); - assert_eq!(sym.file, "src/config.ts"); - } - - #[test] - fn test_ir_function_node() { - let mut file = make_ir_file("src/utils.ts"); - file.functions.push(IrFunctionDef { - name: "helper".to_string(), - kind: FunctionKind::Function, - span: empty_span(), - parameters: vec![], - is_async: false, - is_exported: false, - decorators: vec![], - }); - - let graph = SymbolGraph::build_from_ir(&[file]); - let sym = graph.get_symbol("src/utils.ts::helper").unwrap(); - assert_eq!(sym.kind, SymbolKind::Function); - } - - #[test] - fn test_ir_mixed_definitions() { - let mut file = make_ir_file("src/app.ts"); - file.functions.push(IrFunctionDef { - name: "start".to_string(), - kind: FunctionKind::Function, - span: empty_span(), - parameters: vec![], - is_async: false, - is_exported: true, - decorators: vec![], - }); - file.type_defs.push(IrTypeDef { - name: "Config".to_string(), - kind: TypeDefKind::Interface, - span: empty_span(), - bases: vec![], - is_exported: true, - decorators: vec![], - }); - file.constants.push(IrConstant { - name: "VERSION".to_string(), - span: empty_span(), - is_exported: true, - }); - - let graph = SymbolGraph::build_from_ir(&[file]); - - assert_eq!(graph.node_count(), 4); - assert!(graph.get_node("src/app.ts").is_some()); - assert!(graph.get_node("src/app.ts::start").is_some()); - assert!(graph.get_node("src/app.ts::Config").is_some()); - assert!(graph.get_node("src/app.ts::VERSION").is_some()); - } - - #[test] - fn test_ir_duplicate_definition_name_across_files() { - let mut file_a = make_ir_file("src/a.ts"); - file_a.functions.push(IrFunctionDef { - name: "validate".to_string(), - kind: FunctionKind::Function, - span: empty_span(), - parameters: vec![], - is_async: false, - is_exported: true, - decorators: vec![], - }); - - let mut file_b = make_ir_file("src/b.ts"); - file_b.functions.push(IrFunctionDef { - name: "validate".to_string(), - kind: FunctionKind::Function, - span: empty_span(), - parameters: vec![], - is_async: false, - is_exported: true, - decorators: vec![], - }); - - let graph = SymbolGraph::build_from_ir(&[file_a, file_b]); - - assert!(graph.get_node("src/a.ts::validate").is_some()); - assert!(graph.get_node("src/b.ts::validate").is_some()); - assert_eq!(graph.node_count(), 4); - } - - #[test] - fn test_ir_duplicate_name_within_file_skipped() { - let mut file = make_ir_file("src/lib.ts"); - file.functions.push(IrFunctionDef { - name: "config".to_string(), - kind: FunctionKind::Function, - span: empty_span(), - parameters: vec![], - is_async: false, - is_exported: false, - decorators: vec![], - }); - file.constants.push(IrConstant { - name: "config".to_string(), - span: empty_span(), - is_exported: false, - }); - - let graph = SymbolGraph::build_from_ir(&[file]); - assert_eq!(graph.node_count(), 2); - let sym = graph.get_symbol("src/lib.ts::config").unwrap(); - assert_eq!(sym.kind, SymbolKind::Function); - } - - #[test] - fn test_ir_call_edges_with_containing_function() { - use crate::ir::IrCallExpression; - - let mut utils = make_ir_file("src/utils.ts"); - utils.functions.push(IrFunctionDef { - name: "validate".to_string(), - kind: FunctionKind::Function, - span: empty_span(), - parameters: vec![], - is_async: false, - is_exported: true, - decorators: vec![], - }); - - let mut handler = make_ir_file("src/handler.ts"); - handler.functions.push(IrFunctionDef { - name: "process".to_string(), - kind: FunctionKind::Function, - span: empty_span(), - parameters: vec![], - is_async: false, - is_exported: false, - decorators: vec![], - }); - handler.imports.push(IrImport { - source: "./utils".to_string(), - specifiers: vec![IrImportSpecifier::Named { - name: "validate".to_string(), - alias: None, - }], - span: empty_span(), - }); - handler.call_expressions.push(IrCallExpression { - callee: "validate".to_string(), - arguments: vec!["data".to_string()], - span: empty_span(), - containing_function: Some("process".to_string()), - }); - - let graph = SymbolGraph::build_from_ir(&[utils, handler]); - - assert!(has_edge( - &graph, - "src/handler.ts::process", - "src/utils.ts::validate", - &EdgeType::Calls - )); - } - - #[test] - fn test_ir_module_level_call() { - use crate::ir::IrCallExpression; - - let mut utils = make_ir_file("src/utils.ts"); - utils.functions.push(IrFunctionDef { - name: "init".to_string(), - kind: FunctionKind::Function, - span: empty_span(), - parameters: vec![], - is_async: false, - is_exported: true, - decorators: vec![], - }); - - let mut main_file = make_ir_file("src/main.ts"); - main_file.imports.push(IrImport { - source: "./utils".to_string(), - specifiers: vec![IrImportSpecifier::Named { - name: "init".to_string(), - alias: None, - }], - span: empty_span(), - }); - main_file.call_expressions.push(IrCallExpression { - callee: "init".to_string(), - arguments: vec![], - span: empty_span(), - containing_function: None, - }); - - let graph = SymbolGraph::build_from_ir(&[utils, main_file]); - - assert!(has_edge( - &graph, - "src/main.ts", - "src/utils.ts::init", - &EdgeType::Calls - )); - } - } - - // ======================================================================= - // Edge case tests - // ======================================================================= - - mod edge_case_tests { - use super::*; - use crate::ast::Language; - use crate::ir::{IrFile, Span}; - - fn make_empty_ir(path: &str) -> IrFile { - IrFile { - path: path.to_string(), - language: Language::TypeScript, - functions: vec![], - type_defs: vec![], - constants: vec![], - imports: vec![], - exports: vec![], - call_expressions: vec![], - assignments: vec![], - } - } - - #[test] - fn test_unicode_file_path() { - let file = make_empty_ir("src/日本語/コンポーネント.ts"); - let graph = SymbolGraph::build_from_ir(&[file]); - assert!(graph.get_node("src/日本語/コンポーネント.ts").is_some()); - let sym = graph.get_symbol("src/日本語/コンポーネント.ts").unwrap(); - assert_eq!(sym.name, "コンポーネント"); - } - - #[test] - fn test_unicode_symbol_name() { - use crate::ir::{FunctionKind, IrFunctionDef}; - let mut file = make_empty_ir("src/utils.ts"); - file.functions.push(IrFunctionDef { - name: "überprüfen".to_string(), - kind: FunctionKind::Function, - span: Span::new(1, 1), - parameters: vec![], - is_async: false, - is_exported: false, - decorators: vec![], - }); - let graph = SymbolGraph::build_from_ir(&[file]); - assert!(graph.get_node("src/utils.ts::überprüfen").is_some()); - } - - #[test] - fn test_deeply_nested_path() { - let path = "src/a/b/c/d/e/f/g/h/i/j/deep.ts"; - let file = make_empty_ir(path); - let graph = SymbolGraph::build_from_ir(&[file]); - assert!(graph.get_node(path).is_some()); - let sym = graph.get_symbol(path).unwrap(); - assert_eq!(sym.name, "deep"); - } - - #[test] - fn test_file_only_imports_no_definitions() { - use crate::ir::{IrImport, IrImportSpecifier}; - let mut file = make_empty_ir("src/init.ts"); - file.imports.push(IrImport { - source: "./polyfill".to_string(), - specifiers: vec![IrImportSpecifier::SideEffect], - span: Span::new(1, 1), - }); - let graph = SymbolGraph::build_from_ir(&[file]); - assert_eq!(graph.node_count(), 1); - } - - #[test] - fn test_many_files_scale() { - use crate::ir::{FunctionKind, IrFunctionDef}; - let files: Vec = (0..50) - .map(|i| { - let mut f = make_empty_ir(&format!("src/file_{}.ts", i)); - for j in 0..5 { - f.functions.push(IrFunctionDef { - name: format!("func_{}", j), - kind: FunctionKind::Function, - span: Span::new(1, 1), - parameters: vec![], - is_async: false, - is_exported: true, - decorators: vec![], - }); - } - f - }) - .collect(); - - let graph = SymbolGraph::build_from_ir(&files); - assert_eq!(graph.node_count(), 300); - } - - #[test] - fn test_edges_on_empty_graph() { - let graph = SymbolGraph::build_from_ir(&[]); - assert!(graph.edges().is_empty()); - assert!(graph.node_ids().is_empty()); - } - - #[test] - fn test_node_ids_contains_all() { - use crate::ir::{FunctionKind, IrFunctionDef}; - let mut file = make_empty_ir("src/lib.ts"); - file.functions.push(IrFunctionDef { - name: "alpha".to_string(), - kind: FunctionKind::Function, - span: Span::new(1, 1), - parameters: vec![], - is_async: false, - is_exported: false, - decorators: vec![], - }); - - let graph = SymbolGraph::build_from_ir(&[file]); - let ids = graph.node_ids(); - assert!(ids.contains(&"src/lib.ts")); - assert!(ids.contains(&"src/lib.ts::alpha")); - assert_eq!(ids.len(), 2); - } - - #[test] - fn test_get_symbol_returns_none_for_missing() { - let graph = SymbolGraph::build_from_ir(&[make_empty_ir("src/a.ts")]); - assert!(graph.get_symbol("nonexistent").is_none()); - assert!(graph.get_symbol("src/a.ts::nonexistent").is_none()); - } - - #[test] - fn test_add_edge_directly() { - let files = vec![make_empty_ir("src/x.ts"), make_empty_ir("src/y.ts")]; - let mut graph = SymbolGraph::build_from_ir(&files); - let x_idx = graph.get_node("src/x.ts").unwrap(); - let y_idx = graph.get_node("src/y.ts").unwrap(); - - graph.add_edge( - x_idx, - y_idx, - GraphEdge { - edge_type: EdgeType::Calls, - }, - ); - assert_eq!(graph.edge_count(), 1); - assert!(has_edge(&graph, "src/x.ts", "src/y.ts", &EdgeType::Calls)); - } - - #[test] - fn test_from_serializable_invalid_edge_endpoint() { - let sg = SerializableGraph { - nodes: vec![SymbolNode { - id: "a.ts".to_string(), - name: "a".to_string(), - file: "a.ts".to_string(), - kind: SymbolKind::Module, - }], - edges: vec![SerializableEdge { - from: "a.ts".to_string(), - to: "nonexistent.ts".to_string(), - edge_type: EdgeType::Imports, - }], - }; - - let graph = SymbolGraph::from_serializable(&sg); - assert_eq!(graph.node_count(), 1); - assert_eq!( - graph.edge_count(), - 0, - "edge with invalid endpoint should be skipped" - ); - } - - #[test] - fn test_from_serializable_both_endpoints_invalid() { - let sg = SerializableGraph { - nodes: vec![], - edges: vec![SerializableEdge { - from: "x.ts".to_string(), - to: "y.ts".to_string(), - edge_type: EdgeType::Calls, - }], - }; - - let graph = SymbolGraph::from_serializable(&sg); - assert_eq!(graph.node_count(), 0); - assert_eq!(graph.edge_count(), 0); - } - - #[test] - fn test_serializable_preserves_all_edge_types() { - let nodes = vec![ - SymbolNode { - id: "a.ts".to_string(), - name: "a".to_string(), - file: "a.ts".to_string(), - kind: SymbolKind::Module, - }, - SymbolNode { - id: "b.ts".to_string(), - name: "b".to_string(), - file: "b.ts".to_string(), - kind: SymbolKind::Module, - }, - ]; - let edge_types = vec![ - EdgeType::Imports, - EdgeType::Calls, - EdgeType::Extends, - EdgeType::Instantiates, - EdgeType::Reads, - EdgeType::Writes, - EdgeType::Emits, - EdgeType::Handles, - ]; - let edges: Vec = edge_types - .iter() - .map(|et| SerializableEdge { - from: "a.ts".to_string(), - to: "b.ts".to_string(), - edge_type: et.clone(), - }) - .collect(); - let sg = SerializableGraph { nodes, edges }; - - let json = serde_json::to_string(&sg).unwrap(); - let restored: SerializableGraph = serde_json::from_str(&json).unwrap(); - assert_eq!(sg, restored); - assert_eq!(restored.edges.len(), 8); - } - - #[test] - fn test_same_name_different_directories() { - use crate::ir::{FunctionKind, IrFunctionDef}; - let mut file_a = make_empty_ir("src/auth/utils.ts"); - file_a.functions.push(IrFunctionDef { - name: "validate".to_string(), - kind: FunctionKind::Function, - span: Span::new(1, 1), - parameters: vec![], - is_async: false, - is_exported: true, - decorators: vec![], - }); - - let mut file_b = make_empty_ir("src/data/utils.ts"); - file_b.functions.push(IrFunctionDef { - name: "validate".to_string(), - kind: FunctionKind::Function, - span: Span::new(1, 1), - parameters: vec![], - is_async: false, - is_exported: true, - decorators: vec![], - }); - - let graph = SymbolGraph::build_from_ir(&[file_a, file_b]); - assert!(graph.get_node("src/auth/utils.ts::validate").is_some()); - assert!(graph.get_node("src/data/utils.ts::validate").is_some()); - assert_eq!(graph.node_count(), 4); - } - - #[test] - fn test_multiple_importers_of_same_symbol() { - let graph = build_graph_from_sources(&[ - ( - "src/shared.ts", - r#" -export function log(msg: string) {} -"#, - ), - ( - "src/a.ts", - r#" -import { log } from './shared'; -function doA() { log("a"); } -"#, - ), - ( - "src/b.ts", - r#" -import { log } from './shared'; -function doB() { log("b"); } -"#, - ), - ]); - - assert!(has_edge( - &graph, - "src/a.ts", - "src/shared.ts::log", - &EdgeType::Imports - )); - assert!(has_edge( - &graph, - "src/b.ts", - "src/shared.ts::log", - &EdgeType::Imports - )); - assert!(has_edge( - &graph, - "src/a.ts::doA", - "src/shared.ts::log", - &EdgeType::Calls - )); - assert!(has_edge( - &graph, - "src/b.ts::doB", - "src/shared.ts::log", - &EdgeType::Calls - )); - } - } - - // ======================================================================= - // Additional property-based tests - // ======================================================================= - - mod extended_proptests { - use super::*; - use crate::ast::Language; - use crate::ir::{ - FunctionKind, IrConstant, IrFile, IrFunctionDef, IrTypeDef, Span, TypeDefKind, - }; - use proptest::prelude::*; - - fn symbol_kind_strategy() -> impl Strategy { - prop_oneof![ - Just(SymbolKind::Function), - Just(SymbolKind::Class), - Just(SymbolKind::Interface), - Just(SymbolKind::TypeAlias), - Just(SymbolKind::Constant), - Just(SymbolKind::Module), - Just(SymbolKind::Struct), - ] - } - - fn edge_type_strategy() -> impl Strategy { - prop_oneof![ - Just(EdgeType::Imports), - Just(EdgeType::Calls), - Just(EdgeType::Extends), - Just(EdgeType::Instantiates), - Just(EdgeType::Reads), - Just(EdgeType::Writes), - Just(EdgeType::Emits), - Just(EdgeType::Handles), - ] - } - - fn ir_file_strategy() -> impl Strategy { - ( - "[a-z]{1,6}".prop_map(|s| format!("src/{}.ts", s)), - prop::collection::vec("[a-z][a-zA-Z0-9]{0,10}", 0..8), - prop::collection::vec("[A-Z][a-zA-Z0-9]{0,10}", 0..4), - prop::collection::vec("[A-Z_][A-Z_0-9]{0,10}", 0..3), - ) - .prop_map(|(path, func_names, type_names, const_names)| { - let functions: Vec = func_names - .into_iter() - .map(|name| IrFunctionDef { - name, - kind: FunctionKind::Function, - span: Span::new(1, 1), - parameters: vec![], - is_async: false, - is_exported: false, - decorators: vec![], - }) - .collect(); - let type_defs: Vec = type_names - .into_iter() - .map(|name| IrTypeDef { - name, - kind: TypeDefKind::Class, - span: Span::new(1, 1), - bases: vec![], - is_exported: false, - decorators: vec![], - }) - .collect(); - let constants: Vec = const_names - .into_iter() - .map(|name| IrConstant { - name, - span: Span::new(1, 1), - is_exported: false, - }) - .collect(); - IrFile { - path, - language: Language::TypeScript, - functions, - type_defs, - constants, - imports: vec![], - exports: vec![], - call_expressions: vec![], - assignments: vec![], - } - }) - } - - proptest! { - #[test] - fn prop_all_edges_reference_valid_nodes( - files in prop::collection::vec(ir_file_strategy(), 1..6) - ) { - let graph = SymbolGraph::build_from_ir(&files); - let all_ids: std::collections::HashSet<&str> = - graph.node_ids().into_iter().collect(); - - for (from, to, _) in graph.edges() { - prop_assert!( - all_ids.contains(from), - "edge source {} not in graph nodes", from - ); - prop_assert!( - all_ids.contains(to), - "edge target {} not in graph nodes", to - ); - } - } - - #[test] - fn prop_module_node_id_equals_file_path( - files in prop::collection::vec(ir_file_strategy(), 1..6) - ) { - let graph = SymbolGraph::build_from_ir(&files); - - for file in &files { - if let Some(sym) = graph.get_symbol(&file.path) { - prop_assert_eq!(&sym.id, &file.path); - prop_assert_eq!(&sym.file, &file.path); - prop_assert_eq!(sym.kind, SymbolKind::Module); - } - } - } - - #[test] - fn prop_serializable_roundtrip_preserves_edge_types( - edge_type in edge_type_strategy() - ) { - let sg = SerializableGraph { - nodes: vec![ - SymbolNode { - id: "a.ts".to_string(), - name: "a".to_string(), - file: "a.ts".to_string(), - kind: SymbolKind::Module, - }, - SymbolNode { - id: "b.ts".to_string(), - name: "b".to_string(), - file: "b.ts".to_string(), - kind: SymbolKind::Module, - }, - ], - edges: vec![SerializableEdge { - from: "a.ts".to_string(), - to: "b.ts".to_string(), - edge_type: edge_type.clone(), - }], - }; - - let graph = SymbolGraph::from_serializable(&sg); - let restored = graph.to_serializable(); - prop_assert_eq!(restored.edges.len(), 1); - prop_assert_eq!(&restored.edges[0].edge_type, &edge_type); - } - - #[test] - fn prop_serializable_roundtrip_preserves_symbol_kinds( - kind in symbol_kind_strategy() - ) { - let sg = SerializableGraph { - nodes: vec![SymbolNode { - id: "test::sym".to_string(), - name: "sym".to_string(), - file: "test".to_string(), - kind: kind.clone(), - }], - edges: vec![], - }; - - let graph = SymbolGraph::from_serializable(&sg); - let restored = graph.to_serializable(); - prop_assert_eq!(restored.nodes.len(), 1); - prop_assert_eq!(&restored.nodes[0].kind, &kind); - } - - #[test] - fn prop_node_count_equals_unique_defs_plus_modules( - files in prop::collection::vec(ir_file_strategy(), 1..6) - ) { - // Deduplicate files by path to avoid the edge case where - // duplicate paths create phantom graph nodes (the graph - // unconditionally adds module nodes without checking for - // duplicates — a known characteristic of the current impl). - let mut seen_paths = std::collections::HashSet::new(); - let unique_files: Vec<&IrFile> = files - .iter() - .filter(|f| seen_paths.insert(f.path.clone())) - .collect(); - - let graph = SymbolGraph::build_from_ir( - &unique_files.iter().cloned().cloned().collect::>(), - ); - - let mut expected_ids = std::collections::HashSet::new(); - for file in &unique_files { - expected_ids.insert(file.path.clone()); - for func in &file.functions { - expected_ids.insert(format!("{}::{}", file.path, func.name)); - } - for td in &file.type_defs { - expected_ids.insert(format!("{}::{}", file.path, td.name)); - } - for c in &file.constants { - expected_ids.insert(format!("{}::{}", file.path, c.name)); - } - } - - prop_assert_eq!( - graph.node_count(), - expected_ids.len(), - "node count should equal unique definition ids" - ); - } - - #[test] - fn prop_graph_error_display(msg in "[a-zA-Z0-9 ]{1,50}") { - let err = GraphError::SerializationError(msg.clone()); - let display = format!("{}", err); - prop_assert!(display.contains(&msg)); - } - - #[test] - fn prop_normalize_path_no_panic(path in "[a-z./]{0,30}") { - let _ = normalize_path(&path); - } - - #[test] - fn prop_normalize_python_import_no_panic(input in "[a-z.]{0,20}") { - let _ = normalize_python_import(&input); - } - - #[test] - fn prop_file_stem_no_panic(path in "[a-zA-Z0-9/._-]{0,30}") { - let _ = file_stem(&path); - } - - #[test] - fn prop_resolve_import_never_resolves_absolute( - source in "[a-z]{1,10}", - importer in "[a-z/]{1,15}\\.ts" - ) { - let known = vec!["anything.ts"]; - let result = resolve_import_path(&source, &importer, &known); - prop_assert!(result.is_none(), - "absolute import '{}' should not resolve", source); - } - } - } - - // ======================================================================= - // Workspace graph integration tests - // ======================================================================= - - mod workspace_graph_tests { - use super::*; - use crate::ast; - - #[test] - fn test_workspace_cross_package_import_edges() { - // Simulate a monorepo: shared-types exports User, backend imports it. - let files = vec![ - ( - "packages/shared-types/src/index.ts", - r#" -export interface User { id: string; name: string; } -export function validateUser(user: User): boolean { return true; } -"#, - ), - ( - "packages/backend/src/routes/users.ts", - r#" -import { User, validateUser } from "@monorepo/shared-types"; -export function handleRequest(user: User) { return validateUser(user); } -"#, - ), - ]; - - let parsed: Vec = files - .iter() - .map(|(path, source)| ast::parse_file(path, source).unwrap()) - .collect(); - - // Without workspace map: no cross-package edges. - let graph_no_ws = SymbolGraph::build(&parsed); - let edges_no_ws = graph_no_ws.edges(); - let cross_pkg_edges: Vec<_> = edges_no_ws - .iter() - .filter(|(f, t, _)| f.contains("backend") && t.contains("shared-types")) - .collect(); - assert!( - cross_pkg_edges.is_empty(), - "without workspace map, no cross-package edges should exist" - ); - - // With workspace map: cross-package edges appear. - let mut ws = WorkspaceMap::new(); - ws.insert( - "@monorepo/shared-types".to_string(), - "packages/shared-types/src/index.ts".to_string(), - ); - let graph_ws = SymbolGraph::build_with_workspace(&parsed, &ws); - let edges_ws = graph_ws.edges(); - let cross_pkg_edges: Vec<_> = edges_ws - .iter() - .filter(|(f, t, _)| f.contains("backend") && t.contains("shared-types")) - .collect(); - assert!( - cross_pkg_edges.len() >= 2, - "with workspace map, cross-package import edges should exist, got: {:?}", - cross_pkg_edges - ); - - // Verify specific edges. - assert!( - cross_pkg_edges - .iter() - .any(|(_, t, et)| { t.contains("validateUser") && **et == EdgeType::Imports }), - "should have import edge to validateUser" - ); - } - - #[test] - fn test_workspace_cross_package_call_edges() { - let files = vec![ - ( - "packages/utils/src/index.ts", - r#" -export function formatName(name: string): string { return name.trim(); } -"#, - ), - ( - "packages/app/src/handler.ts", - r#" -import { formatName } from "@my/utils"; -export function handle(name: string) { return formatName(name); } -"#, - ), - ]; - - let parsed: Vec = files - .iter() - .map(|(path, source)| ast::parse_file(path, source).unwrap()) - .collect(); - - let mut ws = WorkspaceMap::new(); - ws.insert( - "@my/utils".to_string(), - "packages/utils/src/index.ts".to_string(), - ); - let graph = SymbolGraph::build_with_workspace(&parsed, &ws); - let edges = graph.edges(); - - // Should have both import and call edges. - let import_edge = edges.iter().any(|(f, t, et)| { - f.contains("handler") && t.contains("formatName") && **et == EdgeType::Imports - }); - let call_edge = edges.iter().any(|(f, t, et)| { - f.contains("handler") && t.contains("formatName") && **et == EdgeType::Calls - }); - assert!(import_edge, "should have import edge to formatName"); - assert!(call_edge, "should have call edge to formatName"); - } - - #[test] - fn test_workspace_empty_map_same_as_build() { - let files = vec![ - ("src/handler.ts", r#"import { foo } from './utils'; foo();"#), - ("src/utils.ts", r#"export function foo() {}"#), - ]; - - let parsed: Vec = files - .iter() - .map(|(path, source)| ast::parse_file(path, source).unwrap()) - .collect(); - - let g1 = SymbolGraph::build(&parsed); - let g2 = SymbolGraph::build_with_workspace(&parsed, &WorkspaceMap::new()); - assert_eq!(g1.node_count(), g2.node_count()); - assert_eq!(g1.edge_count(), g2.edge_count()); - } - } -} diff --git a/crates/diffcore-core/src/graph/mod.rs b/crates/diffcore-core/src/graph/mod.rs new file mode 100644 index 0000000..f175d42 --- /dev/null +++ b/crates/diffcore-core/src/graph/mod.rs @@ -0,0 +1,1290 @@ +//! Symbol graph construction using petgraph. +//! +//! Builds a directed graph `G = (V, E)` from parsed AST data where: +//! - Vertices are symbols (functions, classes, types, modules) +//! - Edges represent relationships (imports, calls, extends) + +use std::collections::HashMap; + +use petgraph::graph::{DiGraph, NodeIndex}; +use rayon::prelude::*; +use serde::{Deserialize, Serialize}; + +use crate::ast::{Definition, ExportInfo, Language, ParsedFile}; +use crate::ir::{IrExport, IrFile, IrImportSpecifier, TypeDefKind}; +use crate::types::{EdgeType, SymbolKind}; + +/// A node in the symbol graph. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct SymbolNode { + /// Unique identifier: `file_path::symbol_name` + pub id: String, + /// The symbol name. + pub name: String, + /// The file this symbol belongs to. + pub file: String, + /// The kind of symbol. + pub kind: SymbolKind, +} + +/// An edge in the symbol graph. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct GraphEdge { + pub edge_type: EdgeType, +} + +/// The complete symbol graph built from parsed files. +#[derive(Debug)] +pub struct SymbolGraph { + pub graph: DiGraph, + /// Map from symbol id (`file::name`) to node index for fast lookup. + id_to_index: HashMap, +} + +/// Errors from graph construction. +#[derive(Debug, thiserror::Error)] +pub enum GraphError { + #[error("graph serialization error: {0}")] + SerializationError(String), +} + +/// Serializable representation for roundtrip testing. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct SerializableGraph { + pub nodes: Vec, + pub edges: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct SerializableEdge { + pub from: String, + pub to: String, + pub edge_type: EdgeType, +} + +impl SymbolGraph { + /// Build a symbol graph from a collection of parsed files. + pub fn build(files: &[ParsedFile]) -> Self { + Self::build_with_workspace(files, &WorkspaceMap::new()) + } + + /// Build a symbol graph with workspace package resolution for monorepos. + /// + /// The `workspace_map` maps package names (e.g. `@scope/pkg`) to their + /// entry file paths (e.g. `packages/pkg/src/index.ts`), enabling cross-package + /// import edges in monorepo workspaces. + pub fn build_with_workspace(files: &[ParsedFile], workspace_map: &WorkspaceMap) -> Self { + let mut graph = DiGraph::new(); + let mut id_to_index: HashMap = HashMap::new(); + + // Phase 1: Collect node data per file in parallel, then merge single-threaded. + let node_batches: Vec> = files + .par_iter() + .map(|file| { + let mut nodes = Vec::new(); + // Module node. + let module_id = file.path.clone(); + nodes.push(( + module_id, + SymbolNode { + id: file.path.clone(), + name: file_stem(&file.path), + file: file.path.clone(), + kind: SymbolKind::Module, + }, + )); + // Definition nodes. + for def in &file.definitions { + let sym_id = format!("{}::{}", file.path, def.name); + nodes.push(( + sym_id.clone(), + SymbolNode { + id: sym_id, + name: def.name.clone(), + file: file.path.clone(), + kind: def.kind.clone(), + }, + )); + } + nodes + }) + .collect(); + + for batch in node_batches { + for (sym_id, node) in batch { + if id_to_index.contains_key(&sym_id) { + continue; // skip duplicates + } + let idx = graph.add_node(node); + id_to_index.insert(sym_id, idx); + } + } + + // Build lookup structures for import resolution. + let file_exports = build_export_map(files); + let file_defs = build_definition_map(files); + + // Phase 2: Compute edges per file in parallel, then add single-threaded. + let edge_batches: Vec> = files + .par_iter() + .map(|file| { + let mut edges = Vec::new(); + collect_import_edges( + file, + files, + &file_exports, + &file_defs, + &id_to_index, + workspace_map, + &mut edges, + ); + collect_call_edges( + file, + files, + &file_exports, + &file_defs, + &id_to_index, + workspace_map, + &mut edges, + ); + collect_extends_edges(file, files, &file_defs, &id_to_index, &mut edges); + edges + }) + .collect(); + + for batch in edge_batches { + for (from_id, to_id, edge_type) in batch { + if let (Some(&from_idx), Some(&to_idx)) = + (id_to_index.get(&from_id), id_to_index.get(&to_id)) + { + graph.add_edge(from_idx, to_idx, GraphEdge { edge_type }); + } + } + } + + SymbolGraph { graph, id_to_index } + } + + /// Build a symbol graph from IR files (declarative query engine / IR path). + /// + /// This is the primary entry point for graph construction from the IR pipeline. + /// It consumes `IrFile` types directly, enabling richer edge construction + /// (e.g., class extends edges from `IrTypeDef.bases`). + pub fn build_from_ir(files: &[IrFile]) -> Self { + Self::build_from_ir_with_workspace(files, &WorkspaceMap::new()) + } + + /// Build a symbol graph from IR files with workspace package resolution. + pub fn build_from_ir_with_workspace(files: &[IrFile], workspace_map: &WorkspaceMap) -> Self { + let mut graph = DiGraph::new(); + let mut id_to_index: HashMap = HashMap::new(); + + // Phase 1: Collect node data per file in parallel, then merge single-threaded. + let node_batches: Vec> = files + .par_iter() + .map(|file| { + let mut nodes = Vec::new(); + // Module node. + nodes.push(( + file.path.clone(), + SymbolNode { + id: file.path.clone(), + name: file_stem(&file.path), + file: file.path.clone(), + kind: SymbolKind::Module, + }, + )); + // Function nodes. + for f in &file.functions { + let sym_id = format!("{}::{}", file.path, f.name); + nodes.push(( + sym_id.clone(), + SymbolNode { + id: sym_id, + name: f.name.clone(), + file: file.path.clone(), + kind: SymbolKind::Function, + }, + )); + } + // Type definition nodes. + for t in &file.type_defs { + let sym_id = format!("{}::{}", file.path, t.name); + let kind = match t.kind { + TypeDefKind::Class => SymbolKind::Class, + TypeDefKind::Struct => SymbolKind::Struct, + TypeDefKind::Interface => SymbolKind::Interface, + TypeDefKind::TypeAlias => SymbolKind::TypeAlias, + TypeDefKind::Enum => SymbolKind::Class, + }; + nodes.push(( + sym_id.clone(), + SymbolNode { + id: sym_id, + name: t.name.clone(), + file: file.path.clone(), + kind, + }, + )); + } + // Constant nodes. + for c in &file.constants { + let sym_id = format!("{}::{}", file.path, c.name); + nodes.push(( + sym_id.clone(), + SymbolNode { + id: sym_id, + name: c.name.clone(), + file: file.path.clone(), + kind: SymbolKind::Constant, + }, + )); + } + nodes + }) + .collect(); + + for batch in node_batches { + for (sym_id, node) in batch { + if id_to_index.contains_key(&sym_id) { + continue; // skip duplicates + } + let idx = graph.add_node(node); + id_to_index.insert(sym_id, idx); + } + } + + // Build lookup structures. + let file_exports = build_ir_export_map(files); + let file_def_names = build_ir_def_names_map(files); + let known_paths: Vec<&str> = files.iter().map(|f| f.path.as_str()).collect(); + + // Phase 2: Compute edges per file in parallel, then add single-threaded. + let edge_batches: Vec> = files + .par_iter() + .map(|file| { + let mut edges = Vec::new(); + collect_ir_import_edges( + file, + &file_exports, + &file_def_names, + &id_to_index, + &known_paths, + workspace_map, + &mut edges, + ); + collect_ir_call_edges( + file, + files, + &file_def_names, + &id_to_index, + &known_paths, + workspace_map, + &mut edges, + ); + collect_ir_extends_edges(file, files, &id_to_index, &known_paths, &mut edges); + edges + }) + .collect(); + + for batch in edge_batches { + for (from_id, to_id, edge_type) in batch { + if let (Some(&from_idx), Some(&to_idx)) = + (id_to_index.get(&from_id), id_to_index.get(&to_id)) + { + graph.add_edge(from_idx, to_idx, GraphEdge { edge_type }); + } + } + } + + SymbolGraph { graph, id_to_index } + } + + /// Number of nodes in the graph. + pub fn node_count(&self) -> usize { + self.graph.node_count() + } + + /// Number of edges in the graph. + pub fn edge_count(&self) -> usize { + self.graph.edge_count() + } + + /// Look up a node index by symbol id. + pub fn get_node(&self, id: &str) -> Option { + self.id_to_index.get(id).copied() + } + + /// Get the symbol node data for a given id. + pub fn get_symbol(&self, id: &str) -> Option<&SymbolNode> { + self.id_to_index.get(id).map(|idx| &self.graph[*idx]) + } + + /// Get all node ids in the graph. + pub fn node_ids(&self) -> Vec<&str> { + self.id_to_index.keys().map(|s| s.as_str()).collect() + } + + /// Add an edge between two nodes by their indices. + pub fn add_edge(&mut self, from: NodeIndex, to: NodeIndex, edge: GraphEdge) { + self.graph.add_edge(from, to, edge); + } + + /// Get all edges as (from_id, to_id, edge_type) tuples. + pub fn edges(&self) -> Vec<(&str, &str, &EdgeType)> { + self.graph + .edge_indices() + .filter_map(|e| { + let (src, tgt) = self.graph.edge_endpoints(e)?; + let edge = &self.graph[e]; + Some(( + self.graph[src].id.as_str(), + self.graph[tgt].id.as_str(), + &edge.edge_type, + )) + }) + .collect() + } + + /// Serialize the graph to a JSON-friendly structure. + pub fn to_serializable(&self) -> SerializableGraph { + let nodes: Vec = self + .graph + .node_indices() + .map(|i| self.graph[i].clone()) + .collect(); + + let edges: Vec = self + .graph + .edge_indices() + .filter_map(|e| { + let (src, tgt) = self.graph.edge_endpoints(e)?; + Some(SerializableEdge { + from: self.graph[src].id.clone(), + to: self.graph[tgt].id.clone(), + edge_type: self.graph[e].edge_type.clone(), + }) + }) + .collect(); + + SerializableGraph { nodes, edges } + } + + /// Deserialize from a serializable graph back into a SymbolGraph. + pub fn from_serializable(sg: &SerializableGraph) -> Self { + let mut graph = DiGraph::new(); + let mut id_to_index: HashMap = HashMap::new(); + + for node in &sg.nodes { + let idx = graph.add_node(node.clone()); + id_to_index.insert(node.id.clone(), idx); + } + + for edge in &sg.edges { + if let (Some(&src), Some(&tgt)) = + (id_to_index.get(&edge.from), id_to_index.get(&edge.to)) + { + graph.add_edge( + src, + tgt, + GraphEdge { + edge_type: edge.edge_type.clone(), + }, + ); + } + } + + SymbolGraph { graph, id_to_index } + } +} + +// --------------------------------------------------------------------------- +// Import resolution helpers +// --------------------------------------------------------------------------- + +/// Map from file path to its exported symbol names. +fn build_export_map(files: &[ParsedFile]) -> HashMap> { + files + .iter() + .map(|f| (f.path.clone(), f.exports.clone())) + .collect() +} + +/// Map from file path to its definitions. +fn build_definition_map(files: &[ParsedFile]) -> HashMap> { + files + .iter() + .map(|f| (f.path.clone(), f.definitions.clone())) + .collect() +} + +/// Resolve an import source path (e.g. `./utils`, `../models/user`) relative to the +/// importing file, returning the resolved file path if it exists in our file set. +/// +/// Handles both JS/TS-style (`./utils`, `../models/user`) and Python-style +/// (`.models`, `..models`, `.models.user`) relative imports. +fn resolve_import_path( + import_source: &str, + importer_path: &str, + known_files: &[&str], +) -> Option { + // Only resolve relative imports + if !import_source.starts_with('.') { + return None; + } + + // Convert Python-style dot imports to path-style. + // `.models` → `./models`, `..models` → `../models`, `.models.user` → `./models/user` + let normalized_source = normalize_python_import(import_source); + + let importer_dir = parent_dir(importer_path); + let resolved = normalize_path(&format!("{}/{}", importer_dir, normalized_source)); + + // Try exact match first, then with common extensions. + let candidates = [ + resolved.clone(), + format!("{}.ts", resolved), + format!("{}.tsx", resolved), + format!("{}.js", resolved), + format!("{}.jsx", resolved), + format!("{}.py", resolved), + format!("{}/index.ts", resolved), + format!("{}/index.js", resolved), + format!("{}/index.tsx", resolved), + ]; + + for candidate in &candidates { + if known_files.contains(&candidate.as_str()) { + return Some(candidate.clone()); + } + } + + None +} + +/// A map from workspace package name (e.g. `@monorepo/shared-types`) to its +/// entry file path relative to the repo root (e.g. `packages/shared-types/src/index.ts`). +pub type WorkspaceMap = HashMap; + +/// Resolve a non-relative import through a workspace package map. +/// +/// When `import_source` is a bare specifier (e.g. `@monorepo/shared-types` or +/// `@monorepo/shared-types/utils`), look it up in the workspace map. If the +/// exact name matches, return its entry file. If only a prefix matches (e.g. +/// `@scope/pkg/sub`), try to resolve the sub-path relative to the package root. +fn resolve_workspace_import( + import_source: &str, + known_files: &[&str], + workspace_map: &WorkspaceMap, +) -> Option { + // Skip relative imports (already handled by resolve_import_path). + if import_source.starts_with('.') { + return None; + } + + // Try exact match first. + if let Some(entry) = workspace_map.get(import_source) { + if known_files.contains(&entry.as_str()) { + return Some(entry.clone()); + } + } + + // Try prefix match for deep imports like `@scope/pkg/sub/path`. + // Find the longest matching package name. + let mut best_match: Option<(&str, &str)> = None; + for (pkg_name, entry_file) in workspace_map { + if import_source.starts_with(pkg_name.as_str()) + && import_source[pkg_name.len()..].starts_with('/') + { + if best_match.map_or(true, |(prev, _)| pkg_name.len() > prev.len()) { + best_match = Some((pkg_name.as_str(), entry_file.as_str())); + } + } + } + + if let Some((pkg_name, entry_file)) = best_match { + // Get package root directory from entry file path. + let pkg_dir = parent_dir(parent_dir(entry_file).as_str()); + let sub_path = &import_source[pkg_name.len() + 1..]; // skip the '/' + let resolved = format!("{}/{}", pkg_dir, sub_path); + + // Try with common extensions. + let candidates = [ + resolved.clone(), + format!("{}.ts", resolved), + format!("{}.tsx", resolved), + format!("{}.js", resolved), + format!("{}.jsx", resolved), + format!("{}/index.ts", resolved), + format!("{}/index.js", resolved), + ]; + + for candidate in &candidates { + if known_files.contains(&candidate.as_str()) { + return Some(candidate.clone()); + } + } + } + + None +} + +/// Try to resolve an import path, falling back to workspace resolution. +fn resolve_import_or_workspace( + import_source: &str, + importer_path: &str, + known_files: &[&str], + workspace_map: &WorkspaceMap, +) -> Option { + resolve_import_path(import_source, importer_path, known_files) + .or_else(|| resolve_workspace_import(import_source, known_files, workspace_map)) +} + +/// Build a workspace package map by scanning `package.json` files in a directory. +/// +/// Reads the root `package.json` for `workspaces` globs, then reads each +/// matched package's `package.json` for its `name` and `main` fields. +/// Returns a map from package name → entry file path (relative to repo root). +pub fn build_workspace_map(repo_root: &std::path::Path) -> WorkspaceMap { + let mut map = WorkspaceMap::new(); + + // Read root package.json for workspaces. + let root_pkg = repo_root.join("package.json"); + let root_content = match std::fs::read_to_string(&root_pkg) { + Ok(c) => c, + Err(_) => return map, + }; + let root_json: serde_json::Value = match serde_json::from_str(&root_content) { + Ok(v) => v, + Err(_) => return map, + }; + + // Extract workspace patterns. + let workspace_patterns: Vec = match root_json.get("workspaces") { + Some(serde_json::Value::Array(arr)) => arr + .iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect(), + // pnpm-style: { packages: [...] } + Some(serde_json::Value::Object(obj)) => obj + .get("packages") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default(), + _ => return map, + }; + + // Expand glob patterns to find package directories. + for pattern in &workspace_patterns { + let full_pattern = repo_root.join(pattern).join("package.json"); + if let Some(pattern_str) = full_pattern.to_str() { + if let Ok(entries) = glob::glob(pattern_str) { + for entry in entries.flatten() { + if let Ok(content) = std::fs::read_to_string(&entry) { + if let Ok(pkg_json) = serde_json::from_str::(&content) { + let name = pkg_json.get("name").and_then(|v| v.as_str()); + let main_field = pkg_json.get("main").and_then(|v| v.as_str()); + + if let Some(name) = name { + // Determine entry file path relative to repo root. + let pkg_dir = entry.parent().unwrap_or(repo_root.as_ref()); + let entry_file = if let Some(main_path) = main_field { + pkg_dir.join(main_path) + } else { + // Default: try src/index.ts, then index.ts + let src_index = pkg_dir.join("src/index.ts"); + if src_index.exists() { + src_index + } else { + pkg_dir.join("index.ts") + } + }; + + if let Ok(relative) = entry_file.strip_prefix(repo_root) { + if let Some(rel_str) = relative.to_str() { + map.insert(name.to_string(), rel_str.to_string()); + } + } + } + } + } + } + } + } + } + + map +} + +/// Get the parent directory of a file path. +fn parent_dir(path: &str) -> String { + match path.rfind('/') { + Some(pos) => path[..pos].to_string(), + None => ".".to_string(), + } +} + +/// Get the file stem (filename without extension). +fn file_stem(path: &str) -> String { + let filename = path.rsplit('/').next().unwrap_or(path); + match filename.find('.') { + Some(pos) => filename[..pos].to_string(), + None => filename.to_string(), + } +} + +/// Normalize a path by resolving `.` and `..` segments. +fn normalize_path(path: &str) -> String { + let mut parts: Vec<&str> = Vec::new(); + for segment in path.split('/') { + match segment { + "." | "" => {} + ".." => { + parts.pop(); + } + s => parts.push(s), + } + } + parts.join("/") +} + +/// Convert Python-style dot imports to path-style relative imports. +/// +/// - `.models` → `./models` +/// - `..models` → `../models` +/// - `.models.user` → `./models/user` +/// - `.` → `.` +/// - `...utils.helpers` → `../../utils/helpers` +fn normalize_python_import(source: &str) -> String { + // Count leading dots. + let dot_count = source.chars().take_while(|c| *c == '.').count(); + let remainder = &source[dot_count..]; + + if dot_count == 0 { + return source.to_string(); + } + + // Build the relative prefix: `.` → `./`, `..` → `../`, `...` → `../../` + let prefix = if dot_count == 1 { + ".".to_string() + } else { + let mut p = String::new(); + for i in 0..dot_count - 1 { + if i > 0 { + p.push('/'); + } + p.push_str(".."); + } + p + }; + + if remainder.is_empty() { + return prefix; + } + + // Convert remaining dots (module separators) to slashes. + let path_part = remainder.replace('.', "/"); + format!("{}/{}", prefix, path_part) +} + +// --------------------------------------------------------------------------- +// Edge construction +// --------------------------------------------------------------------------- + +/// Collect import edge descriptors: file A imports symbol from file B. +/// Pushes `(from_id, to_id, EdgeType)` tuples for later insertion. +fn collect_import_edges( + file: &ParsedFile, + all_files: &[ParsedFile], + file_exports: &HashMap>, + file_defs: &HashMap>, + id_to_index: &HashMap, + workspace_map: &WorkspaceMap, + edges: &mut Vec<(String, String, EdgeType)>, +) { + let known_paths: Vec<&str> = all_files.iter().map(|f| f.path.as_str()).collect(); + + for import in &file.imports { + let resolved = match resolve_import_or_workspace( + &import.source, + &file.path, + &known_paths, + workspace_map, + ) { + Some(p) => p, + None => continue, + }; + + let from_module_id = file.path.clone(); + if !id_to_index.contains_key(&from_module_id) { + continue; + } + + // For each imported name, find matching export or definition in target file. + if import.names.is_empty() { + // Side-effect import: create module-to-module edge. + if id_to_index.contains_key(&resolved) { + edges.push((from_module_id.clone(), resolved.clone(), EdgeType::Imports)); + } + continue; + } + + for imported_name in &import.names { + let target_name = &imported_name.name; + + // Try to find the symbol in the target file's definitions. + let target_sym_id = format!("{}::{}", resolved, target_name); + if id_to_index.contains_key(&target_sym_id) { + edges.push((from_module_id.clone(), target_sym_id, EdgeType::Imports)); + continue; + } + + // If importing a default, check if target has a matching export/def. + if import.is_default || import.is_namespace { + // Link to the module node itself. + if id_to_index.contains_key(&resolved) { + edges.push((from_module_id.clone(), resolved.clone(), EdgeType::Imports)); + } + continue; + } + + // Check re-exports: target file may re-export from another file. + if let Some(exports) = file_exports.get(&resolved) { + for export in exports { + if export.name == *target_name && export.is_reexport { + if let Some(ref reexport_source) = export.source { + if let Some(reexport_resolved) = resolve_import_or_workspace( + reexport_source, + &resolved, + &known_paths, + workspace_map, + ) { + let reexport_sym_id = + format!("{}::{}", reexport_resolved, target_name); + if id_to_index.contains_key(&reexport_sym_id) { + edges.push(( + from_module_id.clone(), + reexport_sym_id, + EdgeType::Imports, + )); + } + } + } + } + } + } + + // Fallback: Python-style — definition name matches directly. + if let Some(defs) = file_defs.get(&resolved) { + if defs.iter().any(|d| d.name == *target_name) { + let sym_id = format!("{}::{}", resolved, target_name); + if id_to_index.contains_key(&sym_id) { + edges.push((from_module_id.clone(), sym_id, EdgeType::Imports)); + } + } + } + } + } +} + +/// Collect call edge descriptors: function A calls function B. +fn collect_call_edges( + file: &ParsedFile, + all_files: &[ParsedFile], + file_exports: &HashMap>, + file_defs: &HashMap>, + id_to_index: &HashMap, + workspace_map: &WorkspaceMap, + edges: &mut Vec<(String, String, EdgeType)>, +) { + let known_paths: Vec<&str> = all_files.iter().map(|f| f.path.as_str()).collect(); + + // Build a map of imported names → resolved symbol ids for this file. + let import_map = build_import_resolution_map( + file, + all_files, + file_exports, + file_defs, + &known_paths, + workspace_map, + ); + + for call in &file.call_sites { + // Determine the calling symbol. + let caller_id = match &call.containing_function { + Some(func_name) => format!("{}::{}", file.path, func_name), + None => file.path.clone(), // module-level call + }; + + // Resolve caller: try exact id, then module node. + let resolved_caller_id = if id_to_index.contains_key(&caller_id) { + caller_id + } else if id_to_index.contains_key(&file.path) { + file.path.clone() + } else { + continue; + }; + + // Resolve the callee. + let callee_name = &call.callee; + + // Simple name (e.g., `validateUser`) — look up in import map or local defs. + if let Some(target_id) = import_map.get(callee_name.as_str()) { + if id_to_index.contains_key(target_id.as_str()) && resolved_caller_id != *target_id { + edges.push(( + resolved_caller_id.clone(), + target_id.clone(), + EdgeType::Calls, + )); + } + continue; + } + + // Method call (e.g., `db.save`) — check if `db` is an imported name. + if let Some(dot_pos) = callee_name.find('.') { + let receiver = &callee_name[..dot_pos]; + if let Some(target_module) = import_map.get(receiver) { + let method = &callee_name[dot_pos + 1..]; + let method_id = format!("{}::{}", target_module.trim_end_matches("::*"), method); + if id_to_index.contains_key(&method_id) && resolved_caller_id != method_id { + edges.push((resolved_caller_id.clone(), method_id, EdgeType::Calls)); + continue; + } + if id_to_index.contains_key(target_module.as_str()) + && resolved_caller_id != *target_module + { + edges.push(( + resolved_caller_id.clone(), + target_module.clone(), + EdgeType::Calls, + )); + continue; + } + } + } + + // Local function call — same file. + let local_id = format!("{}::{}", file.path, callee_name); + if id_to_index.contains_key(&local_id) && resolved_caller_id != local_id { + edges.push((resolved_caller_id.clone(), local_id, EdgeType::Calls)); + } + } +} + +/// Build a map from imported name → resolved symbol id for a given file. +fn build_import_resolution_map( + file: &ParsedFile, + all_files: &[ParsedFile], + _file_exports: &HashMap>, + _file_defs: &HashMap>, + known_paths: &[&str], + workspace_map: &WorkspaceMap, +) -> HashMap { + let mut map = HashMap::new(); + + for import in &file.imports { + let resolved = match resolve_import_or_workspace( + &import.source, + &file.path, + known_paths, + workspace_map, + ) { + Some(p) => p, + None => continue, + }; + + if import.is_namespace { + // `import * as X from './mod'` or Python `import X` + // Map X → resolved module path. + for name in &import.names { + let local_name = name.alias.as_ref().unwrap_or(&name.name); + map.insert(local_name.clone(), resolved.clone()); + } + continue; + } + + for name in &import.names { + let local_name = name.alias.as_ref().unwrap_or(&name.name); + // Try to resolve to a specific symbol in the target file. + let target_sym_id = format!("{}::{}", resolved, name.name); + + // Check if this symbol exists in the target file's definitions. + let target_file = all_files.iter().find(|f| f.path == resolved); + if let Some(tf) = target_file { + if tf.definitions.iter().any(|d| d.name == name.name) { + map.insert(local_name.clone(), target_sym_id); + continue; + } + } + + // Default import — map to module. + if import.is_default { + map.insert(local_name.clone(), resolved.clone()); + } else { + // Map to the symbol id even if we can't verify it exists. + map.insert(local_name.clone(), target_sym_id); + } + } + } + + map +} + +/// Collect extends edge descriptors for class inheritance (Python). +/// Currently a stub — ParsedFile lacks class base info, so no edges are emitted. +fn collect_extends_edges( + file: &ParsedFile, + _all_files: &[ParsedFile], + _file_defs: &HashMap>, + _id_to_index: &HashMap, + _edges: &mut Vec<(String, String, EdgeType)>, +) { + if file.language != Language::Python { + return; + } + // ParsedFile doesn't store class base info, so no extends edges can be produced. + // The IR path (build_from_ir → collect_ir_extends_edges) handles this via IrTypeDef.bases. +} + +// --------------------------------------------------------------------------- +// IR-based lookup helpers +// --------------------------------------------------------------------------- + +/// Map from file path to its IR exports. +fn build_ir_export_map(files: &[IrFile]) -> HashMap> { + files + .iter() + .map(|f| (f.path.clone(), f.exports.clone())) + .collect() +} + +/// Map from file path to (name, kind) pairs for all definitions. +fn build_ir_def_names_map(files: &[IrFile]) -> HashMap> { + files + .iter() + .map(|f| { + let mut defs = Vec::new(); + for func in &f.functions { + defs.push((func.name.clone(), SymbolKind::Function)); + } + for td in &f.type_defs { + let kind = match td.kind { + TypeDefKind::Class => SymbolKind::Class, + TypeDefKind::Struct => SymbolKind::Struct, + TypeDefKind::Interface => SymbolKind::Interface, + TypeDefKind::TypeAlias => SymbolKind::TypeAlias, + TypeDefKind::Enum => SymbolKind::Class, + }; + defs.push((td.name.clone(), kind)); + } + for c in &f.constants { + defs.push((c.name.clone(), SymbolKind::Constant)); + } + (f.path.clone(), defs) + }) + .collect() +} + +/// Collect import edge descriptors from IR imports. +fn collect_ir_import_edges( + file: &IrFile, + file_exports: &HashMap>, + file_defs: &HashMap>, + id_to_index: &HashMap, + known_paths: &[&str], + workspace_map: &WorkspaceMap, + edges: &mut Vec<(String, String, EdgeType)>, +) { + if !id_to_index.contains_key(&file.path) { + return; + } + let from_id = file.path.clone(); + + for import in &file.imports { + let resolved = match resolve_import_or_workspace( + &import.source, + &file.path, + known_paths, + workspace_map, + ) { + Some(p) => p, + None => continue, + }; + + // Check if this import is side-effect only. + let is_side_effect = import.specifiers.is_empty() + || import + .specifiers + .iter() + .all(|s| matches!(s, IrImportSpecifier::SideEffect)); + + if is_side_effect { + if id_to_index.contains_key(&resolved) { + edges.push((from_id.clone(), resolved.clone(), EdgeType::Imports)); + } + continue; + } + + for spec in &import.specifiers { + match spec { + IrImportSpecifier::Named { name, .. } => { + let target_sym_id = format!("{}::{}", resolved, name); + if id_to_index.contains_key(&target_sym_id) { + edges.push((from_id.clone(), target_sym_id, EdgeType::Imports)); + continue; + } + + // Check re-exports. + if let Some(exports) = file_exports.get(&resolved) { + for export in exports { + if export.name == *name && export.is_reexport { + if let Some(ref reexport_source) = export.source { + if let Some(reexport_resolved) = resolve_import_or_workspace( + reexport_source, + &resolved, + known_paths, + workspace_map, + ) { + let reexport_sym_id = + format!("{}::{}", reexport_resolved, name); + if id_to_index.contains_key(&reexport_sym_id) { + edges.push(( + from_id.clone(), + reexport_sym_id, + EdgeType::Imports, + )); + } + } + } + } + } + } + + // Fallback: definition name matches directly. + if let Some(defs) = file_defs.get(&resolved) { + if defs.iter().any(|(n, _): &(String, SymbolKind)| n == name) { + let sym_id = format!("{}::{}", resolved, name); + if id_to_index.contains_key(&sym_id) { + edges.push((from_id.clone(), sym_id, EdgeType::Imports)); + } + } + } + } + IrImportSpecifier::Default(_) | IrImportSpecifier::Namespace(_) => { + if id_to_index.contains_key(&resolved) { + edges.push((from_id.clone(), resolved.clone(), EdgeType::Imports)); + } + } + IrImportSpecifier::SideEffect => { + // Already handled above. + } + } + } + } +} + +/// Build import resolution map from IR imports for call edge resolution. +fn build_ir_import_resolution_map( + file: &IrFile, + all_files: &[IrFile], + _file_defs: &HashMap>, + known_paths: &[&str], + workspace_map: &WorkspaceMap, +) -> HashMap { + let mut map = HashMap::new(); + + for import in &file.imports { + let resolved = match resolve_import_or_workspace( + &import.source, + &file.path, + known_paths, + workspace_map, + ) { + Some(p) => p, + None => continue, + }; + + for spec in &import.specifiers { + match spec { + IrImportSpecifier::Namespace(local) => { + map.insert(local.clone(), resolved.clone()); + } + IrImportSpecifier::Named { name, alias } => { + let local_name = alias.as_deref().unwrap_or(name.as_str()); + let target_sym_id = format!("{}::{}", resolved, name); + + // Check if this symbol exists in the target file. + let target_file = all_files.iter().find(|f| f.path == resolved); + if let Some(tf) = target_file { + let has_def = tf.functions.iter().any(|d| d.name == *name) + || tf.type_defs.iter().any(|d| d.name == *name) + || tf.constants.iter().any(|d| d.name == *name); + if has_def { + map.insert(local_name.to_string(), target_sym_id); + continue; + } + } + + // Map to the symbol id even if we can't verify. + map.insert(local_name.to_string(), target_sym_id); + } + IrImportSpecifier::Default(local) => { + map.insert(local.clone(), resolved.clone()); + } + IrImportSpecifier::SideEffect => {} + } + } + } + + map +} + +/// Collect call edge descriptors from IR call expressions. +fn collect_ir_call_edges( + file: &IrFile, + all_files: &[IrFile], + file_defs: &HashMap>, + id_to_index: &HashMap, + known_paths: &[&str], + workspace_map: &WorkspaceMap, + edges: &mut Vec<(String, String, EdgeType)>, +) { + let import_map = + build_ir_import_resolution_map(file, all_files, file_defs, known_paths, workspace_map); + + for call in &file.call_expressions { + let caller_id = match &call.containing_function { + Some(func_name) => format!("{}::{}", file.path, func_name), + None => file.path.clone(), + }; + + // Resolve caller: try exact id, then module node. + let resolved_caller_id = if id_to_index.contains_key(&caller_id) { + caller_id + } else if id_to_index.contains_key(&file.path) { + file.path.clone() + } else { + continue; + }; + + let callee_name = &call.callee; + + // Simple name — look up in import map or local defs. + if let Some(target_id) = import_map.get(callee_name.as_str()) { + if id_to_index.contains_key(target_id.as_str()) && resolved_caller_id != *target_id { + edges.push(( + resolved_caller_id.clone(), + target_id.clone(), + EdgeType::Calls, + )); + } + continue; + } + + // Method call (e.g., `db.save`). + if let Some(dot_pos) = callee_name.find('.') { + let receiver = &callee_name[..dot_pos]; + if let Some(target_module) = import_map.get(receiver) { + let method = &callee_name[dot_pos + 1..]; + let method_id = format!("{}::{}", target_module.trim_end_matches("::*"), method); + if id_to_index.contains_key(&method_id) && resolved_caller_id != method_id { + edges.push((resolved_caller_id.clone(), method_id, EdgeType::Calls)); + continue; + } + if id_to_index.contains_key(target_module.as_str()) + && resolved_caller_id != *target_module + { + edges.push(( + resolved_caller_id.clone(), + target_module.clone(), + EdgeType::Calls, + )); + continue; + } + } + } + + // Local function call. + let local_id = format!("{}::{}", file.path, callee_name); + if id_to_index.contains_key(&local_id) && resolved_caller_id != local_id { + edges.push((resolved_caller_id.clone(), local_id, EdgeType::Calls)); + } + } +} + +/// Collect extends edge descriptors from IR type definitions with bases. +/// +/// Unlike the ParsedFile-based version which cannot determine class bases, +/// the IR path has `IrTypeDef.bases` populated from the query engine, enabling +/// real extends edge construction. +fn collect_ir_extends_edges( + file: &IrFile, + all_files: &[IrFile], + id_to_index: &HashMap, + known_paths: &[&str], + edges: &mut Vec<(String, String, EdgeType)>, +) { + let import_map = build_ir_import_resolution_map( + file, + all_files, + &HashMap::new(), + known_paths, + &WorkspaceMap::new(), + ); + + for td in &file.type_defs { + if td.bases.is_empty() { + continue; + } + + let child_id = format!("{}::{}", file.path, td.name); + if !id_to_index.contains_key(&child_id) { + continue; + } + + for base in &td.bases { + // Try imported name first. + if let Some(target_id) = import_map.get(base.as_str()) { + if id_to_index.contains_key(target_id.as_str()) && child_id != *target_id { + edges.push((child_id.clone(), target_id.clone(), EdgeType::Extends)); + continue; + } + } + + // Try local definition. + let local_id = format!("{}::{}", file.path, base); + if id_to_index.contains_key(&local_id) && child_id != local_id { + edges.push((child_id.clone(), local_id, EdgeType::Extends)); + } + } + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + + +#[cfg(test)] +#[allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::print_stdout, + clippy::print_stderr +)] +mod tests; + +#[cfg(test)] +#[allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::print_stdout, + clippy::print_stderr +)] +mod tests_ir; diff --git a/crates/diffcore-core/src/graph/tests.rs b/crates/diffcore-core/src/graph/tests.rs new file mode 100644 index 0000000..de4b518 --- /dev/null +++ b/crates/diffcore-core/src/graph/tests.rs @@ -0,0 +1,1425 @@ + use super::*; + use crate::ast::{self, ParsedFile}; + use crate::types::SymbolKind; + + /// Helper: parse multiple files and build a graph. + fn build_graph_from_sources(files: &[(&str, &str)]) -> SymbolGraph { + let parsed: Vec = files + .iter() + .map(|(path, source)| ast::parse_file(path, source).unwrap()) + .collect(); + SymbolGraph::build(&parsed) + } + + /// Helper: check if an edge exists between two symbol ids with a given type. + fn has_edge(graph: &SymbolGraph, from: &str, to: &str, edge_type: &EdgeType) -> bool { + graph + .edges() + .iter() + .any(|(f, t, et)| *f == from && *t == to && *et == edge_type) + } + + /// Helper: count edges of a specific type. + fn count_edges_of_type(graph: &SymbolGraph, edge_type: &EdgeType) -> usize { + graph + .edges() + .iter() + .filter(|(_, _, et)| *et == edge_type) + .count() + } + + // === Import edge tests === + + #[test] + fn test_build_import_edges() { + let graph = build_graph_from_sources(&[ + ( + "src/utils.ts", + r#" +export function validate(data: any) { return data; } +export function sanitize(data: any) { return data; } +"#, + ), + ( + "src/handler.ts", + r#" +import { validate, sanitize } from './utils'; +function handle() { validate({}); } +"#, + ), + ]); + + // handler.ts module should import validate and sanitize from utils.ts + assert!( + has_edge( + &graph, + "src/handler.ts", + "src/utils.ts::validate", + &EdgeType::Imports + ), + "should have import edge to validate" + ); + assert!( + has_edge( + &graph, + "src/handler.ts", + "src/utils.ts::sanitize", + &EdgeType::Imports + ), + "should have import edge to sanitize" + ); + } + + #[test] + fn test_build_import_edges_default() { + let graph = build_graph_from_sources(&[ + ( + "src/app.ts", + r#" +const app = createApp(); +export default app; +"#, + ), + ( + "src/main.ts", + r#" +import App from './app'; +"#, + ), + ]); + + // Default import should link to the module node. + assert!( + has_edge(&graph, "src/main.ts", "src/app.ts", &EdgeType::Imports), + "should have import edge for default import" + ); + } + + #[test] + fn test_build_import_edges_namespace() { + let graph = build_graph_from_sources(&[ + ( + "src/utils.ts", + r#" +export function foo() {} +export function bar() {} +"#, + ), + ( + "src/main.ts", + r#" +import * as utils from './utils'; +"#, + ), + ]); + + assert!( + has_edge(&graph, "src/main.ts", "src/utils.ts", &EdgeType::Imports), + "namespace import should link to module node" + ); + } + + #[test] + fn test_side_effect_import() { + let graph = build_graph_from_sources(&[ + ("src/polyfill.ts", "// polyfill code"), + ( + "src/main.ts", + r#" +import './polyfill'; +"#, + ), + ]); + + assert!( + has_edge(&graph, "src/main.ts", "src/polyfill.ts", &EdgeType::Imports), + "side-effect import should create module-to-module edge" + ); + } + + // === Call edge tests === + + #[test] + fn test_build_call_edges() { + let graph = build_graph_from_sources(&[ + ( + "src/utils.ts", + r#" +export function validate(data: any) { return data; } +"#, + ), + ( + "src/handler.ts", + r#" +import { validate } from './utils'; +function processRequest(req: any) { + const v = validate(req.body); + return v; +} +"#, + ), + ]); + + assert!( + has_edge( + &graph, + "src/handler.ts::processRequest", + "src/utils.ts::validate", + &EdgeType::Calls + ), + "processRequest should have call edge to validate" + ); + } + + #[test] + fn test_build_call_edges_local() { + let graph = build_graph_from_sources(&[( + "src/service.ts", + r#" +function helper() { return 42; } +function main() { + const x = helper(); + return x; +} +"#, + )]); + + assert!( + has_edge( + &graph, + "src/service.ts::main", + "src/service.ts::helper", + &EdgeType::Calls + ), + "main should have call edge to local helper" + ); + } + + #[test] + fn test_build_call_edges_method_on_import() { + let graph = build_graph_from_sources(&[ + ( + "src/db.ts", + r#" +export function save(data: any) { return data; } +export function find(id: string) { return {}; } +"#, + ), + ( + "src/service.ts", + r#" +import * as db from './db'; +function createUser(data: any) { + return db.save(data); +} +"#, + ), + ]); + + assert!( + has_edge( + &graph, + "src/service.ts::createUser", + "src/db.ts::save", + &EdgeType::Calls + ), + "should resolve method call on namespace import" + ); + } + + #[test] + fn test_no_self_call_edge() { + let graph = build_graph_from_sources(&[( + "src/lib.ts", + r#" +function recurse(n: number): number { + if (n <= 0) return 0; + return recurse(n - 1); +} +"#, + )]); + + // Recursive calls should not create self-edges. + let self_edges: Vec<_> = graph + .edges() + .into_iter() + .filter(|(f, t, _)| f == t) + .collect(); + assert!( + self_edges.is_empty(), + "recursive function should not create self-edges" + ); + } + + // === Graph structure tests === + + #[test] + fn test_graph_node_count() { + let graph = build_graph_from_sources(&[ + ( + "src/a.ts", + r#" +export function foo() {} +export function bar() {} +"#, + ), + ( + "src/b.ts", + r#" +export class Baz {} +"#, + ), + ]); + + // 2 module nodes + 2 functions + 1 class = 5 + assert_eq!(graph.node_count(), 5); + } + + #[test] + fn test_graph_edge_count() { + let graph = build_graph_from_sources(&[ + ( + "src/utils.ts", + r#" +export function validate(x: any) { return x; } +"#, + ), + ( + "src/handler.ts", + r#" +import { validate } from './utils'; +function handle() { validate({}); } +"#, + ), + ]); + + // 1 import edge + 1 call edge = 2 + let import_count = count_edges_of_type(&graph, &EdgeType::Imports); + let call_count = count_edges_of_type(&graph, &EdgeType::Calls); + assert_eq!(import_count, 1, "should have 1 import edge"); + assert_eq!(call_count, 1, "should have 1 call edge"); + } + + #[test] + fn test_cyclic_imports() { + let graph = build_graph_from_sources(&[ + ( + "src/a.ts", + r#" +import { funcB } from './b'; +export function funcA() { funcB(); } +"#, + ), + ( + "src/b.ts", + r#" +import { funcA } from './a'; +export function funcB() { funcA(); } +"#, + ), + ]); + + // Should handle cycles without panic/infinite loop. + assert!(graph.node_count() > 0); + + // Both import edges should exist. + assert!(has_edge( + &graph, + "src/a.ts", + "src/b.ts::funcB", + &EdgeType::Imports + )); + assert!(has_edge( + &graph, + "src/b.ts", + "src/a.ts::funcA", + &EdgeType::Imports + )); + + // Both call edges should exist. + assert!(has_edge( + &graph, + "src/a.ts::funcA", + "src/b.ts::funcB", + &EdgeType::Calls + )); + assert!(has_edge( + &graph, + "src/b.ts::funcB", + "src/a.ts::funcA", + &EdgeType::Calls + )); + } + + #[test] + fn test_reexport_chains() { + let graph = build_graph_from_sources(&[ + ( + "src/core/validate.ts", + r#" +export function validate(data: any) { return data; } +"#, + ), + ( + "src/core/index.ts", + r#" +export { validate } from './validate'; +"#, + ), + ( + "src/handler.ts", + r#" +import { validate } from './core/index'; +function handle() { validate({}); } +"#, + ), + ]); + + // The import from handler should resolve through the barrel file to the actual definition. + assert!( + has_edge( + &graph, + "src/handler.ts", + "src/core/validate.ts::validate", + &EdgeType::Imports + ), + "should resolve re-export chain through barrel file" + ); + } + + #[test] + fn test_graph_serialization_roundtrip() { + let original = build_graph_from_sources(&[ + ( + "src/a.ts", + r#" +export function foo() {} +"#, + ), + ( + "src/b.ts", + r#" +import { foo } from './a'; +function bar() { foo(); } +"#, + ), + ]); + + let serialized = original.to_serializable(); + let json = serde_json::to_string(&serialized).unwrap(); + let deserialized_data: SerializableGraph = serde_json::from_str(&json).unwrap(); + let restored = SymbolGraph::from_serializable(&deserialized_data); + + assert_eq!(original.node_count(), restored.node_count()); + assert_eq!(original.edge_count(), restored.edge_count()); + + // Verify all nodes match. + let orig_serialized = original.to_serializable(); + assert_eq!(orig_serialized, deserialized_data); + } + + #[test] + fn test_empty_files() { + let graph = build_graph_from_sources(&[]); + assert_eq!(graph.node_count(), 0); + assert_eq!(graph.edge_count(), 0); + } + + #[test] + fn test_single_file_no_edges() { + let graph = build_graph_from_sources(&[( + "src/lib.ts", + r#" +function hello() { console.log('hi'); } +"#, + )]); + + // 1 module node + 1 function node = 2 + assert_eq!(graph.node_count(), 2); + // console.log is external, no edge should be created. + assert_eq!( + count_edges_of_type(&graph, &EdgeType::Calls), + 0, + "external calls should not create edges" + ); + } + + #[test] + fn test_python_import_edges() { + let graph = build_graph_from_sources(&[ + ( + "src/models.py", + r#" +class User: + def __init__(self, name): + self.name = name +"#, + ), + ( + "src/service.py", + r#" +from .models import User + +def create_user(name): + return User(name) +"#, + ), + ]); + + assert!( + has_edge( + &graph, + "src/service.py", + "src/models.py::User", + &EdgeType::Imports + ), + "Python from-import should create import edge" + ); + } + + #[test] + fn test_python_call_edges() { + let graph = build_graph_from_sources(&[ + ( + "src/utils.py", + r#" +def validate(data): + return data +"#, + ), + ( + "src/handler.py", + r#" +from .utils import validate + +def process(data): + return validate(data) +"#, + ), + ]); + + assert!( + has_edge( + &graph, + "src/handler.py::process", + "src/utils.py::validate", + &EdgeType::Calls + ), + "Python call should create call edge" + ); + } + + #[test] + fn test_cross_directory_imports() { + let graph = build_graph_from_sources(&[ + ( + "src/models/user.ts", + r#" +export interface User { name: string; } +"#, + ), + ( + "src/handlers/auth.ts", + r#" +import { User } from '../models/user'; +function login(user: User) {} +"#, + ), + ]); + + assert!( + has_edge( + &graph, + "src/handlers/auth.ts", + "src/models/user.ts::User", + &EdgeType::Imports + ), + "should resolve cross-directory relative import with .." + ); + } + + #[test] + fn test_unknown_language_no_crash() { + let graph = + build_graph_from_sources(&[("src/main.rs", r#"fn main() { println!("hello"); }"#)]); + + // Should have module node only, no definitions from unknown language. + assert_eq!(graph.node_count(), 1); + assert_eq!(graph.edge_count(), 0); + } + + #[test] + fn test_multiple_call_targets() { + let graph = build_graph_from_sources(&[ + ( + "src/a.ts", + r#" +export function alpha() { return 1; } +"#, + ), + ( + "src/b.ts", + r#" +export function beta() { return 2; } +"#, + ), + ( + "src/c.ts", + r#" +import { alpha } from './a'; +import { beta } from './b'; +function gamma() { + alpha(); + beta(); +} +"#, + ), + ]); + + assert!(has_edge( + &graph, + "src/c.ts::gamma", + "src/a.ts::alpha", + &EdgeType::Calls + )); + assert!(has_edge( + &graph, + "src/c.ts::gamma", + "src/b.ts::beta", + &EdgeType::Calls + )); + } + + #[test] + fn test_aliased_import_call() { + let graph = build_graph_from_sources(&[ + ( + "src/utils.ts", + r#" +export function validate(data: any) { return data; } +"#, + ), + ( + "src/handler.ts", + r#" +import { validate as check } from './utils'; +function handle() { check({}); } +"#, + ), + ]); + + assert!( + has_edge( + &graph, + "src/handler.ts::handle", + "src/utils.ts::validate", + &EdgeType::Calls + ), + "aliased import should resolve calls through the alias" + ); + } + + #[test] + fn test_index_file_resolution() { + let graph = build_graph_from_sources(&[ + ( + "src/lib/index.ts", + r#" +export function helper() { return 42; } +"#, + ), + ( + "src/main.ts", + r#" +import { helper } from './lib'; +function run() { helper(); } +"#, + ), + ]); + + // `./lib` should resolve to `src/lib/index.ts` + assert!( + has_edge( + &graph, + "src/main.ts", + "src/lib/index.ts::helper", + &EdgeType::Imports + ), + "should resolve ./lib to ./lib/index.ts" + ); + } + + #[test] + fn test_node_lookup() { + let graph = build_graph_from_sources(&[( + "src/app.ts", + r#" +export function start() {} +export class Server {} +"#, + )]); + + assert!(graph.get_node("src/app.ts").is_some()); + assert!(graph.get_node("src/app.ts::start").is_some()); + assert!(graph.get_node("src/app.ts::Server").is_some()); + assert!(graph.get_node("src/nonexistent.ts").is_none()); + + let start = graph.get_symbol("src/app.ts::start").unwrap(); + assert_eq!(start.name, "start"); + assert_eq!(start.kind, SymbolKind::Function); + } + + #[test] + fn test_external_imports_no_edges() { + let graph = build_graph_from_sources(&[( + "src/app.ts", + r#" +import express from 'express'; +import { Router } from 'express'; +const app = express(); +"#, + )]); + + // External packages (non-relative imports) should not create edges. + assert_eq!( + count_edges_of_type(&graph, &EdgeType::Imports), + 0, + "external imports should not create edges" + ); + } + + #[test] + fn test_deterministic_output() { + let files = &[ + ( + "src/a.ts", + r#" +export function foo() {} +export function bar() {} +"#, + ), + ( + "src/b.ts", + r#" +import { foo, bar } from './a'; +function baz() { foo(); bar(); } +"#, + ), + ]; + + let g1 = build_graph_from_sources(files); + let g2 = build_graph_from_sources(files); + + assert_eq!(g1.node_count(), g2.node_count()); + assert_eq!(g1.edge_count(), g2.edge_count()); + assert_eq!(g1.to_serializable(), g2.to_serializable()); + } + + // === §13.3 spec-required tests === + + /// §13.3: Creates `extends` edges from class inheritance. + #[test] + fn test_build_extends_edges() { + // TypeScript class inheritance via AST path. + // Note: the AST path's `collect_extends_edges` is a stub — extends edges + // come from the IR path. Verify IR-based extends edges work correctly. + let graph = build_graph_from_sources(&[ + ( + "src/base.ts", + r#" +export class BaseEntity { + id: string; +} +"#, + ), + ( + "src/user.ts", + r#" +import { BaseEntity } from './base'; +export class User extends BaseEntity { + name: string; +} +"#, + ), + ]); + + // Via AST path, extends edges are not yet produced (stub). + // Verify the graph builds without error and has the expected nodes. + assert!(graph.node_count() >= 4, "should have module + class nodes"); + + // Now test via IR path which DOES produce extends edges. + use crate::ir::{IrFile, IrImport, IrImportSpecifier, IrTypeDef, Span, TypeDefKind}; + + let empty_span = || Span { + start_line: 0, + end_line: 0, + }; + + let base_file = IrFile { + path: "src/base.ts".to_string(), + language: crate::ast::Language::TypeScript, + functions: vec![], + type_defs: vec![IrTypeDef { + name: "BaseEntity".to_string(), + kind: TypeDefKind::Class, + span: empty_span(), + bases: vec![], + is_exported: true, + decorators: vec![], + }], + constants: vec![], + imports: vec![], + exports: vec![], + call_expressions: vec![], + assignments: vec![], + }; + + let user_file = IrFile { + path: "src/user.ts".to_string(), + language: crate::ast::Language::TypeScript, + functions: vec![], + type_defs: vec![IrTypeDef { + name: "User".to_string(), + kind: TypeDefKind::Class, + span: empty_span(), + bases: vec!["BaseEntity".to_string()], + is_exported: true, + decorators: vec![], + }], + constants: vec![], + imports: vec![IrImport { + source: "./base".to_string(), + specifiers: vec![IrImportSpecifier::Named { + name: "BaseEntity".to_string(), + alias: None, + }], + span: empty_span(), + }], + exports: vec![], + call_expressions: vec![], + assignments: vec![], + }; + + let ir_graph = SymbolGraph::build_from_ir(&[base_file, user_file]); + assert!( + has_edge( + &ir_graph, + "src/user.ts::User", + "src/base.ts::BaseEntity", + &EdgeType::Extends + ), + "should have Extends edge from User to BaseEntity via IR path" + ); + } + + /// §13.3: Resolves imports across monorepo package boundaries. + #[test] + fn test_cross_package_edges() { + let files = vec![ + ( + "packages/shared/src/index.ts", + r#" +export function formatDate(d: Date): string { return d.toISOString(); } +"#, + ), + ( + "packages/api/src/handler.ts", + r#" +import { formatDate } from "@acme/shared"; +export function handle() { return formatDate(new Date()); } +"#, + ), + ]; + + let parsed: Vec = files + .iter() + .map(|(path, source)| ast::parse_file(path, source).unwrap()) + .collect(); + + let mut ws = WorkspaceMap::new(); + ws.insert( + "@acme/shared".to_string(), + "packages/shared/src/index.ts".to_string(), + ); + let graph = SymbolGraph::build_with_workspace(&parsed, &ws); + + // Should have cross-package import edge + assert!( + has_edge( + &graph, + "packages/api/src/handler.ts", + "packages/shared/src/index.ts::formatDate", + &EdgeType::Imports + ), + "should resolve import across monorepo package boundary" + ); + + // Should have cross-package call edge + assert!( + has_edge( + &graph, + "packages/api/src/handler.ts::handle", + "packages/shared/src/index.ts::formatDate", + &EdgeType::Calls + ), + "should resolve call across monorepo package boundary" + ); + } + + /// §13.3: Handles `import()` / `require()` dynamic imports. + #[test] + fn test_dynamic_imports() { + // Dynamic imports (import() and require()) should not crash the graph builder. + // Whether edges are created depends on whether the callee can be resolved. + let graph = build_graph_from_sources(&[ + ( + "src/utils.ts", + r#" +export function lazyLoad() { return 42; } +"#, + ), + ( + "src/main.ts", + r#" +async function loadModule() { + const mod = await import('./utils'); + return mod.lazyLoad(); +} +function loadSync() { + const mod = require('./utils'); +} +"#, + ), + ]); + + // Graph should build without crashing on dynamic imports. + assert!(graph.node_count() >= 2, "should have nodes for both files"); + + // Dynamic import() and require() are call expressions; they may or may not + // create edges depending on resolution. The key property is no panic. + // Check that the graph is well-formed. + let serialized = graph.to_serializable(); + let json = serde_json::to_string(&serialized).unwrap(); + let _: SerializableGraph = serde_json::from_str(&json).unwrap(); + } + + // === Property-based tests === + + mod proptests { + use super::*; + use proptest::prelude::*; + + /// Generate a random function name. + fn func_name_strategy() -> impl Strategy { + "[a-z][a-zA-Z0-9]{0,15}".prop_map(|s| s) + } + + /// Generate a ParsedFile with random definitions. + fn parsed_file_strategy() -> impl Strategy { + ( + "[a-z]{1,8}".prop_map(|s| format!("src/{}.ts", s)), + prop::collection::vec(func_name_strategy(), 0..10), + ) + .prop_map(|(path, func_names)| { + let definitions: Vec = func_names + .iter() + .enumerate() + .map(|(i, name)| Definition { + name: name.clone(), + kind: SymbolKind::Function, + start_line: i + 1, + end_line: i + 3, + }) + .collect(); + + ParsedFile { + path, + language: Language::TypeScript, + definitions, + imports: vec![], + exports: vec![], + call_sites: vec![], + } + }) + } + + proptest! { + #[test] + fn prop_every_definition_has_node(files in prop::collection::vec(parsed_file_strategy(), 1..5)) { + let graph = SymbolGraph::build(&files); + + for file in &files { + // Module node exists. + prop_assert!(graph.get_node(&file.path).is_some(), + "module node should exist for {}", file.path); + + // Each unique definition has a node. + let mut seen = std::collections::HashSet::new(); + for def in &file.definitions { + let sym_id = format!("{}::{}", file.path, def.name); + if seen.insert(sym_id.clone()) { + prop_assert!(graph.get_node(&sym_id).is_some(), + "node should exist for {}", sym_id); + } + } + } + } + + #[test] + fn prop_node_count_at_least_file_count(files in prop::collection::vec(parsed_file_strategy(), 0..5)) { + let graph = SymbolGraph::build(&files); + // At minimum, one module node per file. + prop_assert!(graph.node_count() >= files.len()); + } + + #[test] + fn prop_no_self_edges(files in prop::collection::vec(parsed_file_strategy(), 0..5)) { + let graph = SymbolGraph::build(&files); + for (from, to, _) in graph.edges() { + prop_assert!(from != to, "self-edge found: {} -> {}", from, to); + } + } + + #[test] + fn prop_serialization_roundtrip(files in prop::collection::vec(parsed_file_strategy(), 0..5)) { + let graph = SymbolGraph::build(&files); + let serialized = graph.to_serializable(); + let json = serde_json::to_string(&serialized).unwrap(); + let deserialized: SerializableGraph = serde_json::from_str(&json).unwrap(); + let restored = SymbolGraph::from_serializable(&deserialized); + + prop_assert_eq!(graph.node_count(), restored.node_count()); + prop_assert_eq!(graph.edge_count(), restored.edge_count()); + } + + #[test] + fn prop_deterministic(files in prop::collection::vec(parsed_file_strategy(), 0..5)) { + let g1 = SymbolGraph::build(&files); + let g2 = SymbolGraph::build(&files); + prop_assert_eq!(g1.node_count(), g2.node_count()); + prop_assert_eq!(g1.edge_count(), g2.edge_count()); + } + + #[test] + fn prop_empty_input_empty_graph(_dummy in 0u32..1) { + let graph = SymbolGraph::build(&[]); + prop_assert_eq!(graph.node_count(), 0); + prop_assert_eq!(graph.edge_count(), 0); + } + } + } + + // ======================================================================= + // IR-based graph parity tests + // ======================================================================= + + mod ir_parity { + use super::*; + use crate::ir::IrFile; + + /// Helper: parse files and build graph via both paths, return both. + fn build_both(files: &[(&str, &str)]) -> (SymbolGraph, SymbolGraph) { + let parsed: Vec = files + .iter() + .map(|(path, source)| ast::parse_file(path, source).unwrap()) + .collect(); + let ir_files: Vec = parsed.iter().map(IrFile::from_parsed_file).collect(); + + let graph_parsed = SymbolGraph::build(&parsed); + let graph_ir = SymbolGraph::build_from_ir(&ir_files); + (graph_parsed, graph_ir) + } + + #[test] + fn test_ir_parity_simple_import() { + let (gp, gi) = build_both(&[ + ( + "src/utils.ts", + r#" +export function validate(data: any) { return data; } +export function sanitize(data: any) { return data; } +"#, + ), + ( + "src/handler.ts", + r#" +import { validate, sanitize } from './utils'; +function handle() { validate({}); } +"#, + ), + ]); + + assert_eq!(gp.node_count(), gi.node_count(), "node counts should match"); + assert_eq!(gp.edge_count(), gi.edge_count(), "edge counts should match"); + } + + #[test] + fn test_ir_parity_call_edges() { + let (gp, gi) = build_both(&[ + ( + "src/utils.ts", + r#" +export function validate(data: any) { return data; } +"#, + ), + ( + "src/handler.ts", + r#" +import { validate } from './utils'; +function processRequest(req: any) { + const v = validate(req.body); + return v; +} +"#, + ), + ]); + + assert_eq!(gp.node_count(), gi.node_count()); + assert_eq!(gp.edge_count(), gi.edge_count()); + + // Verify specific edge exists in IR graph. + assert!( + has_edge( + &gi, + "src/handler.ts::processRequest", + "src/utils.ts::validate", + &EdgeType::Calls + ), + "IR graph should have call edge" + ); + } + + #[test] + fn test_ir_parity_namespace_import() { + let (gp, gi) = build_both(&[ + ( + "src/utils.ts", + r#" +export function foo() {} +export function bar() {} +"#, + ), + ( + "src/main.ts", + r#" +import * as utils from './utils'; +function main() { + utils.foo(); +} +"#, + ), + ]); + + assert_eq!(gp.node_count(), gi.node_count()); + assert_eq!(gp.edge_count(), gi.edge_count()); + } + + #[test] + fn test_ir_parity_default_import() { + let (gp, gi) = build_both(&[ + ( + "src/utils.ts", + r#" +export default function doStuff() {} +"#, + ), + ( + "src/main.ts", + r#" +import doStuff from './utils'; +doStuff(); +"#, + ), + ]); + + assert_eq!(gp.node_count(), gi.node_count()); + assert_eq!(gp.edge_count(), gi.edge_count()); + } + + #[test] + fn test_ir_parity_python_imports() { + let (gp, gi) = build_both(&[ + ( + "models.py", + r#" +class User: + pass + +def create_user(): + pass +"#, + ), + ( + "views.py", + r#" +from .models import User, create_user + +def list_users(): + return create_user() +"#, + ), + ]); + + assert_eq!(gp.node_count(), gi.node_count()); + assert_eq!(gp.edge_count(), gi.edge_count()); + } + + #[test] + fn test_ir_parity_reexport_chain() { + let (gp, gi) = build_both(&[ + ( + "src/core.ts", + r#" +export function coreFunc() {} +"#, + ), + ( + "src/index.ts", + r#" +export { coreFunc } from './core'; +"#, + ), + ( + "src/consumer.ts", + r#" +import { coreFunc } from './index'; +function use() { coreFunc(); } +"#, + ), + ]); + + assert_eq!(gp.node_count(), gi.node_count()); + assert_eq!(gp.edge_count(), gi.edge_count()); + } + + #[test] + fn test_ir_parity_side_effect_import() { + let (gp, gi) = build_both(&[ + ( + "src/polyfill.ts", + r#" +export function polyfill() {} +"#, + ), + ("src/main.ts", r#"import './polyfill';"#), + ]); + + assert_eq!(gp.node_count(), gi.node_count()); + assert_eq!(gp.edge_count(), gi.edge_count()); + } + + #[test] + fn test_ir_parity_empty_input() { + let gi = SymbolGraph::build_from_ir(&[]); + assert_eq!(gi.node_count(), 0); + assert_eq!(gi.edge_count(), 0); + } + + #[test] + fn test_ir_parity_local_call() { + let (gp, gi) = build_both(&[( + "src/app.ts", + r#" +function helper() { return 42; } +function main() { helper(); } +"#, + )]); + + assert_eq!(gp.node_count(), gi.node_count()); + assert_eq!(gp.edge_count(), gi.edge_count()); + + assert!( + has_edge( + &gi, + "src/app.ts::main", + "src/app.ts::helper", + &EdgeType::Calls + ), + "IR graph should have local call edge" + ); + } + + #[test] + fn test_ir_parity_aliased_import() { + let (gp, gi) = build_both(&[ + ( + "src/utils.ts", + r#" +export function validate() {} +"#, + ), + ( + "src/main.ts", + r#" +import { validate as check } from './utils'; +function run() { check(); } +"#, + ), + ]); + + assert_eq!(gp.node_count(), gi.node_count()); + assert_eq!(gp.edge_count(), gi.edge_count()); + } + + #[test] + fn test_ir_parity_multiple_files() { + let (gp, gi) = build_both(&[ + ( + "src/db.ts", + r#" +export function query(sql: string) { return []; } +export function insert(data: any) { } +"#, + ), + ( + "src/service.ts", + r#" +import { query, insert } from './db'; +export function getUsers() { return query('SELECT * FROM users'); } +export function createUser(data: any) { insert(data); } +"#, + ), + ( + "src/handler.ts", + r#" +import { getUsers, createUser } from './service'; +function handleGet(req: any) { return getUsers(); } +function handlePost(req: any) { createUser(req.body); } +"#, + ), + ]); + + assert_eq!( + gp.node_count(), + gi.node_count(), + "3-file graph node count should match" + ); + assert_eq!( + gp.edge_count(), + gi.edge_count(), + "3-file graph edge count should match" + ); + } + } + + // ======================================================================= + // IR-based graph property-based tests + // ======================================================================= + + mod ir_proptest { + use super::*; + use crate::ast::{Definition, Language, ParsedFile}; + use crate::ir::IrFile; + use proptest::prelude::*; + + fn func_name_strategy() -> impl Strategy { + "[a-z][a-zA-Z0-9]{0,15}".prop_map(|s| s) + } + + fn parsed_file_strategy() -> impl Strategy { + ( + "[a-z]{1,8}".prop_map(|s| format!("src/{}.ts", s)), + prop::collection::vec(func_name_strategy(), 0..10), + ) + .prop_map(|(path, func_names)| { + let definitions: Vec = func_names + .iter() + .enumerate() + .map(|(i, name)| Definition { + name: name.clone(), + kind: SymbolKind::Function, + start_line: i + 1, + end_line: i + 3, + }) + .collect(); + + ParsedFile { + path, + language: Language::TypeScript, + definitions, + imports: vec![], + exports: vec![], + call_sites: vec![], + } + }) + } + + proptest! { + #[test] + fn prop_ir_node_count_matches_parsed(files in prop::collection::vec(parsed_file_strategy(), 0..5)) { + let ir_files: Vec = files.iter().map(|f| IrFile::from_parsed_file(f)).collect(); + let g_parsed = SymbolGraph::build(&files); + let g_ir = SymbolGraph::build_from_ir(&ir_files); + prop_assert_eq!(g_parsed.node_count(), g_ir.node_count(), + "node count mismatch: parsed={}, ir={}", g_parsed.node_count(), g_ir.node_count()); + } + + #[test] + fn prop_ir_edge_count_matches_parsed(files in prop::collection::vec(parsed_file_strategy(), 0..5)) { + let ir_files: Vec = files.iter().map(|f| IrFile::from_parsed_file(f)).collect(); + let g_parsed = SymbolGraph::build(&files); + let g_ir = SymbolGraph::build_from_ir(&ir_files); + prop_assert_eq!(g_parsed.edge_count(), g_ir.edge_count()); + } + + #[test] + fn prop_ir_no_self_edges(files in prop::collection::vec(parsed_file_strategy(), 0..5)) { + let ir_files: Vec = files.iter().map(|f| IrFile::from_parsed_file(f)).collect(); + let graph = SymbolGraph::build_from_ir(&ir_files); + for (from, to, _) in graph.edges() { + prop_assert!(from != to, "self-edge found in IR graph: {} -> {}", from, to); + } + } + + #[test] + fn prop_ir_deterministic(files in prop::collection::vec(parsed_file_strategy(), 0..5)) { + let ir_files: Vec = files.iter().map(|f| IrFile::from_parsed_file(f)).collect(); + let g1 = SymbolGraph::build_from_ir(&ir_files); + let g2 = SymbolGraph::build_from_ir(&ir_files); + prop_assert_eq!(g1.node_count(), g2.node_count()); + prop_assert_eq!(g1.edge_count(), g2.edge_count()); + } + + #[test] + fn prop_ir_empty_input_empty_graph(_dummy in 0u32..1) { + let graph = SymbolGraph::build_from_ir(&[]); + prop_assert_eq!(graph.node_count(), 0); + prop_assert_eq!(graph.edge_count(), 0); + } + + #[test] + fn prop_ir_every_definition_has_node(files in prop::collection::vec(parsed_file_strategy(), 1..5)) { + let ir_files: Vec = files.iter().map(|f| IrFile::from_parsed_file(f)).collect(); + let graph = SymbolGraph::build_from_ir(&ir_files); + + for ir_file in &ir_files { + prop_assert!(graph.get_node(&ir_file.path).is_some(), + "module node should exist for {}", ir_file.path); + + let mut seen = std::collections::HashSet::new(); + for func in &ir_file.functions { + let sym_id = format!("{}::{}", ir_file.path, func.name); + if seen.insert(sym_id.clone()) { + prop_assert!(graph.get_node(&sym_id).is_some(), + "node should exist for function {}", sym_id); + } + } + for td in &ir_file.type_defs { + let sym_id = format!("{}::{}", ir_file.path, td.name); + if seen.insert(sym_id.clone()) { + prop_assert!(graph.get_node(&sym_id).is_some(), + "node should exist for type def {}", sym_id); + } + } + for c in &ir_file.constants { + let sym_id = format!("{}::{}", ir_file.path, c.name); + if seen.insert(sym_id.clone()) { + prop_assert!(graph.get_node(&sym_id).is_some(), + "node should exist for constant {}", sym_id); + } + } + } + } + } + } + diff --git a/crates/diffcore-core/src/graph/tests_ir.rs b/crates/diffcore-core/src/graph/tests_ir.rs new file mode 100644 index 0000000..359f9b5 --- /dev/null +++ b/crates/diffcore-core/src/graph/tests_ir.rs @@ -0,0 +1,1666 @@ +use super::*; +use crate::ast::{self, ParsedFile}; +use crate::types::SymbolKind; + +/// Helper: parse multiple files and build a graph. +fn build_graph_from_sources(files: &[(&str, &str)]) -> SymbolGraph { + let parsed: Vec = files + .iter() + .map(|(path, source)| ast::parse_file(path, source).unwrap()) + .collect(); + SymbolGraph::build(&parsed) +} + +/// Helper: check if an edge exists between two symbol ids with a given type. +fn has_edge(graph: &SymbolGraph, from: &str, to: &str, edge_type: &EdgeType) -> bool { + graph + .edges() + .iter() + .any(|(f, t, et)| *f == from && *t == to && *et == edge_type) +} + +/// Helper: count edges of a specific type. +fn count_edges_of_type(graph: &SymbolGraph, edge_type: &EdgeType) -> usize { + graph + .edges() + .iter() + .filter(|(_, _, et)| *et == edge_type) + .count() +} + + // ======================================================================= + // Helper function unit tests + // ======================================================================= + + mod helper_tests { + use super::*; + + // --- normalize_path --- + + #[test] + fn test_normalize_path_simple() { + assert_eq!(normalize_path("src/utils.ts"), "src/utils.ts"); + } + + #[test] + fn test_normalize_path_dot_segments() { + assert_eq!(normalize_path("src/./utils.ts"), "src/utils.ts"); + } + + #[test] + fn test_normalize_path_dotdot_segments() { + assert_eq!(normalize_path("src/handlers/../utils.ts"), "src/utils.ts"); + } + + #[test] + fn test_normalize_path_multiple_dotdot() { + assert_eq!(normalize_path("src/a/b/../../utils.ts"), "src/utils.ts"); + } + + #[test] + fn test_normalize_path_leading_dotdot() { + // More `..` than components — pops everything available. + assert_eq!(normalize_path("../utils.ts"), "utils.ts"); + } + + #[test] + fn test_normalize_path_empty_segments() { + assert_eq!(normalize_path("src//utils.ts"), "src/utils.ts"); + } + + #[test] + fn test_normalize_path_only_dot() { + assert_eq!(normalize_path("."), ""); + } + + #[test] + fn test_normalize_path_trailing_slash() { + assert_eq!(normalize_path("src/lib/"), "src/lib"); + } + + // --- normalize_python_import --- + + #[test] + fn test_python_import_single_dot() { + assert_eq!(normalize_python_import(".models"), "./models"); + } + + #[test] + fn test_python_import_double_dot() { + assert_eq!(normalize_python_import("..models"), "../models"); + } + + #[test] + fn test_python_import_triple_dot() { + assert_eq!( + normalize_python_import("...utils.helpers"), + "../../utils/helpers" + ); + } + + #[test] + fn test_python_import_dot_only() { + assert_eq!(normalize_python_import("."), "."); + } + + #[test] + fn test_python_import_dotdot_only() { + assert_eq!(normalize_python_import(".."), ".."); + } + + #[test] + fn test_python_import_no_dots() { + assert_eq!(normalize_python_import("os.path"), "os.path"); + } + + #[test] + fn test_python_import_dotted_remainder() { + assert_eq!( + normalize_python_import(".models.user.schema"), + "./models/user/schema" + ); + } + + // --- parent_dir --- + + #[test] + fn test_parent_dir_nested() { + assert_eq!(parent_dir("src/handlers/auth.ts"), "src/handlers"); + } + + #[test] + fn test_parent_dir_single_level() { + assert_eq!(parent_dir("src/app.ts"), "src"); + } + + #[test] + fn test_parent_dir_no_slash() { + assert_eq!(parent_dir("app.ts"), "."); + } + + // --- file_stem --- + + #[test] + fn test_file_stem_simple() { + assert_eq!(file_stem("src/utils.ts"), "utils"); + } + + #[test] + fn test_file_stem_no_extension() { + assert_eq!(file_stem("src/Makefile"), "Makefile"); + } + + #[test] + fn test_file_stem_multiple_dots() { + assert_eq!(file_stem("src/utils.test.ts"), "utils"); + } + + #[test] + fn test_file_stem_no_directory() { + assert_eq!(file_stem("app.ts"), "app"); + } + + // --- resolve_import_path --- + + #[test] + fn test_resolve_import_exact_match() { + // Note: resolve_import_path normalizes Python-style dots, so + // explicit extensions like `./utils.ts` get mangled. Use + // extension-less import sources (the normal JS/TS convention). + let known = vec!["src/utils.ts"]; + let result = resolve_import_path("./utils", "src/handler.ts", &known); + assert_eq!(result, Some("src/utils.ts".to_string())); + } + + #[test] + fn test_resolve_import_ts_extension() { + let known = vec!["src/utils.ts"]; + let result = resolve_import_path("./utils", "src/handler.ts", &known); + assert_eq!(result, Some("src/utils.ts".to_string())); + } + + #[test] + fn test_resolve_import_tsx_extension() { + let known = vec!["src/Button.tsx"]; + let result = resolve_import_path("./Button", "src/App.tsx", &known); + assert_eq!(result, Some("src/Button.tsx".to_string())); + } + + #[test] + fn test_resolve_import_index_file() { + let known = vec!["src/lib/index.ts"]; + let result = resolve_import_path("./lib", "src/main.ts", &known); + assert_eq!(result, Some("src/lib/index.ts".to_string())); + } + + #[test] + fn test_resolve_import_parent_dir() { + let known = vec!["src/utils.ts"]; + let result = resolve_import_path("../utils", "src/handlers/auth.ts", &known); + assert_eq!(result, Some("src/utils.ts".to_string())); + } + + #[test] + fn test_resolve_import_nonrelative_ignored() { + let known = vec!["node_modules/express/index.js"]; + let result = resolve_import_path("express", "src/app.ts", &known); + assert_eq!(result, None); + } + + #[test] + fn test_resolve_import_not_found() { + let known = vec!["src/app.ts"]; + let result = resolve_import_path("./nonexistent", "src/main.ts", &known); + assert_eq!(result, None); + } + + #[test] + fn test_resolve_import_python_style() { + let known = vec!["models.py"]; + let result = resolve_import_path(".models", "views.py", &known); + assert_eq!(result, Some("models.py".to_string())); + } + + #[test] + fn test_resolve_import_js_extension() { + let known = vec!["src/helper.js"]; + let result = resolve_import_path("./helper", "src/main.ts", &known); + assert_eq!(result, Some("src/helper.js".to_string())); + } + + #[test] + fn test_resolve_import_priority_exact_over_extension() { + // If both exact match and .ts exist, exact match wins. + let known = vec!["src/utils", "src/utils.ts"]; + let result = resolve_import_path("./utils", "src/main.ts", &known); + assert_eq!(result, Some("src/utils".to_string())); + } + + // --- resolve_workspace_import --- + + #[test] + fn test_workspace_exact_package_match() { + let known = vec!["packages/shared/src/index.ts"]; + let mut ws = WorkspaceMap::new(); + ws.insert( + "@mono/shared".to_string(), + "packages/shared/src/index.ts".to_string(), + ); + let result = resolve_workspace_import("@mono/shared", &known, &ws); + assert_eq!(result, Some("packages/shared/src/index.ts".to_string())); + } + + #[test] + fn test_workspace_package_not_in_known_files() { + let known: Vec<&str> = vec!["src/app.ts"]; + let mut ws = WorkspaceMap::new(); + ws.insert( + "@mono/shared".to_string(), + "packages/shared/src/index.ts".to_string(), + ); + let result = resolve_workspace_import("@mono/shared", &known, &ws); + assert_eq!(result, None); + } + + #[test] + fn test_workspace_deep_import() { + // @mono/shared/utils → packages/shared/utils.ts + let known = vec!["packages/shared/utils.ts"]; + let mut ws = WorkspaceMap::new(); + ws.insert( + "@mono/shared".to_string(), + "packages/shared/src/index.ts".to_string(), + ); + let result = resolve_workspace_import("@mono/shared/utils", &known, &ws); + assert_eq!(result, Some("packages/shared/utils.ts".to_string())); + } + + #[test] + fn test_workspace_deep_import_with_extension() { + let known = vec!["packages/shared/models/user.ts"]; + let mut ws = WorkspaceMap::new(); + ws.insert( + "@mono/shared".to_string(), + "packages/shared/src/index.ts".to_string(), + ); + let result = resolve_workspace_import("@mono/shared/models/user", &known, &ws); + assert_eq!(result, Some("packages/shared/models/user.ts".to_string())); + } + + #[test] + fn test_workspace_relative_import_skipped() { + let known = vec!["packages/shared/src/index.ts"]; + let mut ws = WorkspaceMap::new(); + ws.insert( + "@mono/shared".to_string(), + "packages/shared/src/index.ts".to_string(), + ); + let result = resolve_workspace_import("./utils", &known, &ws); + assert_eq!(result, None); + } + + #[test] + fn test_workspace_no_match() { + let known = vec!["src/app.ts"]; + let ws = WorkspaceMap::new(); + let result = resolve_workspace_import("@mono/shared", &known, &ws); + assert_eq!(result, None); + } + + #[test] + fn test_workspace_longest_prefix_match() { + // @mono/shared/sub should match @mono/shared, not @mono + let known = vec!["packages/shared/sub.ts"]; + let mut ws = WorkspaceMap::new(); + ws.insert( + "@mono".to_string(), + "packages/mono/src/index.ts".to_string(), + ); + ws.insert( + "@mono/shared".to_string(), + "packages/shared/src/index.ts".to_string(), + ); + let result = resolve_workspace_import("@mono/shared/sub", &known, &ws); + assert_eq!(result, Some("packages/shared/sub.ts".to_string())); + } + + // --- resolve_import_or_workspace --- + + #[test] + fn test_resolve_or_workspace_prefers_relative() { + // Relative import should still work, even with workspace map. + let known = vec!["src/utils.ts", "packages/utils/src/index.ts"]; + let mut ws = WorkspaceMap::new(); + ws.insert( + "utils".to_string(), + "packages/utils/src/index.ts".to_string(), + ); + let result = resolve_import_or_workspace("./utils", "src/handler.ts", &known, &ws); + assert_eq!(result, Some("src/utils.ts".to_string())); + } + + #[test] + fn test_resolve_or_workspace_falls_back_to_workspace() { + let known = vec!["packages/types/src/index.ts"]; + let mut ws = WorkspaceMap::new(); + ws.insert( + "@app/types".to_string(), + "packages/types/src/index.ts".to_string(), + ); + let result = resolve_import_or_workspace("@app/types", "src/handler.ts", &known, &ws); + assert_eq!(result, Some("packages/types/src/index.ts".to_string())); + } + } + + // ======================================================================= + // IR extends edge tests + // ======================================================================= + + mod ir_extends_tests { + use super::*; + use crate::ast::Language; + use crate::ir::{IrFile, IrImport, IrImportSpecifier, IrTypeDef, Span, TypeDefKind}; + + fn empty_span() -> Span { + Span::new(1, 1) + } + + fn make_ir_file(path: &str, language: Language) -> IrFile { + IrFile { + path: path.to_string(), + language, + functions: vec![], + type_defs: vec![], + constants: vec![], + imports: vec![], + exports: vec![], + call_expressions: vec![], + assignments: vec![], + } + } + + #[test] + fn test_ir_extends_local_class() { + let mut file = make_ir_file("src/models.ts", Language::TypeScript); + file.type_defs.push(IrTypeDef { + name: "BaseModel".to_string(), + kind: TypeDefKind::Class, + span: empty_span(), + bases: vec![], + is_exported: true, + decorators: vec![], + }); + file.type_defs.push(IrTypeDef { + name: "User".to_string(), + kind: TypeDefKind::Class, + span: empty_span(), + bases: vec!["BaseModel".to_string()], + is_exported: true, + decorators: vec![], + }); + + let graph = SymbolGraph::build_from_ir(&[file]); + + assert!( + has_edge( + &graph, + "src/models.ts::User", + "src/models.ts::BaseModel", + &EdgeType::Extends + ), + "should have extends edge from User to BaseModel" + ); + } + + #[test] + fn test_ir_extends_imported_class() { + let mut base_file = make_ir_file("src/base.ts", Language::TypeScript); + base_file.type_defs.push(IrTypeDef { + name: "Entity".to_string(), + kind: TypeDefKind::Class, + span: empty_span(), + bases: vec![], + is_exported: true, + decorators: vec![], + }); + + let mut child_file = make_ir_file("src/user.ts", Language::TypeScript); + child_file.imports.push(IrImport { + source: "./base".to_string(), + specifiers: vec![IrImportSpecifier::Named { + name: "Entity".to_string(), + alias: None, + }], + span: empty_span(), + }); + child_file.type_defs.push(IrTypeDef { + name: "User".to_string(), + kind: TypeDefKind::Class, + span: empty_span(), + bases: vec!["Entity".to_string()], + is_exported: true, + decorators: vec![], + }); + + let graph = SymbolGraph::build_from_ir(&[base_file, child_file]); + + assert!( + has_edge( + &graph, + "src/user.ts::User", + "src/base.ts::Entity", + &EdgeType::Extends + ), + "should have extends edge to imported base class" + ); + } + + #[test] + fn test_ir_extends_multiple_bases() { + let mut file = make_ir_file("src/mixin.ts", Language::TypeScript); + file.type_defs.push(IrTypeDef { + name: "Serializable".to_string(), + kind: TypeDefKind::Interface, + span: empty_span(), + bases: vec![], + is_exported: false, + decorators: vec![], + }); + file.type_defs.push(IrTypeDef { + name: "Loggable".to_string(), + kind: TypeDefKind::Interface, + span: empty_span(), + bases: vec![], + is_exported: false, + decorators: vec![], + }); + file.type_defs.push(IrTypeDef { + name: "UserService".to_string(), + kind: TypeDefKind::Class, + span: empty_span(), + bases: vec!["Serializable".to_string(), "Loggable".to_string()], + is_exported: true, + decorators: vec![], + }); + + let graph = SymbolGraph::build_from_ir(&[file]); + + assert!( + has_edge( + &graph, + "src/mixin.ts::UserService", + "src/mixin.ts::Serializable", + &EdgeType::Extends + ), + "should have extends edge to Serializable" + ); + assert!( + has_edge( + &graph, + "src/mixin.ts::UserService", + "src/mixin.ts::Loggable", + &EdgeType::Extends + ), + "should have extends edge to Loggable" + ); + } + + #[test] + fn test_ir_extends_no_self_edge() { + let mut file = make_ir_file("src/app.ts", Language::TypeScript); + file.type_defs.push(IrTypeDef { + name: "App".to_string(), + kind: TypeDefKind::Class, + span: empty_span(), + bases: vec!["App".to_string()], + is_exported: false, + decorators: vec![], + }); + + let graph = SymbolGraph::build_from_ir(&[file]); + + let self_edges: Vec<_> = graph + .edges() + .into_iter() + .filter(|(f, t, _)| f == t) + .collect(); + assert!( + self_edges.is_empty(), + "self-referencing base should not create self-edge" + ); + } + + #[test] + fn test_ir_extends_missing_base_no_panic() { + let mut file = make_ir_file("src/app.ts", Language::TypeScript); + file.type_defs.push(IrTypeDef { + name: "App".to_string(), + kind: TypeDefKind::Class, + span: empty_span(), + bases: vec!["NonExistent".to_string()], + is_exported: false, + decorators: vec![], + }); + + let graph = SymbolGraph::build_from_ir(&[file]); + + assert!(graph.get_node("src/app.ts::App").is_some()); + assert_eq!( + count_edges_of_type(&graph, &EdgeType::Extends), + 0, + "missing base should not create extends edge" + ); + } + + #[test] + fn test_ir_extends_empty_bases() { + let mut file = make_ir_file("src/app.ts", Language::TypeScript); + file.type_defs.push(IrTypeDef { + name: "PlainClass".to_string(), + kind: TypeDefKind::Class, + span: empty_span(), + bases: vec![], + is_exported: false, + decorators: vec![], + }); + + let graph = SymbolGraph::build_from_ir(&[file]); + assert_eq!(count_edges_of_type(&graph, &EdgeType::Extends), 0); + } + + #[test] + fn test_ir_extends_cross_file_chain() { + let mut file_a = make_ir_file("src/a.ts", Language::TypeScript); + file_a.type_defs.push(IrTypeDef { + name: "GrandParent".to_string(), + kind: TypeDefKind::Class, + span: empty_span(), + bases: vec![], + is_exported: true, + decorators: vec![], + }); + + let mut file_b = make_ir_file("src/b.ts", Language::TypeScript); + file_b.imports.push(IrImport { + source: "./a".to_string(), + specifiers: vec![IrImportSpecifier::Named { + name: "GrandParent".to_string(), + alias: None, + }], + span: empty_span(), + }); + file_b.type_defs.push(IrTypeDef { + name: "Parent".to_string(), + kind: TypeDefKind::Class, + span: empty_span(), + bases: vec!["GrandParent".to_string()], + is_exported: true, + decorators: vec![], + }); + + let mut file_c = make_ir_file("src/c.ts", Language::TypeScript); + file_c.imports.push(IrImport { + source: "./b".to_string(), + specifiers: vec![IrImportSpecifier::Named { + name: "Parent".to_string(), + alias: None, + }], + span: empty_span(), + }); + file_c.type_defs.push(IrTypeDef { + name: "Child".to_string(), + kind: TypeDefKind::Class, + span: empty_span(), + bases: vec!["Parent".to_string()], + is_exported: false, + decorators: vec![], + }); + + let graph = SymbolGraph::build_from_ir(&[file_a, file_b, file_c]); + + assert!(has_edge( + &graph, + "src/b.ts::Parent", + "src/a.ts::GrandParent", + &EdgeType::Extends + )); + assert!(has_edge( + &graph, + "src/c.ts::Child", + "src/b.ts::Parent", + &EdgeType::Extends + )); + } + } + + // ======================================================================= + // IR-specific node type tests + // ======================================================================= + + mod ir_node_type_tests { + use super::*; + use crate::ast::Language; + use crate::ir::FunctionKind; + use crate::ir::{ + IrConstant, IrFile, IrFunctionDef, IrImport, IrImportSpecifier, IrTypeDef, Span, + TypeDefKind, + }; + + fn empty_span() -> Span { + Span::new(1, 1) + } + + fn make_ir_file(path: &str) -> IrFile { + IrFile { + path: path.to_string(), + language: Language::TypeScript, + functions: vec![], + type_defs: vec![], + constants: vec![], + imports: vec![], + exports: vec![], + call_expressions: vec![], + assignments: vec![], + } + } + + #[test] + fn test_ir_class_node_kind() { + let mut file = make_ir_file("src/app.ts"); + file.type_defs.push(IrTypeDef { + name: "AppServer".to_string(), + kind: TypeDefKind::Class, + span: empty_span(), + bases: vec![], + is_exported: false, + decorators: vec![], + }); + + let graph = SymbolGraph::build_from_ir(&[file]); + let sym = graph.get_symbol("src/app.ts::AppServer").unwrap(); + assert_eq!(sym.kind, SymbolKind::Class); + } + + #[test] + fn test_ir_struct_node_kind() { + let mut file = make_ir_file("src/data.ts"); + file.type_defs.push(IrTypeDef { + name: "Point".to_string(), + kind: TypeDefKind::Struct, + span: empty_span(), + bases: vec![], + is_exported: false, + decorators: vec![], + }); + + let graph = SymbolGraph::build_from_ir(&[file]); + let sym = graph.get_symbol("src/data.ts::Point").unwrap(); + assert_eq!(sym.kind, SymbolKind::Struct); + } + + #[test] + fn test_ir_interface_node_kind() { + let mut file = make_ir_file("src/types.ts"); + file.type_defs.push(IrTypeDef { + name: "Serializable".to_string(), + kind: TypeDefKind::Interface, + span: empty_span(), + bases: vec![], + is_exported: false, + decorators: vec![], + }); + + let graph = SymbolGraph::build_from_ir(&[file]); + let sym = graph.get_symbol("src/types.ts::Serializable").unwrap(); + assert_eq!(sym.kind, SymbolKind::Interface); + } + + #[test] + fn test_ir_type_alias_node_kind() { + let mut file = make_ir_file("src/types.ts"); + file.type_defs.push(IrTypeDef { + name: "UserId".to_string(), + kind: TypeDefKind::TypeAlias, + span: empty_span(), + bases: vec![], + is_exported: false, + decorators: vec![], + }); + + let graph = SymbolGraph::build_from_ir(&[file]); + let sym = graph.get_symbol("src/types.ts::UserId").unwrap(); + assert_eq!(sym.kind, SymbolKind::TypeAlias); + } + + #[test] + fn test_ir_enum_node_kind() { + let mut file = make_ir_file("src/status.ts"); + file.type_defs.push(IrTypeDef { + name: "Status".to_string(), + kind: TypeDefKind::Enum, + span: empty_span(), + bases: vec![], + is_exported: false, + decorators: vec![], + }); + + let graph = SymbolGraph::build_from_ir(&[file]); + let sym = graph.get_symbol("src/status.ts::Status").unwrap(); + assert_eq!(sym.kind, SymbolKind::Class); + } + + #[test] + fn test_ir_constant_node() { + let mut file = make_ir_file("src/config.ts"); + file.constants.push(IrConstant { + name: "MAX_RETRIES".to_string(), + span: empty_span(), + is_exported: true, + }); + + let graph = SymbolGraph::build_from_ir(&[file]); + let sym = graph.get_symbol("src/config.ts::MAX_RETRIES").unwrap(); + assert_eq!(sym.kind, SymbolKind::Constant); + assert_eq!(sym.file, "src/config.ts"); + } + + #[test] + fn test_ir_function_node() { + let mut file = make_ir_file("src/utils.ts"); + file.functions.push(IrFunctionDef { + name: "helper".to_string(), + kind: FunctionKind::Function, + span: empty_span(), + parameters: vec![], + is_async: false, + is_exported: false, + decorators: vec![], + }); + + let graph = SymbolGraph::build_from_ir(&[file]); + let sym = graph.get_symbol("src/utils.ts::helper").unwrap(); + assert_eq!(sym.kind, SymbolKind::Function); + } + + #[test] + fn test_ir_mixed_definitions() { + let mut file = make_ir_file("src/app.ts"); + file.functions.push(IrFunctionDef { + name: "start".to_string(), + kind: FunctionKind::Function, + span: empty_span(), + parameters: vec![], + is_async: false, + is_exported: true, + decorators: vec![], + }); + file.type_defs.push(IrTypeDef { + name: "Config".to_string(), + kind: TypeDefKind::Interface, + span: empty_span(), + bases: vec![], + is_exported: true, + decorators: vec![], + }); + file.constants.push(IrConstant { + name: "VERSION".to_string(), + span: empty_span(), + is_exported: true, + }); + + let graph = SymbolGraph::build_from_ir(&[file]); + + assert_eq!(graph.node_count(), 4); + assert!(graph.get_node("src/app.ts").is_some()); + assert!(graph.get_node("src/app.ts::start").is_some()); + assert!(graph.get_node("src/app.ts::Config").is_some()); + assert!(graph.get_node("src/app.ts::VERSION").is_some()); + } + + #[test] + fn test_ir_duplicate_definition_name_across_files() { + let mut file_a = make_ir_file("src/a.ts"); + file_a.functions.push(IrFunctionDef { + name: "validate".to_string(), + kind: FunctionKind::Function, + span: empty_span(), + parameters: vec![], + is_async: false, + is_exported: true, + decorators: vec![], + }); + + let mut file_b = make_ir_file("src/b.ts"); + file_b.functions.push(IrFunctionDef { + name: "validate".to_string(), + kind: FunctionKind::Function, + span: empty_span(), + parameters: vec![], + is_async: false, + is_exported: true, + decorators: vec![], + }); + + let graph = SymbolGraph::build_from_ir(&[file_a, file_b]); + + assert!(graph.get_node("src/a.ts::validate").is_some()); + assert!(graph.get_node("src/b.ts::validate").is_some()); + assert_eq!(graph.node_count(), 4); + } + + #[test] + fn test_ir_duplicate_name_within_file_skipped() { + let mut file = make_ir_file("src/lib.ts"); + file.functions.push(IrFunctionDef { + name: "config".to_string(), + kind: FunctionKind::Function, + span: empty_span(), + parameters: vec![], + is_async: false, + is_exported: false, + decorators: vec![], + }); + file.constants.push(IrConstant { + name: "config".to_string(), + span: empty_span(), + is_exported: false, + }); + + let graph = SymbolGraph::build_from_ir(&[file]); + assert_eq!(graph.node_count(), 2); + let sym = graph.get_symbol("src/lib.ts::config").unwrap(); + assert_eq!(sym.kind, SymbolKind::Function); + } + + #[test] + fn test_ir_call_edges_with_containing_function() { + use crate::ir::IrCallExpression; + + let mut utils = make_ir_file("src/utils.ts"); + utils.functions.push(IrFunctionDef { + name: "validate".to_string(), + kind: FunctionKind::Function, + span: empty_span(), + parameters: vec![], + is_async: false, + is_exported: true, + decorators: vec![], + }); + + let mut handler = make_ir_file("src/handler.ts"); + handler.functions.push(IrFunctionDef { + name: "process".to_string(), + kind: FunctionKind::Function, + span: empty_span(), + parameters: vec![], + is_async: false, + is_exported: false, + decorators: vec![], + }); + handler.imports.push(IrImport { + source: "./utils".to_string(), + specifiers: vec![IrImportSpecifier::Named { + name: "validate".to_string(), + alias: None, + }], + span: empty_span(), + }); + handler.call_expressions.push(IrCallExpression { + callee: "validate".to_string(), + arguments: vec!["data".to_string()], + span: empty_span(), + containing_function: Some("process".to_string()), + }); + + let graph = SymbolGraph::build_from_ir(&[utils, handler]); + + assert!(has_edge( + &graph, + "src/handler.ts::process", + "src/utils.ts::validate", + &EdgeType::Calls + )); + } + + #[test] + fn test_ir_module_level_call() { + use crate::ir::IrCallExpression; + + let mut utils = make_ir_file("src/utils.ts"); + utils.functions.push(IrFunctionDef { + name: "init".to_string(), + kind: FunctionKind::Function, + span: empty_span(), + parameters: vec![], + is_async: false, + is_exported: true, + decorators: vec![], + }); + + let mut main_file = make_ir_file("src/main.ts"); + main_file.imports.push(IrImport { + source: "./utils".to_string(), + specifiers: vec![IrImportSpecifier::Named { + name: "init".to_string(), + alias: None, + }], + span: empty_span(), + }); + main_file.call_expressions.push(IrCallExpression { + callee: "init".to_string(), + arguments: vec![], + span: empty_span(), + containing_function: None, + }); + + let graph = SymbolGraph::build_from_ir(&[utils, main_file]); + + assert!(has_edge( + &graph, + "src/main.ts", + "src/utils.ts::init", + &EdgeType::Calls + )); + } + } + + // ======================================================================= + // Edge case tests + // ======================================================================= + + mod edge_case_tests { + use super::*; + use crate::ast::Language; + use crate::ir::{IrFile, Span}; + + fn make_empty_ir(path: &str) -> IrFile { + IrFile { + path: path.to_string(), + language: Language::TypeScript, + functions: vec![], + type_defs: vec![], + constants: vec![], + imports: vec![], + exports: vec![], + call_expressions: vec![], + assignments: vec![], + } + } + + #[test] + fn test_unicode_file_path() { + let file = make_empty_ir("src/日本語/コンポーネント.ts"); + let graph = SymbolGraph::build_from_ir(&[file]); + assert!(graph.get_node("src/日本語/コンポーネント.ts").is_some()); + let sym = graph.get_symbol("src/日本語/コンポーネント.ts").unwrap(); + assert_eq!(sym.name, "コンポーネント"); + } + + #[test] + fn test_unicode_symbol_name() { + use crate::ir::{FunctionKind, IrFunctionDef}; + let mut file = make_empty_ir("src/utils.ts"); + file.functions.push(IrFunctionDef { + name: "überprüfen".to_string(), + kind: FunctionKind::Function, + span: Span::new(1, 1), + parameters: vec![], + is_async: false, + is_exported: false, + decorators: vec![], + }); + let graph = SymbolGraph::build_from_ir(&[file]); + assert!(graph.get_node("src/utils.ts::überprüfen").is_some()); + } + + #[test] + fn test_deeply_nested_path() { + let path = "src/a/b/c/d/e/f/g/h/i/j/deep.ts"; + let file = make_empty_ir(path); + let graph = SymbolGraph::build_from_ir(&[file]); + assert!(graph.get_node(path).is_some()); + let sym = graph.get_symbol(path).unwrap(); + assert_eq!(sym.name, "deep"); + } + + #[test] + fn test_file_only_imports_no_definitions() { + use crate::ir::{IrImport, IrImportSpecifier}; + let mut file = make_empty_ir("src/init.ts"); + file.imports.push(IrImport { + source: "./polyfill".to_string(), + specifiers: vec![IrImportSpecifier::SideEffect], + span: Span::new(1, 1), + }); + let graph = SymbolGraph::build_from_ir(&[file]); + assert_eq!(graph.node_count(), 1); + } + + #[test] + fn test_many_files_scale() { + use crate::ir::{FunctionKind, IrFunctionDef}; + let files: Vec = (0..50) + .map(|i| { + let mut f = make_empty_ir(&format!("src/file_{}.ts", i)); + for j in 0..5 { + f.functions.push(IrFunctionDef { + name: format!("func_{}", j), + kind: FunctionKind::Function, + span: Span::new(1, 1), + parameters: vec![], + is_async: false, + is_exported: true, + decorators: vec![], + }); + } + f + }) + .collect(); + + let graph = SymbolGraph::build_from_ir(&files); + assert_eq!(graph.node_count(), 300); + } + + #[test] + fn test_edges_on_empty_graph() { + let graph = SymbolGraph::build_from_ir(&[]); + assert!(graph.edges().is_empty()); + assert!(graph.node_ids().is_empty()); + } + + #[test] + fn test_node_ids_contains_all() { + use crate::ir::{FunctionKind, IrFunctionDef}; + let mut file = make_empty_ir("src/lib.ts"); + file.functions.push(IrFunctionDef { + name: "alpha".to_string(), + kind: FunctionKind::Function, + span: Span::new(1, 1), + parameters: vec![], + is_async: false, + is_exported: false, + decorators: vec![], + }); + + let graph = SymbolGraph::build_from_ir(&[file]); + let ids = graph.node_ids(); + assert!(ids.contains(&"src/lib.ts")); + assert!(ids.contains(&"src/lib.ts::alpha")); + assert_eq!(ids.len(), 2); + } + + #[test] + fn test_get_symbol_returns_none_for_missing() { + let graph = SymbolGraph::build_from_ir(&[make_empty_ir("src/a.ts")]); + assert!(graph.get_symbol("nonexistent").is_none()); + assert!(graph.get_symbol("src/a.ts::nonexistent").is_none()); + } + + #[test] + fn test_add_edge_directly() { + let files = vec![make_empty_ir("src/x.ts"), make_empty_ir("src/y.ts")]; + let mut graph = SymbolGraph::build_from_ir(&files); + let x_idx = graph.get_node("src/x.ts").unwrap(); + let y_idx = graph.get_node("src/y.ts").unwrap(); + + graph.add_edge( + x_idx, + y_idx, + GraphEdge { + edge_type: EdgeType::Calls, + }, + ); + assert_eq!(graph.edge_count(), 1); + assert!(has_edge(&graph, "src/x.ts", "src/y.ts", &EdgeType::Calls)); + } + + #[test] + fn test_from_serializable_invalid_edge_endpoint() { + let sg = SerializableGraph { + nodes: vec![SymbolNode { + id: "a.ts".to_string(), + name: "a".to_string(), + file: "a.ts".to_string(), + kind: SymbolKind::Module, + }], + edges: vec![SerializableEdge { + from: "a.ts".to_string(), + to: "nonexistent.ts".to_string(), + edge_type: EdgeType::Imports, + }], + }; + + let graph = SymbolGraph::from_serializable(&sg); + assert_eq!(graph.node_count(), 1); + assert_eq!( + graph.edge_count(), + 0, + "edge with invalid endpoint should be skipped" + ); + } + + #[test] + fn test_from_serializable_both_endpoints_invalid() { + let sg = SerializableGraph { + nodes: vec![], + edges: vec![SerializableEdge { + from: "x.ts".to_string(), + to: "y.ts".to_string(), + edge_type: EdgeType::Calls, + }], + }; + + let graph = SymbolGraph::from_serializable(&sg); + assert_eq!(graph.node_count(), 0); + assert_eq!(graph.edge_count(), 0); + } + + #[test] + fn test_serializable_preserves_all_edge_types() { + let nodes = vec![ + SymbolNode { + id: "a.ts".to_string(), + name: "a".to_string(), + file: "a.ts".to_string(), + kind: SymbolKind::Module, + }, + SymbolNode { + id: "b.ts".to_string(), + name: "b".to_string(), + file: "b.ts".to_string(), + kind: SymbolKind::Module, + }, + ]; + let edge_types = vec![ + EdgeType::Imports, + EdgeType::Calls, + EdgeType::Extends, + EdgeType::Instantiates, + EdgeType::Reads, + EdgeType::Writes, + EdgeType::Emits, + EdgeType::Handles, + ]; + let edges: Vec = edge_types + .iter() + .map(|et| SerializableEdge { + from: "a.ts".to_string(), + to: "b.ts".to_string(), + edge_type: et.clone(), + }) + .collect(); + let sg = SerializableGraph { nodes, edges }; + + let json = serde_json::to_string(&sg).unwrap(); + let restored: SerializableGraph = serde_json::from_str(&json).unwrap(); + assert_eq!(sg, restored); + assert_eq!(restored.edges.len(), 8); + } + + #[test] + fn test_same_name_different_directories() { + use crate::ir::{FunctionKind, IrFunctionDef}; + let mut file_a = make_empty_ir("src/auth/utils.ts"); + file_a.functions.push(IrFunctionDef { + name: "validate".to_string(), + kind: FunctionKind::Function, + span: Span::new(1, 1), + parameters: vec![], + is_async: false, + is_exported: true, + decorators: vec![], + }); + + let mut file_b = make_empty_ir("src/data/utils.ts"); + file_b.functions.push(IrFunctionDef { + name: "validate".to_string(), + kind: FunctionKind::Function, + span: Span::new(1, 1), + parameters: vec![], + is_async: false, + is_exported: true, + decorators: vec![], + }); + + let graph = SymbolGraph::build_from_ir(&[file_a, file_b]); + assert!(graph.get_node("src/auth/utils.ts::validate").is_some()); + assert!(graph.get_node("src/data/utils.ts::validate").is_some()); + assert_eq!(graph.node_count(), 4); + } + + #[test] + fn test_multiple_importers_of_same_symbol() { + let graph = build_graph_from_sources(&[ + ( + "src/shared.ts", + r#" +export function log(msg: string) {} +"#, + ), + ( + "src/a.ts", + r#" +import { log } from './shared'; +function doA() { log("a"); } +"#, + ), + ( + "src/b.ts", + r#" +import { log } from './shared'; +function doB() { log("b"); } +"#, + ), + ]); + + assert!(has_edge( + &graph, + "src/a.ts", + "src/shared.ts::log", + &EdgeType::Imports + )); + assert!(has_edge( + &graph, + "src/b.ts", + "src/shared.ts::log", + &EdgeType::Imports + )); + assert!(has_edge( + &graph, + "src/a.ts::doA", + "src/shared.ts::log", + &EdgeType::Calls + )); + assert!(has_edge( + &graph, + "src/b.ts::doB", + "src/shared.ts::log", + &EdgeType::Calls + )); + } + } + + // ======================================================================= + // Additional property-based tests + // ======================================================================= + + mod extended_proptests { + use super::*; + use crate::ast::Language; + use crate::ir::{ + FunctionKind, IrConstant, IrFile, IrFunctionDef, IrTypeDef, Span, TypeDefKind, + }; + use proptest::prelude::*; + + fn symbol_kind_strategy() -> impl Strategy { + prop_oneof![ + Just(SymbolKind::Function), + Just(SymbolKind::Class), + Just(SymbolKind::Interface), + Just(SymbolKind::TypeAlias), + Just(SymbolKind::Constant), + Just(SymbolKind::Module), + Just(SymbolKind::Struct), + ] + } + + fn edge_type_strategy() -> impl Strategy { + prop_oneof![ + Just(EdgeType::Imports), + Just(EdgeType::Calls), + Just(EdgeType::Extends), + Just(EdgeType::Instantiates), + Just(EdgeType::Reads), + Just(EdgeType::Writes), + Just(EdgeType::Emits), + Just(EdgeType::Handles), + ] + } + + fn ir_file_strategy() -> impl Strategy { + ( + "[a-z]{1,6}".prop_map(|s| format!("src/{}.ts", s)), + prop::collection::vec("[a-z][a-zA-Z0-9]{0,10}", 0..8), + prop::collection::vec("[A-Z][a-zA-Z0-9]{0,10}", 0..4), + prop::collection::vec("[A-Z_][A-Z_0-9]{0,10}", 0..3), + ) + .prop_map(|(path, func_names, type_names, const_names)| { + let functions: Vec = func_names + .into_iter() + .map(|name| IrFunctionDef { + name, + kind: FunctionKind::Function, + span: Span::new(1, 1), + parameters: vec![], + is_async: false, + is_exported: false, + decorators: vec![], + }) + .collect(); + let type_defs: Vec = type_names + .into_iter() + .map(|name| IrTypeDef { + name, + kind: TypeDefKind::Class, + span: Span::new(1, 1), + bases: vec![], + is_exported: false, + decorators: vec![], + }) + .collect(); + let constants: Vec = const_names + .into_iter() + .map(|name| IrConstant { + name, + span: Span::new(1, 1), + is_exported: false, + }) + .collect(); + IrFile { + path, + language: Language::TypeScript, + functions, + type_defs, + constants, + imports: vec![], + exports: vec![], + call_expressions: vec![], + assignments: vec![], + } + }) + } + + proptest! { + #[test] + fn prop_all_edges_reference_valid_nodes( + files in prop::collection::vec(ir_file_strategy(), 1..6) + ) { + let graph = SymbolGraph::build_from_ir(&files); + let all_ids: std::collections::HashSet<&str> = + graph.node_ids().into_iter().collect(); + + for (from, to, _) in graph.edges() { + prop_assert!( + all_ids.contains(from), + "edge source {} not in graph nodes", from + ); + prop_assert!( + all_ids.contains(to), + "edge target {} not in graph nodes", to + ); + } + } + + #[test] + fn prop_module_node_id_equals_file_path( + files in prop::collection::vec(ir_file_strategy(), 1..6) + ) { + let graph = SymbolGraph::build_from_ir(&files); + + for file in &files { + if let Some(sym) = graph.get_symbol(&file.path) { + prop_assert_eq!(&sym.id, &file.path); + prop_assert_eq!(&sym.file, &file.path); + prop_assert_eq!(sym.kind, SymbolKind::Module); + } + } + } + + #[test] + fn prop_serializable_roundtrip_preserves_edge_types( + edge_type in edge_type_strategy() + ) { + let sg = SerializableGraph { + nodes: vec![ + SymbolNode { + id: "a.ts".to_string(), + name: "a".to_string(), + file: "a.ts".to_string(), + kind: SymbolKind::Module, + }, + SymbolNode { + id: "b.ts".to_string(), + name: "b".to_string(), + file: "b.ts".to_string(), + kind: SymbolKind::Module, + }, + ], + edges: vec![SerializableEdge { + from: "a.ts".to_string(), + to: "b.ts".to_string(), + edge_type: edge_type.clone(), + }], + }; + + let graph = SymbolGraph::from_serializable(&sg); + let restored = graph.to_serializable(); + prop_assert_eq!(restored.edges.len(), 1); + prop_assert_eq!(&restored.edges[0].edge_type, &edge_type); + } + + #[test] + fn prop_serializable_roundtrip_preserves_symbol_kinds( + kind in symbol_kind_strategy() + ) { + let sg = SerializableGraph { + nodes: vec![SymbolNode { + id: "test::sym".to_string(), + name: "sym".to_string(), + file: "test".to_string(), + kind: kind.clone(), + }], + edges: vec![], + }; + + let graph = SymbolGraph::from_serializable(&sg); + let restored = graph.to_serializable(); + prop_assert_eq!(restored.nodes.len(), 1); + prop_assert_eq!(&restored.nodes[0].kind, &kind); + } + + #[test] + fn prop_node_count_equals_unique_defs_plus_modules( + files in prop::collection::vec(ir_file_strategy(), 1..6) + ) { + // Deduplicate files by path to avoid the edge case where + // duplicate paths create phantom graph nodes (the graph + // unconditionally adds module nodes without checking for + // duplicates — a known characteristic of the current impl). + let mut seen_paths = std::collections::HashSet::new(); + let unique_files: Vec<&IrFile> = files + .iter() + .filter(|f| seen_paths.insert(f.path.clone())) + .collect(); + + let graph = SymbolGraph::build_from_ir( + &unique_files.iter().cloned().cloned().collect::>(), + ); + + let mut expected_ids = std::collections::HashSet::new(); + for file in &unique_files { + expected_ids.insert(file.path.clone()); + for func in &file.functions { + expected_ids.insert(format!("{}::{}", file.path, func.name)); + } + for td in &file.type_defs { + expected_ids.insert(format!("{}::{}", file.path, td.name)); + } + for c in &file.constants { + expected_ids.insert(format!("{}::{}", file.path, c.name)); + } + } + + prop_assert_eq!( + graph.node_count(), + expected_ids.len(), + "node count should equal unique definition ids" + ); + } + + #[test] + fn prop_graph_error_display(msg in "[a-zA-Z0-9 ]{1,50}") { + let err = GraphError::SerializationError(msg.clone()); + let display = format!("{}", err); + prop_assert!(display.contains(&msg)); + } + + #[test] + fn prop_normalize_path_no_panic(path in "[a-z./]{0,30}") { + let _ = normalize_path(&path); + } + + #[test] + fn prop_normalize_python_import_no_panic(input in "[a-z.]{0,20}") { + let _ = normalize_python_import(&input); + } + + #[test] + fn prop_file_stem_no_panic(path in "[a-zA-Z0-9/._-]{0,30}") { + let _ = file_stem(&path); + } + + #[test] + fn prop_resolve_import_never_resolves_absolute( + source in "[a-z]{1,10}", + importer in "[a-z/]{1,15}\\.ts" + ) { + let known = vec!["anything.ts"]; + let result = resolve_import_path(&source, &importer, &known); + prop_assert!(result.is_none(), + "absolute import '{}' should not resolve", source); + } + } + } + + // ======================================================================= + // Workspace graph integration tests + // ======================================================================= + + mod workspace_graph_tests { + use super::*; + use crate::ast; + + #[test] + fn test_workspace_cross_package_import_edges() { + // Simulate a monorepo: shared-types exports User, backend imports it. + let files = vec![ + ( + "packages/shared-types/src/index.ts", + r#" +export interface User { id: string; name: string; } +export function validateUser(user: User): boolean { return true; } +"#, + ), + ( + "packages/backend/src/routes/users.ts", + r#" +import { User, validateUser } from "@monorepo/shared-types"; +export function handleRequest(user: User) { return validateUser(user); } +"#, + ), + ]; + + let parsed: Vec = files + .iter() + .map(|(path, source)| ast::parse_file(path, source).unwrap()) + .collect(); + + // Without workspace map: no cross-package edges. + let graph_no_ws = SymbolGraph::build(&parsed); + let edges_no_ws = graph_no_ws.edges(); + let cross_pkg_edges: Vec<_> = edges_no_ws + .iter() + .filter(|(f, t, _)| f.contains("backend") && t.contains("shared-types")) + .collect(); + assert!( + cross_pkg_edges.is_empty(), + "without workspace map, no cross-package edges should exist" + ); + + // With workspace map: cross-package edges appear. + let mut ws = WorkspaceMap::new(); + ws.insert( + "@monorepo/shared-types".to_string(), + "packages/shared-types/src/index.ts".to_string(), + ); + let graph_ws = SymbolGraph::build_with_workspace(&parsed, &ws); + let edges_ws = graph_ws.edges(); + let cross_pkg_edges: Vec<_> = edges_ws + .iter() + .filter(|(f, t, _)| f.contains("backend") && t.contains("shared-types")) + .collect(); + assert!( + cross_pkg_edges.len() >= 2, + "with workspace map, cross-package import edges should exist, got: {:?}", + cross_pkg_edges + ); + + // Verify specific edges. + assert!( + cross_pkg_edges + .iter() + .any(|(_, t, et)| { t.contains("validateUser") && **et == EdgeType::Imports }), + "should have import edge to validateUser" + ); + } + + #[test] + fn test_workspace_cross_package_call_edges() { + let files = vec![ + ( + "packages/utils/src/index.ts", + r#" +export function formatName(name: string): string { return name.trim(); } +"#, + ), + ( + "packages/app/src/handler.ts", + r#" +import { formatName } from "@my/utils"; +export function handle(name: string) { return formatName(name); } +"#, + ), + ]; + + let parsed: Vec = files + .iter() + .map(|(path, source)| ast::parse_file(path, source).unwrap()) + .collect(); + + let mut ws = WorkspaceMap::new(); + ws.insert( + "@my/utils".to_string(), + "packages/utils/src/index.ts".to_string(), + ); + let graph = SymbolGraph::build_with_workspace(&parsed, &ws); + let edges = graph.edges(); + + // Should have both import and call edges. + let import_edge = edges.iter().any(|(f, t, et)| { + f.contains("handler") && t.contains("formatName") && **et == EdgeType::Imports + }); + let call_edge = edges.iter().any(|(f, t, et)| { + f.contains("handler") && t.contains("formatName") && **et == EdgeType::Calls + }); + assert!(import_edge, "should have import edge to formatName"); + assert!(call_edge, "should have call edge to formatName"); + } + + #[test] + fn test_workspace_empty_map_same_as_build() { + let files = vec![ + ("src/handler.ts", r#"import { foo } from './utils'; foo();"#), + ("src/utils.ts", r#"export function foo() {}"#), + ]; + + let parsed: Vec = files + .iter() + .map(|(path, source)| ast::parse_file(path, source).unwrap()) + .collect(); + + let g1 = SymbolGraph::build(&parsed); + let g2 = SymbolGraph::build_with_workspace(&parsed, &WorkspaceMap::new()); + assert_eq!(g1.node_count(), g2.node_count()); + assert_eq!(g1.edge_count(), g2.edge_count()); + } + } From 182c4ca8ba6531d49c31a52d3a8f26dbc6e9778a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Apr 2026 17:42:20 +0000 Subject: [PATCH 06/15] Split e2e_pipeline.rs (3804 lines) into 6 focused test files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Break up the monolithic e2e_pipeline.rs by language group so every file stays under 3000 lines and is easier to navigate: - e2e_pipeline.rs (779 lines) — core TS/JS/Python/metadata tests - e2e_go_rust.rs (500 lines) — Go and Rust language tests - e2e_jvm.rs (762 lines) — Java, Kotlin, and Scala tests - e2e_csharp_php_ruby.rs(818 lines) — C#, PHP, and Ruby tests - e2e_systems.rs (536 lines) — Swift and C/C++ tests - e2e_nextjs_large.rs (496 lines) — Next.js, large diffs, staged, config and the minimal use imports needed for its tests. All six files compile cleanly with `cargo test --test --no-run`. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: mikenrafter <88250914+mikenrafter@users.noreply.github.com> --- .../tests/e2e_csharp_php_ruby.rs | 818 +++++ crates/diffcore-core/tests/e2e_go_rust.rs | 500 +++ crates/diffcore-core/tests/e2e_jvm.rs | 762 +++++ .../diffcore-core/tests/e2e_nextjs_large.rs | 496 +++ crates/diffcore-core/tests/e2e_pipeline.rs | 3025 ----------------- crates/diffcore-core/tests/e2e_systems.rs | 536 +++ 6 files changed, 3112 insertions(+), 3025 deletions(-) create mode 100644 crates/diffcore-core/tests/e2e_csharp_php_ruby.rs create mode 100644 crates/diffcore-core/tests/e2e_go_rust.rs create mode 100644 crates/diffcore-core/tests/e2e_jvm.rs create mode 100644 crates/diffcore-core/tests/e2e_nextjs_large.rs create mode 100644 crates/diffcore-core/tests/e2e_systems.rs diff --git a/crates/diffcore-core/tests/e2e_csharp_php_ruby.rs b/crates/diffcore-core/tests/e2e_csharp_php_ruby.rs new file mode 100644 index 0000000..9c8c97f --- /dev/null +++ b/crates/diffcore-core/tests/e2e_csharp_php_ruby.rs @@ -0,0 +1,818 @@ +#![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::print_stdout, + clippy::print_stderr +)] +//! E2E integration tests for C#, PHP, and Ruby language support. + +mod helpers; + +use helpers::graph_assertions::{ + assert_all_files_accounted, assert_json_roundtrip, assert_language_detected, + assert_valid_json_schema, assert_valid_mermaid, assert_valid_scores, +}; +use helpers::repo_builder::{run_pipeline, RepoBuilder}; + +// --------------------------------------------------------------------------- +// C# integration tests (Phase 11.2) +// --------------------------------------------------------------------------- + +/// Test: C# ASP.NET Core Web API with controller → service → repository pattern. +/// +/// Verifies full pipeline: language detection, file accounting, flow groups, +/// entrypoint detection, framework detection, and Mermaid graph. +#[test] +fn test_e2e_csharp_aspnet_core_api() { + let rb = RepoBuilder::new(); + + // Initial commit + rb.write_file( + "MyApp.csproj", + r#" + + net8.0 + + + + + +"#, + ); + rb.commit("Initial commit: csproj"); + rb.create_branch("main"); + + // Feature branch: add ASP.NET Core API + rb.create_branch("feature/csharp-api"); + rb.checkout("feature/csharp-api"); + + rb.write_file( + "Program.cs", + r#" +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; + +var builder = WebApplication.CreateBuilder(args); +builder.Services.AddControllers(); +var app = builder.Build(); +app.MapControllers(); +app.Run(); +"#, + ); + + rb.write_file( + "Controllers/UsersController.cs", + r#" +using System.Collections.Generic; +using Microsoft.AspNetCore.Mvc; +using MyApp.Models; +using MyApp.Services; + +namespace MyApp.Controllers +{ + [ApiController] + [Route("api/[controller]")] + public class UsersController : ControllerBase + { + private readonly IUserService _userService; + + public UsersController(IUserService userService) + { + _userService = userService; + } + + [HttpGet] + public ActionResult> GetUsers() + { + return Ok(_userService.FindAll()); + } + + [HttpPost] + public ActionResult CreateUser(User user) + { + return Ok(_userService.Save(user)); + } + } +} +"#, + ); + + rb.write_file( + "Services/UserService.cs", + r#" +using System.Collections.Generic; +using MyApp.Models; +using MyApp.Repositories; + +namespace MyApp.Services +{ + public interface IUserService + { + List FindAll(); + User Save(User user); + } + + public class UserService : IUserService + { + private readonly IUserRepository _repository; + + public UserService(IUserRepository repository) + { + _repository = repository; + } + + public List FindAll() + { + return _repository.FindAll(); + } + + public User Save(User user) + { + return _repository.Save(user); + } + } +} +"#, + ); + + rb.write_file( + "Repositories/UserRepository.cs", + r#" +using System.Collections.Generic; +using MyApp.Models; + +namespace MyApp.Repositories +{ + public interface IUserRepository + { + List FindAll(); + User Save(User user); + } + + public class UserRepository : IUserRepository + { + private readonly List _users = new List(); + + public List FindAll() + { + return _users; + } + + public User Save(User user) + { + _users.Add(user); + return user; + } + } +} +"#, + ); + + rb.write_file( + "Models/User.cs", + r#" +namespace MyApp.Models +{ + public record User(int Id, string Name, string Email); +} +"#, + ); + + rb.commit("Add ASP.NET Core Web API with controller-service-repo"); + + let result = run_pipeline(rb.path(), "main", "feature/csharp-api"); + + // Verify basic output shape + assert_valid_json_schema(&result); + assert_valid_scores(&result); + + // Verify C# files were detected + assert_language_detected(&result, "csharp"); + + // Verify all changed files are accounted for + assert_all_files_accounted(&result); + + // Verify there are flow groups + assert!( + !result.groups.is_empty(), + "should produce at least one flow group" + ); + + // Verify entrypoint detection (Main or HTTP routes) + let has_entrypoint = result.groups.iter().any(|g| g.entrypoint.is_some()); + assert!(has_entrypoint, "should detect at least one entrypoint"); + + // Verify ASP.NET Core framework detection + let has_aspnet = result + .summary + .frameworks_detected + .iter() + .any(|f| f.contains("ASP.NET")); + assert!( + has_aspnet, + "should detect ASP.NET Core framework; detected: {:?}", + result.summary.frameworks_detected + ); + + // Verify Mermaid graph is valid + assert_valid_mermaid(&result); +} + +/// Test: C# test file detection. +/// +/// Verifies that *Test.cs and *Tests.cs files are detected as test entrypoints. +#[test] +fn test_e2e_csharp_test_file_detection() { + let rb = RepoBuilder::new(); + + rb.write_file( + "MyApp.csproj", + r#" + + net8.0 + + +"#, + ); + rb.commit("Initial commit"); + rb.create_branch("main"); + + rb.create_branch("feature/tests"); + rb.checkout("feature/tests"); + + rb.write_file( + "Services/UserService.cs", + r#" +namespace MyApp.Services +{ + public class UserService + { + public string GetGreeting(string name) + { + return $"Hello, {name}!"; + } + } +} +"#, + ); + + rb.write_file( + "Tests/UserServiceTests.cs", + r#" +using Xunit; +using MyApp.Services; + +namespace MyApp.Tests +{ + public class UserServiceTests + { + [Fact] + public void GetGreeting_ReturnsExpected() + { + var svc = new UserService(); + var result = svc.GetGreeting("World"); + Assert.Equal("Hello, World!", result); + } + } +} +"#, + ); + + rb.commit("Add UserService and tests"); + + let result = run_pipeline(rb.path(), "main", "feature/tests"); + + // Verify test file detection + let test_eps: Vec<_> = result + .groups + .iter() + .flat_map(|g| g.entrypoint.as_ref()) + .filter(|ep| ep.entrypoint_type == diffcore_core::types::EntrypointType::TestFile) + .collect(); + assert!( + !test_eps.is_empty(), + "should detect *Tests.cs as test file entrypoint" + ); +} + +// ─── PHP E2E Tests ──────────────────────────────────────────────────────── + +/// Test: Synthetic Laravel REST API with controller → service → model pattern. +/// +/// Verifies PHP parsing, import resolution, entrypoint detection (Laravel controllers), +/// framework detection (Laravel), and flow grouping. +#[test] +fn test_e2e_php_laravel_api() { + let rb = RepoBuilder::new(); + + // Initial commit + rb.write_file( + "composer.json", + r#"{"name": "example/demo", "require": {"laravel/framework": "^11.0"}}"#, + ); + rb.commit("Initial commit: composer.json"); + rb.create_branch("main"); + + // Feature branch: add Laravel REST API + rb.create_branch("feature/php-api"); + rb.checkout("feature/php-api"); + + rb.write_file( + "app/Http/Controllers/UserController.php", + r#"userService = $userService; + } + + public function index() + { + $users = User::all(); + return response()->json($users); + } + + public function store(Request $request) + { + $data = $request->validated(); + $user = User::create($data); + return response()->json($user, 201); + } + + public function show(User $user) + { + return response()->json($user); + } + + public function destroy(User $user) + { + $user->delete(); + return response()->json(null, 204); + } +} +"#, + ); + + rb.write_file( + "app/Models/User.php", + r#"hasMany(Post::class); + } +} +"#, + ); + + rb.write_file( + "app/Services/UserService.php", + r#"update($data); + return $user; + } + + public function delete(User $user) + { + $user->delete(); + } +} +"#, + ); + + rb.write_file( + "app/Providers/AppServiceProvider.php", + r#"greet("Alice"); + $this->assertEquals("Hello, Alice", $result); + } + + public function test_greet_empty() + { + $service = new UserService(); + $result = $service->greet(""); + $this->assertEquals("Hello, ", $result); + } +} +"#, + ); + + rb.commit("Add UserService with PHPUnit tests"); + + let result = run_pipeline(rb.path(), "main", "feature/tests"); + + // Verify test file detection + let test_eps: Vec<_> = result + .groups + .iter() + .flat_map(|g| g.entrypoint.as_ref()) + .filter(|ep| ep.entrypoint_type == diffcore_core::types::EntrypointType::TestFile) + .collect(); + assert!( + !test_eps.is_empty(), + "should detect *Test.php as test file entrypoint" + ); + + // Verify PHP language detected + assert_language_detected(&result, "php"); + + // Verify PHPUnit framework detected + let has_phpunit = result + .summary + .frameworks_detected + .iter() + .any(|f| f.contains("PHPUnit")); + assert!( + has_phpunit, + "should detect PHPUnit framework; detected: {:?}", + result.summary.frameworks_detected + ); +} + +/// Test: Ruby Rails REST API with controller→service→model pattern. +/// +/// Verifies that the pipeline can: +/// - Parse Ruby source files via tree-sitter +/// - Extract require/require_relative imports, include/extend mixins +/// - Detect class, module, and method definitions +/// - Detect Rails controller action entrypoints +/// - Detect Rails framework from imports +/// - Cluster files into meaningful flow groups +#[test] +fn test_e2e_ruby_rails_api() { + let rb = RepoBuilder::new(); + + // Initial commit + rb.write_file( + "Gemfile", + "source 'https://rubygems.org'\ngem 'rails', '~> 7.1'\n", + ); + rb.commit("Initial commit: Gemfile"); + rb.create_branch("main"); + + // Feature branch: add Rails REST API + rb.create_branch("feature/ruby-api"); + rb.checkout("feature/ruby-api"); + + rb.write_file( + "app/controllers/users_controller.rb", + r#"require 'action_controller' +require_relative '../models/user' +require_relative '../services/user_service' + +class UsersController < ApplicationController + include Authentication + + def index + @users = User.all() + respond_to() + end + + def show + @user = User.find(params()) + end + + def create + @user = UserService.new().create(user_params()) + redirect_to(@user) + end + + def destroy + @user = User.find(params()) + @user.destroy() + end + + private + + def user_params + params().require().permit() + end +end +"#, + ); + + rb.write_file( + "app/models/user.rb", + r#"require 'active_record' + +class User < ActiveRecord::Base + include Validatable + + def full_name + first_name.to_s() + end + + def active? + status == 'active' + end +end +"#, + ); + + rb.write_file( + "app/services/user_service.rb", + r#"require_relative '../models/user' + +class UserService + def create(attrs) + user = User.new(attrs) + user.save() + notify(user) + user + end + + def find(id) + User.find(id) + end + + private + + def notify(user) + EventBus.publish('user.created', user) + end +end +"#, + ); + + rb.write_file( + "config/routes.rb", + r#"require 'action_controller' + +Rails.application.routes.draw() +"#, + ); + + rb.commit("Add Rails REST API with controller-service-model"); + + let result = run_pipeline(rb.path(), "main", "feature/ruby-api"); + + // Verify basic output shape + assert_valid_json_schema(&result); + assert_valid_scores(&result); + + // Verify Ruby files were detected + assert_language_detected(&result, "ruby"); + + // Verify all changed files are accounted for + assert_all_files_accounted(&result); + + // Verify there are flow groups + assert!( + !result.groups.is_empty(), + "should produce at least one flow group" + ); + + // Verify entrypoint detection (controller action methods) + let has_entrypoint = result.groups.iter().any(|g| g.entrypoint.is_some()); + assert!( + has_entrypoint, + "should detect at least one entrypoint (Rails controller actions)" + ); + + // Verify Rails framework detection + let has_rails = result + .summary + .frameworks_detected + .iter() + .any(|f| f.contains("Rails")); + assert!( + has_rails, + "should detect Rails framework; detected: {:?}", + result.summary.frameworks_detected + ); + + // Verify Mermaid graph is valid + assert_valid_mermaid(&result); + + // Verify JSON roundtrip + assert_json_roundtrip(&result); +} + +/// Test: Ruby test file detection. +/// +/// Verifies that *_spec.rb and *_test.rb files are detected as test entrypoints. +#[test] +fn test_e2e_ruby_test_file_detection() { + let rb = RepoBuilder::new(); + + rb.write_file("Gemfile", "source 'https://rubygems.org'\ngem 'rspec'\n"); + rb.commit("Initial commit"); + rb.create_branch("main"); + + rb.create_branch("feature/tests"); + rb.checkout("feature/tests"); + + rb.write_file( + "app/services/user_service.rb", + "class UserService\n def greet(name)\n name.to_s()\n end\nend\n", + ); + + rb.write_file( + "spec/services/user_service_spec.rb", + r#"require 'rspec' +require_relative '../../app/services/user_service' + +RSpec.describe(UserService) + +class UserServiceSpec + def test_greet + service = UserService.new() + result = service.greet("Alice") + end + + def test_greet_empty + service = UserService.new() + result = service.greet("") + end +end +"#, + ); + + rb.commit("Add UserService with RSpec tests"); + + let result = run_pipeline(rb.path(), "main", "feature/tests"); + + // Verify test file detection + let test_eps: Vec<_> = result + .groups + .iter() + .flat_map(|g| g.entrypoint.as_ref()) + .filter(|ep| ep.entrypoint_type == diffcore_core::types::EntrypointType::TestFile) + .collect(); + assert!( + !test_eps.is_empty(), + "should detect *_spec.rb as test file entrypoint" + ); + + // Verify Ruby language detected + assert_language_detected(&result, "ruby"); + + // Verify RSpec framework detected + let has_rspec = result + .summary + .frameworks_detected + .iter() + .any(|f| f.contains("RSpec")); + assert!( + has_rspec, + "should detect RSpec framework; detected: {:?}", + result.summary.frameworks_detected + ); +} + diff --git a/crates/diffcore-core/tests/e2e_go_rust.rs b/crates/diffcore-core/tests/e2e_go_rust.rs new file mode 100644 index 0000000..b312a3a --- /dev/null +++ b/crates/diffcore-core/tests/e2e_go_rust.rs @@ -0,0 +1,500 @@ +#![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::print_stdout, + clippy::print_stderr +)] +//! E2E integration tests for Go and Rust language support. + +mod helpers; + +use helpers::graph_assertions::{ + assert_all_files_accounted, assert_language_detected, assert_valid_json_schema, + assert_valid_mermaid, assert_valid_scores, +}; +use helpers::repo_builder::{run_pipeline, RepoBuilder}; + +// ─── Go Integration Tests ──────────────────────────────────────────────── + +/// Test: Synthetic Go HTTP API with handler → service → repo pattern. +/// +/// Creates a Go app with Gin framework: +/// main.go → handlers/user.go → services/user.go → repositories/user.go +/// +/// Verifies: +/// - Go language detection +/// - Import extraction from Go files +/// - Function/struct/interface definitions +/// - Call site detection +/// - HTTP route entrypoint detection +/// - Framework detection (Gin) +/// - Pipeline produces valid groups and JSON output +#[test] +fn test_e2e_go_http_api() { + let rb = RepoBuilder::new(); + + // Initial commit + rb.write_file("go.mod", "module github.com/example/api\n\ngo 1.21\n"); + rb.commit("Initial commit: go.mod"); + rb.create_branch("main"); + + // Feature branch: add Go API + rb.create_branch("feature/go-api"); + rb.checkout("feature/go-api"); + + rb.write_file( + "cmd/server/main.go", + r#" +package main + +import ( + "github.com/gin-gonic/gin" + "github.com/example/api/handlers" +) + +func main() { + r := gin.Default() + handlers.RegisterRoutes(r) + r.Run(":8080") +} +"#, + ); + + rb.write_file( + "handlers/user.go", + r#" +package handlers + +import ( + "github.com/gin-gonic/gin" + "github.com/example/api/services" +) + +func RegisterRoutes(r *gin.Engine) { + r.GET("/users/:id", GetUser) + r.POST("/users", CreateUser) +} + +func GetUser(c *gin.Context) { + id := c.Param("id") + user := services.FindUser(id) + c.JSON(200, user) +} + +func CreateUser(c *gin.Context) { + data := services.ParseInput(c) + user := services.CreateUser(data) + c.JSON(201, user) +} +"#, + ); + + rb.write_file( + "services/user.go", + r#" +package services + +import ( + "github.com/gin-gonic/gin" + "github.com/example/api/repositories" +) + +type UserInput struct { + Name string + Email string +} + +func ParseInput(c *gin.Context) UserInput { + var input UserInput + c.BindJSON(&input) + return input +} + +func FindUser(id string) *repositories.User { + return repositories.GetByID(id) +} + +func CreateUser(data UserInput) *repositories.User { + user := repositories.User{ + Name: data.Name, + Email: data.Email, + } + return repositories.Insert(&user) +} +"#, + ); + + rb.write_file( + "repositories/user.go", + r#" +package repositories + +type User struct { + ID string + Name string + Email string +} + +var users = make(map[string]*User) + +func GetByID(id string) *User { + return users[id] +} + +func Insert(user *User) *User { + user.ID = "generated-id" + users[user.ID] = user + return user +} +"#, + ); + + rb.commit("Add Go HTTP API with handler-service-repo"); + + let result = run_pipeline(rb.path(), "main", "feature/go-api"); + + // Verify basic output shape + assert_valid_json_schema(&result); + assert_valid_scores(&result); + + // Verify Go files were detected + assert_language_detected(&result, "go"); + + // Verify all changed files are accounted for + assert_all_files_accounted(&result); + + // Verify there are flow groups + assert!( + !result.groups.is_empty(), + "should produce at least one flow group" + ); + + // Verify CLI entrypoint detection (func main) + let has_cli_ep = result.groups.iter().any(|g| { + g.entrypoint.as_ref().map_or(false, |ep| { + ep.entrypoint_type == diffcore_core::types::EntrypointType::CliCommand + }) + }); + assert!(has_cli_ep, "should detect func main() as CLI entrypoint"); + + // Verify Mermaid graph is valid + assert_valid_mermaid(&result); +} + +/// Test: Go test file detection. +/// +/// Verifies that `_test.go` files are detected as test file entrypoints, +/// and that Go Test* functions are recognized as test symbols. +#[test] +fn test_e2e_go_test_file_detection() { + let rb = RepoBuilder::new(); + + rb.write_file("go.mod", "module example.com/app\n\ngo 1.21\n"); + rb.commit("Initial commit"); + rb.create_branch("main"); + + rb.create_branch("feature/tests"); + rb.checkout("feature/tests"); + + rb.write_file( + "handlers/user.go", + r#" +package handlers + +func GetUser(id string) string { + return "user-" + id +} +"#, + ); + + rb.write_file( + "handlers/user_test.go", + r#" +package handlers + +import "testing" + +func TestGetUser(t *testing.T) { + result := GetUser("123") + if result != "user-123" { + t.Errorf("unexpected: %s", result) + } +} + +func BenchmarkGetUser(b *testing.B) { + for i := 0; i < b.N; i++ { + GetUser("123") + } +} +"#, + ); + + rb.commit("Add user handler with tests"); + + let result = run_pipeline(rb.path(), "main", "feature/tests"); + + // Verify test file entrypoint detection + let test_eps: Vec<_> = result + .groups + .iter() + .flat_map(|g| g.entrypoint.as_ref()) + .filter(|ep| ep.entrypoint_type == diffcore_core::types::EntrypointType::TestFile) + .collect(); + assert!( + !test_eps.is_empty(), + "should detect _test.go as test file entrypoint" + ); +} + +// ===================================================================== +// Rust language integration tests +// ===================================================================== + +/// Test: Synthetic Rust axum API with handler→service→repo pattern. +/// +/// Creates a 5-file Rust HTTP API using axum and verifies the full pipeline: +/// language detection, import extraction, definition extraction, call sites, +/// entrypoint detection, framework detection, grouping, and JSON output. +#[test] +fn test_e2e_rust_axum_api() { + let rb = RepoBuilder::new(); + + // Initial commit + rb.write_file( + "Cargo.toml", + r#"[package] +name = "my-api" +version = "0.1.0" +edition = "2021" + +[dependencies] +axum = "0.7" +tokio = { version = "1", features = ["full"] } +serde = { version = "1", features = ["derive"] } +sqlx = "0.7" +"#, + ); + rb.commit("Initial commit: Cargo.toml"); + rb.create_branch("main"); + + // Feature branch: add Rust API + rb.create_branch("feature/rust-api"); + rb.checkout("feature/rust-api"); + + rb.write_file( + "src/main.rs", + r#" +use axum::{Router, routing::get, routing::post}; +use crate::handlers; + +mod handlers; +mod services; +mod repositories; +mod models; + +#[tokio::main] +async fn main() { + let app = Router::new() + .route("/users/:id", get(handlers::get_user)) + .route("/users", post(handlers::create_user)); + + let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap(); + axum::serve(listener, app).await.unwrap(); +} +"#, + ); + + rb.write_file( + "src/handlers.rs", + r#" +use axum::{extract::Path, Json}; +use crate::models::User; +use crate::services; + +pub async fn get_user(Path(id): Path) -> Json { + let user = services::find_user(id).await; + Json(user) +} + +pub async fn create_user(Json(input): Json) -> Json { + let user = services::create_user(input.name, input.email).await; + Json(user) +} + +#[derive(serde::Deserialize)] +pub struct CreateUserInput { + pub name: String, + pub email: String, +} +"#, + ); + + rb.write_file( + "src/services.rs", + r#" +use crate::models::User; +use crate::repositories; + +pub async fn find_user(id: u64) -> User { + repositories::get_by_id(id).await +} + +pub async fn create_user(name: String, email: String) -> User { + let user = User { + id: 0, + name, + email, + }; + repositories::insert(user).await +} +"#, + ); + + rb.write_file( + "src/repositories.rs", + r#" +use crate::models::User; +use sqlx::PgPool; + +pub async fn get_by_id(id: u64) -> User { + User { + id, + name: "Alice".to_string(), + email: "alice@example.com".to_string(), + } +} + +pub async fn insert(user: User) -> User { + User { + id: 1, + ..user + } +} +"#, + ); + + rb.write_file( + "src/models.rs", + r#" +use serde::{Serialize, Deserialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct User { + pub id: u64, + pub name: String, + pub email: String, +} +"#, + ); + + rb.commit("Add Rust axum HTTP API with handler-service-repo"); + + let result = run_pipeline(rb.path(), "main", "feature/rust-api"); + + // Verify basic output shape + assert_valid_json_schema(&result); + assert_valid_scores(&result); + + // Verify Rust files were detected + assert_language_detected(&result, "rust"); + + // Verify all changed files are accounted for + assert_all_files_accounted(&result); + + // Verify there are flow groups + assert!( + !result.groups.is_empty(), + "should produce at least one flow group" + ); + + // Verify entrypoint detection (fn main or HTTP routes) + let has_entrypoint = result.groups.iter().any(|g| g.entrypoint.is_some()); + assert!(has_entrypoint, "should detect at least one entrypoint"); + + // Verify HTTP route detection (axum Router patterns) + let has_http_ep = result.groups.iter().any(|g| { + g.entrypoint.as_ref().map_or(false, |ep| { + ep.entrypoint_type == diffcore_core::types::EntrypointType::HttpRoute + }) + }); + assert!(has_http_ep, "should detect axum HTTP route entrypoints"); + + // Verify framework detection (Axum) + let has_axum = result + .summary + .frameworks_detected + .iter() + .any(|f| f.contains("Axum") || f.contains("axum")); + assert!( + has_axum, + "should detect Axum framework; detected: {:?}", + result.summary.frameworks_detected + ); + + // Verify Mermaid graph is valid + assert_valid_mermaid(&result); +} + +/// Test: Rust test file detection. +/// +/// Verifies that `_test.rs` files and functions with test_ prefix are detected. +#[test] +fn test_e2e_rust_test_file_detection() { + let rb = RepoBuilder::new(); + + rb.write_file( + "Cargo.toml", + r#"[package] +name = "my-app" +version = "0.1.0" +edition = "2021" +"#, + ); + rb.commit("Initial commit"); + rb.create_branch("main"); + + rb.create_branch("feature/tests"); + rb.checkout("feature/tests"); + + rb.write_file( + "src/lib.rs", + r#" +pub fn add(a: i32, b: i32) -> i32 { + a + b +} +"#, + ); + + rb.write_file( + "src/lib_test.rs", + r#" +use crate::add; + +fn test_add() { + assert_eq!(add(2, 3), 5); +} + +fn test_add_negative() { + assert_eq!(add(-1, 1), 0); +} +"#, + ); + + rb.commit("Add lib with tests"); + + let result = run_pipeline(rb.path(), "main", "feature/tests"); + + // Verify test file detection + let test_eps: Vec<_> = result + .groups + .iter() + .flat_map(|g| g.entrypoint.as_ref()) + .filter(|ep| ep.entrypoint_type == diffcore_core::types::EntrypointType::TestFile) + .collect(); + assert!( + !test_eps.is_empty(), + "should detect _test.rs as test file entrypoint" + ); +} + diff --git a/crates/diffcore-core/tests/e2e_jvm.rs b/crates/diffcore-core/tests/e2e_jvm.rs new file mode 100644 index 0000000..62c4daa --- /dev/null +++ b/crates/diffcore-core/tests/e2e_jvm.rs @@ -0,0 +1,762 @@ +#![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::print_stdout, + clippy::print_stderr +)] +//! E2E integration tests for JVM language support: Java, Kotlin, and Scala. + +mod helpers; + +use helpers::graph_assertions::{ + assert_all_files_accounted, assert_json_roundtrip, assert_language_detected, + assert_valid_json_schema, assert_valid_mermaid, assert_valid_scores, +}; +use helpers::repo_builder::{run_pipeline, RepoBuilder}; + +// --------------------------------------------------------------------------- +// Java integration tests (Phase 11.2) +// --------------------------------------------------------------------------- + +/// Test: Java Spring Boot REST API with controller → service → repository pattern. +/// +/// Verifies full pipeline: language detection, file accounting, flow groups, +/// entrypoint detection, framework detection, and Mermaid graph. +#[test] +fn test_e2e_java_spring_boot_api() { + let rb = RepoBuilder::new(); + + // Initial commit + rb.write_file( + "pom.xml", + r#" + 4.0.0 + com.example + demo + 0.0.1-SNAPSHOT + + + org.springframework.boot + spring-boot-starter-web + + + +"#, + ); + rb.commit("Initial commit: pom.xml"); + rb.create_branch("main"); + + // Feature branch: add Spring Boot API + rb.create_branch("feature/java-api"); + rb.checkout("feature/java-api"); + + rb.write_file( + "src/main/java/com/example/demo/DemoApplication.java", + r#" +package com.example.demo; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class DemoApplication { + public static void main(String[] args) { + SpringApplication.run(DemoApplication.class, args); + } +} +"#, + ); + + rb.write_file( + "src/main/java/com/example/demo/controller/UserController.java", + r#" +package com.example.demo.controller; + +import java.util.List; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import com.example.demo.model.User; +import com.example.demo.service.UserService; + +@RestController +public class UserController { + + private final UserService userService; + + public UserController(UserService userService) { + this.userService = userService; + } + + @GetMapping("/users") + public List getUsers() { + return userService.findAll(); + } + + @PostMapping("/users") + public User createUser(@RequestBody User user) { + return userService.save(user); + } +} +"#, + ); + + rb.write_file( + "src/main/java/com/example/demo/service/UserService.java", + r#" +package com.example.demo.service; + +import java.util.List; +import com.example.demo.model.User; +import com.example.demo.repository.UserRepository; + +public class UserService { + + private final UserRepository userRepository; + + public UserService(UserRepository userRepository) { + this.userRepository = userRepository; + } + + public List findAll() { + return userRepository.findAll(); + } + + public User save(User user) { + return userRepository.save(user); + } +} +"#, + ); + + rb.write_file( + "src/main/java/com/example/demo/repository/UserRepository.java", + r#" +package com.example.demo.repository; + +import java.util.List; +import java.util.ArrayList; +import com.example.demo.model.User; + +public class UserRepository { + + private final List users = new ArrayList<>(); + + public List findAll() { + return users; + } + + public User save(User user) { + users.add(user); + return user; + } +} +"#, + ); + + rb.write_file( + "src/main/java/com/example/demo/model/User.java", + r#" +package com.example.demo.model; + +public class User { + private Long id; + private String name; + private String email; + + public User() {} + + public User(String name, String email) { + this.name = name; + this.email = email; + } + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + public String getName() { return name; } + public void setName(String name) { this.name = name; } + public String getEmail() { return email; } + public void setEmail(String email) { this.email = email; } +} +"#, + ); + + rb.commit("Add Spring Boot REST API with controller-service-repo"); + + let result = run_pipeline(rb.path(), "main", "feature/java-api"); + + // Verify basic output shape + assert_valid_json_schema(&result); + assert_valid_scores(&result); + + // Verify Java files were detected + assert_language_detected(&result, "java"); + + // Verify all changed files are accounted for + assert_all_files_accounted(&result); + + // Verify there are flow groups + assert!( + !result.groups.is_empty(), + "should produce at least one flow group" + ); + + // Verify entrypoint detection (main or HTTP routes) + let has_entrypoint = result.groups.iter().any(|g| g.entrypoint.is_some()); + assert!(has_entrypoint, "should detect at least one entrypoint"); + + // Verify Spring Boot framework detection + let has_spring = result + .summary + .frameworks_detected + .iter() + .any(|f| f.contains("Spring")); + assert!( + has_spring, + "should detect Spring Boot framework; detected: {:?}", + result.summary.frameworks_detected + ); + + // Verify Mermaid graph is valid + assert_valid_mermaid(&result); +} + +/// Test: Java test file detection. +/// +/// Verifies that *Test.java files and @Test annotated methods are detected as test entrypoints. +#[test] +fn test_e2e_java_test_file_detection() { + let rb = RepoBuilder::new(); + + rb.write_file( + "pom.xml", + r#" + 4.0.0 + com.example + demo + 0.0.1-SNAPSHOT + +"#, + ); + rb.commit("Initial commit"); + rb.create_branch("main"); + + rb.create_branch("feature/tests"); + rb.checkout("feature/tests"); + + rb.write_file( + "src/main/java/com/example/demo/UserService.java", + r#" +package com.example.demo; + +public class UserService { + public String greet(String name) { + return "Hello, " + name; + } +} +"#, + ); + + rb.write_file( + "src/test/java/com/example/demo/UserServiceTest.java", + r#" +package com.example.demo; + +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class UserServiceTest { + + @Test + public void testGreet() { + UserService svc = new UserService(); + assertEquals("Hello, Alice", svc.greet("Alice")); + } + + @Test + public void testGreetEmpty() { + UserService svc = new UserService(); + assertEquals("Hello, ", svc.greet("")); + } +} +"#, + ); + + rb.commit("Add UserService and tests"); + + let result = run_pipeline(rb.path(), "main", "feature/tests"); + + // Verify test file detection + let test_eps: Vec<_> = result + .groups + .iter() + .flat_map(|g| g.entrypoint.as_ref()) + .filter(|ep| ep.entrypoint_type == diffcore_core::types::EntrypointType::TestFile) + .collect(); + assert!( + !test_eps.is_empty(), + "should detect *Test.java as test file entrypoint" + ); +} + +/// Test: Kotlin Ktor REST API with handler→service→repo pattern. +/// +/// Verifies that the pipeline can: +/// - Parse Kotlin source files via tree-sitter +/// - Extract import statements (regular, aliased, wildcard) +/// - Detect fun, class, object, val/var definitions +/// - Detect Ktor route handler entrypoints +/// - Detect Ktor framework from imports +/// - Cluster files into meaningful flow groups +#[test] +fn test_e2e_kotlin_ktor_api() { + let rb = RepoBuilder::new(); + + // Initial commit + rb.write_file("build.gradle.kts", "plugins {\n kotlin(\"jvm\")\n}\n"); + rb.commit("Initial commit: build.gradle.kts"); + rb.create_branch("main"); + + // Feature branch: add Ktor REST API + rb.create_branch("feature/kotlin-api"); + rb.checkout("feature/kotlin-api"); + + rb.write_file( + "src/main/kotlin/routes/UserRoutes.kt", + r#"import io.ktor.server.routing.Route +import io.ktor.server.routing.get +import io.ktor.server.routing.post +import io.ktor.server.response.respond +import com.example.services.UserService + +fun Route.userRoutes(userService: UserService) { + get("/users") { + val users = userService.findAll() + call.respond(users) + } + + post("/users") { + val user = userService.create(call) + call.respond(user) + } + + get("/users/{id}") { + val user = userService.findById(call) + call.respond(user) + } +} +"#, + ); + + rb.write_file( + "src/main/kotlin/services/UserService.kt", + r#"import com.example.repositories.UserRepository +import com.example.models.User + +class UserService(private val repository: UserRepository) { + fun findAll(): List { + val users = repository.findAll() + return users + } + + fun findById(id: String): User { + val user = repository.findById(id) + return user + } + + fun create(data: Map): User { + val user = repository.save(data) + return user + } +} +"#, + ); + + rb.write_file( + "src/main/kotlin/repositories/UserRepository.kt", + r#"import org.jetbrains.exposed.sql.Database +import com.example.models.User + +class UserRepository(private val db: Database) { + fun findAll(): List { + val results = db.query("SELECT * FROM users") + return results + } + + fun findById(id: String): User { + val result = db.query("SELECT * FROM users WHERE id = ?") + return result + } + + fun save(data: Map): User { + val result = db.execute("INSERT INTO users ...") + return result + } +} +"#, + ); + + rb.write_file( + "src/main/kotlin/models/User.kt", + r#"import kotlinx.serialization.Serializable + +@Serializable +data class User( + val id: String, + val name: String, + val email: String +) +"#, + ); + + rb.write_file( + "src/main/kotlin/Application.kt", + r#"import io.ktor.server.engine.embeddedServer +import io.ktor.server.netty.Netty +import com.example.routes.userRoutes +import com.example.services.UserService +import com.example.repositories.UserRepository + +fun main() { + val repo = UserRepository() + val service = UserService(repo) + embeddedServer(Netty, port = 8080) { + userRoutes(service) + } +} +"#, + ); + + rb.commit("Add Ktor REST API with routes-service-repo"); + + let result = run_pipeline(rb.path(), "main", "feature/kotlin-api"); + + // Verify basic output shape + assert_valid_json_schema(&result); + assert_valid_scores(&result); + + // Verify Kotlin files were detected + assert_language_detected(&result, "kotlin"); + + // Verify all changed files are accounted for + assert_all_files_accounted(&result); + + // Verify there are flow groups + assert!( + !result.groups.is_empty(), + "should produce at least one flow group" + ); + + // Verify entrypoint detection (Ktor route handlers or main) + let has_entrypoint = result.groups.iter().any(|g| g.entrypoint.is_some()); + assert!( + has_entrypoint, + "should detect at least one entrypoint (Ktor routes or main)" + ); + + // Verify Ktor framework detection + let has_ktor = result + .summary + .frameworks_detected + .iter() + .any(|f| f.contains("Ktor")); + assert!( + has_ktor, + "should detect Ktor framework; detected: {:?}", + result.summary.frameworks_detected + ); + + // Verify Mermaid graph is valid + assert_valid_mermaid(&result); + + // Verify JSON roundtrip + assert_json_roundtrip(&result); +} + +/// Test: Kotlin test file detection. +/// +/// Verifies that *Test.kt files are detected as test entrypoints. +#[test] +fn test_e2e_kotlin_test_file_detection() { + let rb = RepoBuilder::new(); + + rb.write_file("build.gradle.kts", "plugins {\n kotlin(\"jvm\")\n}\n"); + rb.commit("Initial commit"); + rb.create_branch("main"); + + rb.create_branch("feature/tests"); + rb.checkout("feature/tests"); + + rb.write_file( + "src/main/kotlin/services/UserService.kt", + r#"import com.example.models.User + +class UserService { + fun greet(name: String): String { + return "Hello, $name" + } +} +"#, + ); + + rb.write_file( + "src/test/kotlin/services/UserServiceTest.kt", + r#"import org.junit.Test +import com.example.services.UserService + +class UserServiceTest { + fun testGreet() { + val service = UserService() + val result = service.greet("Alice") + } + + fun testGreetEmpty() { + val service = UserService() + val result = service.greet("") + } +} +"#, + ); + + rb.commit("Add UserService with JUnit tests"); + + let result = run_pipeline(rb.path(), "main", "feature/tests"); + + // Verify test file detection + let test_eps: Vec<_> = result + .groups + .iter() + .flat_map(|g| g.entrypoint.as_ref()) + .filter(|ep| ep.entrypoint_type == diffcore_core::types::EntrypointType::TestFile) + .collect(); + assert!( + !test_eps.is_empty(), + "should detect *Test.kt as test file entrypoint" + ); + + // Verify Kotlin language detected + assert_language_detected(&result, "kotlin"); + + // Verify JUnit framework detected + let has_junit = result + .summary + .frameworks_detected + .iter() + .any(|f| f.contains("JUnit")); + assert!( + has_junit, + "should detect JUnit framework; detected: {:?}", + result.summary.frameworks_detected + ); +} + +/// Test: Swift Vapor REST API with controller→service→repo pattern. +/// +/// Creates a synthetic Swift Vapor app to verify: +/// - Swift file detection (.swift extension) +/// - Import extraction (module-level imports) +/// - Definition extraction (struct, class, protocol, func) +/// - Call site extraction (method calls, function calls) +/// - Entrypoint detection (Vapor route handlers) +/// - Framework detection (Vapor, Fluent) +/// - Semantic grouping and ranking +// ─── Scala Integration Tests ───────────────────────────────────────────── + +/// Test: Synthetic Scala Akka HTTP API with handler → service → repository pattern. +#[test] +fn test_e2e_scala_akka_http_api() { + let rb = RepoBuilder::new(); + + rb.write_file( + "build.sbt", + "name := \"akka-api\"\nscalaVersion := \"2.13.12\"\n", + ); + rb.commit("Initial commit"); + rb.create_branch("main"); + + rb.create_branch("feature/users-api"); + rb.checkout("feature/users-api"); + + rb.write_file( + "src/main/scala/routes/UserRoutes.scala", + r#"import akka.http.scaladsl.server.Directives._ +import akka.http.scaladsl.server.Route +import com.example.services.UserService + +class UserRoutes(service: UserService) { + def routes(): Route = { + pathPrefix("users") { + get { + val users = service.listUsers() + complete(users.toString()) + } ~ + post { + val user = service.createUser("test") + complete(user.toString()) + } + } + } +} +"#, + ); + + rb.write_file( + "src/main/scala/services/UserService.scala", + r#"import com.example.repositories.UserRepository + +class UserService(repo: UserRepository) { + def listUsers(): List[User] = { + val users = repo.findAll() + users + } + + def createUser(name: String): User = { + val user = repo.save(name) + println("Created user") + user + } +} +"#, + ); + + rb.write_file( + "src/main/scala/repositories/UserRepository.scala", + r#"import slick.jdbc.PostgresProfile.api._ + +class UserRepository(db: Database) { + def findAll(): List[User] = { + val result = db.run(users.result) + result + } + + def save(name: String): User = { + val user = User(name) + db.run(users.insertOrUpdate(user)) + user + } +} +"#, + ); + + rb.write_file( + "src/main/scala/models/User.scala", + r#"case class User(id: String, name: String, email: String) + +type UserId = String +"#, + ); + + rb.commit("Add users API with Akka HTTP"); + + let result = run_pipeline(rb.path(), "main", "feature/users-api"); + + assert_valid_json_schema(&result); + assert_all_files_accounted(&result); + + // Verify Scala language detected + assert_language_detected(&result, "scala"); + + // Verify HTTP route entrypoint detected + let has_http_entrypoint = result.groups.iter().any(|g| { + g.entrypoint.as_ref().map_or(false, |e| { + e.entrypoint_type == diffcore_core::types::EntrypointType::HttpRoute + }) + }); + assert!( + has_http_entrypoint, + "should detect Akka HTTP route entrypoint; groups: {:?}", + result + .groups + .iter() + .map(|g| (&g.name, &g.entrypoint)) + .collect::>() + ); + + // Verify framework detected + let frameworks = &result.summary.frameworks_detected; + assert!( + frameworks + .iter() + .any(|f| f.contains("Akka") || f.contains("Slick")), + "should detect Akka HTTP or Slick framework; got: {:?}", + frameworks + ); +} + +/// Test: Scala test file detection with ScalaTest. +#[test] +fn test_e2e_scala_test_file_detection() { + let rb = RepoBuilder::new(); + + rb.write_file("build.sbt", "name := \"scala-test\"\n"); + rb.commit("Initial commit"); + rb.create_branch("main"); + + rb.create_branch("feature/tests"); + rb.checkout("feature/tests"); + + rb.write_file( + "src/main/scala/services/Calculator.scala", + r#"object Calculator { + def add(a: Int, b: Int): Int = a + b + def multiply(a: Int, b: Int): Int = a * b +} +"#, + ); + + rb.write_file( + "src/test/scala/services/CalculatorSpec.scala", + r#"import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +class CalculatorSpec extends AnyFlatSpec with Matchers { + def testAdd(): Unit = { + val result = Calculator.add(2, 3) + result shouldEqual 5 + } + + def testMultiply(): Unit = { + val result = Calculator.multiply(3, 4) + result shouldEqual 12 + } +} +"#, + ); + + rb.commit("Add calculator with tests"); + + let result = run_pipeline(rb.path(), "main", "feature/tests"); + + assert_valid_json_schema(&result); + assert_all_files_accounted(&result); + + // Verify Scala language detected + assert_language_detected(&result, "scala"); + + // Verify test file detected as entrypoint + let has_test_entrypoint = result.groups.iter().any(|g| { + g.entrypoint.as_ref().map_or(false, |e| { + e.entrypoint_type == diffcore_core::types::EntrypointType::TestFile + }) + }); + assert!( + has_test_entrypoint, + "should detect ScalaTest spec as test entrypoint; groups: {:?}", + result + .groups + .iter() + .map(|g| (&g.name, &g.entrypoint)) + .collect::>() + ); + + // Verify ScalaTest framework detected + let frameworks = &result.summary.frameworks_detected; + assert!( + frameworks.iter().any(|f| f.contains("ScalaTest")), + "should detect ScalaTest framework; got: {:?}", + frameworks + ); +} + diff --git a/crates/diffcore-core/tests/e2e_nextjs_large.rs b/crates/diffcore-core/tests/e2e_nextjs_large.rs new file mode 100644 index 0000000..c326bd9 --- /dev/null +++ b/crates/diffcore-core/tests/e2e_nextjs_large.rs @@ -0,0 +1,496 @@ +#![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::print_stdout, + clippy::print_stderr +)] +//! E2E integration tests for Next.js, large diffs, staged changes, and config overrides. + +mod helpers; + +use diffcore_core::git; +use diffcore_core::output; +use helpers::graph_assertions::{ + assert_all_files_accounted, assert_json_roundtrip, assert_language_detected, + assert_valid_json_schema, assert_valid_scores, +}; +use helpers::repo_builder::{run_pipeline, RepoBuilder}; + +// ─── Spec §13.5 Missing Integration Tests ───────────────────────────── + +/// Test: Next.js app — modify a page + API route + Prisma model. +/// +/// Creates a Next.js fullstack app with API routes and React pages. +/// Expected: produces 2 groups (API flow and UI flow), correctly separated. +#[test] +fn test_e2e_nextjs_page_change() { + let rb = RepoBuilder::new(); + + // Initial commit: base Next.js app with existing page + API route + rb.write_file( + "package.json", + r#"{"name": "nextjs-app", "dependencies": {"next": "14.0.0", "@prisma/client": "5.0.0"}}"#, + ); + rb.write_file( + "src/app/api/users/route.ts", + r#" +import { NextResponse } from 'next/server'; + +export async function GET() { + return NextResponse.json([]); +} +"#, + ); + rb.write_file( + "src/app/users/page.tsx", + r#" +export default function UsersPage() { + return
Users
; +} +"#, + ); + rb.write_file( + "prisma/schema.prisma", + r#" +model User { + id String @id + name String +} +"#, + ); + rb.commit("Initial Next.js app"); + rb.create_branch("main"); + + rb.create_branch("feature/nextjs-products"); + rb.checkout("feature/nextjs-products"); + + // Add new API route (products) + rb.write_file( + "src/app/api/products/route.ts", + r#" +import { NextResponse } from 'next/server'; +import { getProducts, createProduct } from '../../../services/productService'; + +export async function GET() { + const products = await getProducts(); + return NextResponse.json(products); +} + +export async function POST(request: Request) { + const body = await request.json(); + const product = await createProduct(body); + return NextResponse.json(product, { status: 201 }); +} +"#, + ); + + // Add service layer + rb.write_file( + "src/services/productService.ts", + r#" +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +export async function getProducts() { + return prisma.product.findMany(); +} + +export async function createProduct(data: any) { + return prisma.product.create({ data }); +} +"#, + ); + + // Add new page (products listing) + rb.write_file( + "src/app/products/page.tsx", + r#" +import { ProductList } from '../../components/ProductList'; + +export default async function ProductsPage() { + const res = await fetch('/api/products'); + const products = await res.json(); + return ; +} +"#, + ); + + // Add component + rb.write_file( + "src/components/ProductList.tsx", + r#" +export function ProductList({ products }: { products: any[] }) { + return ( +
    + {products.map(p =>
  • {p.name}
  • )} +
+ ); +} +"#, + ); + + // Update Prisma schema (add Product model) + rb.write_file( + "prisma/schema.prisma", + r#" +model User { + id String @id + name String +} + +model Product { + id String @id + name String + price Float +} +"#, + ); + + rb.commit("Add product listing: API route + page + Prisma model"); + + let output = run_pipeline(rb.path(), "main", "feature/nextjs-products"); + + // Should have at least 4 changed files (API route, service, page, component, schema) + assert!( + output.summary.total_files_changed >= 4, + "expected >= 4 changed files, got {}", + output.summary.total_files_changed + ); + + // Should produce at least 2 groups (API flow and UI flow) + assert!( + output.summary.total_groups >= 2, + "expected >= 2 flow groups for API + UI flows, got {}", + output.summary.total_groups + ); + + assert_language_detected(&output, "typescript"); + assert_all_files_accounted(&output); + assert_json_roundtrip(&output); +} + +/// Test: 50-file diff completes within reasonable time and produces valid output. +/// +/// Generates 50 TypeScript files with import chains and verifies +/// the pipeline handles them without timeout or OOM. +#[test] +fn test_e2e_50_file_diff() { + let rb = RepoBuilder::new(); + + rb.write_file("package.json", r#"{"name": "large-app-50"}"#); + rb.commit("Initial"); + rb.create_branch("main"); + + rb.create_branch("feature/50-files"); + rb.checkout("feature/50-files"); + + // Create 50 files: 5 route entrypoints, each with a chain of 9 downstream files + for group in 0..5 { + let route_file = format!("src/routes/route{}.ts", group); + rb.write_file( + &route_file, + &format!( + "import express from 'express';\nimport {{ service{g} }} from '../services/svc{g}';\n\nconst router = express.Router();\nexport function handle{g}(req: any, res: any) {{ res.json(service{g}()); }}\nrouter.get('/route{g}', handle{g});\nexport default router;\n", + g = group + ), + ); + for depth in 0..9 { + let file = format!("src/services/svc{}_{}.ts", group, depth); + let content = if depth == 0 { + format!( + "import {{ fn{}_{} }} from './svc{}_{}';\nexport function service{}() {{ return fn{}_{}(); }}\n", + group, depth + 1, group, depth + 1, group, group, depth + 1 + ) + } else if depth < 8 { + format!( + "import {{ fn{}_{} }} from './svc{}_{}';\nexport function fn{}_{}() {{ return fn{}_{}(); }}\n", + group, depth + 1, group, depth + 1, group, depth, group, depth + 1 + ) + } else { + format!( + "export function fn{}_{}() {{ return {{ value: {} }}; }}\n", + group, + depth, + group * 10 + depth + ) + }; + rb.write_file(&file, &content); + } + } + rb.commit("Add 50 files across 5 route groups"); + + let start = std::time::Instant::now(); + let output = run_pipeline(rb.path(), "main", "feature/50-files"); + let elapsed = start.elapsed(); + + assert_eq!( + output.summary.total_files_changed, 50, + "expected 50 changed files" + ); + assert!( + elapsed.as_secs() < 5, + "50-file analysis should complete in <5s, took {:?}", + elapsed + ); + assert_all_files_accounted(&output); + assert_valid_scores(&output); + + let json = output::to_json(&output).unwrap(); + let _: serde_json::Value = serde_json::from_str(&json).unwrap(); +} + +/// Test: 100-file diff completes within reasonable time without OOM. +/// +/// Generates 100 TypeScript files with import chains. Verifies the +/// pipeline scales to large diffs without panicking or timing out. +#[test] +fn test_e2e_100_file_diff() { + let rb = RepoBuilder::new(); + + rb.write_file("package.json", r#"{"name": "large-app-100"}"#); + rb.commit("Initial"); + rb.create_branch("main"); + + rb.create_branch("feature/100-files"); + rb.checkout("feature/100-files"); + + // Create 100 files: 10 route entrypoints, each with a chain of 9 downstream files + for group in 0..10 { + let route_file = format!("src/routes/route{}.ts", group); + rb.write_file( + &route_file, + &format!( + "import express from 'express';\nimport {{ svc{g} }} from '../services/svc{g}';\n\nconst router = express.Router();\nexport function handler{g}(req: any, res: any) {{ res.json(svc{g}()); }}\nrouter.get('/r{g}', handler{g});\nexport default router;\n", + g = group + ), + ); + for depth in 0..9 { + let file = format!("src/services/svc{}_{}.ts", group, depth); + let content = if depth == 0 { + format!( + "import {{ f{}_{} }} from './svc{}_{}';\nexport function svc{}() {{ return f{}_{}(); }}\n", + group, depth + 1, group, depth + 1, group, group, depth + 1 + ) + } else if depth < 8 { + format!( + "import {{ f{}_{} }} from './svc{}_{}';\nexport function f{}_{}() {{ return f{}_{}(); }}\n", + group, depth + 1, group, depth + 1, group, depth, group, depth + 1 + ) + } else { + format!( + "export function f{}_{}() {{ return {{ v: {} }}; }}\n", + group, + depth, + group * 10 + depth + ) + }; + rb.write_file(&file, &content); + } + } + rb.commit("Add 100 files across 10 route groups"); + + let start = std::time::Instant::now(); + let output = run_pipeline(rb.path(), "main", "feature/100-files"); + let elapsed = start.elapsed(); + + assert_eq!( + output.summary.total_files_changed, 100, + "expected 100 changed files" + ); + assert!( + elapsed.as_secs() < 15, + "100-file analysis should complete in <15s, took {:?}", + elapsed + ); + assert_all_files_accounted(&output); + assert_valid_scores(&output); + + // Should produce valid JSON + let json = output::to_json(&output).unwrap(); + let _: serde_json::Value = serde_json::from_str(&json).unwrap(); +} + +/// Test: Staged changes — only staged files are included in the diff. +/// +/// Stages some files but leaves others unstaged, then runs diff_staged +/// and verifies only the staged files appear in the result. +#[test] +fn test_e2e_staged_changes() { + let rb = RepoBuilder::new(); + + // Create initial files + rb.write_file( + "src/handler.ts", + "export function handle() { return 'v1'; }\n", + ); + rb.write_file( + "src/service.ts", + "export function serve() { return 'v1'; }\n", + ); + rb.write_file("src/utils.ts", "export function util() { return 'v1'; }\n"); + rb.commit("Initial commit"); + + // Modify all three files in the working directory + rb.write_file( + "src/handler.ts", + "export function handle() { return 'v2-staged'; }\n", + ); + rb.write_file( + "src/service.ts", + "export function serve() { return 'v2-staged'; }\n", + ); + rb.write_file( + "src/utils.ts", + "export function util() { return 'v2-unstaged'; }\n", + ); + + // Stage only handler.ts and service.ts (NOT utils.ts) + { + let repo = rb.repo(); + let mut index = repo.index().unwrap(); + index + .add_path(std::path::Path::new("src/handler.ts")) + .unwrap(); + index + .add_path(std::path::Path::new("src/service.ts")) + .unwrap(); + index.write().unwrap(); + } + + // Run diff_staged + let repo = git2::Repository::open(rb.path()).unwrap(); + let diff_result = git::diff_staged(&repo).unwrap(); + + // Should include exactly the 2 staged files + let staged_paths: Vec<&str> = diff_result.files.iter().map(|f| f.path()).collect(); + assert_eq!( + staged_paths.len(), + 2, + "expected 2 staged files, got {:?}", + staged_paths + ); + assert!( + staged_paths.contains(&"src/handler.ts"), + "handler.ts should be staged" + ); + assert!( + staged_paths.contains(&"src/service.ts"), + "service.ts should be staged" + ); + assert!( + !staged_paths.contains(&"src/utils.ts"), + "utils.ts should NOT be in staged diff" + ); + + // Verify the staged content is correct + for file_diff in &diff_result.files { + if let Some(ref new_content) = file_diff.new_content { + assert!( + new_content.contains("v2-staged"), + "staged file {} should have v2-staged content", + file_diff.path() + ); + } + } +} + +/// Test: Config overrides — custom entrypoint globs detect files that heuristics miss. +/// +/// Creates files in non-standard locations that aren't detected by built-in +/// heuristics, then provides a `.diffcore.toml` with custom entrypoint globs +/// that should pick them up. +#[test] +fn test_e2e_config_overrides() { + let rb = RepoBuilder::new(); + + rb.write_file("package.json", r#"{"name": "custom-app"}"#); + rb.commit("Initial"); + rb.create_branch("main"); + + rb.create_branch("feature/custom-ep"); + rb.checkout("feature/custom-ep"); + + // Files in non-standard locations (no framework imports, no standard paths) + // These would NOT be detected as entrypoints by default heuristics + rb.write_file( + "src/triggers/onUserCreated.ts", + r#" +import { notifyAdmin } from '../notifications/admin'; + +export function handleUserCreated(event: any) { + notifyAdmin(event.userId); + return { processed: true }; +} +"#, + ); + rb.write_file( + "src/triggers/onOrderPlaced.ts", + r#" +import { processPayment } from '../billing/payment'; + +export function handleOrderPlaced(event: any) { + processPayment(event.orderId, event.amount); + return { processed: true }; +} +"#, + ); + rb.write_file( + "src/notifications/admin.ts", + r#" +export function notifyAdmin(userId: string) { + console.log('Notifying admin about user:', userId); +} +"#, + ); + rb.write_file( + "src/billing/payment.ts", + r#" +export function processPayment(orderId: string, amount: number) { + console.log('Processing payment:', orderId, amount); +} +"#, + ); + + // Provide a config that declares triggers as event entrypoints + rb.write_file( + ".diffcore.toml", + r#" +[entrypoints] +events = ["src/triggers/**/*.ts"] +"#, + ); + + rb.commit("Add triggers with custom config"); + + // Run pipeline WITH config loaded from repo + let config = diffcore_core::config::DiffcoreConfig::load_from_dir(rb.path()).unwrap(); + + // Verify config was loaded and has our custom entrypoints + assert!( + !config.entrypoints.events.is_empty(), + "config should have event entrypoint globs" + ); + + // Resolve entrypoint globs + let resolved = config.resolve_entrypoint_globs(rb.path()); + assert!( + resolved + .iter() + .any(|p| p.to_string_lossy().contains("triggers")), + "config should resolve trigger files; resolved: {:?}", + resolved + ); + + // Run full pipeline and verify files are accounted for + let output = run_pipeline(rb.path(), "main", "feature/custom-ep"); + + assert!( + output.summary.total_files_changed >= 4, + "expected >= 4 changed files, got {}", + output.summary.total_files_changed + ); + assert_all_files_accounted(&output); + assert_valid_json_schema(&output); +} diff --git a/crates/diffcore-core/tests/e2e_pipeline.rs b/crates/diffcore-core/tests/e2e_pipeline.rs index 32da3a3..bfa2a1f 100644 --- a/crates/diffcore-core/tests/e2e_pipeline.rs +++ b/crates/diffcore-core/tests/e2e_pipeline.rs @@ -777,3028 +777,3 @@ export default app; ); } -// ─── Go Integration Tests ──────────────────────────────────────────────── - -/// Test: Synthetic Go HTTP API with handler → service → repo pattern. -/// -/// Creates a Go app with Gin framework: -/// main.go → handlers/user.go → services/user.go → repositories/user.go -/// -/// Verifies: -/// - Go language detection -/// - Import extraction from Go files -/// - Function/struct/interface definitions -/// - Call site detection -/// - HTTP route entrypoint detection -/// - Framework detection (Gin) -/// - Pipeline produces valid groups and JSON output -#[test] -fn test_e2e_go_http_api() { - let rb = RepoBuilder::new(); - - // Initial commit - rb.write_file("go.mod", "module github.com/example/api\n\ngo 1.21\n"); - rb.commit("Initial commit: go.mod"); - rb.create_branch("main"); - - // Feature branch: add Go API - rb.create_branch("feature/go-api"); - rb.checkout("feature/go-api"); - - rb.write_file( - "cmd/server/main.go", - r#" -package main - -import ( - "github.com/gin-gonic/gin" - "github.com/example/api/handlers" -) - -func main() { - r := gin.Default() - handlers.RegisterRoutes(r) - r.Run(":8080") -} -"#, - ); - - rb.write_file( - "handlers/user.go", - r#" -package handlers - -import ( - "github.com/gin-gonic/gin" - "github.com/example/api/services" -) - -func RegisterRoutes(r *gin.Engine) { - r.GET("/users/:id", GetUser) - r.POST("/users", CreateUser) -} - -func GetUser(c *gin.Context) { - id := c.Param("id") - user := services.FindUser(id) - c.JSON(200, user) -} - -func CreateUser(c *gin.Context) { - data := services.ParseInput(c) - user := services.CreateUser(data) - c.JSON(201, user) -} -"#, - ); - - rb.write_file( - "services/user.go", - r#" -package services - -import ( - "github.com/gin-gonic/gin" - "github.com/example/api/repositories" -) - -type UserInput struct { - Name string - Email string -} - -func ParseInput(c *gin.Context) UserInput { - var input UserInput - c.BindJSON(&input) - return input -} - -func FindUser(id string) *repositories.User { - return repositories.GetByID(id) -} - -func CreateUser(data UserInput) *repositories.User { - user := repositories.User{ - Name: data.Name, - Email: data.Email, - } - return repositories.Insert(&user) -} -"#, - ); - - rb.write_file( - "repositories/user.go", - r#" -package repositories - -type User struct { - ID string - Name string - Email string -} - -var users = make(map[string]*User) - -func GetByID(id string) *User { - return users[id] -} - -func Insert(user *User) *User { - user.ID = "generated-id" - users[user.ID] = user - return user -} -"#, - ); - - rb.commit("Add Go HTTP API with handler-service-repo"); - - let result = run_pipeline(rb.path(), "main", "feature/go-api"); - - // Verify basic output shape - assert_valid_json_schema(&result); - assert_valid_scores(&result); - - // Verify Go files were detected - assert_language_detected(&result, "go"); - - // Verify all changed files are accounted for - assert_all_files_accounted(&result); - - // Verify there are flow groups - assert!( - !result.groups.is_empty(), - "should produce at least one flow group" - ); - - // Verify CLI entrypoint detection (func main) - let has_cli_ep = result.groups.iter().any(|g| { - g.entrypoint.as_ref().map_or(false, |ep| { - ep.entrypoint_type == diffcore_core::types::EntrypointType::CliCommand - }) - }); - assert!(has_cli_ep, "should detect func main() as CLI entrypoint"); - - // Verify Mermaid graph is valid - assert_valid_mermaid(&result); -} - -/// Test: Go test file detection. -/// -/// Verifies that `_test.go` files are detected as test file entrypoints, -/// and that Go Test* functions are recognized as test symbols. -#[test] -fn test_e2e_go_test_file_detection() { - let rb = RepoBuilder::new(); - - rb.write_file("go.mod", "module example.com/app\n\ngo 1.21\n"); - rb.commit("Initial commit"); - rb.create_branch("main"); - - rb.create_branch("feature/tests"); - rb.checkout("feature/tests"); - - rb.write_file( - "handlers/user.go", - r#" -package handlers - -func GetUser(id string) string { - return "user-" + id -} -"#, - ); - - rb.write_file( - "handlers/user_test.go", - r#" -package handlers - -import "testing" - -func TestGetUser(t *testing.T) { - result := GetUser("123") - if result != "user-123" { - t.Errorf("unexpected: %s", result) - } -} - -func BenchmarkGetUser(b *testing.B) { - for i := 0; i < b.N; i++ { - GetUser("123") - } -} -"#, - ); - - rb.commit("Add user handler with tests"); - - let result = run_pipeline(rb.path(), "main", "feature/tests"); - - // Verify test file entrypoint detection - let test_eps: Vec<_> = result - .groups - .iter() - .flat_map(|g| g.entrypoint.as_ref()) - .filter(|ep| ep.entrypoint_type == diffcore_core::types::EntrypointType::TestFile) - .collect(); - assert!( - !test_eps.is_empty(), - "should detect _test.go as test file entrypoint" - ); -} - -// ===================================================================== -// Rust language integration tests -// ===================================================================== - -/// Test: Synthetic Rust axum API with handler→service→repo pattern. -/// -/// Creates a 5-file Rust HTTP API using axum and verifies the full pipeline: -/// language detection, import extraction, definition extraction, call sites, -/// entrypoint detection, framework detection, grouping, and JSON output. -#[test] -fn test_e2e_rust_axum_api() { - let rb = RepoBuilder::new(); - - // Initial commit - rb.write_file( - "Cargo.toml", - r#"[package] -name = "my-api" -version = "0.1.0" -edition = "2021" - -[dependencies] -axum = "0.7" -tokio = { version = "1", features = ["full"] } -serde = { version = "1", features = ["derive"] } -sqlx = "0.7" -"#, - ); - rb.commit("Initial commit: Cargo.toml"); - rb.create_branch("main"); - - // Feature branch: add Rust API - rb.create_branch("feature/rust-api"); - rb.checkout("feature/rust-api"); - - rb.write_file( - "src/main.rs", - r#" -use axum::{Router, routing::get, routing::post}; -use crate::handlers; - -mod handlers; -mod services; -mod repositories; -mod models; - -#[tokio::main] -async fn main() { - let app = Router::new() - .route("/users/:id", get(handlers::get_user)) - .route("/users", post(handlers::create_user)); - - let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap(); - axum::serve(listener, app).await.unwrap(); -} -"#, - ); - - rb.write_file( - "src/handlers.rs", - r#" -use axum::{extract::Path, Json}; -use crate::models::User; -use crate::services; - -pub async fn get_user(Path(id): Path) -> Json { - let user = services::find_user(id).await; - Json(user) -} - -pub async fn create_user(Json(input): Json) -> Json { - let user = services::create_user(input.name, input.email).await; - Json(user) -} - -#[derive(serde::Deserialize)] -pub struct CreateUserInput { - pub name: String, - pub email: String, -} -"#, - ); - - rb.write_file( - "src/services.rs", - r#" -use crate::models::User; -use crate::repositories; - -pub async fn find_user(id: u64) -> User { - repositories::get_by_id(id).await -} - -pub async fn create_user(name: String, email: String) -> User { - let user = User { - id: 0, - name, - email, - }; - repositories::insert(user).await -} -"#, - ); - - rb.write_file( - "src/repositories.rs", - r#" -use crate::models::User; -use sqlx::PgPool; - -pub async fn get_by_id(id: u64) -> User { - User { - id, - name: "Alice".to_string(), - email: "alice@example.com".to_string(), - } -} - -pub async fn insert(user: User) -> User { - User { - id: 1, - ..user - } -} -"#, - ); - - rb.write_file( - "src/models.rs", - r#" -use serde::{Serialize, Deserialize}; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct User { - pub id: u64, - pub name: String, - pub email: String, -} -"#, - ); - - rb.commit("Add Rust axum HTTP API with handler-service-repo"); - - let result = run_pipeline(rb.path(), "main", "feature/rust-api"); - - // Verify basic output shape - assert_valid_json_schema(&result); - assert_valid_scores(&result); - - // Verify Rust files were detected - assert_language_detected(&result, "rust"); - - // Verify all changed files are accounted for - assert_all_files_accounted(&result); - - // Verify there are flow groups - assert!( - !result.groups.is_empty(), - "should produce at least one flow group" - ); - - // Verify entrypoint detection (fn main or HTTP routes) - let has_entrypoint = result.groups.iter().any(|g| g.entrypoint.is_some()); - assert!(has_entrypoint, "should detect at least one entrypoint"); - - // Verify HTTP route detection (axum Router patterns) - let has_http_ep = result.groups.iter().any(|g| { - g.entrypoint.as_ref().map_or(false, |ep| { - ep.entrypoint_type == diffcore_core::types::EntrypointType::HttpRoute - }) - }); - assert!(has_http_ep, "should detect axum HTTP route entrypoints"); - - // Verify framework detection (Axum) - let has_axum = result - .summary - .frameworks_detected - .iter() - .any(|f| f.contains("Axum") || f.contains("axum")); - assert!( - has_axum, - "should detect Axum framework; detected: {:?}", - result.summary.frameworks_detected - ); - - // Verify Mermaid graph is valid - assert_valid_mermaid(&result); -} - -/// Test: Rust test file detection. -/// -/// Verifies that `_test.rs` files and functions with test_ prefix are detected. -#[test] -fn test_e2e_rust_test_file_detection() { - let rb = RepoBuilder::new(); - - rb.write_file( - "Cargo.toml", - r#"[package] -name = "my-app" -version = "0.1.0" -edition = "2021" -"#, - ); - rb.commit("Initial commit"); - rb.create_branch("main"); - - rb.create_branch("feature/tests"); - rb.checkout("feature/tests"); - - rb.write_file( - "src/lib.rs", - r#" -pub fn add(a: i32, b: i32) -> i32 { - a + b -} -"#, - ); - - rb.write_file( - "src/lib_test.rs", - r#" -use crate::add; - -fn test_add() { - assert_eq!(add(2, 3), 5); -} - -fn test_add_negative() { - assert_eq!(add(-1, 1), 0); -} -"#, - ); - - rb.commit("Add lib with tests"); - - let result = run_pipeline(rb.path(), "main", "feature/tests"); - - // Verify test file detection - let test_eps: Vec<_> = result - .groups - .iter() - .flat_map(|g| g.entrypoint.as_ref()) - .filter(|ep| ep.entrypoint_type == diffcore_core::types::EntrypointType::TestFile) - .collect(); - assert!( - !test_eps.is_empty(), - "should detect _test.rs as test file entrypoint" - ); -} - -// --------------------------------------------------------------------------- -// Java integration tests (Phase 11.2) -// --------------------------------------------------------------------------- - -/// Test: Java Spring Boot REST API with controller → service → repository pattern. -/// -/// Verifies full pipeline: language detection, file accounting, flow groups, -/// entrypoint detection, framework detection, and Mermaid graph. -#[test] -fn test_e2e_java_spring_boot_api() { - let rb = RepoBuilder::new(); - - // Initial commit - rb.write_file( - "pom.xml", - r#" - 4.0.0 - com.example - demo - 0.0.1-SNAPSHOT - - - org.springframework.boot - spring-boot-starter-web - - - -"#, - ); - rb.commit("Initial commit: pom.xml"); - rb.create_branch("main"); - - // Feature branch: add Spring Boot API - rb.create_branch("feature/java-api"); - rb.checkout("feature/java-api"); - - rb.write_file( - "src/main/java/com/example/demo/DemoApplication.java", - r#" -package com.example.demo; - -import org.springframework.boot.SpringApplication; -import org.springframework.boot.autoconfigure.SpringBootApplication; - -@SpringBootApplication -public class DemoApplication { - public static void main(String[] args) { - SpringApplication.run(DemoApplication.class, args); - } -} -"#, - ); - - rb.write_file( - "src/main/java/com/example/demo/controller/UserController.java", - r#" -package com.example.demo.controller; - -import java.util.List; -import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import com.example.demo.model.User; -import com.example.demo.service.UserService; - -@RestController -public class UserController { - - private final UserService userService; - - public UserController(UserService userService) { - this.userService = userService; - } - - @GetMapping("/users") - public List getUsers() { - return userService.findAll(); - } - - @PostMapping("/users") - public User createUser(@RequestBody User user) { - return userService.save(user); - } -} -"#, - ); - - rb.write_file( - "src/main/java/com/example/demo/service/UserService.java", - r#" -package com.example.demo.service; - -import java.util.List; -import com.example.demo.model.User; -import com.example.demo.repository.UserRepository; - -public class UserService { - - private final UserRepository userRepository; - - public UserService(UserRepository userRepository) { - this.userRepository = userRepository; - } - - public List findAll() { - return userRepository.findAll(); - } - - public User save(User user) { - return userRepository.save(user); - } -} -"#, - ); - - rb.write_file( - "src/main/java/com/example/demo/repository/UserRepository.java", - r#" -package com.example.demo.repository; - -import java.util.List; -import java.util.ArrayList; -import com.example.demo.model.User; - -public class UserRepository { - - private final List users = new ArrayList<>(); - - public List findAll() { - return users; - } - - public User save(User user) { - users.add(user); - return user; - } -} -"#, - ); - - rb.write_file( - "src/main/java/com/example/demo/model/User.java", - r#" -package com.example.demo.model; - -public class User { - private Long id; - private String name; - private String email; - - public User() {} - - public User(String name, String email) { - this.name = name; - this.email = email; - } - - public Long getId() { return id; } - public void setId(Long id) { this.id = id; } - public String getName() { return name; } - public void setName(String name) { this.name = name; } - public String getEmail() { return email; } - public void setEmail(String email) { this.email = email; } -} -"#, - ); - - rb.commit("Add Spring Boot REST API with controller-service-repo"); - - let result = run_pipeline(rb.path(), "main", "feature/java-api"); - - // Verify basic output shape - assert_valid_json_schema(&result); - assert_valid_scores(&result); - - // Verify Java files were detected - assert_language_detected(&result, "java"); - - // Verify all changed files are accounted for - assert_all_files_accounted(&result); - - // Verify there are flow groups - assert!( - !result.groups.is_empty(), - "should produce at least one flow group" - ); - - // Verify entrypoint detection (main or HTTP routes) - let has_entrypoint = result.groups.iter().any(|g| g.entrypoint.is_some()); - assert!(has_entrypoint, "should detect at least one entrypoint"); - - // Verify Spring Boot framework detection - let has_spring = result - .summary - .frameworks_detected - .iter() - .any(|f| f.contains("Spring")); - assert!( - has_spring, - "should detect Spring Boot framework; detected: {:?}", - result.summary.frameworks_detected - ); - - // Verify Mermaid graph is valid - assert_valid_mermaid(&result); -} - -/// Test: Java test file detection. -/// -/// Verifies that *Test.java files and @Test annotated methods are detected as test entrypoints. -#[test] -fn test_e2e_java_test_file_detection() { - let rb = RepoBuilder::new(); - - rb.write_file( - "pom.xml", - r#" - 4.0.0 - com.example - demo - 0.0.1-SNAPSHOT - -"#, - ); - rb.commit("Initial commit"); - rb.create_branch("main"); - - rb.create_branch("feature/tests"); - rb.checkout("feature/tests"); - - rb.write_file( - "src/main/java/com/example/demo/UserService.java", - r#" -package com.example.demo; - -public class UserService { - public String greet(String name) { - return "Hello, " + name; - } -} -"#, - ); - - rb.write_file( - "src/test/java/com/example/demo/UserServiceTest.java", - r#" -package com.example.demo; - -import org.junit.jupiter.api.Test; -import static org.junit.jupiter.api.Assertions.assertEquals; - -public class UserServiceTest { - - @Test - public void testGreet() { - UserService svc = new UserService(); - assertEquals("Hello, Alice", svc.greet("Alice")); - } - - @Test - public void testGreetEmpty() { - UserService svc = new UserService(); - assertEquals("Hello, ", svc.greet("")); - } -} -"#, - ); - - rb.commit("Add UserService and tests"); - - let result = run_pipeline(rb.path(), "main", "feature/tests"); - - // Verify test file detection - let test_eps: Vec<_> = result - .groups - .iter() - .flat_map(|g| g.entrypoint.as_ref()) - .filter(|ep| ep.entrypoint_type == diffcore_core::types::EntrypointType::TestFile) - .collect(); - assert!( - !test_eps.is_empty(), - "should detect *Test.java as test file entrypoint" - ); -} - -// --------------------------------------------------------------------------- -// C# integration tests (Phase 11.2) -// --------------------------------------------------------------------------- - -/// Test: C# ASP.NET Core Web API with controller → service → repository pattern. -/// -/// Verifies full pipeline: language detection, file accounting, flow groups, -/// entrypoint detection, framework detection, and Mermaid graph. -#[test] -fn test_e2e_csharp_aspnet_core_api() { - let rb = RepoBuilder::new(); - - // Initial commit - rb.write_file( - "MyApp.csproj", - r#" - - net8.0 - - - - - -"#, - ); - rb.commit("Initial commit: csproj"); - rb.create_branch("main"); - - // Feature branch: add ASP.NET Core API - rb.create_branch("feature/csharp-api"); - rb.checkout("feature/csharp-api"); - - rb.write_file( - "Program.cs", - r#" -using Microsoft.AspNetCore.Builder; -using Microsoft.Extensions.DependencyInjection; - -var builder = WebApplication.CreateBuilder(args); -builder.Services.AddControllers(); -var app = builder.Build(); -app.MapControllers(); -app.Run(); -"#, - ); - - rb.write_file( - "Controllers/UsersController.cs", - r#" -using System.Collections.Generic; -using Microsoft.AspNetCore.Mvc; -using MyApp.Models; -using MyApp.Services; - -namespace MyApp.Controllers -{ - [ApiController] - [Route("api/[controller]")] - public class UsersController : ControllerBase - { - private readonly IUserService _userService; - - public UsersController(IUserService userService) - { - _userService = userService; - } - - [HttpGet] - public ActionResult> GetUsers() - { - return Ok(_userService.FindAll()); - } - - [HttpPost] - public ActionResult CreateUser(User user) - { - return Ok(_userService.Save(user)); - } - } -} -"#, - ); - - rb.write_file( - "Services/UserService.cs", - r#" -using System.Collections.Generic; -using MyApp.Models; -using MyApp.Repositories; - -namespace MyApp.Services -{ - public interface IUserService - { - List FindAll(); - User Save(User user); - } - - public class UserService : IUserService - { - private readonly IUserRepository _repository; - - public UserService(IUserRepository repository) - { - _repository = repository; - } - - public List FindAll() - { - return _repository.FindAll(); - } - - public User Save(User user) - { - return _repository.Save(user); - } - } -} -"#, - ); - - rb.write_file( - "Repositories/UserRepository.cs", - r#" -using System.Collections.Generic; -using MyApp.Models; - -namespace MyApp.Repositories -{ - public interface IUserRepository - { - List FindAll(); - User Save(User user); - } - - public class UserRepository : IUserRepository - { - private readonly List _users = new List(); - - public List FindAll() - { - return _users; - } - - public User Save(User user) - { - _users.Add(user); - return user; - } - } -} -"#, - ); - - rb.write_file( - "Models/User.cs", - r#" -namespace MyApp.Models -{ - public record User(int Id, string Name, string Email); -} -"#, - ); - - rb.commit("Add ASP.NET Core Web API with controller-service-repo"); - - let result = run_pipeline(rb.path(), "main", "feature/csharp-api"); - - // Verify basic output shape - assert_valid_json_schema(&result); - assert_valid_scores(&result); - - // Verify C# files were detected - assert_language_detected(&result, "csharp"); - - // Verify all changed files are accounted for - assert_all_files_accounted(&result); - - // Verify there are flow groups - assert!( - !result.groups.is_empty(), - "should produce at least one flow group" - ); - - // Verify entrypoint detection (Main or HTTP routes) - let has_entrypoint = result.groups.iter().any(|g| g.entrypoint.is_some()); - assert!(has_entrypoint, "should detect at least one entrypoint"); - - // Verify ASP.NET Core framework detection - let has_aspnet = result - .summary - .frameworks_detected - .iter() - .any(|f| f.contains("ASP.NET")); - assert!( - has_aspnet, - "should detect ASP.NET Core framework; detected: {:?}", - result.summary.frameworks_detected - ); - - // Verify Mermaid graph is valid - assert_valid_mermaid(&result); -} - -/// Test: C# test file detection. -/// -/// Verifies that *Test.cs and *Tests.cs files are detected as test entrypoints. -#[test] -fn test_e2e_csharp_test_file_detection() { - let rb = RepoBuilder::new(); - - rb.write_file( - "MyApp.csproj", - r#" - - net8.0 - - -"#, - ); - rb.commit("Initial commit"); - rb.create_branch("main"); - - rb.create_branch("feature/tests"); - rb.checkout("feature/tests"); - - rb.write_file( - "Services/UserService.cs", - r#" -namespace MyApp.Services -{ - public class UserService - { - public string GetGreeting(string name) - { - return $"Hello, {name}!"; - } - } -} -"#, - ); - - rb.write_file( - "Tests/UserServiceTests.cs", - r#" -using Xunit; -using MyApp.Services; - -namespace MyApp.Tests -{ - public class UserServiceTests - { - [Fact] - public void GetGreeting_ReturnsExpected() - { - var svc = new UserService(); - var result = svc.GetGreeting("World"); - Assert.Equal("Hello, World!", result); - } - } -} -"#, - ); - - rb.commit("Add UserService and tests"); - - let result = run_pipeline(rb.path(), "main", "feature/tests"); - - // Verify test file detection - let test_eps: Vec<_> = result - .groups - .iter() - .flat_map(|g| g.entrypoint.as_ref()) - .filter(|ep| ep.entrypoint_type == diffcore_core::types::EntrypointType::TestFile) - .collect(); - assert!( - !test_eps.is_empty(), - "should detect *Tests.cs as test file entrypoint" - ); -} - -// ─── PHP E2E Tests ──────────────────────────────────────────────────────── - -/// Test: Synthetic Laravel REST API with controller → service → model pattern. -/// -/// Verifies PHP parsing, import resolution, entrypoint detection (Laravel controllers), -/// framework detection (Laravel), and flow grouping. -#[test] -fn test_e2e_php_laravel_api() { - let rb = RepoBuilder::new(); - - // Initial commit - rb.write_file( - "composer.json", - r#"{"name": "example/demo", "require": {"laravel/framework": "^11.0"}}"#, - ); - rb.commit("Initial commit: composer.json"); - rb.create_branch("main"); - - // Feature branch: add Laravel REST API - rb.create_branch("feature/php-api"); - rb.checkout("feature/php-api"); - - rb.write_file( - "app/Http/Controllers/UserController.php", - r#"userService = $userService; - } - - public function index() - { - $users = User::all(); - return response()->json($users); - } - - public function store(Request $request) - { - $data = $request->validated(); - $user = User::create($data); - return response()->json($user, 201); - } - - public function show(User $user) - { - return response()->json($user); - } - - public function destroy(User $user) - { - $user->delete(); - return response()->json(null, 204); - } -} -"#, - ); - - rb.write_file( - "app/Models/User.php", - r#"hasMany(Post::class); - } -} -"#, - ); - - rb.write_file( - "app/Services/UserService.php", - r#"update($data); - return $user; - } - - public function delete(User $user) - { - $user->delete(); - } -} -"#, - ); - - rb.write_file( - "app/Providers/AppServiceProvider.php", - r#"greet("Alice"); - $this->assertEquals("Hello, Alice", $result); - } - - public function test_greet_empty() - { - $service = new UserService(); - $result = $service->greet(""); - $this->assertEquals("Hello, ", $result); - } -} -"#, - ); - - rb.commit("Add UserService with PHPUnit tests"); - - let result = run_pipeline(rb.path(), "main", "feature/tests"); - - // Verify test file detection - let test_eps: Vec<_> = result - .groups - .iter() - .flat_map(|g| g.entrypoint.as_ref()) - .filter(|ep| ep.entrypoint_type == diffcore_core::types::EntrypointType::TestFile) - .collect(); - assert!( - !test_eps.is_empty(), - "should detect *Test.php as test file entrypoint" - ); - - // Verify PHP language detected - assert_language_detected(&result, "php"); - - // Verify PHPUnit framework detected - let has_phpunit = result - .summary - .frameworks_detected - .iter() - .any(|f| f.contains("PHPUnit")); - assert!( - has_phpunit, - "should detect PHPUnit framework; detected: {:?}", - result.summary.frameworks_detected - ); -} - -/// Test: Ruby Rails REST API with controller→service→model pattern. -/// -/// Verifies that the pipeline can: -/// - Parse Ruby source files via tree-sitter -/// - Extract require/require_relative imports, include/extend mixins -/// - Detect class, module, and method definitions -/// - Detect Rails controller action entrypoints -/// - Detect Rails framework from imports -/// - Cluster files into meaningful flow groups -#[test] -fn test_e2e_ruby_rails_api() { - let rb = RepoBuilder::new(); - - // Initial commit - rb.write_file( - "Gemfile", - "source 'https://rubygems.org'\ngem 'rails', '~> 7.1'\n", - ); - rb.commit("Initial commit: Gemfile"); - rb.create_branch("main"); - - // Feature branch: add Rails REST API - rb.create_branch("feature/ruby-api"); - rb.checkout("feature/ruby-api"); - - rb.write_file( - "app/controllers/users_controller.rb", - r#"require 'action_controller' -require_relative '../models/user' -require_relative '../services/user_service' - -class UsersController < ApplicationController - include Authentication - - def index - @users = User.all() - respond_to() - end - - def show - @user = User.find(params()) - end - - def create - @user = UserService.new().create(user_params()) - redirect_to(@user) - end - - def destroy - @user = User.find(params()) - @user.destroy() - end - - private - - def user_params - params().require().permit() - end -end -"#, - ); - - rb.write_file( - "app/models/user.rb", - r#"require 'active_record' - -class User < ActiveRecord::Base - include Validatable - - def full_name - first_name.to_s() - end - - def active? - status == 'active' - end -end -"#, - ); - - rb.write_file( - "app/services/user_service.rb", - r#"require_relative '../models/user' - -class UserService - def create(attrs) - user = User.new(attrs) - user.save() - notify(user) - user - end - - def find(id) - User.find(id) - end - - private - - def notify(user) - EventBus.publish('user.created', user) - end -end -"#, - ); - - rb.write_file( - "config/routes.rb", - r#"require 'action_controller' - -Rails.application.routes.draw() -"#, - ); - - rb.commit("Add Rails REST API with controller-service-model"); - - let result = run_pipeline(rb.path(), "main", "feature/ruby-api"); - - // Verify basic output shape - assert_valid_json_schema(&result); - assert_valid_scores(&result); - - // Verify Ruby files were detected - assert_language_detected(&result, "ruby"); - - // Verify all changed files are accounted for - assert_all_files_accounted(&result); - - // Verify there are flow groups - assert!( - !result.groups.is_empty(), - "should produce at least one flow group" - ); - - // Verify entrypoint detection (controller action methods) - let has_entrypoint = result.groups.iter().any(|g| g.entrypoint.is_some()); - assert!( - has_entrypoint, - "should detect at least one entrypoint (Rails controller actions)" - ); - - // Verify Rails framework detection - let has_rails = result - .summary - .frameworks_detected - .iter() - .any(|f| f.contains("Rails")); - assert!( - has_rails, - "should detect Rails framework; detected: {:?}", - result.summary.frameworks_detected - ); - - // Verify Mermaid graph is valid - assert_valid_mermaid(&result); - - // Verify JSON roundtrip - assert_json_roundtrip(&result); -} - -/// Test: Ruby test file detection. -/// -/// Verifies that *_spec.rb and *_test.rb files are detected as test entrypoints. -#[test] -fn test_e2e_ruby_test_file_detection() { - let rb = RepoBuilder::new(); - - rb.write_file("Gemfile", "source 'https://rubygems.org'\ngem 'rspec'\n"); - rb.commit("Initial commit"); - rb.create_branch("main"); - - rb.create_branch("feature/tests"); - rb.checkout("feature/tests"); - - rb.write_file( - "app/services/user_service.rb", - "class UserService\n def greet(name)\n name.to_s()\n end\nend\n", - ); - - rb.write_file( - "spec/services/user_service_spec.rb", - r#"require 'rspec' -require_relative '../../app/services/user_service' - -RSpec.describe(UserService) - -class UserServiceSpec - def test_greet - service = UserService.new() - result = service.greet("Alice") - end - - def test_greet_empty - service = UserService.new() - result = service.greet("") - end -end -"#, - ); - - rb.commit("Add UserService with RSpec tests"); - - let result = run_pipeline(rb.path(), "main", "feature/tests"); - - // Verify test file detection - let test_eps: Vec<_> = result - .groups - .iter() - .flat_map(|g| g.entrypoint.as_ref()) - .filter(|ep| ep.entrypoint_type == diffcore_core::types::EntrypointType::TestFile) - .collect(); - assert!( - !test_eps.is_empty(), - "should detect *_spec.rb as test file entrypoint" - ); - - // Verify Ruby language detected - assert_language_detected(&result, "ruby"); - - // Verify RSpec framework detected - let has_rspec = result - .summary - .frameworks_detected - .iter() - .any(|f| f.contains("RSpec")); - assert!( - has_rspec, - "should detect RSpec framework; detected: {:?}", - result.summary.frameworks_detected - ); -} - -/// Test: Kotlin Ktor REST API with handler→service→repo pattern. -/// -/// Verifies that the pipeline can: -/// - Parse Kotlin source files via tree-sitter -/// - Extract import statements (regular, aliased, wildcard) -/// - Detect fun, class, object, val/var definitions -/// - Detect Ktor route handler entrypoints -/// - Detect Ktor framework from imports -/// - Cluster files into meaningful flow groups -#[test] -fn test_e2e_kotlin_ktor_api() { - let rb = RepoBuilder::new(); - - // Initial commit - rb.write_file("build.gradle.kts", "plugins {\n kotlin(\"jvm\")\n}\n"); - rb.commit("Initial commit: build.gradle.kts"); - rb.create_branch("main"); - - // Feature branch: add Ktor REST API - rb.create_branch("feature/kotlin-api"); - rb.checkout("feature/kotlin-api"); - - rb.write_file( - "src/main/kotlin/routes/UserRoutes.kt", - r#"import io.ktor.server.routing.Route -import io.ktor.server.routing.get -import io.ktor.server.routing.post -import io.ktor.server.response.respond -import com.example.services.UserService - -fun Route.userRoutes(userService: UserService) { - get("/users") { - val users = userService.findAll() - call.respond(users) - } - - post("/users") { - val user = userService.create(call) - call.respond(user) - } - - get("/users/{id}") { - val user = userService.findById(call) - call.respond(user) - } -} -"#, - ); - - rb.write_file( - "src/main/kotlin/services/UserService.kt", - r#"import com.example.repositories.UserRepository -import com.example.models.User - -class UserService(private val repository: UserRepository) { - fun findAll(): List { - val users = repository.findAll() - return users - } - - fun findById(id: String): User { - val user = repository.findById(id) - return user - } - - fun create(data: Map): User { - val user = repository.save(data) - return user - } -} -"#, - ); - - rb.write_file( - "src/main/kotlin/repositories/UserRepository.kt", - r#"import org.jetbrains.exposed.sql.Database -import com.example.models.User - -class UserRepository(private val db: Database) { - fun findAll(): List { - val results = db.query("SELECT * FROM users") - return results - } - - fun findById(id: String): User { - val result = db.query("SELECT * FROM users WHERE id = ?") - return result - } - - fun save(data: Map): User { - val result = db.execute("INSERT INTO users ...") - return result - } -} -"#, - ); - - rb.write_file( - "src/main/kotlin/models/User.kt", - r#"import kotlinx.serialization.Serializable - -@Serializable -data class User( - val id: String, - val name: String, - val email: String -) -"#, - ); - - rb.write_file( - "src/main/kotlin/Application.kt", - r#"import io.ktor.server.engine.embeddedServer -import io.ktor.server.netty.Netty -import com.example.routes.userRoutes -import com.example.services.UserService -import com.example.repositories.UserRepository - -fun main() { - val repo = UserRepository() - val service = UserService(repo) - embeddedServer(Netty, port = 8080) { - userRoutes(service) - } -} -"#, - ); - - rb.commit("Add Ktor REST API with routes-service-repo"); - - let result = run_pipeline(rb.path(), "main", "feature/kotlin-api"); - - // Verify basic output shape - assert_valid_json_schema(&result); - assert_valid_scores(&result); - - // Verify Kotlin files were detected - assert_language_detected(&result, "kotlin"); - - // Verify all changed files are accounted for - assert_all_files_accounted(&result); - - // Verify there are flow groups - assert!( - !result.groups.is_empty(), - "should produce at least one flow group" - ); - - // Verify entrypoint detection (Ktor route handlers or main) - let has_entrypoint = result.groups.iter().any(|g| g.entrypoint.is_some()); - assert!( - has_entrypoint, - "should detect at least one entrypoint (Ktor routes or main)" - ); - - // Verify Ktor framework detection - let has_ktor = result - .summary - .frameworks_detected - .iter() - .any(|f| f.contains("Ktor")); - assert!( - has_ktor, - "should detect Ktor framework; detected: {:?}", - result.summary.frameworks_detected - ); - - // Verify Mermaid graph is valid - assert_valid_mermaid(&result); - - // Verify JSON roundtrip - assert_json_roundtrip(&result); -} - -/// Test: Kotlin test file detection. -/// -/// Verifies that *Test.kt files are detected as test entrypoints. -#[test] -fn test_e2e_kotlin_test_file_detection() { - let rb = RepoBuilder::new(); - - rb.write_file("build.gradle.kts", "plugins {\n kotlin(\"jvm\")\n}\n"); - rb.commit("Initial commit"); - rb.create_branch("main"); - - rb.create_branch("feature/tests"); - rb.checkout("feature/tests"); - - rb.write_file( - "src/main/kotlin/services/UserService.kt", - r#"import com.example.models.User - -class UserService { - fun greet(name: String): String { - return "Hello, $name" - } -} -"#, - ); - - rb.write_file( - "src/test/kotlin/services/UserServiceTest.kt", - r#"import org.junit.Test -import com.example.services.UserService - -class UserServiceTest { - fun testGreet() { - val service = UserService() - val result = service.greet("Alice") - } - - fun testGreetEmpty() { - val service = UserService() - val result = service.greet("") - } -} -"#, - ); - - rb.commit("Add UserService with JUnit tests"); - - let result = run_pipeline(rb.path(), "main", "feature/tests"); - - // Verify test file detection - let test_eps: Vec<_> = result - .groups - .iter() - .flat_map(|g| g.entrypoint.as_ref()) - .filter(|ep| ep.entrypoint_type == diffcore_core::types::EntrypointType::TestFile) - .collect(); - assert!( - !test_eps.is_empty(), - "should detect *Test.kt as test file entrypoint" - ); - - // Verify Kotlin language detected - assert_language_detected(&result, "kotlin"); - - // Verify JUnit framework detected - let has_junit = result - .summary - .frameworks_detected - .iter() - .any(|f| f.contains("JUnit")); - assert!( - has_junit, - "should detect JUnit framework; detected: {:?}", - result.summary.frameworks_detected - ); -} - -/// Test: Swift Vapor REST API with controller→service→repo pattern. -/// -/// Creates a synthetic Swift Vapor app to verify: -/// - Swift file detection (.swift extension) -/// - Import extraction (module-level imports) -/// - Definition extraction (struct, class, protocol, func) -/// - Call site extraction (method calls, function calls) -/// - Entrypoint detection (Vapor route handlers) -/// - Framework detection (Vapor, Fluent) -/// - Semantic grouping and ranking -#[test] -fn test_e2e_swift_vapor_api() { - let rb = RepoBuilder::new(); - - // Initial commit - rb.write_file( - "Package.swift", - "// swift-tools-version:5.9\nimport PackageDescription\n", - ); - rb.commit("Initial commit: Package.swift"); - rb.create_branch("main"); - - // Feature branch: add Vapor REST API - rb.create_branch("feature/swift-api"); - rb.checkout("feature/swift-api"); - - rb.write_file( - "Sources/App/Controllers/UserController.swift", - r#"import Vapor -import Fluent - -struct UserController: RouteCollection { - func boot(routes: RoutesBuilder) throws { - let users = routes.grouped("users") - users.get(use: index) - users.post(use: create) - } - - func index(req: Request) throws -> EventLoopFuture<[User]> { - return User.query(on: req.db).all() - } - - func create(req: Request) throws -> EventLoopFuture { - let user = try req.content.decode(User.self) - return user.save(on: req.db).map { user } - } -} -"#, - ); - - rb.write_file( - "Sources/App/Services/UserService.swift", - r#"import Foundation -import Vapor - -class UserService { - let repository: UserRepository - - init(repository: UserRepository) { - self.repository = repository - } - - func findAll() -> [User] { - let users = repository.findAll() - return users - } - - func findById(id: UUID) -> User? { - let user = repository.findById(id: id) - return user - } - - func create(name: String) -> User { - let user = repository.save(name: name) - return user - } -} -"#, - ); - - rb.write_file( - "Sources/App/Repositories/UserRepository.swift", - r#"import Foundation -import Fluent - -class UserRepository { - let db: Database - - init(db: Database) { - self.db = db - } - - func findAll() -> [User] { - let results = db.query(User.self) - return results - } - - func findById(id: UUID) -> User? { - let result = db.find(User.self, id: id) - return result - } - - func save(name: String) -> User { - let user = User(name: name) - db.save(user) - return user - } -} -"#, - ); - - rb.write_file( - "Sources/App/Models/User.swift", - r#"import Foundation -import Fluent - -final class User: Model, Content { - static let schema = "users" - - var id: UUID? - var name: String - - init() {} - - init(id: UUID? = nil, name: String) { - self.id = id - self.name = name - } -} -"#, - ); - - rb.write_file( - "Sources/App/configure.swift", - r#"import Vapor -import Fluent - -func configure(app: Application) throws { - let controller = UserController() - try app.register(collection: controller) -} -"#, - ); - - rb.commit("Add Vapor REST API with controller-service-repo"); - - let result = run_pipeline(rb.path(), "main", "feature/swift-api"); - - // Verify basic output shape - assert_valid_json_schema(&result); - assert_valid_scores(&result); - - // Verify Swift files were detected - assert_language_detected(&result, "swift"); - - // Verify all changed files are accounted for - assert_all_files_accounted(&result); - - // Verify there are flow groups - assert!( - !result.groups.is_empty(), - "should produce at least one flow group" - ); - - // Verify entrypoint detection (Vapor route handlers) - let has_entrypoint = result.groups.iter().any(|g| g.entrypoint.is_some()); - assert!( - has_entrypoint, - "should detect at least one entrypoint (Vapor routes)" - ); - - // Verify Vapor framework detection - let has_vapor = result - .summary - .frameworks_detected - .iter() - .any(|f| f.contains("Vapor")); - assert!( - has_vapor, - "should detect Vapor framework; detected: {:?}", - result.summary.frameworks_detected - ); - - // Verify Mermaid graph is valid - assert_valid_mermaid(&result); - - // Verify JSON roundtrip - assert_json_roundtrip(&result); -} - -/// Test: Swift test file detection. -/// -/// Verifies that *Tests.swift and *Test.swift files are detected as test entrypoints. -#[test] -fn test_e2e_swift_test_file_detection() { - let rb = RepoBuilder::new(); - - rb.write_file("Package.swift", "// swift-tools-version:5.9\n"); - rb.commit("Initial commit"); - rb.create_branch("main"); - - rb.create_branch("feature/tests"); - rb.checkout("feature/tests"); - - rb.write_file( - "Sources/App/Services/UserService.swift", - r#"import Foundation - -class UserService { - func greet(name: String) -> String { - return "Hello, \(name)" - } -} -"#, - ); - - rb.write_file( - "Tests/AppTests/UserServiceTests.swift", - r#"import XCTest -import Foundation - -final class UserServiceTests: XCTestCase { - func testGreet() { - let service = UserService() - let result = service.greet(name: "Alice") - } - - func testGreetEmpty() { - let service = UserService() - let result = service.greet(name: "") - } -} -"#, - ); - - rb.commit("Add UserService with XCTest tests"); - - let result = run_pipeline(rb.path(), "main", "feature/tests"); - - // Verify test file detection - let test_eps: Vec<_> = result - .groups - .iter() - .flat_map(|g| g.entrypoint.as_ref()) - .filter(|ep| ep.entrypoint_type == diffcore_core::types::EntrypointType::TestFile) - .collect(); - assert!( - !test_eps.is_empty(), - "should detect *Tests.swift as test file entrypoint" - ); - - // Verify Swift language detected - assert_language_detected(&result, "swift"); - - // Verify XCTest framework detected - let has_xctest = result - .summary - .frameworks_detected - .iter() - .any(|f| f.contains("XCTest")); - assert!( - has_xctest, - "should detect XCTest framework; detected: {:?}", - result.summary.frameworks_detected - ); -} - -// ─── C++ Integration Tests ────────────────────────────────────────────── - -/// Test: Synthetic C++ REST API with handler→service→repo pattern. -/// -/// Verifies: C++ language detection, #include import resolution, -/// class/function extraction, call graph across files, framework detection, -/// entrypoint detection (main), semantic grouping. -#[test] -fn test_e2e_cpp_http_server() { - let rb = RepoBuilder::new(); - - // Initial commit - rb.write_file( - "CMakeLists.txt", - "cmake_minimum_required(VERSION 3.14)\nproject(myapp)\n", - ); - rb.commit("Initial commit: CMakeLists.txt"); - rb.create_branch("main"); - - // Feature branch: add HTTP server - rb.create_branch("feature/cpp-api"); - rb.checkout("feature/cpp-api"); - - rb.write_file( - "src/handlers/user_handler.cpp", - r#"#include -#include "user_handler.hpp" -#include "../services/user_service.hpp" - -void UserHandler::handle_list(const Request& req) { - auto users = service_.list_users(); - send_response(req, users); -} - -void UserHandler::handle_create(const Request& req) { - auto name = parse_body(req); - auto user = service_.create_user(name); - send_response(req, user); -} -"#, - ); - - rb.write_file( - "src/services/user_service.hpp", - r#"#pragma once -#include -#include -#include "../models/user.hpp" -#include "../repositories/user_repository.hpp" - -class UserService { -public: - std::vector list_users(); - User create_user(const std::string& name); -private: - UserRepository repo_; -}; -"#, - ); - - rb.write_file( - "src/services/user_service.cpp", - r#"#include "user_service.hpp" - -std::vector UserService::list_users() { - auto result = repo_.find_all(); - return result; -} - -User UserService::create_user(const std::string& name) { - auto user = repo_.save(name); - return user; -} -"#, - ); - - rb.write_file( - "src/repositories/user_repository.hpp", - r#"#pragma once -#include -#include -#include "../models/user.hpp" - -class UserRepository { -public: - std::vector find_all(); - User save(const std::string& name); -}; -"#, - ); - - rb.write_file( - "src/repositories/user_repository.cpp", - r#"#include "user_repository.hpp" -#include - -std::vector UserRepository::find_all() { - auto db = open_db(); - auto results = query(db, "SELECT * FROM users"); - return results; -} - -User UserRepository::save(const std::string& name) { - auto db = open_db(); - auto result = execute(db, "INSERT INTO users (name) VALUES (?)", name); - return result; -} -"#, - ); - - rb.write_file( - "src/models/user.hpp", - r#"#pragma once -#include - -struct User { - int id; - std::string name; -}; -"#, - ); - - rb.write_file( - "src/main.cpp", - r#"#include -#include "handlers/user_handler.hpp" - -int main() { - auto handler = UserHandler(); - auto server = init_server(); - register_routes(server, handler); - start_server(server); - return 0; -} -"#, - ); - - rb.commit("Add C++ HTTP server with handler-service-repo"); - - let result = run_pipeline(rb.path(), "main", "feature/cpp-api"); - - // Verify basic output shape - assert_valid_json_schema(&result); - assert_valid_scores(&result); - - // Verify C++ files were detected - assert_language_detected(&result, "cpp"); - - // Verify all changed files are accounted for - assert_all_files_accounted(&result); - - // Verify there are flow groups - assert!( - !result.groups.is_empty(), - "should produce at least one flow group" - ); - - // Verify entrypoint detection (main function) - let has_entrypoint = result.groups.iter().any(|g| g.entrypoint.is_some()); - assert!( - has_entrypoint, - "should detect at least one entrypoint (main)" - ); - - // Verify C++ STL framework detection - let has_stl = result - .summary - .frameworks_detected - .iter() - .any(|f| f.contains("STL") || f.contains("C++")); - assert!( - has_stl, - "should detect C++ STL; detected: {:?}", - result.summary.frameworks_detected - ); - - // Verify Mermaid graph is valid - assert_valid_mermaid(&result); - - // Verify JSON roundtrip - assert_json_roundtrip(&result); -} - -/// Test: C test file detection. -/// -/// Verifies that *_test.c and files in test/ directories are detected as test entrypoints. -#[test] -fn test_e2e_c_test_file_detection() { - let rb = RepoBuilder::new(); - - rb.write_file("Makefile", "all: build\n"); - rb.commit("Initial commit"); - rb.create_branch("main"); - - rb.create_branch("feature/tests"); - rb.checkout("feature/tests"); - - rb.write_file( - "src/math.c", - r#"#include "math.h" - -int add(int a, int b) { - return a + b; -} - -int multiply(int a, int b) { - return a * b; -} -"#, - ); - - rb.write_file( - "tests/math_test.c", - r#"#include -#include "../src/math.h" - -void test_add() { - int result = add(2, 3); - printf("test_add: %s\n", result == 5 ? "PASS" : "FAIL"); -} - -void test_multiply() { - int result = multiply(3, 4); - printf("test_multiply: %s\n", result == 12 ? "PASS" : "FAIL"); -} - -int main() { - test_add(); - test_multiply(); - return 0; -} -"#, - ); - - rb.commit("Add math module with tests"); - - let result = run_pipeline(rb.path(), "main", "feature/tests"); - - assert_valid_json_schema(&result); - assert_all_files_accounted(&result); - - // Verify C language detected - assert_language_detected(&result, "c"); - - // Verify test file detected as entrypoint - let has_test_entrypoint = result.groups.iter().any(|g| { - g.entrypoint.as_ref().map_or(false, |e| { - e.entrypoint_type == diffcore_core::types::EntrypointType::TestFile - || e.entrypoint_type == diffcore_core::types::EntrypointType::CliCommand - }) - }); - assert!( - has_test_entrypoint, - "should detect test file entrypoint; groups: {:?}", - result - .groups - .iter() - .map(|g| (&g.name, &g.entrypoint)) - .collect::>() - ); -} - -// ─── Scala Integration Tests ───────────────────────────────────────────── - -/// Test: Synthetic Scala Akka HTTP API with handler → service → repository pattern. -#[test] -fn test_e2e_scala_akka_http_api() { - let rb = RepoBuilder::new(); - - rb.write_file( - "build.sbt", - "name := \"akka-api\"\nscalaVersion := \"2.13.12\"\n", - ); - rb.commit("Initial commit"); - rb.create_branch("main"); - - rb.create_branch("feature/users-api"); - rb.checkout("feature/users-api"); - - rb.write_file( - "src/main/scala/routes/UserRoutes.scala", - r#"import akka.http.scaladsl.server.Directives._ -import akka.http.scaladsl.server.Route -import com.example.services.UserService - -class UserRoutes(service: UserService) { - def routes(): Route = { - pathPrefix("users") { - get { - val users = service.listUsers() - complete(users.toString()) - } ~ - post { - val user = service.createUser("test") - complete(user.toString()) - } - } - } -} -"#, - ); - - rb.write_file( - "src/main/scala/services/UserService.scala", - r#"import com.example.repositories.UserRepository - -class UserService(repo: UserRepository) { - def listUsers(): List[User] = { - val users = repo.findAll() - users - } - - def createUser(name: String): User = { - val user = repo.save(name) - println("Created user") - user - } -} -"#, - ); - - rb.write_file( - "src/main/scala/repositories/UserRepository.scala", - r#"import slick.jdbc.PostgresProfile.api._ - -class UserRepository(db: Database) { - def findAll(): List[User] = { - val result = db.run(users.result) - result - } - - def save(name: String): User = { - val user = User(name) - db.run(users.insertOrUpdate(user)) - user - } -} -"#, - ); - - rb.write_file( - "src/main/scala/models/User.scala", - r#"case class User(id: String, name: String, email: String) - -type UserId = String -"#, - ); - - rb.commit("Add users API with Akka HTTP"); - - let result = run_pipeline(rb.path(), "main", "feature/users-api"); - - assert_valid_json_schema(&result); - assert_all_files_accounted(&result); - - // Verify Scala language detected - assert_language_detected(&result, "scala"); - - // Verify HTTP route entrypoint detected - let has_http_entrypoint = result.groups.iter().any(|g| { - g.entrypoint.as_ref().map_or(false, |e| { - e.entrypoint_type == diffcore_core::types::EntrypointType::HttpRoute - }) - }); - assert!( - has_http_entrypoint, - "should detect Akka HTTP route entrypoint; groups: {:?}", - result - .groups - .iter() - .map(|g| (&g.name, &g.entrypoint)) - .collect::>() - ); - - // Verify framework detected - let frameworks = &result.summary.frameworks_detected; - assert!( - frameworks - .iter() - .any(|f| f.contains("Akka") || f.contains("Slick")), - "should detect Akka HTTP or Slick framework; got: {:?}", - frameworks - ); -} - -/// Test: Scala test file detection with ScalaTest. -#[test] -fn test_e2e_scala_test_file_detection() { - let rb = RepoBuilder::new(); - - rb.write_file("build.sbt", "name := \"scala-test\"\n"); - rb.commit("Initial commit"); - rb.create_branch("main"); - - rb.create_branch("feature/tests"); - rb.checkout("feature/tests"); - - rb.write_file( - "src/main/scala/services/Calculator.scala", - r#"object Calculator { - def add(a: Int, b: Int): Int = a + b - def multiply(a: Int, b: Int): Int = a * b -} -"#, - ); - - rb.write_file( - "src/test/scala/services/CalculatorSpec.scala", - r#"import org.scalatest.flatspec.AnyFlatSpec -import org.scalatest.matchers.should.Matchers - -class CalculatorSpec extends AnyFlatSpec with Matchers { - def testAdd(): Unit = { - val result = Calculator.add(2, 3) - result shouldEqual 5 - } - - def testMultiply(): Unit = { - val result = Calculator.multiply(3, 4) - result shouldEqual 12 - } -} -"#, - ); - - rb.commit("Add calculator with tests"); - - let result = run_pipeline(rb.path(), "main", "feature/tests"); - - assert_valid_json_schema(&result); - assert_all_files_accounted(&result); - - // Verify Scala language detected - assert_language_detected(&result, "scala"); - - // Verify test file detected as entrypoint - let has_test_entrypoint = result.groups.iter().any(|g| { - g.entrypoint.as_ref().map_or(false, |e| { - e.entrypoint_type == diffcore_core::types::EntrypointType::TestFile - }) - }); - assert!( - has_test_entrypoint, - "should detect ScalaTest spec as test entrypoint; groups: {:?}", - result - .groups - .iter() - .map(|g| (&g.name, &g.entrypoint)) - .collect::>() - ); - - // Verify ScalaTest framework detected - let frameworks = &result.summary.frameworks_detected; - assert!( - frameworks.iter().any(|f| f.contains("ScalaTest")), - "should detect ScalaTest framework; got: {:?}", - frameworks - ); -} - -// ─── Spec §13.5 Missing Integration Tests ───────────────────────────── - -/// Test: Next.js app — modify a page + API route + Prisma model. -/// -/// Creates a Next.js fullstack app with API routes and React pages. -/// Expected: produces 2 groups (API flow and UI flow), correctly separated. -#[test] -fn test_e2e_nextjs_page_change() { - let rb = RepoBuilder::new(); - - // Initial commit: base Next.js app with existing page + API route - rb.write_file( - "package.json", - r#"{"name": "nextjs-app", "dependencies": {"next": "14.0.0", "@prisma/client": "5.0.0"}}"#, - ); - rb.write_file( - "src/app/api/users/route.ts", - r#" -import { NextResponse } from 'next/server'; - -export async function GET() { - return NextResponse.json([]); -} -"#, - ); - rb.write_file( - "src/app/users/page.tsx", - r#" -export default function UsersPage() { - return
Users
; -} -"#, - ); - rb.write_file( - "prisma/schema.prisma", - r#" -model User { - id String @id - name String -} -"#, - ); - rb.commit("Initial Next.js app"); - rb.create_branch("main"); - - rb.create_branch("feature/nextjs-products"); - rb.checkout("feature/nextjs-products"); - - // Add new API route (products) - rb.write_file( - "src/app/api/products/route.ts", - r#" -import { NextResponse } from 'next/server'; -import { getProducts, createProduct } from '../../../services/productService'; - -export async function GET() { - const products = await getProducts(); - return NextResponse.json(products); -} - -export async function POST(request: Request) { - const body = await request.json(); - const product = await createProduct(body); - return NextResponse.json(product, { status: 201 }); -} -"#, - ); - - // Add service layer - rb.write_file( - "src/services/productService.ts", - r#" -import { PrismaClient } from '@prisma/client'; - -const prisma = new PrismaClient(); - -export async function getProducts() { - return prisma.product.findMany(); -} - -export async function createProduct(data: any) { - return prisma.product.create({ data }); -} -"#, - ); - - // Add new page (products listing) - rb.write_file( - "src/app/products/page.tsx", - r#" -import { ProductList } from '../../components/ProductList'; - -export default async function ProductsPage() { - const res = await fetch('/api/products'); - const products = await res.json(); - return ; -} -"#, - ); - - // Add component - rb.write_file( - "src/components/ProductList.tsx", - r#" -export function ProductList({ products }: { products: any[] }) { - return ( -
    - {products.map(p =>
  • {p.name}
  • )} -
- ); -} -"#, - ); - - // Update Prisma schema (add Product model) - rb.write_file( - "prisma/schema.prisma", - r#" -model User { - id String @id - name String -} - -model Product { - id String @id - name String - price Float -} -"#, - ); - - rb.commit("Add product listing: API route + page + Prisma model"); - - let output = run_pipeline(rb.path(), "main", "feature/nextjs-products"); - - // Should have at least 4 changed files (API route, service, page, component, schema) - assert!( - output.summary.total_files_changed >= 4, - "expected >= 4 changed files, got {}", - output.summary.total_files_changed - ); - - // Should produce at least 2 groups (API flow and UI flow) - assert!( - output.summary.total_groups >= 2, - "expected >= 2 flow groups for API + UI flows, got {}", - output.summary.total_groups - ); - - assert_language_detected(&output, "typescript"); - assert_all_files_accounted(&output); - assert_json_roundtrip(&output); -} - -/// Test: 50-file diff completes within reasonable time and produces valid output. -/// -/// Generates 50 TypeScript files with import chains and verifies -/// the pipeline handles them without timeout or OOM. -#[test] -fn test_e2e_50_file_diff() { - let rb = RepoBuilder::new(); - - rb.write_file("package.json", r#"{"name": "large-app-50"}"#); - rb.commit("Initial"); - rb.create_branch("main"); - - rb.create_branch("feature/50-files"); - rb.checkout("feature/50-files"); - - // Create 50 files: 5 route entrypoints, each with a chain of 9 downstream files - for group in 0..5 { - let route_file = format!("src/routes/route{}.ts", group); - rb.write_file( - &route_file, - &format!( - "import express from 'express';\nimport {{ service{g} }} from '../services/svc{g}';\n\nconst router = express.Router();\nexport function handle{g}(req: any, res: any) {{ res.json(service{g}()); }}\nrouter.get('/route{g}', handle{g});\nexport default router;\n", - g = group - ), - ); - for depth in 0..9 { - let file = format!("src/services/svc{}_{}.ts", group, depth); - let content = if depth == 0 { - format!( - "import {{ fn{}_{} }} from './svc{}_{}';\nexport function service{}() {{ return fn{}_{}(); }}\n", - group, depth + 1, group, depth + 1, group, group, depth + 1 - ) - } else if depth < 8 { - format!( - "import {{ fn{}_{} }} from './svc{}_{}';\nexport function fn{}_{}() {{ return fn{}_{}(); }}\n", - group, depth + 1, group, depth + 1, group, depth, group, depth + 1 - ) - } else { - format!( - "export function fn{}_{}() {{ return {{ value: {} }}; }}\n", - group, - depth, - group * 10 + depth - ) - }; - rb.write_file(&file, &content); - } - } - rb.commit("Add 50 files across 5 route groups"); - - let start = std::time::Instant::now(); - let output = run_pipeline(rb.path(), "main", "feature/50-files"); - let elapsed = start.elapsed(); - - assert_eq!( - output.summary.total_files_changed, 50, - "expected 50 changed files" - ); - assert!( - elapsed.as_secs() < 5, - "50-file analysis should complete in <5s, took {:?}", - elapsed - ); - assert_all_files_accounted(&output); - assert_valid_scores(&output); - - let json = output::to_json(&output).unwrap(); - let _: serde_json::Value = serde_json::from_str(&json).unwrap(); -} - -/// Test: 100-file diff completes within reasonable time without OOM. -/// -/// Generates 100 TypeScript files with import chains. Verifies the -/// pipeline scales to large diffs without panicking or timing out. -#[test] -fn test_e2e_100_file_diff() { - let rb = RepoBuilder::new(); - - rb.write_file("package.json", r#"{"name": "large-app-100"}"#); - rb.commit("Initial"); - rb.create_branch("main"); - - rb.create_branch("feature/100-files"); - rb.checkout("feature/100-files"); - - // Create 100 files: 10 route entrypoints, each with a chain of 9 downstream files - for group in 0..10 { - let route_file = format!("src/routes/route{}.ts", group); - rb.write_file( - &route_file, - &format!( - "import express from 'express';\nimport {{ svc{g} }} from '../services/svc{g}';\n\nconst router = express.Router();\nexport function handler{g}(req: any, res: any) {{ res.json(svc{g}()); }}\nrouter.get('/r{g}', handler{g});\nexport default router;\n", - g = group - ), - ); - for depth in 0..9 { - let file = format!("src/services/svc{}_{}.ts", group, depth); - let content = if depth == 0 { - format!( - "import {{ f{}_{} }} from './svc{}_{}';\nexport function svc{}() {{ return f{}_{}(); }}\n", - group, depth + 1, group, depth + 1, group, group, depth + 1 - ) - } else if depth < 8 { - format!( - "import {{ f{}_{} }} from './svc{}_{}';\nexport function f{}_{}() {{ return f{}_{}(); }}\n", - group, depth + 1, group, depth + 1, group, depth, group, depth + 1 - ) - } else { - format!( - "export function f{}_{}() {{ return {{ v: {} }}; }}\n", - group, - depth, - group * 10 + depth - ) - }; - rb.write_file(&file, &content); - } - } - rb.commit("Add 100 files across 10 route groups"); - - let start = std::time::Instant::now(); - let output = run_pipeline(rb.path(), "main", "feature/100-files"); - let elapsed = start.elapsed(); - - assert_eq!( - output.summary.total_files_changed, 100, - "expected 100 changed files" - ); - assert!( - elapsed.as_secs() < 15, - "100-file analysis should complete in <15s, took {:?}", - elapsed - ); - assert_all_files_accounted(&output); - assert_valid_scores(&output); - - // Should produce valid JSON - let json = output::to_json(&output).unwrap(); - let _: serde_json::Value = serde_json::from_str(&json).unwrap(); -} - -/// Test: Staged changes — only staged files are included in the diff. -/// -/// Stages some files but leaves others unstaged, then runs diff_staged -/// and verifies only the staged files appear in the result. -#[test] -fn test_e2e_staged_changes() { - let rb = RepoBuilder::new(); - - // Create initial files - rb.write_file( - "src/handler.ts", - "export function handle() { return 'v1'; }\n", - ); - rb.write_file( - "src/service.ts", - "export function serve() { return 'v1'; }\n", - ); - rb.write_file("src/utils.ts", "export function util() { return 'v1'; }\n"); - rb.commit("Initial commit"); - - // Modify all three files in the working directory - rb.write_file( - "src/handler.ts", - "export function handle() { return 'v2-staged'; }\n", - ); - rb.write_file( - "src/service.ts", - "export function serve() { return 'v2-staged'; }\n", - ); - rb.write_file( - "src/utils.ts", - "export function util() { return 'v2-unstaged'; }\n", - ); - - // Stage only handler.ts and service.ts (NOT utils.ts) - { - let repo = rb.repo(); - let mut index = repo.index().unwrap(); - index - .add_path(std::path::Path::new("src/handler.ts")) - .unwrap(); - index - .add_path(std::path::Path::new("src/service.ts")) - .unwrap(); - index.write().unwrap(); - } - - // Run diff_staged - let repo = git2::Repository::open(rb.path()).unwrap(); - let diff_result = git::diff_staged(&repo).unwrap(); - - // Should include exactly the 2 staged files - let staged_paths: Vec<&str> = diff_result.files.iter().map(|f| f.path()).collect(); - assert_eq!( - staged_paths.len(), - 2, - "expected 2 staged files, got {:?}", - staged_paths - ); - assert!( - staged_paths.contains(&"src/handler.ts"), - "handler.ts should be staged" - ); - assert!( - staged_paths.contains(&"src/service.ts"), - "service.ts should be staged" - ); - assert!( - !staged_paths.contains(&"src/utils.ts"), - "utils.ts should NOT be in staged diff" - ); - - // Verify the staged content is correct - for file_diff in &diff_result.files { - if let Some(ref new_content) = file_diff.new_content { - assert!( - new_content.contains("v2-staged"), - "staged file {} should have v2-staged content", - file_diff.path() - ); - } - } -} - -/// Test: Config overrides — custom entrypoint globs detect files that heuristics miss. -/// -/// Creates files in non-standard locations that aren't detected by built-in -/// heuristics, then provides a `.diffcore.toml` with custom entrypoint globs -/// that should pick them up. -#[test] -fn test_e2e_config_overrides() { - let rb = RepoBuilder::new(); - - rb.write_file("package.json", r#"{"name": "custom-app"}"#); - rb.commit("Initial"); - rb.create_branch("main"); - - rb.create_branch("feature/custom-ep"); - rb.checkout("feature/custom-ep"); - - // Files in non-standard locations (no framework imports, no standard paths) - // These would NOT be detected as entrypoints by default heuristics - rb.write_file( - "src/triggers/onUserCreated.ts", - r#" -import { notifyAdmin } from '../notifications/admin'; - -export function handleUserCreated(event: any) { - notifyAdmin(event.userId); - return { processed: true }; -} -"#, - ); - rb.write_file( - "src/triggers/onOrderPlaced.ts", - r#" -import { processPayment } from '../billing/payment'; - -export function handleOrderPlaced(event: any) { - processPayment(event.orderId, event.amount); - return { processed: true }; -} -"#, - ); - rb.write_file( - "src/notifications/admin.ts", - r#" -export function notifyAdmin(userId: string) { - console.log('Notifying admin about user:', userId); -} -"#, - ); - rb.write_file( - "src/billing/payment.ts", - r#" -export function processPayment(orderId: string, amount: number) { - console.log('Processing payment:', orderId, amount); -} -"#, - ); - - // Provide a config that declares triggers as event entrypoints - rb.write_file( - ".diffcore.toml", - r#" -[entrypoints] -events = ["src/triggers/**/*.ts"] -"#, - ); - - rb.commit("Add triggers with custom config"); - - // Run pipeline WITH config loaded from repo - let config = diffcore_core::config::DiffcoreConfig::load_from_dir(rb.path()).unwrap(); - - // Verify config was loaded and has our custom entrypoints - assert!( - !config.entrypoints.events.is_empty(), - "config should have event entrypoint globs" - ); - - // Resolve entrypoint globs - let resolved = config.resolve_entrypoint_globs(rb.path()); - assert!( - resolved - .iter() - .any(|p| p.to_string_lossy().contains("triggers")), - "config should resolve trigger files; resolved: {:?}", - resolved - ); - - // Run full pipeline and verify files are accounted for - let output = run_pipeline(rb.path(), "main", "feature/custom-ep"); - - assert!( - output.summary.total_files_changed >= 4, - "expected >= 4 changed files, got {}", - output.summary.total_files_changed - ); - assert_all_files_accounted(&output); - assert_valid_json_schema(&output); -} diff --git a/crates/diffcore-core/tests/e2e_systems.rs b/crates/diffcore-core/tests/e2e_systems.rs new file mode 100644 index 0000000..85a7a29 --- /dev/null +++ b/crates/diffcore-core/tests/e2e_systems.rs @@ -0,0 +1,536 @@ +#![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::print_stdout, + clippy::print_stderr +)] +//! E2E integration tests for systems languages: Swift and C/C++. + +mod helpers; + +use helpers::graph_assertions::{ + assert_all_files_accounted, assert_json_roundtrip, assert_language_detected, + assert_valid_json_schema, assert_valid_mermaid, assert_valid_scores, +}; +use helpers::repo_builder::{run_pipeline, RepoBuilder}; + +#[test] +fn test_e2e_swift_vapor_api() { + let rb = RepoBuilder::new(); + + // Initial commit + rb.write_file( + "Package.swift", + "// swift-tools-version:5.9\nimport PackageDescription\n", + ); + rb.commit("Initial commit: Package.swift"); + rb.create_branch("main"); + + // Feature branch: add Vapor REST API + rb.create_branch("feature/swift-api"); + rb.checkout("feature/swift-api"); + + rb.write_file( + "Sources/App/Controllers/UserController.swift", + r#"import Vapor +import Fluent + +struct UserController: RouteCollection { + func boot(routes: RoutesBuilder) throws { + let users = routes.grouped("users") + users.get(use: index) + users.post(use: create) + } + + func index(req: Request) throws -> EventLoopFuture<[User]> { + return User.query(on: req.db).all() + } + + func create(req: Request) throws -> EventLoopFuture { + let user = try req.content.decode(User.self) + return user.save(on: req.db).map { user } + } +} +"#, + ); + + rb.write_file( + "Sources/App/Services/UserService.swift", + r#"import Foundation +import Vapor + +class UserService { + let repository: UserRepository + + init(repository: UserRepository) { + self.repository = repository + } + + func findAll() -> [User] { + let users = repository.findAll() + return users + } + + func findById(id: UUID) -> User? { + let user = repository.findById(id: id) + return user + } + + func create(name: String) -> User { + let user = repository.save(name: name) + return user + } +} +"#, + ); + + rb.write_file( + "Sources/App/Repositories/UserRepository.swift", + r#"import Foundation +import Fluent + +class UserRepository { + let db: Database + + init(db: Database) { + self.db = db + } + + func findAll() -> [User] { + let results = db.query(User.self) + return results + } + + func findById(id: UUID) -> User? { + let result = db.find(User.self, id: id) + return result + } + + func save(name: String) -> User { + let user = User(name: name) + db.save(user) + return user + } +} +"#, + ); + + rb.write_file( + "Sources/App/Models/User.swift", + r#"import Foundation +import Fluent + +final class User: Model, Content { + static let schema = "users" + + var id: UUID? + var name: String + + init() {} + + init(id: UUID? = nil, name: String) { + self.id = id + self.name = name + } +} +"#, + ); + + rb.write_file( + "Sources/App/configure.swift", + r#"import Vapor +import Fluent + +func configure(app: Application) throws { + let controller = UserController() + try app.register(collection: controller) +} +"#, + ); + + rb.commit("Add Vapor REST API with controller-service-repo"); + + let result = run_pipeline(rb.path(), "main", "feature/swift-api"); + + // Verify basic output shape + assert_valid_json_schema(&result); + assert_valid_scores(&result); + + // Verify Swift files were detected + assert_language_detected(&result, "swift"); + + // Verify all changed files are accounted for + assert_all_files_accounted(&result); + + // Verify there are flow groups + assert!( + !result.groups.is_empty(), + "should produce at least one flow group" + ); + + // Verify entrypoint detection (Vapor route handlers) + let has_entrypoint = result.groups.iter().any(|g| g.entrypoint.is_some()); + assert!( + has_entrypoint, + "should detect at least one entrypoint (Vapor routes)" + ); + + // Verify Vapor framework detection + let has_vapor = result + .summary + .frameworks_detected + .iter() + .any(|f| f.contains("Vapor")); + assert!( + has_vapor, + "should detect Vapor framework; detected: {:?}", + result.summary.frameworks_detected + ); + + // Verify Mermaid graph is valid + assert_valid_mermaid(&result); + + // Verify JSON roundtrip + assert_json_roundtrip(&result); +} + +/// Test: Swift test file detection. +/// +/// Verifies that *Tests.swift and *Test.swift files are detected as test entrypoints. +#[test] +fn test_e2e_swift_test_file_detection() { + let rb = RepoBuilder::new(); + + rb.write_file("Package.swift", "// swift-tools-version:5.9\n"); + rb.commit("Initial commit"); + rb.create_branch("main"); + + rb.create_branch("feature/tests"); + rb.checkout("feature/tests"); + + rb.write_file( + "Sources/App/Services/UserService.swift", + r#"import Foundation + +class UserService { + func greet(name: String) -> String { + return "Hello, \(name)" + } +} +"#, + ); + + rb.write_file( + "Tests/AppTests/UserServiceTests.swift", + r#"import XCTest +import Foundation + +final class UserServiceTests: XCTestCase { + func testGreet() { + let service = UserService() + let result = service.greet(name: "Alice") + } + + func testGreetEmpty() { + let service = UserService() + let result = service.greet(name: "") + } +} +"#, + ); + + rb.commit("Add UserService with XCTest tests"); + + let result = run_pipeline(rb.path(), "main", "feature/tests"); + + // Verify test file detection + let test_eps: Vec<_> = result + .groups + .iter() + .flat_map(|g| g.entrypoint.as_ref()) + .filter(|ep| ep.entrypoint_type == diffcore_core::types::EntrypointType::TestFile) + .collect(); + assert!( + !test_eps.is_empty(), + "should detect *Tests.swift as test file entrypoint" + ); + + // Verify Swift language detected + assert_language_detected(&result, "swift"); + + // Verify XCTest framework detected + let has_xctest = result + .summary + .frameworks_detected + .iter() + .any(|f| f.contains("XCTest")); + assert!( + has_xctest, + "should detect XCTest framework; detected: {:?}", + result.summary.frameworks_detected + ); +} + +// ─── C++ Integration Tests ────────────────────────────────────────────── + +/// Test: Synthetic C++ REST API with handler→service→repo pattern. +/// +/// Verifies: C++ language detection, #include import resolution, +/// class/function extraction, call graph across files, framework detection, +/// entrypoint detection (main), semantic grouping. +#[test] +fn test_e2e_cpp_http_server() { + let rb = RepoBuilder::new(); + + // Initial commit + rb.write_file( + "CMakeLists.txt", + "cmake_minimum_required(VERSION 3.14)\nproject(myapp)\n", + ); + rb.commit("Initial commit: CMakeLists.txt"); + rb.create_branch("main"); + + // Feature branch: add HTTP server + rb.create_branch("feature/cpp-api"); + rb.checkout("feature/cpp-api"); + + rb.write_file( + "src/handlers/user_handler.cpp", + r#"#include +#include "user_handler.hpp" +#include "../services/user_service.hpp" + +void UserHandler::handle_list(const Request& req) { + auto users = service_.list_users(); + send_response(req, users); +} + +void UserHandler::handle_create(const Request& req) { + auto name = parse_body(req); + auto user = service_.create_user(name); + send_response(req, user); +} +"#, + ); + + rb.write_file( + "src/services/user_service.hpp", + r#"#pragma once +#include +#include +#include "../models/user.hpp" +#include "../repositories/user_repository.hpp" + +class UserService { +public: + std::vector list_users(); + User create_user(const std::string& name); +private: + UserRepository repo_; +}; +"#, + ); + + rb.write_file( + "src/services/user_service.cpp", + r#"#include "user_service.hpp" + +std::vector UserService::list_users() { + auto result = repo_.find_all(); + return result; +} + +User UserService::create_user(const std::string& name) { + auto user = repo_.save(name); + return user; +} +"#, + ); + + rb.write_file( + "src/repositories/user_repository.hpp", + r#"#pragma once +#include +#include +#include "../models/user.hpp" + +class UserRepository { +public: + std::vector find_all(); + User save(const std::string& name); +}; +"#, + ); + + rb.write_file( + "src/repositories/user_repository.cpp", + r#"#include "user_repository.hpp" +#include + +std::vector UserRepository::find_all() { + auto db = open_db(); + auto results = query(db, "SELECT * FROM users"); + return results; +} + +User UserRepository::save(const std::string& name) { + auto db = open_db(); + auto result = execute(db, "INSERT INTO users (name) VALUES (?)", name); + return result; +} +"#, + ); + + rb.write_file( + "src/models/user.hpp", + r#"#pragma once +#include + +struct User { + int id; + std::string name; +}; +"#, + ); + + rb.write_file( + "src/main.cpp", + r#"#include +#include "handlers/user_handler.hpp" + +int main() { + auto handler = UserHandler(); + auto server = init_server(); + register_routes(server, handler); + start_server(server); + return 0; +} +"#, + ); + + rb.commit("Add C++ HTTP server with handler-service-repo"); + + let result = run_pipeline(rb.path(), "main", "feature/cpp-api"); + + // Verify basic output shape + assert_valid_json_schema(&result); + assert_valid_scores(&result); + + // Verify C++ files were detected + assert_language_detected(&result, "cpp"); + + // Verify all changed files are accounted for + assert_all_files_accounted(&result); + + // Verify there are flow groups + assert!( + !result.groups.is_empty(), + "should produce at least one flow group" + ); + + // Verify entrypoint detection (main function) + let has_entrypoint = result.groups.iter().any(|g| g.entrypoint.is_some()); + assert!( + has_entrypoint, + "should detect at least one entrypoint (main)" + ); + + // Verify C++ STL framework detection + let has_stl = result + .summary + .frameworks_detected + .iter() + .any(|f| f.contains("STL") || f.contains("C++")); + assert!( + has_stl, + "should detect C++ STL; detected: {:?}", + result.summary.frameworks_detected + ); + + // Verify Mermaid graph is valid + assert_valid_mermaid(&result); + + // Verify JSON roundtrip + assert_json_roundtrip(&result); +} + +/// Test: C test file detection. +/// +/// Verifies that *_test.c and files in test/ directories are detected as test entrypoints. +#[test] +fn test_e2e_c_test_file_detection() { + let rb = RepoBuilder::new(); + + rb.write_file("Makefile", "all: build\n"); + rb.commit("Initial commit"); + rb.create_branch("main"); + + rb.create_branch("feature/tests"); + rb.checkout("feature/tests"); + + rb.write_file( + "src/math.c", + r#"#include "math.h" + +int add(int a, int b) { + return a + b; +} + +int multiply(int a, int b) { + return a * b; +} +"#, + ); + + rb.write_file( + "tests/math_test.c", + r#"#include +#include "../src/math.h" + +void test_add() { + int result = add(2, 3); + printf("test_add: %s\n", result == 5 ? "PASS" : "FAIL"); +} + +void test_multiply() { + int result = multiply(3, 4); + printf("test_multiply: %s\n", result == 12 ? "PASS" : "FAIL"); +} + +int main() { + test_add(); + test_multiply(); + return 0; +} +"#, + ); + + rb.commit("Add math module with tests"); + + let result = run_pipeline(rb.path(), "main", "feature/tests"); + + assert_valid_json_schema(&result); + assert_all_files_accounted(&result); + + // Verify C language detected + assert_language_detected(&result, "c"); + + // Verify test file detected as entrypoint + let has_test_entrypoint = result.groups.iter().any(|g| { + g.entrypoint.as_ref().map_or(false, |e| { + e.entrypoint_type == diffcore_core::types::EntrypointType::TestFile + || e.entrypoint_type == diffcore_core::types::EntrypointType::CliCommand + }) + }); + assert!( + has_test_entrypoint, + "should detect test file entrypoint; groups: {:?}", + result + .groups + .iter() + .map(|g| (&g.name, &g.entrypoint)) + .collect::>() + ); +} + From 6e7845af058d0b90d17f6c381b4133c4f2b9723c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Apr 2026 22:09:24 +0000 Subject: [PATCH 07/15] refactor: split commands.rs (4517 lines) into 8 submodules - commands/mod.rs (2034): AppState, CommandError, core analysis cmds, shared helpers - commands/llm.rs (1057): LLM annotation/refinement commands - commands/workspace.rs (338): git info + workspace file ops - commands/settings.rs (279): API keys + LLM config + LlmSettings struct - commands/editor.rs (274): editor integration + file-write commands - commands/comments.rs (430): review comment CRUD (private fn tests moved here) - commands/manifest.rs (110): groups manifest import/export/watch - commands/app_state.rs (88): snapshot persistence All public interfaces preserved; main.rs updated to use full module paths in generate_handler! so Tauri's __cmd__ wrappers resolve correctly. Agent-Logs-Url: https://github.com/mikenrafter/diff-core/sessions/22a705a7-65f2-4133-8d93-e9e3d306a8ca Co-authored-by: mikenrafter <88250914+mikenrafter@users.noreply.github.com> --- crates/diffcore-tauri/src/commands.rs | 4517 ----------------- .../diffcore-tauri/src/commands/app_state.rs | 88 + .../diffcore-tauri/src/commands/comments.rs | 430 ++ crates/diffcore-tauri/src/commands/editor.rs | 274 + crates/diffcore-tauri/src/commands/llm.rs | 1057 ++++ .../diffcore-tauri/src/commands/manifest.rs | 110 + crates/diffcore-tauri/src/commands/mod.rs | 2034 ++++++++ .../diffcore-tauri/src/commands/settings.rs | 279 + .../diffcore-tauri/src/commands/workspace.rs | 338 ++ crates/diffcore-tauri/src/main.rs | 94 +- 10 files changed, 4661 insertions(+), 4560 deletions(-) delete mode 100644 crates/diffcore-tauri/src/commands.rs create mode 100644 crates/diffcore-tauri/src/commands/app_state.rs create mode 100644 crates/diffcore-tauri/src/commands/comments.rs create mode 100644 crates/diffcore-tauri/src/commands/editor.rs create mode 100644 crates/diffcore-tauri/src/commands/llm.rs create mode 100644 crates/diffcore-tauri/src/commands/manifest.rs create mode 100644 crates/diffcore-tauri/src/commands/mod.rs create mode 100644 crates/diffcore-tauri/src/commands/settings.rs create mode 100644 crates/diffcore-tauri/src/commands/workspace.rs diff --git a/crates/diffcore-tauri/src/commands.rs b/crates/diffcore-tauri/src/commands.rs deleted file mode 100644 index fd7a392..0000000 --- a/crates/diffcore-tauri/src/commands.rs +++ /dev/null @@ -1,4517 +0,0 @@ -//! Tauri IPC commands — bridge between the React frontend and diffcore-core. -//! -//! Each `#[tauri::command]` function is callable from the frontend via `invoke()`. - -use std::collections::HashSet; -use std::path::PathBuf; -use std::sync::{Arc, Mutex}; - -use grep_regex::RegexMatcherBuilder; -use grep_searcher::{sinks, SearcherBuilder}; -use ignore::WalkBuilder; -use log::warn; -use tauri::Emitter; - -use crate::activity_stream::{self, ActivityEntry, JobHandle}; -use diffcore_core::cache; -use diffcore_core::cluster; -use diffcore_core::config::DiffcoreConfig; -use diffcore_core::entrypoint; -use diffcore_core::flow::{self, FlowConfig}; -use diffcore_core::git; -use diffcore_core::graph::SymbolGraph; -use diffcore_core::llm; -use diffcore_core::llm::refinement; -use diffcore_core::llm::schema::{Pass1Response, Pass2Response, RefinementResponse}; -use diffcore_core::output::{self, build_analysis_output}; -use diffcore_core::pipeline; -use diffcore_core::query_engine::QueryEngine; -use diffcore_core::rank; -use diffcore_core::types::{AnalysisOutput, GroupRankInput}; - -/// Application state shared across commands. -pub struct AppState { - /// The most recent analysis result, available for subsequent queries. - pub last_analysis: Mutex>, - /// Cached diff result from the most recent analysis, for instant file diff lookups. - pub last_diff: Mutex>, - /// Background LLM job manager for live SSE activity streams. - pub activity_manager: Arc, - /// Base URL for the embedded localhost SSE server. - pub activity_stream_base_url: Mutex>, - /// Cache key from the most recent analysis, for refinement cache lookups. - pub last_cache_key: Mutex>, - /// Path to the currently watched manifest file. - pub watched_manifest_path: Mutex>, - /// Long-lived QueryEngine instance for on-demand single-file parsing - /// (e.g. the source-explorer outline). Uses internal `OnceCell`s to - /// cache compiled tree-sitter queries per language across calls, so - /// the first parse of any given language pays the compilation cost - /// once for the whole app lifetime. - pub query_engine: Arc, -} - -/// Cached diff result with the parameters that produced it, for cache invalidation. -pub struct CachedDiff { - pub repo_path: PathBuf, - pub base: Option, - pub diff_result: git::DiffResult, -} - -impl AppState { - pub fn new() -> Self { - // Construct the QueryEngine eagerly so the field is non-Optional. - // QueryEngine::new() itself is cheap — per-language tree-sitter - // query compilation is deferred to the first parse of each - // language via internal OnceCells. We fall back to a fresh - // construction on error rather than panicking at startup; in - // practice QueryEngine::new() is infallible today, but the - // Result return type leaves room for future configuration loading. - let query_engine = Arc::new( - QueryEngine::new().unwrap_or_else(|e| { - log::error!("QueryEngine construction failed at startup: {e}"); - // Re-attempt; if this also fails the app cannot parse files - // but other commands continue to work, so we panic only as - // a last resort. (Today new() can't actually fail.) - QueryEngine::new().expect("QueryEngine::new() failed twice") - }), - ); - Self { - last_analysis: Mutex::new(None), - last_diff: Mutex::new(None), - activity_manager: Arc::new(activity_stream::ActivityManager::new()), - activity_stream_base_url: Mutex::new(None), - last_cache_key: Mutex::new(None), - watched_manifest_path: Mutex::new(None), - query_engine, - } - } - - pub fn init_activity_stream(&self) -> Result<(), CommandError> { - let mut base_url = self - .activity_stream_base_url - .lock() - .map_err(|e| CommandError::Analysis(format!("Lock poisoned: {}", e)))?; - if base_url.is_none() { - *base_url = Some( - activity_stream::spawn_sse_server(Arc::clone(&self.activity_manager)) - .map_err(|e| CommandError::Io(format!("Failed to start SSE server: {}", e)))?, - ); - } - Ok(()) - } - - pub fn create_llm_job( - &self, - operation: &str, - provider: &str, - model: &str, - title: &str, - ) -> Result<(JobHandle, AsyncLlmJobStart), CommandError> { - self.init_activity_stream()?; - let base_url = self - .activity_stream_base_url - .lock() - .map_err(|e| CommandError::Analysis(format!("Lock poisoned: {}", e)))? - .clone() - .ok_or_else(|| { - CommandError::Io("Activity SSE server was not initialized".to_string()) - })?; - - let manager = Arc::clone(&self.activity_manager); - let operation = operation.to_string(); - let provider = provider.to_string(); - let model = model.to_string(); - let title = title.to_string(); - let job_operation = operation.clone(); - let job_provider = provider.clone(); - let job_model = model.clone(); - let job_title = title.clone(); - - let handle = tauri::async_runtime::block_on(async move { - manager - .create_job(job_operation, job_provider, job_model, job_title) - .await - }); - - let start = AsyncLlmJobStart { - job_id: handle.job_id().to_string(), - stream_url: format!("{}/llm/jobs/{}/events", base_url, handle.job_id()), - operation, - provider, - model, - title, - }; - - Ok((handle, start)) - } -} - -/// Error type for Tauri commands — must implement `Into`. -#[derive(Debug, thiserror::Error)] -pub enum CommandError { - #[error("Git error: {0}")] - Git(String), - #[error("Analysis error: {0}")] - Analysis(String), - #[error("Config error: {0}")] - Config(String), - #[error("IO error: {0}")] - Io(String), - #[error("LLM error: {0}")] - Llm(String), - #[error("Network error: {0}")] - Network(String), -} - -impl serde::Serialize for CommandError { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - serializer.serialize_str(&self.to_string()) - } -} - -/// Analyze a git diff and return semantic flow groups. -/// -/// This is the primary IPC command — equivalent to `diffcore analyze` in the CLI. -/// When `pr_preview` is true, uses merge-base diff (shows what the branch introduces -/// relative to where it diverged from the base). -#[tauri::command] -pub fn analyze( - repo_path: String, - base: Option, - head: Option, - range: Option, - staged: bool, - unstaged: bool, - pr_preview: Option, - include_uncommitted: Option, - state: tauri::State<'_, AppState>, -) -> Result { - let repo_path = PathBuf::from(&repo_path); - let repo_path = std::fs::canonicalize(&repo_path) - .map_err(|e| CommandError::Io(format!("Invalid repo path: {}", e)))?; - - let repo = git2::Repository::discover(&repo_path) - .map_err(|e| CommandError::Git(format!("Not a git repository: {}", e)))?; - - let workdir = repo - .workdir() - .ok_or_else(|| CommandError::Git("Bare repositories are not supported".to_string()))? - .to_path_buf(); - - // Load config - let config = DiffcoreConfig::load_with_global_llm_from_dir(&workdir) - .map_err(|e| CommandError::Config(format!("{}", e)))?; - - // Resolve include_uncommitted: UI override > config > default (true) - let effective_include_uncommitted = include_uncommitted.unwrap_or(config.diff.include_uncommitted); - - // Extract diff - let (diff_result, diff_source) = extract_diff( - &repo, - base.clone(), - head, - range, - staged, - unstaged, - pr_preview.unwrap_or(false), - effective_include_uncommitted, - )?; - - // Cache the diff result for subsequent get_file_diff() calls - match state.last_diff.lock() { - Ok(mut cached) => { - *cached = Some(CachedDiff { - repo_path: repo_path.clone(), - base: base, - diff_result: diff_result.clone(), - }); - } - Err(e) => warn!("Failed to update last_diff state (lock poisoned): {}", e), - } - - if diff_result.files.is_empty() { - let empty_output = AnalysisOutput { - version: "1.0.0".to_string(), - diff_source, - summary: diffcore_core::types::AnalysisSummary { - total_files_changed: 0, - total_groups: 0, - languages_detected: vec![], - frameworks_detected: vec![], - }, - groups: vec![], - infrastructure_group: None, - annotations: None, - }; - match state.last_analysis.lock() { - Ok(mut last) => *last = Some(empty_output.clone()), - Err(e) => warn!( - "Failed to update last_analysis state (lock poisoned): {}", - e - ), - } - return Ok(empty_output); - } - - // Check cache for previously computed results - let cache_key = if staged || unstaged { - cache::compute_cache_key_working_dir(&diff_result, &workdir) - } else { - cache::compute_cache_key(&diff_result) - }; - if let Some(cached) = cache::load_cached(&workdir, &cache_key) { - match state.last_analysis.lock() { - Ok(mut last) => *last = Some(cached.clone()), - Err(e) => warn!( - "Failed to update last_analysis state (lock poisoned): {}", - e - ), - } - if let Ok(mut key) = state.last_cache_key.lock() { - *key = Some(cache_key); - } - return Ok(cached); - } - - // Parse all changed files in parallel - let file_inputs: Vec<(&str, &str)> = diff_result - .files - .iter() - .filter_map(|file_diff| { - let content = file_diff - .new_content - .as_deref() - .or(file_diff.old_content.as_deref())?; - let path = file_diff.path(); - if config.is_ignored(path) { - return None; - } - Some((path, content)) - }) - .collect(); - let parsed_files = pipeline::parse_files_parallel(&file_inputs); - - // Build workspace map for monorepo cross-package import resolution - let workspace_map = diffcore_core::graph::build_workspace_map(&workdir); - - // Build symbol graph - let mut graph = SymbolGraph::build_with_workspace(&parsed_files, &workspace_map); - - // Detect entrypoints - let entrypoints = entrypoint::detect_entrypoints(&parsed_files); - - // Run data flow analysis and enrich graph - let flow_analysis = flow::analyze_data_flow(&parsed_files, &FlowConfig::default()); - flow::enrich_graph(&mut graph, &flow_analysis); - - // Cluster changed files - let changed_files: Vec = diff_result - .files - .iter() - .filter(|f| !config.is_ignored(f.path())) - .map(|f| f.path().to_string()) - .collect(); - let cluster_result = cluster::cluster_files(&graph, &entrypoints, &changed_files); - - // Rank groups - let weights = config.ranking.clone(); - let rank_inputs: Vec = cluster_result - .groups - .iter() - .map(|group| { - let risk_flags = output::compute_group_risk_flags( - &group - .files - .iter() - .map(|f| f.path.as_str()) - .collect::>(), - ); - let total_add: u32 = group.files.iter().map(|f| f.changes.additions).sum(); - let total_del: u32 = group.files.iter().map(|f| f.changes.deletions).sum(); - - GroupRankInput { - group_id: group.id.clone(), - risk: rank::compute_risk_score( - risk_flags.has_schema_change, - risk_flags.has_api_change, - risk_flags.has_auth_change, - false, - ), - centrality: 0.5, - surface_area: rank::compute_surface_area(total_add, total_del, 1000), - uncertainty: if risk_flags.has_test_only { 0.1 } else { 0.5 }, - } - }) - .collect(); - - let ranked = rank::rank_groups(&rank_inputs, &weights); - - // Build output - let analysis_output = build_analysis_output( - &diff_result, - diff_source, - &parsed_files, - &cluster_result, - &ranked, - ); - - // Cache the deterministic analysis result - cache::store_cached(&workdir, &cache_key, &analysis_output); - - // Store cache key for refinement cache lookups - if let Ok(mut key) = state.last_cache_key.lock() { - *key = Some(cache_key); - } - - // Store for subsequent queries - match state.last_analysis.lock() { - Ok(mut last) => *last = Some(analysis_output.clone()), - Err(e) => warn!( - "Failed to update last_analysis state (lock poisoned): {}", - e - ), - } - - Ok(analysis_output) -} - -/// Get the most recent analysis result without re-running. -#[tauri::command] -pub fn get_last_analysis( - state: tauri::State<'_, AppState>, -) -> Result, CommandError> { - let last = state - .last_analysis - .lock() - .map_err(|e| CommandError::Analysis(format!("Lock poisoned: {}", e)))?; - Ok(last.clone()) -} - -/// Generate a Mermaid diagram for a specific group by ID. -#[tauri::command] -pub fn get_mermaid( - group_id: String, - state: tauri::State<'_, AppState>, -) -> Result { - let last = state - .last_analysis - .lock() - .map_err(|e| CommandError::Analysis(format!("Lock poisoned: {}", e)))?; - - let analysis = last.as_ref().ok_or_else(|| { - CommandError::Analysis("No analysis available. Run analyze first.".into()) - })?; - - let group = analysis - .groups - .iter() - .find(|g| g.id == group_id) - .ok_or_else(|| CommandError::Analysis(format!("Group '{}' not found", group_id)))?; - - Ok(output::generate_mermaid(group)) -} - -/// Get the diff content (old + new) for a specific file. -/// Returns the raw old and new content for the Monaco diff viewer. -/// Uses the cached DiffResult from the last `analyze()` call when parameters match, -/// avoiding redundant git diff extraction for every file navigation. -#[tauri::command] -pub fn get_file_diff( - repo_path: String, - file_path: String, - base: Option, - head: Option, - range: Option, - staged: bool, - unstaged: bool, - include_uncommitted: Option, - state: tauri::State<'_, AppState>, -) -> Result { - // Try to use cached diff from the last analyze() call - let cached_file = { - let repo_path_buf = PathBuf::from(&repo_path); - let repo_path_buf = std::fs::canonicalize(&repo_path_buf).ok(); - let cached = state.last_diff.lock().ok(); - cached.and_then(|guard| { - let c = guard.as_ref()?; - let rp = repo_path_buf.as_ref()?; - if &c.repo_path == rp && c.base == base { - c.diff_result - .files - .iter() - .find(|f| f.path() == file_path) - .map(|f| FileDiffContent { - path: file_path.clone(), - old_content: f.old_content.clone().unwrap_or_default(), - new_content: f.new_content.clone().unwrap_or_default(), - language: detect_language(&f.path()), - }) - } else { - None - } - }) - }; - - if let Some(content) = cached_file { - return Ok(content); - } - - // Cache miss — fall back to extracting from git - get_file_diff_uncached(repo_path, file_path, base, head, range, staged, unstaged, include_uncommitted.unwrap_or(true)) -} - -/// Core file diff logic without caching — also callable from integration tests. -pub fn get_file_diff_uncached( - repo_path: String, - file_path: String, - base: Option, - head: Option, - range: Option, - staged: bool, - unstaged: bool, - include_uncommitted: bool, -) -> Result { - // Security: reject paths with traversal components or absolute paths - // to prevent path traversal via IPC from a compromised frontend. - let fp = std::path::Path::new(&file_path); - if fp.is_absolute() - || fp - .components() - .any(|c| c == std::path::Component::ParentDir) - { - return Err(CommandError::Io(format!( - "Invalid file path (path traversal rejected): {}", - file_path - ))); - } - - let repo_path_buf = PathBuf::from(&repo_path); - let repo_path_buf = std::fs::canonicalize(&repo_path_buf) - .map_err(|e| CommandError::Io(format!("Invalid repo path: {}", e)))?; - - let repo = git2::Repository::discover(&repo_path_buf) - .map_err(|e| CommandError::Git(format!("Not a git repository: {}", e)))?; - - let (diff_result, _) = extract_diff(&repo, base, head, range, staged, unstaged, false, include_uncommitted)?; - - let file_diff = diff_result - .files - .iter() - .find(|f| f.path() == file_path) - .ok_or_else(|| CommandError::Analysis(format!("File '{}' not found in diff", file_path)))?; - - Ok(FileDiffContent { - path: file_path, - old_content: file_diff.old_content.clone().unwrap_or_default(), - new_content: file_diff.new_content.clone().unwrap_or_default(), - language: detect_language(&file_diff.path()), - }) -} - -fn load_cached_analysis( - state: &tauri::State<'_, AppState>, -) -> Result { - let last = state - .last_analysis - .lock() - .map_err(|e| CommandError::Analysis(format!("Lock poisoned: {}", e)))?; - last.clone() - .ok_or_else(|| CommandError::Analysis("No analysis available. Run analyze first.".into())) -} - -fn build_pass1_request( - analysis: &AnalysisOutput, - reanalysis_context: Option<&str>, -) -> llm::schema::Pass1Request { - let flow_groups: Vec = analysis - .groups - .iter() - .map(|g| llm::schema::Pass1GroupInput { - id: g.id.clone(), - name: g.name.clone(), - entrypoint: g - .entrypoint - .as_ref() - .map(|ep| format!("{}::{}", ep.file, ep.symbol)), - files: g.files.iter().map(|f| f.path.clone()).collect(), - risk_score: g.risk_score, - edge_summary: g - .edges - .iter() - .map(|e| format!("{} -> {}", e.from, e.to)) - .collect::>() - .join(", "), - }) - .collect(); - - let mut diff_summary = format!( - "{} files changed across {} groups", - analysis.summary.total_files_changed, analysis.summary.total_groups, - ); - - if let Some(context) = reanalysis_context { - let trimmed = context.trim(); - if !trimmed.is_empty() { - diff_summary.push_str("\n\n## Reanalysis Context\n"); - diff_summary.push_str(trimmed); - } - } - - llm::schema::Pass1Request { - diff_summary, - flow_groups, - graph_summary: format!( - "{} groups, {} total files", - analysis.summary.total_groups, analysis.summary.total_files_changed, - ), - } -} - -fn build_overview_reanalysis_context( - user_feedback: Option, - include_previous_output: Option, - previous_output: Option, - user_comments: Option>, -) -> Option { - let mut parts: Vec = Vec::new(); - - if let Some(feedback) = user_feedback { - let trimmed = feedback.trim(); - if !trimmed.is_empty() { - parts.push(format!("User feedback/question:\n{}", trimmed)); - } - } - - if let Some(comments) = user_comments { - let non_empty: Vec = comments - .into_iter() - .map(|c| c.trim().to_string()) - .filter(|c| !c.is_empty()) - .collect(); - if !non_empty.is_empty() { - parts.push(format!("Review comments:\n- {}", non_empty.join("\n- "))); - } - } - - if include_previous_output.unwrap_or(false) { - if let Some(previous) = previous_output { - let trimmed = previous.trim(); - if !trimmed.is_empty() { - parts.push(format!("Previous output to consider:\n{}", trimmed)); - } - } - } - - if parts.is_empty() { - None - } else { - Some(parts.join("\n\n")) - } -} - -fn build_pass2_request( - analysis: &AnalysisOutput, - group_id: &str, - repo_path: &str, - base: Option, - head: Option, - range: Option, - staged: bool, - unstaged: bool, - include_uncommitted: bool, -) -> Result { - let group = analysis - .groups - .iter() - .find(|g| g.id == group_id) - .ok_or_else(|| CommandError::Analysis(format!("Group '{}' not found", group_id)))? - .clone(); - - let repo_path_buf = PathBuf::from(repo_path); - let repo_path_buf = std::fs::canonicalize(&repo_path_buf) - .map_err(|e| CommandError::Io(format!("Invalid repo path: {}", e)))?; - let repo = git2::Repository::discover(&repo_path_buf) - .map_err(|e| CommandError::Git(format!("Not a git repository: {}", e)))?; - - let (diff_result, _) = extract_diff(&repo, base, head, range, staged, unstaged, false, include_uncommitted)?; - - let files: Vec = group - .files - .iter() - .map(|f| { - let file_diff = diff_result.files.iter().find(|d| d.path() == f.path); - let diff_text = file_diff - .map(|d| { - let old = d.old_content.as_deref().unwrap_or(""); - let new = d.new_content.as_deref().unwrap_or(""); - format!( - "--- a/{}\n+++ b/{}\n{}", - f.path, - f.path, - simple_unified_diff(old, new) - ) - }) - .unwrap_or_default(); - let new_content = file_diff.and_then(|d| d.new_content.clone()); - - llm::schema::Pass2FileInput { - path: f.path.clone(), - diff: diff_text, - new_content, - role: format!("{:?}", f.role), - } - }) - .collect(); - - let graph_context = group - .edges - .iter() - .map(|e| format!("{} --{:?}--> {}", e.from, e.edge_type, e.to)) - .collect::>() - .join("\n"); - - Ok(llm::schema::Pass2Request { - group_id: group.id.clone(), - group_name: group.name.clone(), - files, - graph_context, - }) -} - -fn make_activity_callback( - job: JobHandle, -) -> Arc { - Arc::new(move |update| { - let job = job.clone(); - tauri::async_runtime::spawn(async move { - job.emit(ActivityEntry { - source: update.source, - level: update.level, - message: update.message, - event_type: update.event_type, - payload: update.payload, - timestamp_ms: update.timestamp_ms, - }) - .await; - }); - }) -} - -async fn emit_diffcore_activity(job: &JobHandle, message: impl Into) { - job.emit(ActivityEntry::info("diffcore", message, None)) - .await; -} - -fn provider_supports_tool_activity(provider: &str) -> bool { - matches!(provider, "codex" | "claude") -} - -async fn emit_direct_api_activity_notice(job: &JobHandle, provider: &str) { - if provider_supports_tool_activity(provider) { - return; - } - - emit_diffcore_activity( - job, - "Direct API mode only shows high-level progress. Switch to Codex CLI or Claude Code to stream file reads, greps, and shell activity.", - ) - .await; -} - -fn refinement_reasoning_excerpt(reasoning: &str) -> Option { - let trimmed = reasoning.split_whitespace().collect::>().join(" "); - if trimmed.is_empty() { - return None; - } - - let sentence_end = trimmed.find(". ").map(|index| index + 1); - let excerpt = sentence_end - .map(|index| trimmed[..index].trim().to_string()) - .unwrap_or_else(|| trimmed.chars().take(220).collect::()); - - if excerpt.is_empty() { - None - } else if excerpt.chars().count() < trimmed.chars().count() && sentence_end.is_none() { - Some(format!("{}...", excerpt)) - } else { - Some(excerpt) - } -} - -fn refinement_operations_summary(response: &RefinementResponse) -> String { - let mut parts = Vec::new(); - - if !response.splits.is_empty() { - parts.push(format!( - "{} split{}", - response.splits.len(), - if response.splits.len() == 1 { "" } else { "s" } - )); - } - if !response.merges.is_empty() { - parts.push(format!( - "{} merge{}", - response.merges.len(), - if response.merges.len() == 1 { "" } else { "s" } - )); - } - if !response.re_ranks.is_empty() { - parts.push(format!( - "{} re-rank{}", - response.re_ranks.len(), - if response.re_ranks.len() == 1 { - "" - } else { - "s" - } - )); - } - if !response.reclassifications.is_empty() { - parts.push(format!( - "{} reclassification{}", - response.reclassifications.len(), - if response.reclassifications.len() == 1 { - "" - } else { - "s" - } - )); - } - - if parts.is_empty() { - "no structural changes".to_string() - } else { - parts.join(", ") - } -} - -async fn run_overview_with_activity( - request: llm::schema::Pass1Request, - llm_config: diffcore_core::config::LlmConfig, - workdir: Option, - job: JobHandle, -) -> Result { - emit_diffcore_activity(&job, "Preparing overview request").await; - let provider = llm::create_provider_for_workdir(&llm_config, workdir.as_deref()) - .map_err(|e| CommandError::Llm(format!("{}", e)))?; - let provider_name = provider.name().to_string(); - let provider_model = provider.model().to_string(); - emit_diffcore_activity( - &job, - format!("Using {} / {}", provider_name, provider_model), - ) - .await; - emit_direct_api_activity_notice(&job, &provider_name).await; - - llm::with_activity_callback(make_activity_callback(job), async { - provider.annotate_overview(&request).await - }) - .await - .map_err(|e| CommandError::Llm(format!("{}", e))) -} - -async fn run_group_with_activity( - request: llm::schema::Pass2Request, - llm_config: diffcore_core::config::LlmConfig, - workdir: Option, - job: JobHandle, -) -> Result { - emit_diffcore_activity(&job, "Preparing deep analysis request").await; - let provider = llm::create_provider_for_workdir(&llm_config, workdir.as_deref()) - .map_err(|e| CommandError::Llm(format!("{}", e)))?; - let provider_name = provider.name().to_string(); - let provider_model = provider.model().to_string(); - emit_diffcore_activity( - &job, - format!("Using {} / {}", provider_name, provider_model), - ) - .await; - emit_direct_api_activity_notice(&job, &provider_name).await; - - llm::with_activity_callback(make_activity_callback(job), async { - provider.annotate_group(&request).await - }) - .await - .map_err(|e| CommandError::Llm(format!("{}", e))) -} - -async fn run_refinement_with_activity( - analysis: AnalysisOutput, - refinement_llm_config: diffcore_core::config::LlmConfig, - workdir: Option, - job: JobHandle, -) -> Result { - emit_diffcore_activity(&job, "Preparing refinement request").await; - let provider = llm::create_provider_for_workdir(&refinement_llm_config, workdir.as_deref()) - .map_err(|e| CommandError::Llm(format!("{}", e)))?; - let provider_name = provider.name().to_string(); - let provider_model = provider.model().to_string(); - emit_diffcore_activity( - &job, - format!("Using {} / {}", provider_name, provider_model), - ) - .await; - emit_direct_api_activity_notice(&job, &provider_name).await; - - let analysis_json = serde_json::to_string_pretty(&analysis) - .map_err(|e| CommandError::Llm(format!("Failed to serialize analysis: {}", e)))?; - let diff_summary = format!( - "{} files changed across {} groups", - analysis.summary.total_files_changed, analysis.summary.total_groups, - ); - let request = refinement::build_refinement_request( - &analysis.groups, - analysis.infrastructure_group.as_ref(), - &analysis_json, - &diff_summary, - ); - - let response = llm::with_activity_callback(make_activity_callback(job.clone()), async { - provider.refine_groups(&request).await - }) - .await - .map_err(|e| CommandError::Llm(format!("{}", e)))?; - - if let Some(reasoning) = refinement_reasoning_excerpt(&response.reasoning) { - job.emit(ActivityEntry::info( - provider_name.clone(), - format!("Refinement rationale: {}", reasoning), - Some("refinement.reasoning".to_string()), - )) - .await; - } - - let provider_name = refinement_llm_config - .provider - .clone() - .unwrap_or_else(|| "anthropic".to_string()); - let model_name = refinement_llm_config - .model - .clone() - .unwrap_or_else(|| default_model_for_provider(&provider_name).to_string()); - - if !refinement::has_refinements(&response) { - emit_diffcore_activity(&job, "Refinement kept the current grouping").await; - return Ok(RefinementResult { - refined_groups: analysis.groups.clone(), - infrastructure_group: analysis.infrastructure_group.clone(), - refinement_response: response, - provider: provider_name, - model: model_name, - had_changes: false, - warnings: Vec::new(), - }); - } - - let (refined_groups, infra, warnings) = refinement::apply_refinement_lenient( - &analysis.groups, - analysis.infrastructure_group.as_ref(), - &response, - ); - - for warning in &warnings { - job.emit(ActivityEntry::info( - provider_name.clone(), - format!("Refinement repair: {}", warning.message), - Some("refinement.repair".to_string()), - )) - .await; - } - - emit_diffcore_activity( - &job, - format!( - "Refinement proposed {}", - refinement_operations_summary(&response) - ), - ) - .await; - - Ok(RefinementResult { - refined_groups, - infrastructure_group: infra, - refinement_response: response, - provider: provider_name, - model: model_name, - had_changes: true, - warnings, - }) -} - -#[tauri::command] -pub fn start_annotate_overview( - repo_path: Option, - llm_provider: Option, - llm_model: Option, - user_feedback: Option, - include_previous_output: Option, - previous_output: Option, - user_comments: Option>, - state: tauri::State<'_, AppState>, -) -> Result { - let analysis = load_cached_analysis(&state)?; - let (mut config, workdir) = load_config_from_path(repo_path.as_deref()); - if let Some(provider) = llm_provider { - config.llm.provider = Some(provider); - } - if let Some(model) = llm_model { - config.llm.model = Some(model); - } - - let provider_name = config - .llm - .provider - .clone() - .unwrap_or_else(|| "anthropic".to_string()); - let model_name = config - .llm - .model - .clone() - .unwrap_or_else(|| default_model_for_provider(&provider_name).to_string()); - let (job, start) = - state.create_llm_job("overview", &provider_name, &model_name, "Summarizing PR")?; - let llm_config = config.llm.clone(); - let reanalysis_context = build_overview_reanalysis_context( - user_feedback, - include_previous_output, - previous_output, - user_comments, - ); - let request = build_pass1_request(&analysis, reanalysis_context.as_deref()); - - tauri::async_runtime::spawn(async move { - match run_overview_with_activity(request, llm_config, workdir, job.clone()).await { - Ok(response) => match serde_json::to_value(&response) { - Ok(value) => job.complete("overview", value).await, - Err(error) => { - job.fail(format!("Failed to serialize overview response: {}", error)) - .await - } - }, - Err(error) => job.fail(error.to_string()).await, - } - }); - - Ok(start) -} - -#[tauri::command] -pub fn start_annotate_group( - group_id: String, - repo_path: String, - base: Option, - head: Option, - range: Option, - staged: bool, - unstaged: bool, - include_uncommitted: Option, - llm_provider: Option, - llm_model: Option, - state: tauri::State<'_, AppState>, -) -> Result { - let analysis = load_cached_analysis(&state)?; - let request = build_pass2_request( - &analysis, &group_id, &repo_path, base, head, range, staged, unstaged, include_uncommitted.unwrap_or(true), - )?; - let (mut config, workdir) = load_config_from_path(Some(&repo_path)); - if let Some(provider) = llm_provider { - config.llm.provider = Some(provider); - } - if let Some(model) = llm_model { - config.llm.model = Some(model); - } - - let provider_name = config - .llm - .provider - .clone() - .unwrap_or_else(|| "anthropic".to_string()); - let model_name = config - .llm - .model - .clone() - .unwrap_or_else(|| default_model_for_provider(&provider_name).to_string()); - let (job, start) = state.create_llm_job( - "group", - &provider_name, - &model_name, - &format!("Analyzing {}", group_id), - )?; - let llm_config = config.llm.clone(); - - tauri::async_runtime::spawn(async move { - match run_group_with_activity(request, llm_config, workdir, job.clone()).await { - Ok(response) => match serde_json::to_value(&response) { - Ok(value) => job.complete("group", value).await, - Err(error) => { - job.fail(format!("Failed to serialize group response: {}", error)) - .await - } - }, - Err(error) => job.fail(error.to_string()).await, - } - }); - - Ok(start) -} - -#[tauri::command] -pub fn start_refine_groups( - repo_path: Option, - llm_provider: Option, - llm_model: Option, - state: tauri::State<'_, AppState>, -) -> Result { - let analysis = load_cached_analysis(&state)?; - let (mut config, workdir) = load_config_from_path(repo_path.as_deref()); - if let Some(provider) = llm_provider { - config.llm.refinement.provider = Some(provider.clone()); - if config.llm.provider.is_none() { - config.llm.provider = Some(provider); - } - } - if let Some(model) = llm_model { - config.llm.refinement.model = Some(model.clone()); - if config.llm.model.is_none() { - config.llm.model = Some(model); - } - } - - let refinement_llm_config = diffcore_core::config::LlmConfig { - provider: config - .llm - .refinement - .provider - .clone() - .or(config.llm.provider.clone()), - model: config - .llm - .refinement - .model - .clone() - .or(config.llm.model.clone()), - key_cmd: config - .llm - .refinement - .key_cmd - .clone() - .or(config.llm.key_cmd.clone()), - key: config.llm.key.clone(), - annotations_enabled: config.llm.annotations_enabled, - refinement: config.llm.refinement.clone(), - }; - - let provider_name = refinement_llm_config - .provider - .clone() - .unwrap_or_else(|| "anthropic".to_string()); - let model_name = refinement_llm_config - .model - .clone() - .unwrap_or_else(|| default_model_for_provider(&provider_name).to_string()); - let (job, start) = - state.create_llm_job("refinement", &provider_name, &model_name, "Refining groups")?; - - tauri::async_runtime::spawn(async move { - match run_refinement_with_activity(analysis, refinement_llm_config, workdir, job.clone()) - .await - { - Ok(response) => match serde_json::to_value(&response) { - Ok(value) => job.complete("refinement", value).await, - Err(error) => { - job.fail(format!( - "Failed to serialize refinement response: {}", - error - )) - .await - } - }, - Err(error) => job.fail(error.to_string()).await, - } - }); - - Ok(start) -} - -/// Run LLM Pass 1 (overview annotation) on the cached analysis. -/// -/// Returns structured overview with per-group summaries, risk flags, -/// and suggested review order. The result is also stored in the cached -/// analysis output's `annotations` field. -#[tauri::command] -pub async fn annotate_overview( - repo_path: Option, - llm_provider: Option, - llm_model: Option, - user_feedback: Option, - include_previous_output: Option, - previous_output: Option, - user_comments: Option>, - state: tauri::State<'_, AppState>, -) -> Result { - // Get the cached analysis to build the request - let analysis = { - let last = state - .last_analysis - .lock() - .map_err(|e| CommandError::Analysis(format!("Lock poisoned: {}", e)))?; - last.clone().ok_or_else(|| { - CommandError::Analysis("No analysis available. Run analyze first.".into()) - })? - }; - - // Load config from the repo directory (not default) - let (mut config, workdir) = load_config_from_path(repo_path.as_deref()); - - // Apply frontend overrides if provided - if let Some(p) = llm_provider { - config.llm.provider = Some(p); - } - if let Some(m) = llm_model { - config.llm.model = Some(m); - } - - // Create LLM provider - let provider = llm::create_provider_for_workdir(&config.llm, workdir.as_deref()) - .map_err(|e| CommandError::Llm(format!("{}", e)))?; - - let reanalysis_context = build_overview_reanalysis_context( - user_feedback, - include_previous_output, - previous_output, - user_comments, - ); - let request = build_pass1_request(&analysis, reanalysis_context.as_deref()); - - let response = provider - .annotate_overview(&request) - .await - .map_err(|e| CommandError::Llm(format!("{}", e)))?; - - // Store the annotations in the cached analysis - match state.last_analysis.lock() { - Ok(mut last) => { - if let Some(ref mut a) = *last { - a.annotations = Some(serde_json::to_value(&response).map_err(|e| { - CommandError::Llm(format!("Failed to serialize response: {}", e)) - })?); - } - } - Err(e) => warn!( - "Failed to update last_analysis annotations (lock poisoned): {}", - e - ), - } - - Ok(response) -} - -/// Run LLM Pass 2 (deep analysis) on a specific group. -/// -/// Returns per-file annotations, flow narrative, and cross-cutting concerns. -#[tauri::command] -pub async fn annotate_group( - group_id: String, - repo_path: String, - base: Option, - head: Option, - range: Option, - staged: bool, - unstaged: bool, - include_uncommitted: Option, - llm_provider: Option, - llm_model: Option, - state: tauri::State<'_, AppState>, -) -> Result { - // Get the cached analysis to find the group - let analysis = { - let last = state - .last_analysis - .lock() - .map_err(|e| CommandError::Analysis(format!("Lock poisoned: {}", e)))?; - last.clone().ok_or_else(|| { - CommandError::Analysis("No analysis available. Run analyze first.".into()) - })? - }; - - let group = analysis - .groups - .iter() - .find(|g| g.id == group_id) - .ok_or_else(|| CommandError::Analysis(format!("Group '{}' not found", group_id)))? - .clone(); - - // Get file diffs for Pass 2 context - let repo_path_buf = PathBuf::from(&repo_path); - let repo_path_buf = std::fs::canonicalize(&repo_path_buf) - .map_err(|e| CommandError::Io(format!("Invalid repo path: {}", e)))?; - let repo = git2::Repository::discover(&repo_path_buf) - .map_err(|e| CommandError::Git(format!("Not a git repository: {}", e)))?; - - let (diff_result, _) = extract_diff(&repo, base, head, range, staged, unstaged, false, include_uncommitted.unwrap_or(true))?; - - // Build Pass 2 file inputs with diffs - let files: Vec = group - .files - .iter() - .map(|f| { - let file_diff = diff_result.files.iter().find(|d| d.path() == f.path); - let diff_text = file_diff - .map(|d| { - // Build a simple unified diff representation - let old = d.old_content.as_deref().unwrap_or(""); - let new = d.new_content.as_deref().unwrap_or(""); - format!( - "--- a/{}\n+++ b/{}\n{}", - f.path, - f.path, - simple_unified_diff(old, new) - ) - }) - .unwrap_or_default(); - let new_content = file_diff.and_then(|d| d.new_content.clone()); - - llm::schema::Pass2FileInput { - path: f.path.clone(), - diff: diff_text, - new_content, - role: format!("{:?}", f.role), - } - }) - .collect(); - - // Build graph context - let graph_context = group - .edges - .iter() - .map(|e| format!("{} --{:?}--> {}", e.from, e.edge_type, e.to)) - .collect::>() - .join("\n"); - - let (mut config, workdir) = load_config_from_path(Some(&repo_path)); - if let Some(p) = llm_provider { - config.llm.provider = Some(p); - } - if let Some(m) = llm_model { - config.llm.model = Some(m); - } - let provider = llm::create_provider_for_workdir(&config.llm, workdir.as_deref()) - .map_err(|e| CommandError::Llm(format!("{}", e)))?; - - let request = llm::schema::Pass2Request { - group_id: group.id.clone(), - group_name: group.name.clone(), - files, - graph_context, - }; - - let response = provider - .annotate_group(&request) - .await - .map_err(|e| CommandError::Llm(format!("{}", e)))?; - - Ok(response) -} - -/// Run LLM refinement pass on the cached analysis groups. -/// -/// Takes the deterministic groups (v1) and asks an LLM to suggest structural -/// improvements: splits, merges, re-ranks, and reclassifications. Applies the -/// refinement operations and returns the result containing both the refined -/// groups and the raw refinement response (for change indicators in the UI). -/// -/// Falls back to returning the original groups if refinement produces no changes -/// or validation fails. -#[tauri::command] -pub async fn refine_groups( - repo_path: Option, - llm_provider: Option, - llm_model: Option, - state: tauri::State<'_, AppState>, -) -> Result { - // Get the cached analysis - let analysis = { - let last = state - .last_analysis - .lock() - .map_err(|e| CommandError::Analysis(format!("Lock poisoned: {}", e)))?; - last.clone().ok_or_else(|| { - CommandError::Analysis("No analysis available. Run analyze first.".into()) - })? - }; - - // Load config, applying frontend overrides - let (mut config, workdir) = load_config_from_path(repo_path.as_deref()); - // Use refinement-specific provider/model if set, otherwise fall back to overrides - if let Some(p) = llm_provider { - config.llm.refinement.provider = Some(p.clone()); - if config.llm.provider.is_none() { - config.llm.provider = Some(p); - } - } - if let Some(m) = llm_model { - config.llm.refinement.model = Some(m.clone()); - if config.llm.model.is_none() { - config.llm.model = Some(m); - } - } - - // Build LLM config for the refinement provider - let refinement_llm_config = diffcore_core::config::LlmConfig { - provider: config - .llm - .refinement - .provider - .clone() - .or(config.llm.provider.clone()), - model: config - .llm - .refinement - .model - .clone() - .or(config.llm.model.clone()), - key_cmd: config - .llm - .refinement - .key_cmd - .clone() - .or(config.llm.key_cmd.clone()), - key: config.llm.key.clone(), - annotations_enabled: config.llm.annotations_enabled, - refinement: config.llm.refinement.clone(), - }; - - let provider = llm::create_provider_for_workdir(&refinement_llm_config, workdir.as_deref()) - .map_err(|e| CommandError::Llm(format!("{}", e)))?; - - // Serialize analysis for the refinement request - let analysis_json = serde_json::to_string_pretty(&analysis) - .map_err(|e| CommandError::Llm(format!("Failed to serialize analysis: {}", e)))?; - - let diff_summary = format!( - "{} files changed across {} groups", - analysis.summary.total_files_changed, analysis.summary.total_groups, - ); - - let request = refinement::build_refinement_request( - &analysis.groups, - analysis.infrastructure_group.as_ref(), - &analysis_json, - &diff_summary, - ); - - let response = provider - .refine_groups(&request) - .await - .map_err(|e| CommandError::Llm(format!("{}", e)))?; - - let provider_name = refinement_llm_config - .provider - .unwrap_or_else(|| "anthropic".to_string()); - let model_name = refinement_llm_config - .model - .unwrap_or_else(|| default_model_for_provider(&provider_name).to_string()); - - if !refinement::has_refinements(&response) { - return Ok(RefinementResult { - refined_groups: analysis.groups.clone(), - infrastructure_group: analysis.infrastructure_group.clone(), - refinement_response: response, - provider: provider_name, - model: model_name, - had_changes: false, - warnings: Vec::new(), - }); - } - - // Apply the refinement leniently: repair what we can, drop what we can't, - // surface warnings instead of erroring on individual hallucinated ops. - let (refined_groups, infra, warnings) = refinement::apply_refinement_lenient( - &analysis.groups, - analysis.infrastructure_group.as_ref(), - &response, - ); - - for w in &warnings { - warn!("Refinement repair: {}", w.message); - } - - // Update cached analysis with refined groups - match state.last_analysis.lock() { - Ok(mut last) => { - if let Some(ref mut a) = *last { - a.groups = refined_groups.clone(); - a.infrastructure_group = infra.clone(); - } - } - Err(e) => warn!( - "Failed to update last_analysis with refinement (lock poisoned): {}", - e - ), - } - - Ok(RefinementResult { - refined_groups, - infrastructure_group: infra, - refinement_response: response, - provider: provider_name, - model: model_name, - had_changes: true, - warnings, - }) -} - -/// Result of a refinement pass, including both the refined groups and -/// the raw refinement operations (for UI change indicators). -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct RefinementResult { - /// The refined flow groups (v2) — or original groups if no changes. - pub refined_groups: Vec, - /// The refined infrastructure group. - pub infrastructure_group: Option, - /// The raw refinement response with split/merge/re-rank/reclassify operations. - pub refinement_response: RefinementResponse, - /// Which provider performed the refinement. - pub provider: String, - /// Which model performed the refinement. - pub model: String, - /// Whether the refinement actually produced changes. - pub had_changes: bool, - /// Non-fatal warnings from the lenient apply path: repaired IDs and - /// dropped operations. Empty in the common case. - #[serde(default)] - pub warnings: Vec, -} - -/// Load cached refinement result for the current analysis. -/// -/// Tries two keys: (1) diff-hash key (exact match), (2) branch-based key (same branch -/// across worktrees, even with different uncommitted changes). -#[tauri::command] -pub fn get_cached_refinement( - repo_path: Option, - state: tauri::State<'_, AppState>, -) -> Result, CommandError> { - // Try diff-hash key first (exact content match) - let diff_key = state.last_cache_key.lock().ok().and_then(|k| k.clone()); - if let Some(ref key) = diff_key { - if let Some(json) = cache::load_cached_refinement(key) { - if let Ok(result) = serde_json::from_str::(&json) { - return Ok(Some(result)); - } - } - } - - // Fallback: try branch-based key (works across worktrees on same branch) - if let Some(ref repo) = repo_path { - if let Ok(branch_key) = comment_cache_key(repo) { - let branch_refine_key = format!("branch_{}", branch_key); - if let Some(json) = cache::load_cached_refinement(&branch_refine_key) { - if let Ok(result) = serde_json::from_str::(&json) { - return Ok(Some(result)); - } - } - } - } - - Ok(None) -} - -/// Store a refinement result in the global cache (~/.diffcore/cache/refinements/). -/// -/// Stores under both diff-hash key and branch-based key for cross-worktree access. -#[tauri::command] -pub fn store_refinement_cache( - result: RefinementResult, - repo_path: Option, - state: tauri::State<'_, AppState>, -) -> Result<(), CommandError> { - let json = match serde_json::to_string(&result) { - Ok(j) => j, - Err(e) => { - warn!("Failed to serialize refinement for caching: {}", e); - return Ok(()); - } - }; - - // Store under diff-hash key - if let Some(cache_key) = state.last_cache_key.lock().ok().and_then(|k| k.clone()) { - cache::store_cached_refinement(&cache_key, &json); - } - - // Also store under branch-based key for cross-worktree access - if let Some(ref repo) = repo_path { - if let Ok(branch_key) = comment_cache_key(repo) { - let branch_refine_key = format!("branch_{}", branch_key); - cache::store_cached_refinement(&branch_refine_key, &json); - } - } - - Ok(()) -} - -/// Background LLM job registration payload returned before SSE streaming begins. -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct AsyncLlmJobStart { - pub job_id: String, - pub stream_url: String, - pub operation: String, - pub provider: String, - pub model: String, - pub title: String, -} - -/// List all local branches in the repository. -/// -/// Returns branches sorted with current branch first, then alphabetically. -#[tauri::command] -pub fn list_branches(repo_path: String) -> Result, CommandError> { - let repo = open_repo(&repo_path)?; - git::list_branches(&repo).map_err(|e| CommandError::Git(format!("{}", e))) -} - -/// List recent commits for commit-level ref selection in the UI. -#[tauri::command] -pub fn list_commits(repo_path: String, limit: Option) -> Result, CommandError> { - let repo = open_repo(&repo_path)?; - let bounded_limit = limit.unwrap_or(50).clamp(1, 200); - git::list_recent_commits(&repo, bounded_limit) - .map_err(|e| CommandError::Git(format!("{}", e))) -} - -/// List all git worktrees for the repository. -#[tauri::command] -pub fn list_worktrees(repo_path: String) -> Result, CommandError> { - let repo = open_repo(&repo_path)?; - git::list_worktrees(&repo).map_err(|e| CommandError::Git(format!("{}", e))) -} - -/// Get the current branch's tracking status (ahead/behind upstream). -#[tauri::command] -pub fn get_branch_status(repo_path: String) -> Result { - let repo = open_repo(&repo_path)?; - git::get_branch_status(&repo).map_err(|e| CommandError::Git(format!("{}", e))) -} - -/// Auto-detect the default branch and current branch for a repository. -/// -/// Returns a summary useful for the UI to set up initial state. -#[tauri::command] -pub fn get_repo_info(repo_path: String) -> Result { - let repo = open_repo(&repo_path)?; - - let current = git::current_branch(&repo); - let default_branch = git::detect_default_branch(&repo).unwrap_or_else(|_| "main".to_string()); - let branches = git::list_branches(&repo).map_err(|e| CommandError::Git(format!("{}", e)))?; - let worktrees = git::list_worktrees(&repo).map_err(|e| CommandError::Git(format!("{}", e)))?; - let status = git::get_branch_status(&repo).ok(); - let is_worktree = git::is_linked_worktree(&repo); - - Ok(RepoInfo { - current_branch: current, - default_branch, - branches, - worktrees, - status, - is_worktree, - }) -} - -/// Return the first directory argument passed at app launch, if any. -#[tauri::command] -pub fn get_launch_directory() -> Option { - std::env::args_os() - .skip(1) - .map(PathBuf::from) - .find(|path| path.is_dir()) - .and_then(|path| std::fs::canonicalize(path).ok()) - .map(|path| path.to_string_lossy().to_string()) -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct FileShortStatus { - pub path: String, - pub status: String, -} - -#[tauri::command] -pub fn get_last_diff_file_statuses( - state: tauri::State<'_, AppState>, -) -> Result, CommandError> { - let guard = state - .last_diff - .lock() - .map_err(|e| CommandError::Analysis(format!("Lock poisoned: {}", e)))?; - - let Some(cached) = guard.as_ref() else { - return Ok(vec![]); - }; - - let mut out = Vec::with_capacity(cached.diff_result.files.len()); - for file in &cached.diff_result.files { - let status = match file.status { - diffcore_core::git::FileStatus::Added => "A", - diffcore_core::git::FileStatus::Modified => "M", - diffcore_core::git::FileStatus::Deleted => "D", - diffcore_core::git::FileStatus::Renamed => "R", - diffcore_core::git::FileStatus::Copied => "C", - }; - out.push(FileShortStatus { - path: file.path().to_string(), - status: status.to_string(), - }); - } - - Ok(out) -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct CrossFileSearchMatch { - pub line_number: u32, - pub line_text: String, -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct CrossFileSearchResult { - pub file_path: String, - pub matches: Vec, -} - -fn changed_files_from_state(state: &AppState) -> HashSet { - let mut files = HashSet::new(); - - if let Ok(guard) = state.last_analysis.lock() { - if let Some(analysis) = guard.as_ref() { - for group in &analysis.groups { - for file in &group.files { - files.insert(file.path.clone()); - } - } - if let Some(infra) = &analysis.infrastructure_group { - for file in &infra.files { - files.insert(file.clone()); - } - } - } - } - - if files.is_empty() { - if let Ok(guard) = state.last_diff.lock() { - if let Some(cached) = guard.as_ref() { - for file in &cached.diff_result.files { - files.insert(file.path().to_string()); - } - } - } - } - - files -} - -fn workspace_files(workdir: &std::path::Path) -> Vec { - let mut builder = WalkBuilder::new(workdir); - builder - .hidden(false) - .ignore(true) - .git_ignore(true) - .git_exclude(true) - .parents(true); - - builder - .build() - .into_iter() - .filter_map(Result::ok) - .filter(|entry| entry.file_type().map(|ft| ft.is_file()).unwrap_or(false)) - .filter_map(|entry| { - let rel = entry.path().strip_prefix(workdir).ok()?; - let rel_str = rel.to_string_lossy().replace('\\', "/"); - if rel_str.starts_with(".git/") { - return None; - } - Some(rel_str) - }) - .collect() -} - -#[tauri::command] -pub fn cross_file_search( - repo_path: String, - query: String, - show_unchanged_files: bool, - max_results: Option, - state: tauri::State<'_, AppState>, -) -> Result, CommandError> { - let query = query.trim(); - if query.is_empty() { - return Ok(vec![]); - } - - let repo = open_repo(&repo_path)?; - let workdir = repo - .workdir() - .ok_or_else(|| CommandError::Git("Bare repositories are not supported".to_string()))? - .to_path_buf(); - - let mut candidates: Vec = if show_unchanged_files { - workspace_files(&workdir) - } else { - changed_files_from_state(&state).into_iter().collect() - }; - candidates.sort(); - - let matcher = RegexMatcherBuilder::new() - .case_insensitive(true) - .fixed_strings(true) - .build(query) - .map_err(|e| CommandError::Analysis(format!("Invalid search query: {}", e)))?; - - let mut searcher = SearcherBuilder::new() - .line_number(true) - .multi_line(false) - .binary_detection(grep_searcher::BinaryDetection::quit(b'\x00')) - .build(); - - let max_file_results = max_results.unwrap_or(200).max(1); - let mut results = Vec::new(); - let mut total_matches = 0usize; - - for relative_path in candidates { - if results.len() >= max_file_results || total_matches >= 1000 { - break; - } - - let absolute_path = workdir.join(&relative_path); - let metadata = match std::fs::metadata(&absolute_path) { - Ok(meta) => meta, - Err(_) => continue, - }; - if metadata.len() > 2 * 1024 * 1024 { - continue; - } - - let mut file_matches = Vec::new(); - - let sink = sinks::UTF8(|line_number: u64, line: &str| { - if total_matches >= 1000 || file_matches.len() >= 50 { - return Ok(false); - } - let clean = line.trim_end_matches(&['\r', '\n'][..]).to_string(); - file_matches.push(CrossFileSearchMatch { - line_number: line_number as u32, - line_text: clean, - }); - total_matches += 1; - Ok(true) - }); - - if searcher.search_path(&matcher, &absolute_path, sink).is_err() { - continue; - } - - if !file_matches.is_empty() { - results.push(CrossFileSearchResult { - file_path: relative_path, - matches: file_matches, - }); - } - } - - Ok(results) -} - -#[tauri::command] -pub fn get_workspace_file_content( - repo_path: String, - file_path: String, -) -> Result { - let repo = open_repo(&repo_path)?; - let workdir = repo - .workdir() - .ok_or_else(|| CommandError::Git("Bare repositories are not supported".to_string()))? - .to_path_buf(); - - let absolute = workdir.join(&file_path); - if !absolute.exists() || !absolute.is_file() { - return Err(CommandError::Io(format!("File not found: {}", file_path))); - } - - let content = std::fs::read_to_string(&absolute) - .map_err(|e| CommandError::Io(format!("Failed to read file '{}': {}", file_path, e)))?; - - Ok(FileDiffContent { - path: file_path.clone(), - old_content: content.clone(), - new_content: content, - language: detect_language(&file_path), - }) -} - -/// Parse a single file's source via the diffcore-core query engine and -/// return the language-agnostic IR (definitions, imports, exports, call -/// sites). Used by the source-explorer outline panel so it can show -/// symbols for any language the engine supports — replacing the -/// hand-written per-language regex parsers that used to live in -/// `SourceExplorer.tsx` and only covered TS/JS/Python/Go/Rust. -/// -/// `path` is used only for language detection (via file extension); no -/// disk access happens. `source` is the raw text to parse. The shared -/// `QueryEngine` instance held on `AppState` caches per-language -/// tree-sitter query compilation across calls, so repeated outline -/// updates for the same language are cheap. -/// -/// Returns an empty `ParsedFile` (with `Language::Unknown`) when the -/// path's extension is not recognised — the caller is expected to -/// degrade gracefully rather than treat that as an error. -#[tauri::command] -pub fn parse_file_content( - path: String, - source: String, - state: tauri::State<'_, AppState>, -) -> Result { - state - .query_engine - .parse_file(&path, &source) - .map_err(|e| CommandError::Analysis(format!("parse_file failed: {e}"))) -} - -/// Check whether LLM access is configured and available. -/// -/// This includes API-key-based providers plus subscription-backed Codex/Claude CLIs. -#[tauri::command] -pub fn check_api_key(repo_path: Option) -> Result { - Ok(get_llm_settings(repo_path)?.has_api_key) -} - -/// Get LLM settings from the shared global config plus repo-local overrides. -/// -/// Reads `~/.diffcore/config.toml`, merges in any repo-local `[llm]` overrides, resolves -/// CLI/API availability, and returns a unified `LlmSettings` struct for the settings panel. -#[tauri::command] -pub fn get_llm_settings(repo_path: Option) -> Result { - let (config, workdir) = load_config_from_path(repo_path.as_deref()); - let codex_status = llm::codex_cli::detect_status(); - let claude_status = llm::claude_cli::detect_status(); - - let configured_provider = config.llm.provider.as_deref(); - let provider = - preferred_provider_for_runtime(configured_provider, &codex_status, &claude_status); - let model = - preferred_model_for_runtime(config.llm.model.clone(), configured_provider, &provider); - - let has_api_key = match provider.as_str() { - "codex" => codex_status.authenticated, - "claude" => claude_status.authenticated, - _ => llm::resolve_api_key(&config.llm, &provider).is_ok(), - }; - - let api_key_source = match provider.as_str() { - "codex" => match (codex_status.installed, codex_status.authenticated) { - (true, true) => "Codex CLI login".to_string(), - (true, false) => "Codex CLI installed, not logged in".to_string(), - (false, _) => "Codex CLI not installed".to_string(), - }, - "claude" => match (claude_status.installed, claude_status.authenticated) { - (true, true) => "Claude Code subscription".to_string(), - (true, false) => "Claude Code installed, not logged in".to_string(), - (false, _) => "Claude Code not installed".to_string(), - }, - _ if config.llm.key_cmd.is_some() => "key_cmd".to_string(), - _ if config.llm.key.as_ref().is_some_and(|k| !k.is_empty()) => { - "~/.diffcore/config.toml".to_string() - } - _ if std::env::var("DIFFCORE_API_KEY").is_ok() => "DIFFCORE_API_KEY".to_string(), - _ => { - let env_var = match provider.as_str() { - "anthropic" => "ANTHROPIC_API_KEY", - "openai" => "OPENAI_API_KEY", - "gemini" => "GEMINI_API_KEY", - "openrouter" => "OPENROUTER_API_KEY", - "github_copilot" => "GITHUB_COPILOT_TOKEN", - _ => "none", - }; - if std::env::var(env_var).is_ok() { - env_var.to_string() - } else if workdir.is_some() { - "none (configure in ~/.diffcore/config.toml or env)".to_string() - } else { - "none".to_string() - } - } - }; - - let configured_refinement_provider = config - .llm - .refinement - .provider - .as_deref() - .or(configured_provider); - let refinement_provider = preferred_provider_for_runtime( - configured_refinement_provider, - &codex_status, - &claude_status, - ); - let refinement_model = preferred_model_for_runtime( - config - .llm - .refinement - .model - .clone() - .or(config.llm.model.clone()), - configured_refinement_provider, - &refinement_provider, - ); - - Ok(LlmSettings { - annotations_enabled: config.llm.annotations_enabled, - refinement_enabled: config.llm.refinement.enabled, - provider, - model, - api_key_source, - has_api_key, - refinement_provider, - refinement_model, - refinement_max_iterations: config.llm.refinement.max_iterations, - global_config_path: display_global_config_path(), - codex_available: codex_status.installed, - codex_authenticated: codex_status.authenticated, - claude_available: claude_status.installed, - claude_authenticated: claude_status.authenticated, - include_uncommitted: config.diff.include_uncommitted, - }) -} - -/// Save LLM settings to the shared global config. -/// -/// Loads the existing global config, updates the `[llm]` section with the provided -/// settings, and writes back to `~/.diffcore/config.toml`. -#[tauri::command] -pub fn save_llm_settings(_repo_path: String, settings: LlmSettings) -> Result<(), CommandError> { - let mut config = - DiffcoreConfig::load_global().map_err(|e| CommandError::Config(format!("{}", e)))?; - - // Update LLM section - config.llm.provider = Some(settings.provider); - config.llm.model = Some(settings.model); - // Don't overwrite key_cmd — that's managed manually - config.llm.refinement.enabled = settings.refinement_enabled; - config.llm.refinement.provider = Some(settings.refinement_provider); - config.llm.refinement.model = Some(settings.refinement_model); - config.llm.refinement.max_iterations = settings.refinement_max_iterations; - config.llm.annotations_enabled = settings.annotations_enabled; - - // Update diff behavior - config.diff.include_uncommitted = settings.include_uncommitted; - - config - .save_global() - .map_err(|e| CommandError::Config(format!("Failed to save config: {}", e)))?; - - Ok(()) -} - -/// Save an API key to `~/.diffcore/config.toml` under `[llm] key = "..."`. -/// -/// The key is stored directly in the config file. Precedence is maintained: -/// `key_cmd` > `key` (config) > env vars. -#[tauri::command] -pub fn save_api_key(_repo_path: String, api_key: String) -> Result<(), CommandError> { - let mut config = - DiffcoreConfig::load_global().map_err(|e| CommandError::Config(format!("{}", e)))?; - - config.llm.key = Some(api_key); - - config - .save_global() - .map_err(|e| CommandError::Config(format!("Failed to save config: {}", e)))?; - - Ok(()) -} - -/// Remove the stored API key from `~/.diffcore/config.toml`. -#[tauri::command] -pub fn clear_api_key(_repo_path: String) -> Result<(), CommandError> { - let mut config = - DiffcoreConfig::load_global().map_err(|e| CommandError::Config(format!("{}", e)))?; - - config.llm.key = None; - - config - .save_global() - .map_err(|e| CommandError::Config(format!("Failed to save config: {}", e)))?; - - Ok(()) -} - -/// Re-export the shared `ModelInfo` type for the Tauri frontend. -pub use llm::models::ModelInfo; - -/// Fetch available models from a provider's API. -/// -/// Delegates to the shared `diffcore-core` model listing module, which handles -/// caching, API key resolution, and provider-specific fetching. -/// Pass `force_refresh: true` to bypass the 24-hour cache. -#[tauri::command] -pub async fn fetch_provider_models( - provider: String, - force_refresh: bool, -) -> Result, CommandError> { - llm::models::fetch_provider_models(&provider, force_refresh) - .await - .map_err(|e| match e { - llm::models::ModelListError::Network(msg) => CommandError::Network(msg), - llm::models::ModelListError::Config(msg) => CommandError::Config(msg), - llm::models::ModelListError::UnknownProvider(p) => { - CommandError::Config(format!("Unknown provider: {}", p)) - } - }) -} - -/// Get the current ignore paths from `.diffcore.toml`. -#[tauri::command] -pub fn get_ignore_paths(repo_path: Option) -> Result, CommandError> { - let (config, _workdir) = load_config_from_path(repo_path.as_deref()); - Ok(config.ignore.paths) -} - -/// Save ignore paths to `.diffcore.toml`. -/// -/// Loads the existing config (preserving other sections), updates the ignore -/// paths, and writes back. -#[tauri::command] -pub fn save_ignore_paths(repo_path: String, paths: Vec) -> Result<(), CommandError> { - let repo_path_buf = PathBuf::from(&repo_path); - let repo_path_buf = std::fs::canonicalize(&repo_path_buf) - .map_err(|e| CommandError::Io(format!("Invalid repo path: {}", e)))?; - let repo = git2::Repository::discover(&repo_path_buf) - .map_err(|e| CommandError::Git(format!("Not a git repository: {}", e)))?; - let workdir = repo - .workdir() - .ok_or_else(|| CommandError::Git("Bare repositories are not supported".to_string()))?; - - let mut config = DiffcoreConfig::load_from_dir(workdir) - .map_err(|e| CommandError::Config(format!("{}", e)))?; - - config.ignore.paths = paths; - - config - .save_to_dir(workdir) - .map_err(|e| CommandError::Config(format!("Failed to save config: {}", e)))?; - - Ok(()) -} - -/// macOS app bundle name for each editor. -#[cfg(target_os = "macos")] -fn macos_app_name(editor: &str) -> Option<&'static str> { - match editor { - "vscode" => Some("Visual Studio Code"), - "cursor" => Some("Cursor"), - "zed" => Some("Zed"), - _ => None, - } -} - -/// Check if a macOS .app bundle exists in /Applications or ~/Applications. -#[cfg(target_os = "macos")] -fn macos_app_exists(app_name: &str) -> bool { - let global = format!("/Applications/{}.app", app_name); - if PathBuf::from(&global).exists() { - return true; - } - if let Ok(home) = std::env::var("HOME") { - let user = format!("{}/Applications/{}.app", home, app_name); - if PathBuf::from(&user).exists() { - return true; - } - } - false -} - -/// Open a file in an external editor. -/// -/// On macOS, uses `open -a "App Name"` for GUI editors (works without PATH). -/// Falls back to CLI binary for non-macOS or terminal-based editors. -#[tauri::command] -pub fn open_in_editor(editor: String, file_path: String) -> Result<(), CommandError> { - let path = PathBuf::from(&file_path); - if !path.exists() { - return Err(CommandError::Io(format!("File not found: {}", file_path))); - } - - let result = match editor.as_str() { - "vscode" | "cursor" | "zed" => { - #[cfg(target_os = "macos")] - { - // Use the CLI binary via the app bundle's bin/ path for proper workspace trust. - // `open -a` opens files as untrusted; the CLI opens in the existing workspace. - let cli_path = match editor.as_str() { - "vscode" => { - "/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code" - } - "cursor" => "/Applications/Cursor.app/Contents/Resources/app/bin/cursor", - "zed" => "/Applications/Zed.app/Contents/MacOS/cli", - _ => unreachable!(), - }; - if std::path::Path::new(cli_path).exists() { - std::process::Command::new(cli_path) - .args(["--reuse-window", "--goto", &file_path]) - .spawn() - } else { - // Fallback to `open -a` if CLI path not found - let app_name = macos_app_name(&editor).unwrap(); - std::process::Command::new("open") - .args(["-a", app_name, &file_path]) - .spawn() - } - } - #[cfg(not(target_os = "macos"))] - { - let bin = match editor.as_str() { - "vscode" => "code", - "cursor" => "cursor", - "zed" => "zed", - _ => unreachable!(), - }; - std::process::Command::new(bin) - .args(["--reuse-window", "--goto", &file_path]) - .spawn() - } - } - "vim" => { - #[cfg(target_os = "macos")] - { - // Open vim in a NEW Terminal window via AppleScript - let escaped = file_path.replace('\\', "\\\\").replace('"', "\\\""); - std::process::Command::new("osascript") - .args([ - "-e", - &format!( - "tell application \"Terminal\"\n\ - activate\n\ - do script \"vim \\\"{}\\\"\" \n\ - end tell", - escaped - ), - ]) - .spawn() - } - #[cfg(not(target_os = "macos"))] - { - std::process::Command::new("vim").arg(&file_path).spawn() - } - } - "terminal" => { - let dir = if path.is_dir() { - file_path.clone() - } else { - path.parent() - .map(|p| p.to_string_lossy().to_string()) - .unwrap_or_else(|| file_path.clone()) - }; - #[cfg(target_os = "macos")] - { - // Use AppleScript to open Terminal and cd to the directory - let escaped = dir.replace('\\', "\\\\").replace('"', "\\\""); - std::process::Command::new("osascript") - .args([ - "-e", - &format!( - "tell application \"Terminal\"\n\ - activate\n\ - do script \"cd \\\"{}\\\"\" \n\ - end tell", - escaped - ), - ]) - .spawn() - } - #[cfg(target_os = "linux")] - { - std::process::Command::new("xdg-open").arg(&dir).spawn() - } - #[cfg(target_os = "windows")] - { - std::process::Command::new("cmd") - .args(["/c", "start", "cmd", "/k", &format!("cd /d {}", dir)]) - .spawn() - } - } - other => { - return Err(CommandError::Io(format!("Unknown editor: {}", other))); - } - }; - - match result { - Ok(_) => Ok(()), - Err(e) => { - let label = match editor.as_str() { - "vscode" => "VS Code", - "cursor" => "Cursor", - "zed" => "Zed", - "vim" => "Vim", - "terminal" => "Terminal", - _ => &editor, - }; - Err(CommandError::Io(format!( - "Failed to open {} — is it installed? ({})", - label, e - ))) - } - } -} - -/// Check which editors are available on the system. -/// -/// On macOS, checks for .app bundles in /Applications (works without PATH). -/// On other platforms, uses `which`/`where` to find CLI binaries. -#[tauri::command] -pub fn check_editors_available() -> std::collections::HashMap { - let mut result = std::collections::HashMap::new(); - - // GUI editors - for id in &["vscode", "cursor", "zed"] { - let available = { - #[cfg(target_os = "macos")] - { - macos_app_name(id) - .map(|name| macos_app_exists(name)) - .unwrap_or(false) - } - #[cfg(not(target_os = "macos"))] - { - let bin = match *id { - "vscode" => "code", - "cursor" => "cursor", - "zed" => "zed", - _ => id, - }; - #[cfg(unix)] - { - std::process::Command::new("which") - .arg(bin) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .status() - .map(|s| s.success()) - .unwrap_or(false) - } - #[cfg(windows)] - { - std::process::Command::new("where") - .arg(bin) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .status() - .map(|s| s.success()) - .unwrap_or(false) - } - } - }; - result.insert(id.to_string(), available); - } - - // vim — check binary in PATH (available on most systems) - let vim_available = { - #[cfg(unix)] - { - std::process::Command::new("which") - .arg("vim") - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .status() - .map(|s| s.success()) - .unwrap_or(false) - } - #[cfg(windows)] - { - std::process::Command::new("where") - .arg("vim") - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .status() - .map(|s| s.success()) - .unwrap_or(false) - } - }; - result.insert("vim".to_string(), vim_available); - - // Terminal is always available - result.insert("terminal".to_string(), true); - - result -} - -/// Persist edited file content to disk. -/// -/// Failure modes: -/// - Returns IO error when the path does not exist or is a directory. -/// - Returns IO error when the parent directory is missing. -/// - Returns IO error when the write fails (permissions, disk full, etc). -#[tauri::command] -pub fn save_file_content(file_path: String, content: String) -> Result<(), CommandError> { - let path = PathBuf::from(&file_path); - if !path.exists() { - return Err(CommandError::Io(format!("File not found: {}", file_path))); - } - if !path.is_file() { - return Err(CommandError::Io(format!("Path is not a file: {}", file_path))); - } - let parent = path.parent().ok_or_else(|| { - CommandError::Io(format!("Cannot determine parent directory for: {}", file_path)) - })?; - if !parent.exists() { - return Err(CommandError::Io(format!( - "Parent directory does not exist: {}", - parent.display() - ))); - } - - std::fs::write(&path, content) - .map_err(|e| CommandError::Io(format!("Failed to write file '{}': {}", file_path, e))) -} - -/// Load config from a repo path, returning both config and optional workdir. -fn load_config_from_path(repo_path: Option<&str>) -> (DiffcoreConfig, Option) { - if let Some(path) = repo_path { - let repo_path = PathBuf::from(path); - if let Ok(canonical) = std::fs::canonicalize(&repo_path) { - if let Ok(repo) = git2::Repository::discover(&canonical) { - if let Some(workdir) = repo.workdir() { - let config = - DiffcoreConfig::load_with_global_llm_from_dir(workdir).unwrap_or_default(); - return (config, Some(workdir.to_path_buf())); - } - } - } - } - (DiffcoreConfig::load_global().unwrap_or_default(), None) -} - -/// Get the default model for a provider. -fn default_model_for_provider(provider: &str) -> &str { - match provider { - "codex" => "default", - "claude" => "default", - "anthropic" => "claude-sonnet-4-6", - "openai" => "gpt-4.1", - "gemini" => "gemini-2.5-flash", - "openrouter" => "anthropic/claude-sonnet-4-6", - "github_copilot" => "gpt-4.1", - _ => "default", - } -} - -fn default_provider_for_machine( - codex_status: &llm::BackendStatus, - claude_status: &llm::BackendStatus, -) -> &'static str { - if codex_status.authenticated { - "codex" - } else if claude_status.authenticated { - "claude" - } else { - "anthropic" - } -} - -fn preferred_provider_for_runtime( - configured_provider: Option<&str>, - codex_status: &llm::BackendStatus, - claude_status: &llm::BackendStatus, -) -> String { - match configured_provider { - Some("codex") if codex_status.authenticated => "codex".to_string(), - Some("claude") if claude_status.authenticated => "claude".to_string(), - Some(provider) if provider_supports_tool_activity(provider) => { - default_provider_for_machine(codex_status, claude_status).to_string() - } - Some(provider) => { - if codex_status.authenticated || claude_status.authenticated { - default_provider_for_machine(codex_status, claude_status).to_string() - } else { - provider.to_string() - } - } - None => default_provider_for_machine(codex_status, claude_status).to_string(), - } -} - -fn preferred_model_for_runtime( - configured_model: Option, - configured_provider: Option<&str>, - resolved_provider: &str, -) -> String { - if configured_provider == Some(resolved_provider) { - configured_model - .unwrap_or_else(|| default_model_for_provider(resolved_provider).to_string()) - } else { - default_model_for_provider(resolved_provider).to_string() - } -} - -fn display_global_config_path() -> String { - DiffcoreConfig::global_config_path() - .map(|path| path.to_string_lossy().to_string()) - .unwrap_or_else(|| "~/.diffcore/config.toml".to_string()) -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -struct AppStateSnapshotFile { - version: String, - saved_at_epoch_ms: u128, - snapshot: serde_json::Value, -} - -fn app_logs_dir() -> Result { - if let Some(global_config) = DiffcoreConfig::global_config_path() { - let config_dir = global_config - .parent() - .ok_or_else(|| CommandError::Io("Failed to resolve config directory".to_string()))?; - return Ok(config_dir.join("logs")); - } - - let home = std::env::var_os("HOME") - .ok_or_else(|| CommandError::Io("Cannot determine HOME for log directory".to_string()))?; - Ok(PathBuf::from(home).join(".diffcore").join("logs")) -} - -fn app_state_snapshot_dir() -> Result { - Ok(app_logs_dir()?.join("app-state")) -} - -#[tauri::command] -pub fn save_app_state(snapshot: serde_json::Value) -> Result { - // TODO: re-enable app state save/restore after UX and reliability pass. - let _ = snapshot; - Err(CommandError::Analysis( - "App state save/restore is temporarily disabled".to_string(), - )) - - // let dir = app_state_snapshot_dir()?; - // std::fs::create_dir_all(&dir) - // .map_err(|e| CommandError::Io(format!("Failed to create app-state dir: {}", e)))?; - - // let now = std::time::SystemTime::now() - // .duration_since(std::time::UNIX_EPOCH) - // .map_err(|e| CommandError::Io(format!("System clock error: {}", e)))?; - // let saved_at_epoch_ms = now.as_millis(); - - // let payload = AppStateSnapshotFile { - // version: "1".to_string(), - // saved_at_epoch_ms, - // snapshot, - // }; - - // let latest_path = dir.join("latest.json"); - // let archive_path = dir.join(format!("snapshot-{}.json", saved_at_epoch_ms)); - // let json = serde_json::to_string_pretty(&payload) - // .map_err(|e| CommandError::Io(format!("Failed to serialize app state: {}", e)))?; - - // std::fs::write(&latest_path, &json) - // .map_err(|e| CommandError::Io(format!("Failed to write latest app state: {}", e)))?; - // std::fs::write(&archive_path, json) - // .map_err(|e| CommandError::Io(format!("Failed to write archived app state: {}", e)))?; - - // Ok(latest_path.to_string_lossy().to_string()) -} - -#[tauri::command] -pub fn load_last_app_state() -> Result, CommandError> { - // TODO: re-enable app state save/restore after UX and reliability pass. - Err(CommandError::Analysis( - "App state save/restore is temporarily disabled".to_string(), - )) - - // let latest_path = app_state_snapshot_dir()?.join("latest.json"); - // if !latest_path.exists() { - // return Ok(None); - // } - - // let raw = std::fs::read_to_string(&latest_path) - // .map_err(|e| CommandError::Io(format!("Failed to read latest app state: {}", e)))?; - // let payload: AppStateSnapshotFile = serde_json::from_str(&raw) - // .map_err(|e| CommandError::Io(format!("Failed to parse latest app state: {}", e)))?; - - // Ok(Some(payload.snapshot)) -} - -/// LLM settings for the UI — surface for the settings panel. -/// -/// Contains the current provider/model configuration, API key status, -/// and annotation/refinement toggle states. Returned by `get_llm_settings` -/// and accepted by `save_llm_settings`. -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct LlmSettings { - /// Whether LLM annotations are enabled (controls visibility of Summarize PR / Analyze buttons). - pub annotations_enabled: bool, - /// Whether LLM refinement is enabled. - pub refinement_enabled: bool, - /// Selected LLM backend: subscription-backed CLI or direct API provider. - pub provider: String, - /// Selected model identifier. - pub model: String, - /// How the API key is configured. - pub api_key_source: String, - /// Whether an API key is actually available (resolvable). - pub has_api_key: bool, - /// Refinement provider (can differ from annotation provider). - pub refinement_provider: String, - /// Refinement model. - pub refinement_model: String, - /// Maximum refinement iterations. - pub refinement_max_iterations: u32, - /// Where shared LLM settings are stored. - pub global_config_path: String, - /// Whether Codex CLI is installed. - pub codex_available: bool, - /// Whether Codex CLI is logged in and ready. - pub codex_authenticated: bool, - /// Whether Claude Code is installed. - pub claude_available: bool, - /// Whether Claude Code is logged in and ready. - pub claude_authenticated: bool, - /// Whether to include uncommitted working tree changes in branch comparisons. - pub include_uncommitted: bool, -} - -/// Summary of repository state for the UI. -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct RepoInfo { - pub current_branch: Option, - pub default_branch: String, - pub branches: Vec, - pub worktrees: Vec, - pub status: Option, - /// Whether the opened path is a linked worktree (not the main worktree). - pub is_worktree: bool, -} - -/// Open a repository from a path, with canonicalization and error handling. -fn open_repo(repo_path: &str) -> Result { - let path = PathBuf::from(repo_path); - let path = std::fs::canonicalize(&path) - .map_err(|e| CommandError::Io(format!("Invalid repo path: {}", e)))?; - git2::Repository::discover(&path) - .map_err(|e| CommandError::Git(format!("Not a git repository: {}", e))) -} - -/// Build a simple unified diff from old and new content. -fn simple_unified_diff(old: &str, new: &str) -> String { - let old_lines: Vec<&str> = old.lines().collect(); - let new_lines: Vec<&str> = new.lines().collect(); - let mut result = String::new(); - // Simple approach: show all old lines as removed, all new lines as added - // For a real implementation, use a proper diff algorithm - for line in &old_lines { - result.push_str(&format!("-{}\n", line)); - } - for line in &new_lines { - result.push_str(&format!("+{}\n", line)); - } - result -} - -/// File diff content for the Monaco diff viewer. -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct FileDiffContent { - pub path: String, - pub old_content: String, - pub new_content: String, - pub language: String, -} - -// ── Internal helpers ── - -fn extract_diff( - repo: &git2::Repository, - base: Option, - head: Option, - range: Option, - staged: bool, - unstaged: bool, - pr_preview: bool, - include_uncommitted: bool, -) -> Result<(git::DiffResult, diffcore_core::types::DiffSource), CommandError> { - if let Some(ref range) = range { - let diff = git::diff_range(repo, range).map_err(|e| CommandError::Git(format!("{}", e)))?; - let source = - output::diff_source_range(range, diff.base_sha.as_deref(), diff.head_sha.as_deref()); - Ok((diff, source)) - } else if staged { - let diff = git::diff_staged(repo).map_err(|e| CommandError::Git(format!("{}", e)))?; - let source = output::diff_source_staged(); - Ok((diff, source)) - } else if unstaged { - let diff = git::diff_unstaged(repo).map_err(|e| CommandError::Git(format!("{}", e)))?; - let source = output::diff_source_unstaged(); - Ok((diff, source)) - } else if pr_preview { - // PR preview mode: use merge-base diff - // Auto-detect default branch if no base ref provided - let detected_default = if base.is_none() { - git::detect_default_branch(repo).ok() - } else { - None - }; - let base_ref = base - .as_deref() - .or(detected_default.as_deref()) - .unwrap_or("main"); - let head_ref = head.as_deref().unwrap_or("HEAD"); - if include_uncommitted { - let diff = - git::diff_merge_base_to_workdir(repo, base_ref, head_ref).map_err(|e| { - CommandError::Git(format!( - "Failed to compute merge-base-to-workdir diff between '{}' and '{}': {}", - base_ref, head_ref, e - )) - })?; - let source = output::diff_source_branch_with_worktree( - base_ref, - diff.base_sha.as_deref(), - ); - Ok((diff, source)) - } else { - let selected = - git::diff_merge_base_with_worktree_fallback(repo, base_ref, head_ref).map_err( - |e| { - CommandError::Git(format!( - "Failed to compute merge-base diff between '{}' and '{}': {}", - base_ref, head_ref, e - )) - }, - )?; - let source = if selected.used_worktree_fallback { - output::diff_source_worktree( - Some(base_ref), - Some(head_ref), - selected.comparison_base_sha.as_deref(), - selected.comparison_head_sha.as_deref(), - ) - } else { - output::diff_source_branch( - base_ref, - head_ref, - selected.diff.base_sha.as_deref(), - selected.diff.head_sha.as_deref(), - ) - }; - Ok((selected.diff, source)) - } - } else { - let base_ref = base.as_deref().unwrap_or("main"); - let head_ref = head.as_deref().unwrap_or("HEAD"); - if include_uncommitted { - let diff = git::diff_branch_to_workdir(repo, base_ref) - .map_err(|e| CommandError::Git(format!("{}", e)))?; - let source = output::diff_source_branch_with_worktree( - base_ref, - diff.base_sha.as_deref(), - ); - Ok((diff, source)) - } else { - let selected = git::diff_refs_with_worktree_fallback(repo, base_ref, head_ref) - .map_err(|e| CommandError::Git(format!("{}", e)))?; - let source = if selected.used_worktree_fallback { - output::diff_source_worktree( - Some(base_ref), - Some(head_ref), - selected.comparison_base_sha.as_deref(), - selected.comparison_head_sha.as_deref(), - ) - } else { - output::diff_source_branch( - base_ref, - head_ref, - selected.diff.base_sha.as_deref(), - selected.diff.head_sha.as_deref(), - ) - }; - Ok((selected.diff, source)) - } - } -} - -// ── Review Comments ────────────────────────────────────────────────── - -/// A single review comment — can be scoped to a group, file, or code range. -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct ReviewComment { - /// Unique identifier for the comment. - pub id: String, - /// Comment scope: "code", "file", or "group". - #[serde(rename = "type")] - pub comment_type: String, - /// The flow group this comment belongs to. - pub group_id: String, - /// File path (null for group-level comments). - pub file_path: Option, - /// Start line (null for file/group-level comments). - pub start_line: Option, - /// End line (null for file/group-level comments). - pub end_line: Option, - /// The selected code snippet (for code-level comments). - pub selected_code: Option, - /// The comment text. - pub text: String, - /// ISO 8601 timestamp when the comment was created. - pub created_at: String, -} - -/// Container for persisted comments, keyed by analysis hash. -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct CommentsFile { - /// Hash of the analysis run these comments belong to. - pub analysis_hash: String, - /// All comments for this analysis. - pub comments: Vec, -} - -/// Get the `.diffcore/comments.json` path for a repo. -fn comments_file_path(repo_path: &str) -> Result { - let repo_path = PathBuf::from(repo_path); - let repo_path = std::fs::canonicalize(&repo_path) - .map_err(|e| CommandError::Io(format!("Invalid repo path: {}", e)))?; - let repo = git2::Repository::discover(&repo_path) - .map_err(|e| CommandError::Git(format!("Not a git repository: {}", e)))?; - let workdir = repo - .workdir() - .ok_or_else(|| CommandError::Git("Bare repositories are not supported".to_string()))?; - Ok(workdir.join(".diffcore").join("comments.json")) -} - -/// Save a comment to `.diffcore/comments.json`. -/// -/// Creates the `.diffcore/` directory if it doesn't exist. Appends to existing -/// comments if the analysis hash matches, otherwise starts fresh. -#[tauri::command] -pub fn save_comment( - repo_path: String, - analysis_hash: String, - comment: ReviewComment, -) -> Result<(), CommandError> { - let path = comments_file_path(&repo_path)?; - - // Ensure .diffcore directory exists - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent).map_err(|e| { - CommandError::Io(format!("Failed to create .diffcore directory: {}", e)) - })?; - } - - // Load existing comments or start fresh - let mut comments_file = load_comments_from_file(&path, &analysis_hash); - comments_file.comments.push(comment); - - // Write back - let json = serde_json::to_string_pretty(&comments_file) - .map_err(|e| CommandError::Io(format!("Failed to serialize comments: {}", e)))?; - std::fs::write(&path, json) - .map_err(|e| CommandError::Io(format!("Failed to write comments file: {}", e)))?; - - Ok(()) -} - -/// Delete a comment by ID from `.diffcore/comments.json`. -#[tauri::command] -pub fn delete_comment( - repo_path: String, - analysis_hash: String, - comment_id: String, -) -> Result<(), CommandError> { - let path = comments_file_path(&repo_path)?; - let mut comments_file = load_comments_from_file(&path, &analysis_hash); - comments_file.comments.retain(|c| c.id != comment_id); - - let json = serde_json::to_string_pretty(&comments_file) - .map_err(|e| CommandError::Io(format!("Failed to serialize comments: {}", e)))?; - std::fs::write(&path, json) - .map_err(|e| CommandError::Io(format!("Failed to write comments file: {}", e)))?; - - Ok(()) -} - -/// Load all comments for a given analysis hash from `.diffcore/comments.json`. -#[tauri::command] -pub fn load_comments( - repo_path: String, - analysis_hash: String, -) -> Result, CommandError> { - let path = comments_file_path(&repo_path)?; - let comments_file = load_comments_from_file(&path, &analysis_hash); - Ok(comments_file.comments) -} - -/// Export all comments as a formatted string ready for pasting to an AI agent. -/// -/// Includes absolute file paths, code snippets for code-level comments, -/// and group context. -#[tauri::command] -pub fn export_comments(repo_path: String, analysis_hash: String) -> Result { - let path = comments_file_path(&repo_path)?; - let comments_file = load_comments_from_file(&path, &analysis_hash); - - let repo_path_buf = PathBuf::from(&repo_path); - let repo_path_buf = std::fs::canonicalize(&repo_path_buf) - .map_err(|e| CommandError::Io(format!("Invalid repo path: {}", e)))?; - let repo = git2::Repository::discover(&repo_path_buf) - .map_err(|e| CommandError::Git(format!("Not a git repository: {}", e)))?; - let workdir = repo - .workdir() - .ok_or_else(|| CommandError::Git("Bare repositories are not supported".to_string()))? - .to_string_lossy() - .to_string(); - let workdir = if workdir.ends_with('/') { - workdir[..workdir.len() - 1].to_string() - } else { - workdir - }; - - let mut output = String::new(); - - for comment in &comments_file.comments { - match comment.comment_type.as_str() { - "code" => { - if let Some(ref fp) = comment.file_path { - let abs_path = format!("{}/{}", workdir, fp); - if let (Some(start), Some(end)) = (comment.start_line, comment.end_line) { - output.push_str(&format!("{}:{}-{}\n", abs_path, start, end)); - } else { - output.push_str(&format!("{}\n", abs_path)); - } - if let Some(ref code) = comment.selected_code { - output.push_str("```\n"); - output.push_str(code); - if !code.ends_with('\n') { - output.push('\n'); - } - output.push_str("```\n"); - } - output.push_str(&format!("> {}\n\n", comment.text)); - } - } - "file" => { - if let Some(ref fp) = comment.file_path { - let abs_path = format!("{}/{}", workdir, fp); - output.push_str(&format!("{}\n", abs_path)); - output.push_str(&format!("> {}\n\n", comment.text)); - } - } - "group" => { - output.push_str(&format!("Flow: \"{}\"\n", comment.group_id)); - output.push_str(&format!("> {}\n\n", comment.text)); - } - _ => {} - } - } - - Ok(output) -} - -/// Load comments from a file, returning empty if file doesn't exist or hash doesn't match. -fn load_comments_from_file(path: &PathBuf, analysis_hash: &str) -> CommentsFile { - if let Ok(data) = std::fs::read_to_string(path) { - if let Ok(existing) = serde_json::from_str::(&data) { - if existing.analysis_hash == analysis_hash { - return existing; - } - } - } - CommentsFile { - analysis_hash: analysis_hash.to_string(), - comments: vec![], - } -} - -// ══════════════════════════════════════════════════════════════════════ -// Branch-based comment cache (~/.diffcore/cache/comments/) -// ══════════════════════════════════════════════════════════════════════ - -/// Resolve the global comment cache directory. -/// Respects `DIFFCORE_COMMENT_CACHE_DIR` for testing. -fn comment_cache_dir() -> Option { - std::env::var_os("DIFFCORE_COMMENT_CACHE_DIR") - .map(PathBuf::from) - .or_else(|| { - std::env::var_os("HOME").map(|home| { - PathBuf::from(home) - .join(".diffcore") - .join("cache") - .join("comments") - }) - }) -} - -/// Compute a cache key for a repo+branch combo. -/// -/// Uses the git common dir (shared across worktrees) + current branch name, -/// so worktrees on the same branch share comments, while different branches -/// on the same repo are isolated. -pub fn comment_cache_key(repo_path: &str) -> Result { - use sha2::{Digest, Sha256}; - - let repo_path_buf = PathBuf::from(repo_path); - let repo_path_buf = std::fs::canonicalize(&repo_path_buf) - .map_err(|e| CommandError::Io(format!("Invalid repo path: {}", e)))?; - let repo = git2::Repository::discover(&repo_path_buf) - .map_err(|e| CommandError::Git(format!("Not a git repository: {}", e)))?; - - // Use the git dir path for identity. For worktrees, resolve the main - // repo's git dir via `commondir()` if available, otherwise use `path()`. - // git2 stores the common dir at `.git/commondir` for linked worktrees. - let git_dir = repo.path(); - let common_dir_file = git_dir.join("commondir"); - let identity_dir = if common_dir_file.exists() { - // Linked worktree — read the commondir reference to get the main repo's git dir - std::fs::read_to_string(&common_dir_file) - .ok() - .and_then(|rel| { - let trimmed = rel.trim(); - let resolved = if std::path::Path::new(trimmed).is_absolute() { - PathBuf::from(trimmed) - } else { - git_dir.join(trimmed) - }; - std::fs::canonicalize(resolved).ok() - }) - .unwrap_or_else(|| git_dir.to_path_buf()) - } else { - git_dir.to_path_buf() - }; - let common_dir = identity_dir.to_string_lossy().to_string(); - - // Get current branch name - let branch = match repo.head() { - Ok(head) => head - .shorthand() - .unwrap_or("HEAD") - .to_string(), - Err(_) => "HEAD".to_string(), - }; - - let mut hasher = Sha256::new(); - hasher.update(common_dir.as_bytes()); - hasher.update(b"\n"); - hasher.update(branch.as_bytes()); - Ok(hex::encode(hasher.finalize())) -} - -/// Branch-cached comment file — just a Vec of comments, no analysis hash. -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -struct CachedCommentsFile { - pub comments: Vec, -} - -/// Load comments for the current repo+branch from the global cache. -fn load_cached_comments_file(cache_key: &str) -> CachedCommentsFile { - let Some(dir) = comment_cache_dir() else { - return CachedCommentsFile { comments: vec![] }; - }; - let path = dir.join(format!("{}.json", cache_key)); - match std::fs::read_to_string(&path) { - Ok(data) => serde_json::from_str(&data).unwrap_or(CachedCommentsFile { comments: vec![] }), - Err(_) => CachedCommentsFile { comments: vec![] }, - } -} - -/// Write comments for the current repo+branch to the global cache. -fn write_cached_comments_file( - cache_key: &str, - file: &CachedCommentsFile, -) -> Result<(), CommandError> { - let dir = comment_cache_dir().ok_or_else(|| { - CommandError::Io("Cannot determine comment cache directory".to_string()) - })?; - std::fs::create_dir_all(&dir) - .map_err(|e| CommandError::Io(format!("Failed to create comment cache dir: {}", e)))?; - let path = dir.join(format!("{}.json", cache_key)); - let json = serde_json::to_string_pretty(file) - .map_err(|e| CommandError::Io(format!("Failed to serialize comments: {}", e)))?; - std::fs::write(&path, json) - .map_err(|e| CommandError::Io(format!("Failed to write comment cache: {}", e)))?; - Ok(()) -} - -/// Save a comment to the branch-based cache. -#[tauri::command] -pub fn save_comment_cached( - repo_path: String, - comment: ReviewComment, -) -> Result<(), CommandError> { - let key = comment_cache_key(&repo_path)?; - let mut file = load_cached_comments_file(&key); - file.comments.push(comment); - write_cached_comments_file(&key, &file) -} - -/// Load all comments for the current repo+branch from the cache. -#[tauri::command] -pub fn load_comments_cached(repo_path: String) -> Result, CommandError> { - let key = comment_cache_key(&repo_path)?; - let file = load_cached_comments_file(&key); - Ok(file.comments) -} - -/// Delete a comment by ID from the branch-based cache. -#[tauri::command] -pub fn delete_comment_cached( - repo_path: String, - comment_id: String, -) -> Result<(), CommandError> { - let key = comment_cache_key(&repo_path)?; - let mut file = load_cached_comments_file(&key); - file.comments.retain(|c| c.id != comment_id); - write_cached_comments_file(&key, &file) -} - -/// Update a comment's text by ID in the branch-based cache. -#[tauri::command] -pub fn update_comment_cached( - repo_path: String, - comment_id: String, - new_text: String, -) -> Result<(), CommandError> { - let key = comment_cache_key(&repo_path)?; - let mut file = load_cached_comments_file(&key); - if let Some(comment) = file.comments.iter_mut().find(|c| c.id == comment_id) { - comment.text = new_text; - } - write_cached_comments_file(&key, &file) -} - -// ══════════════════════════════════════════════════════════════════════ -// Groups manifest import / file watching -// ══════════════════════════════════════════════════════════════════════ - -/// Import a groups manifest JSON and apply it to the current analysis. -/// -/// Returns the updated `AnalysisOutput` with groups replaced by the manifest. -#[tauri::command] -pub fn import_groups_manifest( - manifest_path: String, - state: tauri::State<'_, AppState>, -) -> Result { - use diffcore_core::manifest; - - let manifest = manifest::read_manifest(std::path::Path::new(&manifest_path)) - .map_err(|e| CommandError::Io(e))?; - - let analysis = state - .last_analysis - .lock() - .map_err(|e| CommandError::Analysis(format!("Lock poisoned: {}", e)))? - .clone() - .ok_or_else(|| CommandError::Analysis("No analysis loaded".to_string()))?; - - let updated = manifest::import_manifest(&analysis, &manifest); - - // Update cached analysis - if let Ok(mut last) = state.last_analysis.lock() { - *last = Some(updated.clone()); - } - - Ok(updated) -} - -/// Export the current analysis groups as a manifest JSON file. -#[tauri::command] -pub fn export_groups_manifest( - output_path: String, - state: tauri::State<'_, AppState>, -) -> Result<(), CommandError> { - use diffcore_core::manifest; - - let analysis = state - .last_analysis - .lock() - .map_err(|e| CommandError::Analysis(format!("Lock poisoned: {}", e)))? - .clone() - .ok_or_else(|| CommandError::Analysis("No analysis loaded".to_string()))?; - - let groups_manifest = manifest::export_manifest(&analysis); - manifest::write_manifest(std::path::Path::new(&output_path), &groups_manifest) - .map_err(|e| CommandError::Io(e))?; - - Ok(()) -} - -/// Start watching a manifest file for changes. Emits "manifest-changed" events -/// to the frontend when the file is modified. -#[tauri::command] -pub fn watch_manifest( - manifest_path: String, - app_handle: tauri::AppHandle, - state: tauri::State<'_, AppState>, -) -> Result<(), CommandError> { - // Store the path for the watcher - if let Ok(mut path) = state.watched_manifest_path.lock() { - *path = Some(PathBuf::from(&manifest_path)); - } - - // Spawn a background thread that polls the file for changes - let path = PathBuf::from(manifest_path); - std::thread::spawn(move || { - let mut last_modified = std::fs::metadata(&path) - .and_then(|m| m.modified()) - .ok(); - - loop { - std::thread::sleep(std::time::Duration::from_millis(500)); - - let current_modified = std::fs::metadata(&path) - .and_then(|m| m.modified()) - .ok(); - - if current_modified != last_modified && current_modified.is_some() { - last_modified = current_modified; - // Emit event to frontend - let _ = app_handle.emit("manifest-changed", &path.to_string_lossy().to_string()); - } - } - }); - - Ok(()) -} - -/// Stop watching the manifest file. -#[tauri::command] -pub fn unwatch_manifest( - state: tauri::State<'_, AppState>, -) -> Result<(), CommandError> { - if let Ok(mut path) = state.watched_manifest_path.lock() { - *path = None; - } - Ok(()) -} - -/// Map a file path's extension to a lowercase language tag the UI can -/// look up in its Monaco-language map. Mirrors -/// `diffcore_core::ast::Language::from_path` but emits a string the UI -/// already keys on (we keep this thin wrapper rather than serialising -/// `Language` directly so the wire shape stays a plain `String`). -fn detect_language(path: &str) -> String { - match path.rsplit('.').next() { - // ── Core 13 ───────────────────────────────────────────── - Some("ts" | "tsx") => "typescript".to_string(), - Some("js" | "jsx" | "mjs" | "cjs") => "javascript".to_string(), - Some("py" | "pyi") => "python".to_string(), - Some("go") => "go".to_string(), - Some("rs") => "rust".to_string(), - Some("java") => "java".to_string(), - Some("cs") => "csharp".to_string(), - Some("php") => "php".to_string(), - Some("rb") => "ruby".to_string(), - Some("kt" | "kts") => "kotlin".to_string(), - Some("swift") => "swift".to_string(), - Some("c" | "h") => "c".to_string(), - Some("cpp" | "cc" | "cxx" | "c++" | "hpp" | "hxx" | "h++" | "hh") => "cpp".to_string(), - Some("scala" | "sc") => "scala".to_string(), - // ── Extras (matching the lang-* Cargo features) ───────── - Some("sh" | "bash" | "zsh") => "shell".to_string(), - Some("hs" | "lhs") => "haskell".to_string(), - Some("nix") => "nix".to_string(), - Some("lua") => "lua".to_string(), - Some("pl" | "pm" | "perl") => "perl".to_string(), - Some("ex" | "exs") => "elixir".to_string(), - Some("erl" | "hrl") => "erlang".to_string(), - Some("zig" | "zon") => "zig".to_string(), - Some("ml" | "mli") => "ocaml".to_string(), - Some("jl") => "julia".to_string(), - Some("dart") => "dart".to_string(), - Some("r" | "R") => "r".to_string(), - Some("fish") => "fish".to_string(), - Some("html" | "htm") => "html".to_string(), - Some("css") => "css".to_string(), - Some("scss" | "sass") => "scss".to_string(), - Some("vue") => "vue".to_string(), - Some("svelte") => "svelte".to_string(), - Some("graphql" | "gql") => "graphql".to_string(), - // ── Data formats ──────────────────────────────────────── - Some("json") => "json".to_string(), - Some("toml") => "toml".to_string(), - Some("yaml" | "yml") => "yaml".to_string(), - Some("md" | "markdown") => "markdown".to_string(), - Some("sql") => "sql".to_string(), - Some("prisma") => "prisma".to_string(), - _ => "plaintext".to_string(), - } -} - -#[cfg(test)] -#[allow( - clippy::unwrap_used, - clippy::expect_used, - clippy::panic, - clippy::print_stdout, - clippy::print_stderr -)] -mod tests { - use super::*; - - #[test] - fn test_detect_language_typescript() { - assert_eq!(detect_language("src/app.ts"), "typescript"); - assert_eq!(detect_language("src/App.tsx"), "typescript"); - } - - #[test] - fn test_detect_language_javascript() { - assert_eq!(detect_language("index.js"), "javascript"); - assert_eq!(detect_language("App.jsx"), "javascript"); - } - - #[test] - fn test_detect_language_python() { - assert_eq!(detect_language("main.py"), "python"); - } - - #[test] - fn test_detect_language_rust() { - assert_eq!(detect_language("lib.rs"), "rust"); - } - - #[test] - fn test_detect_language_json() { - assert_eq!(detect_language("package.json"), "json"); - } - - #[test] - fn test_detect_language_unknown() { - assert_eq!(detect_language("Makefile"), "plaintext"); - assert_eq!(detect_language("noext"), "plaintext"); - } - - #[test] - fn test_detect_language_yaml() { - assert_eq!(detect_language("config.yaml"), "yaml"); - assert_eq!(detect_language("ci.yml"), "yaml"); - } - - #[test] - fn test_detect_language_shell() { - assert_eq!(detect_language("run.sh"), "shell"); - assert_eq!(detect_language("init.bash"), "shell"); - } - - #[test] - fn test_detect_language_various() { - assert_eq!(detect_language("main.go"), "go"); - assert_eq!(detect_language("App.java"), "java"); - assert_eq!(detect_language("app.rb"), "ruby"); - assert_eq!(detect_language("schema.prisma"), "prisma"); - assert_eq!(detect_language("query.sql"), "sql"); - assert_eq!(detect_language("style.css"), "css"); - assert_eq!(detect_language("page.html"), "html"); - assert_eq!(detect_language("README.md"), "markdown"); - assert_eq!(detect_language("config.toml"), "toml"); - } - - #[test] - fn test_app_state_new() { - let state = AppState::new(); - let last = state.last_analysis.lock().unwrap(); - assert!(last.is_none()); - } - - #[test] - fn test_command_error_display() { - let err = CommandError::Git("not found".to_string()); - assert_eq!(err.to_string(), "Git error: not found"); - - let err = CommandError::Analysis("no data".to_string()); - assert_eq!(err.to_string(), "Analysis error: no data"); - - let err = CommandError::Config("invalid".to_string()); - assert_eq!(err.to_string(), "Config error: invalid"); - - let err = CommandError::Io("permission denied".to_string()); - assert_eq!(err.to_string(), "IO error: permission denied"); - - let err = CommandError::Llm("no api key".to_string()); - assert_eq!(err.to_string(), "LLM error: no api key"); - } - - #[test] - fn test_command_error_serialize() { - let err = CommandError::Git("test error".to_string()); - let json = serde_json::to_string(&err).unwrap(); - assert_eq!(json, "\"Git error: test error\""); - - let err = CommandError::Llm("rate limited".to_string()); - let json = serde_json::to_string(&err).unwrap(); - assert_eq!(json, "\"LLM error: rate limited\""); - } - - #[test] - fn test_simple_unified_diff_basic() { - let diff = simple_unified_diff("old line", "new line"); - assert!(diff.contains("-old line")); - assert!(diff.contains("+new line")); - } - - #[test] - fn test_simple_unified_diff_empty() { - let diff = simple_unified_diff("", ""); - assert!(diff.is_empty()); - } - - #[test] - fn test_simple_unified_diff_multiline() { - let diff = simple_unified_diff("a\nb", "c\nd\ne"); - assert!(diff.contains("-a\n")); - assert!(diff.contains("-b\n")); - assert!(diff.contains("+c\n")); - assert!(diff.contains("+d\n")); - assert!(diff.contains("+e\n")); - } - - #[test] - fn test_repo_info_serde_roundtrip() { - let info = RepoInfo { - current_branch: Some("feature-branch".to_string()), - default_branch: "main".to_string(), - branches: vec![ - git::BranchInfo { - name: "main".to_string(), - is_current: false, - has_upstream: true, - }, - git::BranchInfo { - name: "feature-branch".to_string(), - is_current: true, - has_upstream: false, - }, - ], - worktrees: vec![git::WorktreeInfo { - path: "/tmp/repo".to_string(), - branch: Some("main".to_string()), - is_main: true, - }], - status: Some(git::BranchStatus { - branch: "feature-branch".to_string(), - upstream: None, - ahead: 0, - behind: 0, - }), - is_worktree: false, - }; - let json = serde_json::to_string(&info).unwrap(); - let back: RepoInfo = serde_json::from_str(&json).unwrap(); - assert_eq!(back.current_branch, Some("feature-branch".to_string())); - assert_eq!(back.default_branch, "main"); - assert_eq!(back.branches.len(), 2); - assert_eq!(back.worktrees.len(), 1); - assert!(back.status.is_some()); - } - - #[test] - fn test_repo_info_no_status() { - let info = RepoInfo { - current_branch: None, - default_branch: "main".to_string(), - branches: vec![], - worktrees: vec![], - status: None, - is_worktree: false, - }; - let json = serde_json::to_string(&info).unwrap(); - let back: RepoInfo = serde_json::from_str(&json).unwrap(); - assert!(back.current_branch.is_none()); - assert!(back.status.is_none()); - } - - #[test] - fn test_check_api_key_no_repo() { - // Without any env vars or config, should return false (no key configured) - // Note: this test may pass or fail depending on whether env vars are set, - // but it should never panic. - let result = check_api_key(None); - assert!(result.is_ok()); - } - - #[test] - fn test_check_api_key_invalid_path() { - // Invalid path should not panic, should return Ok(bool) - let result = check_api_key(Some("/nonexistent/path/to/repo".to_string())); - assert!(result.is_ok()); - } - - #[test] - fn test_llm_settings_serde_roundtrip() { - let settings = LlmSettings { - annotations_enabled: true, - refinement_enabled: false, - provider: "codex".to_string(), - model: "default".to_string(), - api_key_source: "Codex CLI login".to_string(), - has_api_key: true, - refinement_provider: "claude".to_string(), - refinement_model: "default".to_string(), - refinement_max_iterations: 2, - global_config_path: "~/.diffcore/config.toml".to_string(), - codex_available: true, - codex_authenticated: true, - claude_available: true, - claude_authenticated: true, - include_uncommitted: true, - }; - let json = serde_json::to_string(&settings).unwrap(); - let back: LlmSettings = serde_json::from_str(&json).unwrap(); - assert_eq!(back.provider, "codex"); - assert_eq!(back.model, "default"); - assert!(back.annotations_enabled); - assert!(!back.refinement_enabled); - assert!(back.has_api_key); - assert_eq!(back.refinement_provider, "claude"); - assert_eq!(back.refinement_model, "default"); - assert_eq!(back.refinement_max_iterations, 2); - assert!(back.codex_available); - assert!(back.claude_authenticated); - } - - #[test] - fn test_llm_settings_all_providers() { - for provider in &["codex", "claude", "anthropic", "openai", "gemini"] { - let expected = default_model_for_provider(provider); - assert!( - !expected.is_empty(), - "Provider '{}' should have a default model", - provider - ); - } - } - - #[test] - fn test_default_model_for_provider() { - assert_eq!(default_model_for_provider("codex"), "default"); - assert_eq!(default_model_for_provider("claude"), "default"); - assert_eq!(default_model_for_provider("anthropic"), "claude-sonnet-4-6"); - assert_eq!(default_model_for_provider("openai"), "gpt-4.1"); - assert_eq!(default_model_for_provider("gemini"), "gemini-2.5-flash"); - assert_eq!(default_model_for_provider("unknown"), "default"); - } - - #[test] - fn test_preferred_provider_for_runtime_prefers_authenticated_codex_over_direct_api() { - let codex = llm::BackendStatus { - installed: true, - authenticated: true, - }; - let claude = llm::BackendStatus { - installed: true, - authenticated: false, - }; - - assert_eq!( - preferred_provider_for_runtime(Some("openai"), &codex, &claude), - "codex" - ); - assert_eq!( - preferred_provider_for_runtime(Some("anthropic"), &codex, &claude), - "codex" - ); - } - - #[test] - fn test_preferred_model_for_runtime_resets_to_provider_default_when_backend_changes() { - assert_eq!( - preferred_model_for_runtime(Some("gpt-5.4".to_string()), Some("openai"), "codex"), - "default" - ); - assert_eq!( - preferred_model_for_runtime(Some("default".to_string()), Some("codex"), "codex"), - "default" - ); - } - - #[test] - fn test_get_llm_settings_no_repo() { - let result = get_llm_settings(None); - assert!(result.is_ok()); - let settings = result.unwrap(); - assert!(!settings.provider.is_empty()); - assert!(!settings.model.is_empty()); - assert!(!settings.global_config_path.is_empty()); - } - - #[test] - fn test_get_llm_settings_invalid_path() { - let result = get_llm_settings(Some("/nonexistent/path".to_string())); - assert!(result.is_ok()); - let settings = result.unwrap(); - assert!(!settings.provider.is_empty()); - } - - #[test] - fn test_load_config_from_path_none() { - let (_config, workdir) = load_config_from_path(None); - assert!(workdir.is_none()); - } - - #[test] - fn test_load_config_from_path_invalid() { - let (_config, workdir) = load_config_from_path(Some("/nonexistent/path")); - assert!(workdir.is_none()); - } - - #[test] - fn test_refinement_result_serde_roundtrip() { - use diffcore_core::llm::schema::RefinementResponse; - - let result = RefinementResult { - refined_groups: vec![], - infrastructure_group: None, - refinement_response: RefinementResponse { - splits: vec![], - merges: vec![], - re_ranks: vec![], - reclassifications: vec![], - reasoning: "No changes needed".to_string(), - }, - provider: "anthropic".to_string(), - model: "claude-sonnet-4-6".to_string(), - had_changes: false, - warnings: Vec::new(), - }; - let json = serde_json::to_string(&result).unwrap(); - let back: RefinementResult = serde_json::from_str(&json).unwrap(); - assert_eq!(back.provider, "anthropic"); - assert_eq!(back.model, "claude-sonnet-4-6"); - assert!(!back.had_changes); - assert!(back.refined_groups.is_empty()); - assert!(back.infrastructure_group.is_none()); - assert!(back.warnings.is_empty()); - } - - #[test] - fn test_refinement_result_with_changes() { - use diffcore_core::llm::schema::{RefinementNewGroup, RefinementResponse, RefinementSplit}; - use diffcore_core::types::{ChangeStats, FileChange, FileRole, FlowGroup}; - - let result = RefinementResult { - refined_groups: vec![FlowGroup { - id: "g1".to_string(), - name: "Refined group".to_string(), - entrypoint: None, - files: vec![FileChange { - path: "test.ts".to_string(), - flow_position: 0, - role: FileRole::Entrypoint, - changes: ChangeStats { - additions: 10, - deletions: 5, - }, - symbols_changed: vec![], - }], - edges: vec![], - risk_score: 0.5, - review_order: 1, - }], - infrastructure_group: None, - refinement_response: RefinementResponse { - splits: vec![RefinementSplit { - source_group_id: "g1".to_string(), - new_groups: vec![RefinementNewGroup { - name: "Sub A".to_string(), - files: vec!["test.ts".to_string()], - }], - reason: "test split".to_string(), - }], - merges: vec![], - re_ranks: vec![], - reclassifications: vec![], - reasoning: "Split for clarity".to_string(), - }, - provider: "openai".to_string(), - model: "gpt-4.1".to_string(), - had_changes: true, - warnings: Vec::new(), - }; - let json = serde_json::to_string(&result).unwrap(); - let back: RefinementResult = serde_json::from_str(&json).unwrap(); - assert!(back.had_changes); - assert_eq!(back.refined_groups.len(), 1); - assert_eq!(back.refinement_response.splits.len(), 1); - } - - #[test] - fn test_file_diff_content_serde_roundtrip() { - let content = FileDiffContent { - path: "src/main.ts".to_string(), - old_content: "const x = 1;".to_string(), - new_content: "const x = 2;".to_string(), - language: "typescript".to_string(), - }; - let json = serde_json::to_string(&content).unwrap(); - let back: FileDiffContent = serde_json::from_str(&json).unwrap(); - assert_eq!(back.path, "src/main.ts"); - assert_eq!(back.old_content, "const x = 1;"); - assert_eq!(back.new_content, "const x = 2;"); - assert_eq!(back.language, "typescript"); - } - - // ── Error handling edge case tests ──────────────────────────────── - - #[test] - fn test_command_error_all_variants_display() { - let variants = vec![ - CommandError::Git("git error".into()), - CommandError::Analysis("analysis error".into()), - CommandError::Config("config error".into()), - CommandError::Io("io error".into()), - CommandError::Llm("llm error".into()), - ]; - for err in &variants { - let msg = err.to_string(); - assert!(!msg.is_empty()); - // Verify serialization works for all variants (sent to frontend) - let json = serde_json::to_string(err).unwrap(); - assert!(!json.is_empty()); - } - } - - #[test] - fn test_detect_language_edge_cases() { - // Path with multiple dots - assert_eq!(detect_language("my.file.test.ts"), "typescript"); - // Hidden file - assert_eq!(detect_language(".hidden.js"), "javascript"); - // No extension - assert_eq!(detect_language("Makefile"), "plaintext"); - // Empty string - assert_eq!(detect_language(""), "plaintext"); - // Path with spaces - assert_eq!(detect_language("path with spaces/file.ts"), "typescript"); - } - - #[test] - fn test_simple_unified_diff_only_additions() { - let diff = simple_unified_diff("", "new line 1\nnew line 2"); - assert!(diff.contains("+new line 1")); - assert!(diff.contains("+new line 2")); - assert!(!diff.contains("-")); - } - - #[test] - fn test_simple_unified_diff_only_deletions() { - let diff = simple_unified_diff("old line 1\nold line 2", ""); - assert!(diff.contains("-old line 1")); - assert!(diff.contains("-old line 2")); - assert!(!diff.contains("+")); - } - - #[test] - fn test_app_state_mutex_not_poisoned() { - let state = AppState::new(); - // Lock, set, release - { - let mut last = state.last_analysis.lock().unwrap(); - *last = None; - } - // Lock again should succeed - let last = state.last_analysis.lock().unwrap(); - assert!(last.is_none()); - } - - #[test] - fn test_default_model_for_unknown_provider() { - // Unknown providers should get a reasonable default - let model = default_model_for_provider("nonexistent"); - assert!(!model.is_empty()); - } - - #[test] - fn test_open_in_editor_nonexistent_file() { - let result = open_in_editor( - "vscode".to_string(), - "/tmp/__nonexistent_file_12345__".to_string(), - ); - assert!(result.is_err()); - let err = result.unwrap_err().to_string(); - assert!( - err.contains("File not found"), - "Expected file-not-found error, got: {}", - err - ); - } - - #[test] - fn test_open_in_editor_unknown_editor() { - // Create a temporary file to pass the file-exists check - let tmp = std::env::temp_dir().join("diffcore_test_open_editor"); - std::fs::write(&tmp, "test").unwrap(); - let result = open_in_editor( - "unknown_editor".to_string(), - tmp.to_str().unwrap().to_string(), - ); - std::fs::remove_file(&tmp).ok(); - assert!(result.is_err()); - let err = result.unwrap_err().to_string(); - assert!( - err.contains("Unknown editor"), - "Expected unknown-editor error, got: {}", - err - ); - } - - // ── Review comment tests ──────────────────────────────────────── - - #[test] - fn test_review_comment_serde_roundtrip() { - let comment = ReviewComment { - id: "c1".to_string(), - comment_type: "code".to_string(), - group_id: "group_1".to_string(), - file_path: Some("src/auth.ts".to_string()), - start_line: Some(42), - end_line: Some(58), - selected_code: Some("function validate() {}".to_string()), - text: "Missing validation".to_string(), - created_at: "2026-03-20T14:30:00Z".to_string(), - }; - let json = serde_json::to_string(&comment).unwrap(); - let back: ReviewComment = serde_json::from_str(&json).unwrap(); - assert_eq!(back.id, "c1"); - assert_eq!(back.comment_type, "code"); - assert_eq!(back.group_id, "group_1"); - assert_eq!(back.file_path, Some("src/auth.ts".to_string())); - assert_eq!(back.start_line, Some(42)); - assert_eq!(back.end_line, Some(58)); - assert_eq!( - back.selected_code, - Some("function validate() {}".to_string()) - ); - assert_eq!(back.text, "Missing validation"); - } - - #[test] - fn test_review_comment_file_level() { - let comment = ReviewComment { - id: "c2".to_string(), - comment_type: "file".to_string(), - group_id: "group_1".to_string(), - file_path: Some("src/auth.ts".to_string()), - start_line: None, - end_line: None, - selected_code: None, - text: "Should we add rate limiting?".to_string(), - created_at: "2026-03-20T14:30:00Z".to_string(), - }; - let json = serde_json::to_string(&comment).unwrap(); - let back: ReviewComment = serde_json::from_str(&json).unwrap(); - assert_eq!(back.comment_type, "file"); - assert!(back.start_line.is_none()); - assert!(back.selected_code.is_none()); - } - - #[test] - fn test_review_comment_group_level() { - let comment = ReviewComment { - id: "c3".to_string(), - comment_type: "group".to_string(), - group_id: "group_1".to_string(), - file_path: None, - start_line: None, - end_line: None, - selected_code: None, - text: "Overall looks good".to_string(), - created_at: "2026-03-20T14:31:00Z".to_string(), - }; - let json = serde_json::to_string(&comment).unwrap(); - let back: ReviewComment = serde_json::from_str(&json).unwrap(); - assert_eq!(back.comment_type, "group"); - assert!(back.file_path.is_none()); - } - - #[test] - fn test_comments_file_serde_roundtrip() { - let comments_file = CommentsFile { - analysis_hash: "abc123".to_string(), - comments: vec![ - ReviewComment { - id: "c1".to_string(), - comment_type: "code".to_string(), - group_id: "group_1".to_string(), - file_path: Some("src/auth.ts".to_string()), - start_line: Some(42), - end_line: Some(58), - selected_code: Some("fn validate()".to_string()), - text: "Missing validation".to_string(), - created_at: "2026-03-20T14:30:00Z".to_string(), - }, - ReviewComment { - id: "c2".to_string(), - comment_type: "group".to_string(), - group_id: "group_1".to_string(), - file_path: None, - start_line: None, - end_line: None, - selected_code: None, - text: "Needs review".to_string(), - created_at: "2026-03-20T14:31:00Z".to_string(), - }, - ], - }; - let json = serde_json::to_string_pretty(&comments_file).unwrap(); - let back: CommentsFile = serde_json::from_str(&json).unwrap(); - assert_eq!(back.analysis_hash, "abc123"); - assert_eq!(back.comments.len(), 2); - assert_eq!(back.comments[0].comment_type, "code"); - assert_eq!(back.comments[1].comment_type, "group"); - } - - #[test] - fn test_load_comments_from_file_missing() { - let path = std::env::temp_dir().join("diffcore_test_no_such_file.json"); - let result = load_comments_from_file(&path, "test_hash"); - assert_eq!(result.analysis_hash, "test_hash"); - assert!(result.comments.is_empty()); - } - - #[test] - fn test_load_comments_from_file_wrong_hash() { - let path = std::env::temp_dir().join("diffcore_test_wrong_hash.json"); - let data = CommentsFile { - analysis_hash: "old_hash".to_string(), - comments: vec![ReviewComment { - id: "c1".to_string(), - comment_type: "group".to_string(), - group_id: "g1".to_string(), - file_path: None, - start_line: None, - end_line: None, - selected_code: None, - text: "old comment".to_string(), - created_at: "2026-03-20T14:30:00Z".to_string(), - }], - }; - std::fs::write(&path, serde_json::to_string(&data).unwrap()).unwrap(); - let result = load_comments_from_file(&path, "new_hash"); - assert_eq!(result.analysis_hash, "new_hash"); - assert!(result.comments.is_empty()); - std::fs::remove_file(&path).ok(); - } - - #[test] - fn test_load_comments_from_file_matching_hash() { - let path = std::env::temp_dir().join("diffcore_test_matching_hash.json"); - let data = CommentsFile { - analysis_hash: "matching_hash".to_string(), - comments: vec![ReviewComment { - id: "c1".to_string(), - comment_type: "file".to_string(), - group_id: "g1".to_string(), - file_path: Some("test.ts".to_string()), - start_line: None, - end_line: None, - selected_code: None, - text: "test comment".to_string(), - created_at: "2026-03-20T14:30:00Z".to_string(), - }], - }; - std::fs::write(&path, serde_json::to_string(&data).unwrap()).unwrap(); - let result = load_comments_from_file(&path, "matching_hash"); - assert_eq!(result.analysis_hash, "matching_hash"); - assert_eq!(result.comments.len(), 1); - assert_eq!(result.comments[0].text, "test comment"); - std::fs::remove_file(&path).ok(); - } - - #[test] - fn test_review_comment_json_type_field() { - // Verify the "type" field is correctly renamed from comment_type - let comment = ReviewComment { - id: "c1".to_string(), - comment_type: "code".to_string(), - group_id: "g1".to_string(), - file_path: None, - start_line: None, - end_line: None, - selected_code: None, - text: "test".to_string(), - created_at: "2026-03-20T14:30:00Z".to_string(), - }; - let json = serde_json::to_string(&comment).unwrap(); - assert!( - json.contains("\"type\":\"code\""), - "JSON should use 'type' not 'comment_type': {}", - json - ); - // Verify deserialization from "type" field - let back: ReviewComment = serde_json::from_str(&json).unwrap(); - assert_eq!(back.comment_type, "code"); - } - - // ── Open-in-editor / editor detection tests ───────────────────── - - #[test] - fn test_check_editors_available_returns_all_editor_ids() { - let result = check_editors_available(); - // Should always contain all 5 editor IDs - for id in &["vscode", "cursor", "zed", "vim", "terminal"] { - assert!(result.contains_key(*id), "Missing editor id: {}", id); - } - // Terminal should always be available - assert_eq!(result["terminal"], true); - } - - #[cfg(target_os = "macos")] - #[test] - fn test_macos_app_name_mapping() { - assert_eq!(macos_app_name("vscode"), Some("Visual Studio Code")); - assert_eq!(macos_app_name("cursor"), Some("Cursor")); - assert_eq!(macos_app_name("zed"), Some("Zed")); - assert_eq!(macos_app_name("vim"), None); - assert_eq!(macos_app_name("terminal"), None); - assert_eq!(macos_app_name("unknown"), None); - } - - #[cfg(target_os = "macos")] - #[test] - fn test_macos_app_exists_nonexistent() { - // An app that definitely doesn't exist - assert!(!macos_app_exists("Diffcore Nonexistent App 12345")); - } - - /// Gated behind `DIFFCORE_RUN_EDITOR_TESTS=1` because it actually spawns editor processes. - #[test] - fn test_open_in_editor_all_known_editors_accept_temp_file() { - if std::env::var("DIFFCORE_RUN_EDITOR_TESTS").is_err() { - eprintln!("Skipped: set DIFFCORE_RUN_EDITOR_TESTS=1 to run (launches real editors)"); - return; - } - - // All known editor IDs should not return "Unknown editor" for a valid file - let tmp = std::env::temp_dir().join("diffcore_test_known_editors"); - std::fs::write(&tmp, "test").unwrap(); - let path = tmp.to_str().unwrap().to_string(); - - for editor in &["vscode", "cursor", "zed", "vim", "terminal"] { - let result = open_in_editor(editor.to_string(), path.clone()); - // Result may be Ok (if editor is installed) or Err (not installed), - // but should never be "Unknown editor" - if let Err(e) = &result { - let msg = e.to_string(); - assert!( - !msg.contains("Unknown editor"), - "Editor '{}' treated as unknown: {}", - editor, - msg - ); - } - } - - std::fs::remove_file(&tmp).ok(); - } - - // ── Update Comment Tests ── - - #[test] - fn test_update_comment_cached_changes_text() { - let dir = tempfile::tempdir().unwrap(); - let repo_path = init_test_repo(dir.path()); - - let comment = ReviewComment { - id: "test_update_1".to_string(), - comment_type: "code".to_string(), - group_id: "g1".to_string(), - file_path: Some("src/main.ts".to_string()), - start_line: Some(10), - end_line: Some(15), - selected_code: Some("const x = 1;".to_string()), - text: "Original text".to_string(), - created_at: "2026-01-01T00:00:00Z".to_string(), - }; - - // Save then update - save_comment_cached(repo_path.clone(), comment).unwrap(); - update_comment_cached(repo_path.clone(), "test_update_1".to_string(), "Updated text".to_string()).unwrap(); - - let loaded = load_comments_cached(repo_path).unwrap(); - assert_eq!(loaded.len(), 1); - assert_eq!(loaded[0].text, "Updated text"); - assert_eq!(loaded[0].id, "test_update_1"); - } - - #[test] - fn test_update_comment_preserves_other_fields() { - let dir = tempfile::tempdir().unwrap(); - let repo_path = init_test_repo(dir.path()); - - let comment = ReviewComment { - id: "test_preserve_1".to_string(), - comment_type: "code".to_string(), - group_id: "group-abc".to_string(), - file_path: Some("src/handler.ts".to_string()), - start_line: Some(42), - end_line: Some(50), - selected_code: Some("function handler() {}".to_string()), - text: "Before update".to_string(), - created_at: "2026-03-15T12:00:00Z".to_string(), - }; - - save_comment_cached(repo_path.clone(), comment).unwrap(); - update_comment_cached(repo_path.clone(), "test_preserve_1".to_string(), "After update".to_string()).unwrap(); - - let loaded = load_comments_cached(repo_path).unwrap(); - let c = &loaded[0]; - assert_eq!(c.text, "After update"); - assert_eq!(c.comment_type, "code"); - assert_eq!(c.group_id, "group-abc"); - assert_eq!(c.file_path, Some("src/handler.ts".to_string())); - assert_eq!(c.start_line, Some(42)); - assert_eq!(c.end_line, Some(50)); - assert_eq!(c.selected_code, Some("function handler() {}".to_string())); - assert_eq!(c.created_at, "2026-03-15T12:00:00Z"); - } - - #[test] - fn test_update_nonexistent_comment_is_noop() { - let dir = tempfile::tempdir().unwrap(); - let repo_path = init_test_repo(dir.path()); - - let comment = ReviewComment { - id: "existing_1".to_string(), - comment_type: "file".to_string(), - group_id: "g1".to_string(), - file_path: Some("test.ts".to_string()), - start_line: None, - end_line: None, - selected_code: None, - text: "Should not change".to_string(), - created_at: "2026-01-01T00:00:00Z".to_string(), - }; - - save_comment_cached(repo_path.clone(), comment).unwrap(); - // Update a non-existent ID - update_comment_cached(repo_path.clone(), "nonexistent_id".to_string(), "New text".to_string()).unwrap(); - - let loaded = load_comments_cached(repo_path).unwrap(); - assert_eq!(loaded.len(), 1); - assert_eq!(loaded[0].text, "Should not change"); - } - - #[test] - fn test_update_comment_among_multiple() { - let dir = tempfile::tempdir().unwrap(); - let repo_path = init_test_repo(dir.path()); - - for i in 1..=5 { - let comment = ReviewComment { - id: format!("multi_{}", i), - comment_type: "code".to_string(), - group_id: "g1".to_string(), - file_path: Some("src/main.ts".to_string()), - start_line: Some(i * 10), - end_line: Some(i * 10 + 5), - selected_code: None, - text: format!("Comment {}", i), - created_at: "2026-01-01T00:00:00Z".to_string(), - }; - save_comment_cached(repo_path.clone(), comment).unwrap(); - } - - // Update only the 3rd comment - update_comment_cached(repo_path.clone(), "multi_3".to_string(), "Updated comment 3".to_string()).unwrap(); - - let loaded = load_comments_cached(repo_path).unwrap(); - assert_eq!(loaded.len(), 5); - assert_eq!(loaded[0].text, "Comment 1"); - assert_eq!(loaded[1].text, "Comment 2"); - assert_eq!(loaded[2].text, "Updated comment 3"); - assert_eq!(loaded[3].text, "Comment 4"); - assert_eq!(loaded[4].text, "Comment 5"); - } - - #[test] - fn test_update_comment_with_empty_text() { - let dir = tempfile::tempdir().unwrap(); - let repo_path = init_test_repo(dir.path()); - - let comment = ReviewComment { - id: "empty_text_1".to_string(), - comment_type: "file".to_string(), - group_id: "g1".to_string(), - file_path: Some("file.ts".to_string()), - start_line: None, - end_line: None, - selected_code: None, - text: "Has text".to_string(), - created_at: "2026-01-01T00:00:00Z".to_string(), - }; - - save_comment_cached(repo_path.clone(), comment).unwrap(); - update_comment_cached(repo_path.clone(), "empty_text_1".to_string(), "".to_string()).unwrap(); - - let loaded = load_comments_cached(repo_path).unwrap(); - assert_eq!(loaded[0].text, ""); - } - - #[test] - fn test_update_comment_with_special_characters() { - let dir = tempfile::tempdir().unwrap(); - let repo_path = init_test_repo(dir.path()); - - let comment = ReviewComment { - id: "special_chars_1".to_string(), - comment_type: "code".to_string(), - group_id: "g1".to_string(), - file_path: Some("src/main.ts".to_string()), - start_line: Some(1), - end_line: Some(5), - selected_code: None, - text: "Plain text".to_string(), - created_at: "2026-01-01T00:00:00Z".to_string(), - }; - - save_comment_cached(repo_path.clone(), comment).unwrap(); - let special_text = "Contains \"quotes\", newlines\n\ttabs, unicode: 🦀, and & entities"; - update_comment_cached(repo_path.clone(), "special_chars_1".to_string(), special_text.to_string()).unwrap(); - - let loaded = load_comments_cached(repo_path).unwrap(); - assert_eq!(loaded[0].text, special_text); - } - - #[test] - fn test_update_then_delete_comment() { - let dir = tempfile::tempdir().unwrap(); - let repo_path = init_test_repo(dir.path()); - - let comment = ReviewComment { - id: "update_delete_1".to_string(), - comment_type: "file".to_string(), - group_id: "g1".to_string(), - file_path: Some("test.ts".to_string()), - start_line: None, - end_line: None, - selected_code: None, - text: "Will be updated then deleted".to_string(), - created_at: "2026-01-01T00:00:00Z".to_string(), - }; - - save_comment_cached(repo_path.clone(), comment).unwrap(); - update_comment_cached(repo_path.clone(), "update_delete_1".to_string(), "Updated".to_string()).unwrap(); - delete_comment_cached(repo_path.clone(), "update_delete_1".to_string()).unwrap(); - - let loaded = load_comments_cached(repo_path).unwrap(); - assert!(loaded.is_empty()); - } - - #[test] - fn test_multiple_updates_to_same_comment() { - let dir = tempfile::tempdir().unwrap(); - let repo_path = init_test_repo(dir.path()); - - let comment = ReviewComment { - id: "multi_update_1".to_string(), - comment_type: "code".to_string(), - group_id: "g1".to_string(), - file_path: Some("src/main.ts".to_string()), - start_line: Some(1), - end_line: Some(3), - selected_code: None, - text: "Version 1".to_string(), - created_at: "2026-01-01T00:00:00Z".to_string(), - }; - - save_comment_cached(repo_path.clone(), comment).unwrap(); - - for i in 2..=10 { - update_comment_cached(repo_path.clone(), "multi_update_1".to_string(), format!("Version {}", i)).unwrap(); - } - - let loaded = load_comments_cached(repo_path).unwrap(); - assert_eq!(loaded.len(), 1); - assert_eq!(loaded[0].text, "Version 10"); - } - - #[test] - fn test_update_comment_on_empty_cache() { - let dir = tempfile::tempdir().unwrap(); - let repo_path = init_test_repo(dir.path()); - - // Update on empty cache should succeed (no comment found, noop) - let result = update_comment_cached(repo_path.clone(), "no_such_id".to_string(), "text".to_string()); - assert!(result.is_ok()); - - let loaded = load_comments_cached(repo_path).unwrap(); - assert!(loaded.is_empty()); - } - - #[test] - fn test_update_comment_cached_invalid_repo() { - let result = update_comment_cached( - "/nonexistent/repo/path".to_string(), - "id".to_string(), - "text".to_string(), - ); - assert!(result.is_err()); - } - - // ── ReviewComment Serde Tests ── - - #[test] - fn test_review_comment_serde_all_fields() { - let comment = ReviewComment { - id: "c1".to_string(), - comment_type: "code".to_string(), - group_id: "g1".to_string(), - file_path: Some("src/main.ts".to_string()), - start_line: Some(10), - end_line: Some(20), - selected_code: Some("const x = 1;".to_string()), - text: "This needs refactoring".to_string(), - created_at: "2026-04-06T12:00:00Z".to_string(), - }; - - let json = serde_json::to_string(&comment).unwrap(); - let back: ReviewComment = serde_json::from_str(&json).unwrap(); - assert_eq!(back.id, "c1"); - assert_eq!(back.comment_type, "code"); - assert_eq!(back.group_id, "g1"); - assert_eq!(back.file_path, Some("src/main.ts".to_string())); - assert_eq!(back.start_line, Some(10)); - assert_eq!(back.end_line, Some(20)); - assert_eq!(back.selected_code, Some("const x = 1;".to_string())); - assert_eq!(back.text, "This needs refactoring"); - assert_eq!(back.created_at, "2026-04-06T12:00:00Z"); - } - - #[test] - fn test_review_comment_serde_minimal_fields() { - let comment = ReviewComment { - id: "c2".to_string(), - comment_type: "group".to_string(), - group_id: "g2".to_string(), - file_path: None, - start_line: None, - end_line: None, - selected_code: None, - text: "Group-level comment".to_string(), - created_at: "2026-01-01T00:00:00Z".to_string(), - }; - - let json = serde_json::to_string(&comment).unwrap(); - let back: ReviewComment = serde_json::from_str(&json).unwrap(); - assert_eq!(back.comment_type, "group"); - assert!(back.file_path.is_none()); - assert!(back.start_line.is_none()); - assert!(back.end_line.is_none()); - assert!(back.selected_code.is_none()); - } - - #[test] - fn test_review_comment_type_rename_in_json() { - // The `comment_type` field is serialized as `type` in JSON (via serde rename) - let comment = ReviewComment { - id: "c3".to_string(), - comment_type: "file".to_string(), - group_id: "g1".to_string(), - file_path: Some("test.ts".to_string()), - start_line: None, - end_line: None, - selected_code: None, - text: "File comment".to_string(), - created_at: "2026-01-01T00:00:00Z".to_string(), - }; - - let json = serde_json::to_string(&comment).unwrap(); - assert!(json.contains(r#""type":"file""#)); - assert!(!json.contains("comment_type")); - } - - // ── LLM Settings Annotations Tests ── - - #[test] - fn test_llm_settings_annotations_enabled_roundtrip_true() { - let settings = LlmSettings { - annotations_enabled: true, - refinement_enabled: true, - provider: "codex".to_string(), - model: "default".to_string(), - api_key_source: "test".to_string(), - has_api_key: true, - refinement_provider: "codex".to_string(), - refinement_model: "default".to_string(), - refinement_max_iterations: 1, - global_config_path: "~/.diffcore/config.toml".to_string(), - codex_available: false, - codex_authenticated: false, - claude_available: false, - claude_authenticated: false, - include_uncommitted: true, - }; - let json = serde_json::to_string(&settings).unwrap(); - let back: LlmSettings = serde_json::from_str(&json).unwrap(); - assert!(back.annotations_enabled); - assert!(back.refinement_enabled); - } - - #[test] - fn test_llm_settings_annotations_enabled_roundtrip_false() { - let settings = LlmSettings { - annotations_enabled: false, - refinement_enabled: false, - provider: "anthropic".to_string(), - model: "claude-sonnet-4-6".to_string(), - api_key_source: "env".to_string(), - has_api_key: false, - refinement_provider: "anthropic".to_string(), - refinement_model: "claude-sonnet-4-6".to_string(), - refinement_max_iterations: 3, - global_config_path: "/tmp/config.toml".to_string(), - codex_available: true, - codex_authenticated: true, - claude_available: true, - claude_authenticated: true, - include_uncommitted: false, - }; - let json = serde_json::to_string(&settings).unwrap(); - let back: LlmSettings = serde_json::from_str(&json).unwrap(); - assert!(!back.annotations_enabled); - assert!(!back.refinement_enabled); - } - - #[test] - fn test_llm_settings_all_fields_present_in_json() { - let settings = LlmSettings { - annotations_enabled: true, - refinement_enabled: true, - provider: "openai".to_string(), - model: "gpt-4.1".to_string(), - api_key_source: "config".to_string(), - has_api_key: true, - refinement_provider: "gemini".to_string(), - refinement_model: "gemini-2.5-flash".to_string(), - refinement_max_iterations: 2, - global_config_path: "~/.diffcore/config.toml".to_string(), - codex_available: true, - codex_authenticated: false, - claude_available: true, - claude_authenticated: true, - include_uncommitted: true, - }; - let json = serde_json::to_string(&settings).unwrap(); - assert!(json.contains("annotations_enabled")); - assert!(json.contains("refinement_enabled")); - assert!(json.contains("provider")); - assert!(json.contains("model")); - assert!(json.contains("has_api_key")); - assert!(json.contains("refinement_provider")); - assert!(json.contains("refinement_model")); - assert!(json.contains("refinement_max_iterations")); - assert!(json.contains("global_config_path")); - assert!(json.contains("include_uncommitted")); - } - - // ── Comment CRUD Integration Tests ── - - #[test] - fn test_full_comment_lifecycle_save_load_update_delete() { - let dir = tempfile::tempdir().unwrap(); - let repo_path = init_test_repo(dir.path()); - - // 1. Start empty - let loaded = load_comments_cached(repo_path.clone()).unwrap(); - assert!(loaded.is_empty()); - - // 2. Save two comments - let c1 = ReviewComment { - id: "lifecycle_1".to_string(), - comment_type: "code".to_string(), - group_id: "g1".to_string(), - file_path: Some("src/a.ts".to_string()), - start_line: Some(5), - end_line: Some(10), - selected_code: Some("let a = 1;".to_string()), - text: "First comment".to_string(), - created_at: "2026-01-01T00:00:00Z".to_string(), - }; - let c2 = ReviewComment { - id: "lifecycle_2".to_string(), - comment_type: "file".to_string(), - group_id: "g1".to_string(), - file_path: Some("src/b.ts".to_string()), - start_line: None, - end_line: None, - selected_code: None, - text: "Second comment".to_string(), - created_at: "2026-01-01T01:00:00Z".to_string(), - }; - save_comment_cached(repo_path.clone(), c1).unwrap(); - save_comment_cached(repo_path.clone(), c2).unwrap(); - - let loaded = load_comments_cached(repo_path.clone()).unwrap(); - assert_eq!(loaded.len(), 2); - - // 3. Update first comment - update_comment_cached(repo_path.clone(), "lifecycle_1".to_string(), "Edited first".to_string()).unwrap(); - let loaded = load_comments_cached(repo_path.clone()).unwrap(); - assert_eq!(loaded[0].text, "Edited first"); - assert_eq!(loaded[1].text, "Second comment"); - - // 4. Delete second comment - delete_comment_cached(repo_path.clone(), "lifecycle_2".to_string()).unwrap(); - let loaded = load_comments_cached(repo_path.clone()).unwrap(); - assert_eq!(loaded.len(), 1); - assert_eq!(loaded[0].id, "lifecycle_1"); - - // 5. Update the remaining comment again - update_comment_cached(repo_path.clone(), "lifecycle_1".to_string(), "Final edit".to_string()).unwrap(); - let loaded = load_comments_cached(repo_path.clone()).unwrap(); - assert_eq!(loaded[0].text, "Final edit"); - - // 6. Delete last comment - delete_comment_cached(repo_path.clone(), "lifecycle_1".to_string()).unwrap(); - let loaded = load_comments_cached(repo_path).unwrap(); - assert!(loaded.is_empty()); - } - - #[test] - fn test_comment_types_code_file_group() { - let dir = tempfile::tempdir().unwrap(); - let repo_path = init_test_repo(dir.path()); - - let types = vec!["code", "file", "group"]; - for (i, t) in types.iter().enumerate() { - let comment = ReviewComment { - id: format!("type_test_{}", i), - comment_type: t.to_string(), - group_id: "g1".to_string(), - file_path: if *t != "group" { Some("test.ts".to_string()) } else { None }, - start_line: if *t == "code" { Some(1) } else { None }, - end_line: if *t == "code" { Some(5) } else { None }, - selected_code: if *t == "code" { Some("code".to_string()) } else { None }, - text: format!("{} comment", t), - created_at: "2026-01-01T00:00:00Z".to_string(), - }; - save_comment_cached(repo_path.clone(), comment).unwrap(); - } - - let loaded = load_comments_cached(repo_path).unwrap(); - assert_eq!(loaded.len(), 3); - assert_eq!(loaded[0].comment_type, "code"); - assert_eq!(loaded[1].comment_type, "file"); - assert_eq!(loaded[2].comment_type, "group"); - } - - /// Helper to create a minimal git repo for comment cache tests. - fn init_test_repo(dir: &std::path::Path) -> String { - use std::process::Command; - Command::new("git") - .args(["init"]) - .current_dir(dir) - .output() - .unwrap(); - Command::new("git") - .args(["commit", "--allow-empty", "-m", "init"]) - .current_dir(dir) - .output() - .unwrap(); - dir.to_str().unwrap().to_string() - } -} diff --git a/crates/diffcore-tauri/src/commands/app_state.rs b/crates/diffcore-tauri/src/commands/app_state.rs new file mode 100644 index 0000000..9eabfde --- /dev/null +++ b/crates/diffcore-tauri/src/commands/app_state.rs @@ -0,0 +1,88 @@ +//! Application state persistence commands. + +use std::path::PathBuf; + +use diffcore_core::config::DiffcoreConfig; + +use super::CommandError; + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +struct AppStateSnapshotFile { + version: String, + saved_at_epoch_ms: u128, + snapshot: serde_json::Value, +} + +fn app_logs_dir() -> Result { + if let Some(global_config) = DiffcoreConfig::global_config_path() { + let config_dir = global_config + .parent() + .ok_or_else(|| CommandError::Io("Failed to resolve config directory".to_string()))?; + return Ok(config_dir.join("logs")); + } + + let home = std::env::var_os("HOME") + .ok_or_else(|| CommandError::Io("Cannot determine HOME for log directory".to_string()))?; + Ok(PathBuf::from(home).join(".diffcore").join("logs")) +} + +fn app_state_snapshot_dir() -> Result { + Ok(app_logs_dir()?.join("app-state")) +} + +#[tauri::command] +pub fn save_app_state(snapshot: serde_json::Value) -> Result { + // TODO: re-enable app state save/restore after UX and reliability pass. + let _ = snapshot; + Err(CommandError::Analysis( + "App state save/restore is temporarily disabled".to_string(), + )) + + // let dir = app_state_snapshot_dir()?; + // std::fs::create_dir_all(&dir) + // .map_err(|e| CommandError::Io(format!("Failed to create app-state dir: {}", e)))?; + + // let now = std::time::SystemTime::now() + // .duration_since(std::time::UNIX_EPOCH) + // .map_err(|e| CommandError::Io(format!("System clock error: {}", e)))?; + // let saved_at_epoch_ms = now.as_millis(); + + // let payload = AppStateSnapshotFile { + // version: "1".to_string(), + // saved_at_epoch_ms, + // snapshot, + // }; + + // let latest_path = dir.join("latest.json"); + // let archive_path = dir.join(format!("snapshot-{}.json", saved_at_epoch_ms)); + // let json = serde_json::to_string_pretty(&payload) + // .map_err(|e| CommandError::Io(format!("Failed to serialize app state: {}", e)))?; + + // std::fs::write(&latest_path, &json) + // .map_err(|e| CommandError::Io(format!("Failed to write latest app state: {}", e)))?; + // std::fs::write(&archive_path, json) + // .map_err(|e| CommandError::Io(format!("Failed to write archived app state: {}", e)))?; + + // Ok(latest_path.to_string_lossy().to_string()) +} + +#[tauri::command] +pub fn load_last_app_state() -> Result, CommandError> { + // TODO: re-enable app state save/restore after UX and reliability pass. + Err(CommandError::Analysis( + "App state save/restore is temporarily disabled".to_string(), + )) + + // let latest_path = app_state_snapshot_dir()?.join("latest.json"); + // if !latest_path.exists() { + // return Ok(None); + // } + + // let raw = std::fs::read_to_string(&latest_path) + // .map_err(|e| CommandError::Io(format!("Failed to read latest app state: {}", e)))?; + // let payload: AppStateSnapshotFile = serde_json::from_str(&raw) + // .map_err(|e| CommandError::Io(format!("Failed to parse latest app state: {}", e)))?; + + // Ok(Some(payload.snapshot)) +} + diff --git a/crates/diffcore-tauri/src/commands/comments.rs b/crates/diffcore-tauri/src/commands/comments.rs new file mode 100644 index 0000000..9baa6a4 --- /dev/null +++ b/crates/diffcore-tauri/src/commands/comments.rs @@ -0,0 +1,430 @@ +//! Review comment management commands. + +use std::path::PathBuf; + +use super::CommandError; + +// ── Review Comments ────────────────────────────────────────────────── + +/// A single review comment — can be scoped to a group, file, or code range. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct ReviewComment { + /// Unique identifier for the comment. + pub id: String, + /// Comment scope: "code", "file", or "group". + #[serde(rename = "type")] + pub comment_type: String, + /// The flow group this comment belongs to. + pub group_id: String, + /// File path (null for group-level comments). + pub file_path: Option, + /// Start line (null for file/group-level comments). + pub start_line: Option, + /// End line (null for file/group-level comments). + pub end_line: Option, + /// The selected code snippet (for code-level comments). + pub selected_code: Option, + /// The comment text. + pub text: String, + /// ISO 8601 timestamp when the comment was created. + pub created_at: String, +} + +/// Container for persisted comments, keyed by analysis hash. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct CommentsFile { + /// Hash of the analysis run these comments belong to. + pub analysis_hash: String, + /// All comments for this analysis. + pub comments: Vec, +} + +/// Get the `.diffcore/comments.json` path for a repo. +fn comments_file_path(repo_path: &str) -> Result { + let repo_path = PathBuf::from(repo_path); + let repo_path = std::fs::canonicalize(&repo_path) + .map_err(|e| CommandError::Io(format!("Invalid repo path: {}", e)))?; + let repo = git2::Repository::discover(&repo_path) + .map_err(|e| CommandError::Git(format!("Not a git repository: {}", e)))?; + let workdir = repo + .workdir() + .ok_or_else(|| CommandError::Git("Bare repositories are not supported".to_string()))?; + Ok(workdir.join(".diffcore").join("comments.json")) +} + +/// Save a comment to `.diffcore/comments.json`. +/// +/// Creates the `.diffcore/` directory if it doesn't exist. Appends to existing +/// comments if the analysis hash matches, otherwise starts fresh. +#[tauri::command] +pub fn save_comment( + repo_path: String, + analysis_hash: String, + comment: ReviewComment, +) -> Result<(), CommandError> { + let path = comments_file_path(&repo_path)?; + + // Ensure .diffcore directory exists + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| { + CommandError::Io(format!("Failed to create .diffcore directory: {}", e)) + })?; + } + + // Load existing comments or start fresh + let mut comments_file = load_comments_from_file(&path, &analysis_hash); + comments_file.comments.push(comment); + + // Write back + let json = serde_json::to_string_pretty(&comments_file) + .map_err(|e| CommandError::Io(format!("Failed to serialize comments: {}", e)))?; + std::fs::write(&path, json) + .map_err(|e| CommandError::Io(format!("Failed to write comments file: {}", e)))?; + + Ok(()) +} + +/// Delete a comment by ID from `.diffcore/comments.json`. +#[tauri::command] +pub fn delete_comment( + repo_path: String, + analysis_hash: String, + comment_id: String, +) -> Result<(), CommandError> { + let path = comments_file_path(&repo_path)?; + let mut comments_file = load_comments_from_file(&path, &analysis_hash); + comments_file.comments.retain(|c| c.id != comment_id); + + let json = serde_json::to_string_pretty(&comments_file) + .map_err(|e| CommandError::Io(format!("Failed to serialize comments: {}", e)))?; + std::fs::write(&path, json) + .map_err(|e| CommandError::Io(format!("Failed to write comments file: {}", e)))?; + + Ok(()) +} + +/// Load all comments for a given analysis hash from `.diffcore/comments.json`. +#[tauri::command] +pub fn load_comments( + repo_path: String, + analysis_hash: String, +) -> Result, CommandError> { + let path = comments_file_path(&repo_path)?; + let comments_file = load_comments_from_file(&path, &analysis_hash); + Ok(comments_file.comments) +} + +/// Export all comments as a formatted string ready for pasting to an AI agent. +/// +/// Includes absolute file paths, code snippets for code-level comments, +/// and group context. +#[tauri::command] +pub fn export_comments(repo_path: String, analysis_hash: String) -> Result { + let path = comments_file_path(&repo_path)?; + let comments_file = load_comments_from_file(&path, &analysis_hash); + + let repo_path_buf = PathBuf::from(&repo_path); + let repo_path_buf = std::fs::canonicalize(&repo_path_buf) + .map_err(|e| CommandError::Io(format!("Invalid repo path: {}", e)))?; + let repo = git2::Repository::discover(&repo_path_buf) + .map_err(|e| CommandError::Git(format!("Not a git repository: {}", e)))?; + let workdir = repo + .workdir() + .ok_or_else(|| CommandError::Git("Bare repositories are not supported".to_string()))? + .to_string_lossy() + .to_string(); + let workdir = if workdir.ends_with('/') { + workdir[..workdir.len() - 1].to_string() + } else { + workdir + }; + + let mut output = String::new(); + + for comment in &comments_file.comments { + match comment.comment_type.as_str() { + "code" => { + if let Some(ref fp) = comment.file_path { + let abs_path = format!("{}/{}", workdir, fp); + if let (Some(start), Some(end)) = (comment.start_line, comment.end_line) { + output.push_str(&format!("{}:{}-{}\n", abs_path, start, end)); + } else { + output.push_str(&format!("{}\n", abs_path)); + } + if let Some(ref code) = comment.selected_code { + output.push_str("```\n"); + output.push_str(code); + if !code.ends_with('\n') { + output.push('\n'); + } + output.push_str("```\n"); + } + output.push_str(&format!("> {}\n\n", comment.text)); + } + } + "file" => { + if let Some(ref fp) = comment.file_path { + let abs_path = format!("{}/{}", workdir, fp); + output.push_str(&format!("{}\n", abs_path)); + output.push_str(&format!("> {}\n\n", comment.text)); + } + } + "group" => { + output.push_str(&format!("Flow: \"{}\"\n", comment.group_id)); + output.push_str(&format!("> {}\n\n", comment.text)); + } + _ => {} + } + } + + Ok(output) +} + +/// Load comments from a file, returning empty if file doesn't exist or hash doesn't match. +fn load_comments_from_file(path: &PathBuf, analysis_hash: &str) -> CommentsFile { + if let Ok(data) = std::fs::read_to_string(path) { + if let Ok(existing) = serde_json::from_str::(&data) { + if existing.analysis_hash == analysis_hash { + return existing; + } + } + } + CommentsFile { + analysis_hash: analysis_hash.to_string(), + comments: vec![], + } +} + +// ══════════════════════════════════════════════════════════════════════ +// Branch-based comment cache (~/.diffcore/cache/comments/) +// ══════════════════════════════════════════════════════════════════════ + +/// Resolve the global comment cache directory. +/// Respects `DIFFCORE_COMMENT_CACHE_DIR` for testing. +fn comment_cache_dir() -> Option { + std::env::var_os("DIFFCORE_COMMENT_CACHE_DIR") + .map(PathBuf::from) + .or_else(|| { + std::env::var_os("HOME").map(|home| { + PathBuf::from(home) + .join(".diffcore") + .join("cache") + .join("comments") + }) + }) +} + +/// Compute a cache key for a repo+branch combo. +/// +/// Uses the git common dir (shared across worktrees) + current branch name, +/// so worktrees on the same branch share comments, while different branches +/// on the same repo are isolated. +pub fn comment_cache_key(repo_path: &str) -> Result { + use sha2::{Digest, Sha256}; + + let repo_path_buf = PathBuf::from(repo_path); + let repo_path_buf = std::fs::canonicalize(&repo_path_buf) + .map_err(|e| CommandError::Io(format!("Invalid repo path: {}", e)))?; + let repo = git2::Repository::discover(&repo_path_buf) + .map_err(|e| CommandError::Git(format!("Not a git repository: {}", e)))?; + + // Use the git dir path for identity. For worktrees, resolve the main + // repo's git dir via `commondir()` if available, otherwise use `path()`. + // git2 stores the common dir at `.git/commondir` for linked worktrees. + let git_dir = repo.path(); + let common_dir_file = git_dir.join("commondir"); + let identity_dir = if common_dir_file.exists() { + // Linked worktree — read the commondir reference to get the main repo's git dir + std::fs::read_to_string(&common_dir_file) + .ok() + .and_then(|rel| { + let trimmed = rel.trim(); + let resolved = if std::path::Path::new(trimmed).is_absolute() { + PathBuf::from(trimmed) + } else { + git_dir.join(trimmed) + }; + std::fs::canonicalize(resolved).ok() + }) + .unwrap_or_else(|| git_dir.to_path_buf()) + } else { + git_dir.to_path_buf() + }; + let common_dir = identity_dir.to_string_lossy().to_string(); + + // Get current branch name + let branch = match repo.head() { + Ok(head) => head + .shorthand() + .unwrap_or("HEAD") + .to_string(), + Err(_) => "HEAD".to_string(), + }; + + let mut hasher = Sha256::new(); + hasher.update(common_dir.as_bytes()); + hasher.update(b"\n"); + hasher.update(branch.as_bytes()); + Ok(hex::encode(hasher.finalize())) +} + +/// Branch-cached comment file — just a Vec of comments, no analysis hash. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +struct CachedCommentsFile { + pub comments: Vec, +} + +/// Load comments for the current repo+branch from the global cache. +fn load_cached_comments_file(cache_key: &str) -> CachedCommentsFile { + let Some(dir) = comment_cache_dir() else { + return CachedCommentsFile { comments: vec![] }; + }; + let path = dir.join(format!("{}.json", cache_key)); + match std::fs::read_to_string(&path) { + Ok(data) => serde_json::from_str(&data).unwrap_or(CachedCommentsFile { comments: vec![] }), + Err(_) => CachedCommentsFile { comments: vec![] }, + } +} + +/// Write comments for the current repo+branch to the global cache. +fn write_cached_comments_file( + cache_key: &str, + file: &CachedCommentsFile, +) -> Result<(), CommandError> { + let dir = comment_cache_dir().ok_or_else(|| { + CommandError::Io("Cannot determine comment cache directory".to_string()) + })?; + std::fs::create_dir_all(&dir) + .map_err(|e| CommandError::Io(format!("Failed to create comment cache dir: {}", e)))?; + let path = dir.join(format!("{}.json", cache_key)); + let json = serde_json::to_string_pretty(file) + .map_err(|e| CommandError::Io(format!("Failed to serialize comments: {}", e)))?; + std::fs::write(&path, json) + .map_err(|e| CommandError::Io(format!("Failed to write comment cache: {}", e)))?; + Ok(()) +} + +/// Save a comment to the branch-based cache. +#[tauri::command] +pub fn save_comment_cached( + repo_path: String, + comment: ReviewComment, +) -> Result<(), CommandError> { + let key = comment_cache_key(&repo_path)?; + let mut file = load_cached_comments_file(&key); + file.comments.push(comment); + write_cached_comments_file(&key, &file) +} + +/// Load all comments for the current repo+branch from the cache. +#[tauri::command] +pub fn load_comments_cached(repo_path: String) -> Result, CommandError> { + let key = comment_cache_key(&repo_path)?; + let file = load_cached_comments_file(&key); + Ok(file.comments) +} + +/// Delete a comment by ID from the branch-based cache. +#[tauri::command] +pub fn delete_comment_cached( + repo_path: String, + comment_id: String, +) -> Result<(), CommandError> { + let key = comment_cache_key(&repo_path)?; + let mut file = load_cached_comments_file(&key); + file.comments.retain(|c| c.id != comment_id); + write_cached_comments_file(&key, &file) +} + +/// Update a comment's text by ID in the branch-based cache. +#[tauri::command] +pub fn update_comment_cached( + repo_path: String, + comment_id: String, + new_text: String, +) -> Result<(), CommandError> { + let key = comment_cache_key(&repo_path)?; + let mut file = load_cached_comments_file(&key); + if let Some(comment) = file.comments.iter_mut().find(|c| c.id == comment_id) { + comment.text = new_text; + } + write_cached_comments_file(&key, &file) +} + +// ══════════════════════════════════════════════════════════════════════ +// Groups manifest import / file watching +// ══════════════════════════════════════════════════════════════════════ + +/// Import a groups manifest JSON and apply it to the current analysis. +/// +/// Returns the updated `AnalysisOutput` with groups replaced by the manifest. +#[tauri::command] + +#[cfg(test)] +#[allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::print_stdout, + clippy::print_stderr +)] +mod tests { + use super::*; + + #[test] + fn test_load_comments_from_file_missing() { + let path = std::env::temp_dir().join("diffcore_test_no_such_file.json"); + let result = load_comments_from_file(&path, "test_hash"); + assert_eq!(result.analysis_hash, "test_hash"); + assert!(result.comments.is_empty()); + } + + #[test] + fn test_load_comments_from_file_wrong_hash() { + let path = std::env::temp_dir().join("diffcore_test_wrong_hash.json"); + let data = CommentsFile { + analysis_hash: "old_hash".to_string(), + comments: vec![ReviewComment { + id: "c1".to_string(), + comment_type: "group".to_string(), + group_id: "g1".to_string(), + file_path: None, + start_line: None, + end_line: None, + selected_code: None, + text: "old comment".to_string(), + created_at: "2026-03-20T14:30:00Z".to_string(), + }], + }; + std::fs::write(&path, serde_json::to_string(&data).unwrap()).unwrap(); + let result = load_comments_from_file(&path, "new_hash"); + assert_eq!(result.analysis_hash, "new_hash"); + assert!(result.comments.is_empty()); + std::fs::remove_file(&path).ok(); + } + + #[test] + fn test_load_comments_from_file_matching_hash() { + let path = std::env::temp_dir().join("diffcore_test_matching_hash.json"); + let data = CommentsFile { + analysis_hash: "matching_hash".to_string(), + comments: vec![ReviewComment { + id: "c1".to_string(), + comment_type: "file".to_string(), + group_id: "g1".to_string(), + file_path: Some("test.ts".to_string()), + start_line: None, + end_line: None, + selected_code: None, + text: "test comment".to_string(), + created_at: "2026-03-20T14:30:00Z".to_string(), + }], + }; + std::fs::write(&path, serde_json::to_string(&data).unwrap()).unwrap(); + let result = load_comments_from_file(&path, "matching_hash"); + assert_eq!(result.analysis_hash, "matching_hash"); + assert_eq!(result.comments.len(), 1); + assert_eq!(result.comments[0].text, "test comment"); + std::fs::remove_file(&path).ok(); + } +} diff --git a/crates/diffcore-tauri/src/commands/editor.rs b/crates/diffcore-tauri/src/commands/editor.rs new file mode 100644 index 0000000..f14f862 --- /dev/null +++ b/crates/diffcore-tauri/src/commands/editor.rs @@ -0,0 +1,274 @@ +//! Editor integration and file-save commands. + +use std::path::PathBuf; + +use super::CommandError; + +fn macos_app_name(editor: &str) -> Option<&'static str> { + match editor { + "vscode" => Some("Visual Studio Code"), + "cursor" => Some("Cursor"), + "zed" => Some("Zed"), + _ => None, + } +} + +/// Check if a macOS .app bundle exists in /Applications or ~/Applications. +#[cfg(target_os = "macos")] +fn macos_app_exists(app_name: &str) -> bool { + let global = format!("/Applications/{}.app", app_name); + if PathBuf::from(&global).exists() { + return true; + } + if let Ok(home) = std::env::var("HOME") { + let user = format!("{}/Applications/{}.app", home, app_name); + if PathBuf::from(&user).exists() { + return true; + } + } + false +} + +/// Open a file in an external editor. +/// +/// On macOS, uses `open -a "App Name"` for GUI editors (works without PATH). +/// Falls back to CLI binary for non-macOS or terminal-based editors. +#[tauri::command] +pub fn open_in_editor(editor: String, file_path: String) -> Result<(), CommandError> { + let path = PathBuf::from(&file_path); + if !path.exists() { + return Err(CommandError::Io(format!("File not found: {}", file_path))); + } + + let result = match editor.as_str() { + "vscode" | "cursor" | "zed" => { + #[cfg(target_os = "macos")] + { + // Use the CLI binary via the app bundle's bin/ path for proper workspace trust. + // `open -a` opens files as untrusted; the CLI opens in the existing workspace. + let cli_path = match editor.as_str() { + "vscode" => { + "/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code" + } + "cursor" => "/Applications/Cursor.app/Contents/Resources/app/bin/cursor", + "zed" => "/Applications/Zed.app/Contents/MacOS/cli", + _ => unreachable!(), + }; + if std::path::Path::new(cli_path).exists() { + std::process::Command::new(cli_path) + .args(["--reuse-window", "--goto", &file_path]) + .spawn() + } else { + // Fallback to `open -a` if CLI path not found + let app_name = macos_app_name(&editor).unwrap(); + std::process::Command::new("open") + .args(["-a", app_name, &file_path]) + .spawn() + } + } + #[cfg(not(target_os = "macos"))] + { + let bin = match editor.as_str() { + "vscode" => "code", + "cursor" => "cursor", + "zed" => "zed", + _ => unreachable!(), + }; + std::process::Command::new(bin) + .args(["--reuse-window", "--goto", &file_path]) + .spawn() + } + } + "vim" => { + #[cfg(target_os = "macos")] + { + // Open vim in a NEW Terminal window via AppleScript + let escaped = file_path.replace('\\', "\\\\").replace('"', "\\\""); + std::process::Command::new("osascript") + .args([ + "-e", + &format!( + "tell application \"Terminal\"\n\ + activate\n\ + do script \"vim \\\"{}\\\"\" \n\ + end tell", + escaped + ), + ]) + .spawn() + } + #[cfg(not(target_os = "macos"))] + { + std::process::Command::new("vim").arg(&file_path).spawn() + } + } + "terminal" => { + let dir = if path.is_dir() { + file_path.clone() + } else { + path.parent() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|| file_path.clone()) + }; + #[cfg(target_os = "macos")] + { + // Use AppleScript to open Terminal and cd to the directory + let escaped = dir.replace('\\', "\\\\").replace('"', "\\\""); + std::process::Command::new("osascript") + .args([ + "-e", + &format!( + "tell application \"Terminal\"\n\ + activate\n\ + do script \"cd \\\"{}\\\"\" \n\ + end tell", + escaped + ), + ]) + .spawn() + } + #[cfg(target_os = "linux")] + { + std::process::Command::new("xdg-open").arg(&dir).spawn() + } + #[cfg(target_os = "windows")] + { + std::process::Command::new("cmd") + .args(["/c", "start", "cmd", "/k", &format!("cd /d {}", dir)]) + .spawn() + } + } + other => { + return Err(CommandError::Io(format!("Unknown editor: {}", other))); + } + }; + + match result { + Ok(_) => Ok(()), + Err(e) => { + let label = match editor.as_str() { + "vscode" => "VS Code", + "cursor" => "Cursor", + "zed" => "Zed", + "vim" => "Vim", + "terminal" => "Terminal", + _ => &editor, + }; + Err(CommandError::Io(format!( + "Failed to open {} — is it installed? ({})", + label, e + ))) + } + } +} + +/// Check which editors are available on the system. +/// +/// On macOS, checks for .app bundles in /Applications (works without PATH). +/// On other platforms, uses `which`/`where` to find CLI binaries. +#[tauri::command] +pub fn check_editors_available() -> std::collections::HashMap { + let mut result = std::collections::HashMap::new(); + + // GUI editors + for id in &["vscode", "cursor", "zed"] { + let available = { + #[cfg(target_os = "macos")] + { + macos_app_name(id) + .map(|name| macos_app_exists(name)) + .unwrap_or(false) + } + #[cfg(not(target_os = "macos"))] + { + let bin = match *id { + "vscode" => "code", + "cursor" => "cursor", + "zed" => "zed", + _ => id, + }; + #[cfg(unix)] + { + std::process::Command::new("which") + .arg(bin) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) + } + #[cfg(windows)] + { + std::process::Command::new("where") + .arg(bin) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) + } + } + }; + result.insert(id.to_string(), available); + } + + // vim — check binary in PATH (available on most systems) + let vim_available = { + #[cfg(unix)] + { + std::process::Command::new("which") + .arg("vim") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) + } + #[cfg(windows)] + { + std::process::Command::new("where") + .arg("vim") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) + } + }; + result.insert("vim".to_string(), vim_available); + + // Terminal is always available + result.insert("terminal".to_string(), true); + + result +} + +/// Persist edited file content to disk. +/// +/// Failure modes: +/// - Returns IO error when the path does not exist or is a directory. +/// - Returns IO error when the parent directory is missing. +/// - Returns IO error when the write fails (permissions, disk full, etc). +#[tauri::command] +pub fn save_file_content(file_path: String, content: String) -> Result<(), CommandError> { + let path = PathBuf::from(&file_path); + if !path.exists() { + return Err(CommandError::Io(format!("File not found: {}", file_path))); + } + if !path.is_file() { + return Err(CommandError::Io(format!("Path is not a file: {}", file_path))); + } + let parent = path.parent().ok_or_else(|| { + CommandError::Io(format!("Cannot determine parent directory for: {}", file_path)) + })?; + if !parent.exists() { + return Err(CommandError::Io(format!( + "Parent directory does not exist: {}", + parent.display() + ))); + } + + std::fs::write(&path, content) + .map_err(|e| CommandError::Io(format!("Failed to write file '{}': {}", file_path, e))) +} + diff --git a/crates/diffcore-tauri/src/commands/llm.rs b/crates/diffcore-tauri/src/commands/llm.rs new file mode 100644 index 0000000..33f117e --- /dev/null +++ b/crates/diffcore-tauri/src/commands/llm.rs @@ -0,0 +1,1057 @@ +//! LLM annotation and refinement commands. + +use std::path::PathBuf; +use std::sync::Arc; + +use log::warn; + +use crate::activity_stream::{ActivityEntry, JobHandle}; +use diffcore_core::cache; +use diffcore_core::llm; +use diffcore_core::llm::refinement; +use diffcore_core::llm::schema::{Pass1Response, Pass2Response, RefinementResponse}; +use diffcore_core::types::AnalysisOutput; + +use super::{AppState, CommandError}; + +fn build_pass1_request( + analysis: &AnalysisOutput, + reanalysis_context: Option<&str>, +) -> llm::schema::Pass1Request { + let flow_groups: Vec = analysis + .groups + .iter() + .map(|g| llm::schema::Pass1GroupInput { + id: g.id.clone(), + name: g.name.clone(), + entrypoint: g + .entrypoint + .as_ref() + .map(|ep| format!("{}::{}", ep.file, ep.symbol)), + files: g.files.iter().map(|f| f.path.clone()).collect(), + risk_score: g.risk_score, + edge_summary: g + .edges + .iter() + .map(|e| format!("{} -> {}", e.from, e.to)) + .collect::>() + .join(", "), + }) + .collect(); + + let mut diff_summary = format!( + "{} files changed across {} groups", + analysis.summary.total_files_changed, analysis.summary.total_groups, + ); + + if let Some(context) = reanalysis_context { + let trimmed = context.trim(); + if !trimmed.is_empty() { + diff_summary.push_str("\n\n## Reanalysis Context\n"); + diff_summary.push_str(trimmed); + } + } + + llm::schema::Pass1Request { + diff_summary, + flow_groups, + graph_summary: format!( + "{} groups, {} total files", + analysis.summary.total_groups, analysis.summary.total_files_changed, + ), + } +} + +fn build_overview_reanalysis_context( + user_feedback: Option, + include_previous_output: Option, + previous_output: Option, + user_comments: Option>, +) -> Option { + let mut parts: Vec = Vec::new(); + + if let Some(feedback) = user_feedback { + let trimmed = feedback.trim(); + if !trimmed.is_empty() { + parts.push(format!("User feedback/question:\n{}", trimmed)); + } + } + + if let Some(comments) = user_comments { + let non_empty: Vec = comments + .into_iter() + .map(|c| c.trim().to_string()) + .filter(|c| !c.is_empty()) + .collect(); + if !non_empty.is_empty() { + parts.push(format!("Review comments:\n- {}", non_empty.join("\n- "))); + } + } + + if include_previous_output.unwrap_or(false) { + if let Some(previous) = previous_output { + let trimmed = previous.trim(); + if !trimmed.is_empty() { + parts.push(format!("Previous output to consider:\n{}", trimmed)); + } + } + } + + if parts.is_empty() { + None + } else { + Some(parts.join("\n\n")) + } +} + +fn build_pass2_request( + analysis: &AnalysisOutput, + group_id: &str, + repo_path: &str, + base: Option, + head: Option, + range: Option, + staged: bool, + unstaged: bool, + include_uncommitted: bool, +) -> Result { + let group = analysis + .groups + .iter() + .find(|g| g.id == group_id) + .ok_or_else(|| CommandError::Analysis(format!("Group '{}' not found", group_id)))? + .clone(); + + let repo_path_buf = PathBuf::from(repo_path); + let repo_path_buf = std::fs::canonicalize(&repo_path_buf) + .map_err(|e| CommandError::Io(format!("Invalid repo path: {}", e)))?; + let repo = git2::Repository::discover(&repo_path_buf) + .map_err(|e| CommandError::Git(format!("Not a git repository: {}", e)))?; + + let (diff_result, _) = super::extract_diff(&repo, base, head, range, staged, unstaged, false, include_uncommitted)?; + + let files: Vec = group + .files + .iter() + .map(|f| { + let file_diff = diff_result.files.iter().find(|d| d.path() == f.path); + let diff_text = file_diff + .map(|d| { + let old = d.old_content.as_deref().unwrap_or(""); + let new = d.new_content.as_deref().unwrap_or(""); + format!( + "--- a/{}\n+++ b/{}\n{}", + f.path, + f.path, + super::simple_unified_diff(old, new) + ) + }) + .unwrap_or_default(); + let new_content = file_diff.and_then(|d| d.new_content.clone()); + + llm::schema::Pass2FileInput { + path: f.path.clone(), + diff: diff_text, + new_content, + role: format!("{:?}", f.role), + } + }) + .collect(); + + let graph_context = group + .edges + .iter() + .map(|e| format!("{} --{:?}--> {}", e.from, e.edge_type, e.to)) + .collect::>() + .join("\n"); + + Ok(llm::schema::Pass2Request { + group_id: group.id.clone(), + group_name: group.name.clone(), + files, + graph_context, + }) +} + +fn make_activity_callback( + job: JobHandle, +) -> Arc { + Arc::new(move |update| { + let job = job.clone(); + tauri::async_runtime::spawn(async move { + job.emit(ActivityEntry { + source: update.source, + level: update.level, + message: update.message, + event_type: update.event_type, + payload: update.payload, + timestamp_ms: update.timestamp_ms, + }) + .await; + }); + }) +} + +async fn emit_diffcore_activity(job: &JobHandle, message: impl Into) { + job.emit(ActivityEntry::info("diffcore", message, None)) + .await; +} + +fn provider_supports_tool_activity(provider: &str) -> bool { + matches!(provider, "codex" | "claude") +} + +async fn emit_direct_api_activity_notice(job: &JobHandle, provider: &str) { + if provider_supports_tool_activity(provider) { + return; + } + + emit_diffcore_activity( + job, + "Direct API mode only shows high-level progress. Switch to Codex CLI or Claude Code to stream file reads, greps, and shell activity.", + ) + .await; +} + +fn refinement_reasoning_excerpt(reasoning: &str) -> Option { + let trimmed = reasoning.split_whitespace().collect::>().join(" "); + if trimmed.is_empty() { + return None; + } + + let sentence_end = trimmed.find(". ").map(|index| index + 1); + let excerpt = sentence_end + .map(|index| trimmed[..index].trim().to_string()) + .unwrap_or_else(|| trimmed.chars().take(220).collect::()); + + if excerpt.is_empty() { + None + } else if excerpt.chars().count() < trimmed.chars().count() && sentence_end.is_none() { + Some(format!("{}...", excerpt)) + } else { + Some(excerpt) + } +} + +fn refinement_operations_summary(response: &RefinementResponse) -> String { + let mut parts = Vec::new(); + + if !response.splits.is_empty() { + parts.push(format!( + "{} split{}", + response.splits.len(), + if response.splits.len() == 1 { "" } else { "s" } + )); + } + if !response.merges.is_empty() { + parts.push(format!( + "{} merge{}", + response.merges.len(), + if response.merges.len() == 1 { "" } else { "s" } + )); + } + if !response.re_ranks.is_empty() { + parts.push(format!( + "{} re-rank{}", + response.re_ranks.len(), + if response.re_ranks.len() == 1 { + "" + } else { + "s" + } + )); + } + if !response.reclassifications.is_empty() { + parts.push(format!( + "{} reclassification{}", + response.reclassifications.len(), + if response.reclassifications.len() == 1 { + "" + } else { + "s" + } + )); + } + + if parts.is_empty() { + "no structural changes".to_string() + } else { + parts.join(", ") + } +} + +async fn run_overview_with_activity( + request: llm::schema::Pass1Request, + llm_config: diffcore_core::config::LlmConfig, + workdir: Option, + job: JobHandle, +) -> Result { + emit_diffcore_activity(&job, "Preparing overview request").await; + let provider = llm::create_provider_for_workdir(&llm_config, workdir.as_deref()) + .map_err(|e| CommandError::Llm(format!("{}", e)))?; + let provider_name = provider.name().to_string(); + let provider_model = provider.model().to_string(); + emit_diffcore_activity( + &job, + format!("Using {} / {}", provider_name, provider_model), + ) + .await; + emit_direct_api_activity_notice(&job, &provider_name).await; + + llm::with_activity_callback(make_activity_callback(job), async { + provider.annotate_overview(&request).await + }) + .await + .map_err(|e| CommandError::Llm(format!("{}", e))) +} + +async fn run_group_with_activity( + request: llm::schema::Pass2Request, + llm_config: diffcore_core::config::LlmConfig, + workdir: Option, + job: JobHandle, +) -> Result { + emit_diffcore_activity(&job, "Preparing deep analysis request").await; + let provider = llm::create_provider_for_workdir(&llm_config, workdir.as_deref()) + .map_err(|e| CommandError::Llm(format!("{}", e)))?; + let provider_name = provider.name().to_string(); + let provider_model = provider.model().to_string(); + emit_diffcore_activity( + &job, + format!("Using {} / {}", provider_name, provider_model), + ) + .await; + emit_direct_api_activity_notice(&job, &provider_name).await; + + llm::with_activity_callback(make_activity_callback(job), async { + provider.annotate_group(&request).await + }) + .await + .map_err(|e| CommandError::Llm(format!("{}", e))) +} + +async fn run_refinement_with_activity( + analysis: AnalysisOutput, + refinement_llm_config: diffcore_core::config::LlmConfig, + workdir: Option, + job: JobHandle, +) -> Result { + emit_diffcore_activity(&job, "Preparing refinement request").await; + let provider = llm::create_provider_for_workdir(&refinement_llm_config, workdir.as_deref()) + .map_err(|e| CommandError::Llm(format!("{}", e)))?; + let provider_name = provider.name().to_string(); + let provider_model = provider.model().to_string(); + emit_diffcore_activity( + &job, + format!("Using {} / {}", provider_name, provider_model), + ) + .await; + emit_direct_api_activity_notice(&job, &provider_name).await; + + let analysis_json = serde_json::to_string_pretty(&analysis) + .map_err(|e| CommandError::Llm(format!("Failed to serialize analysis: {}", e)))?; + let diff_summary = format!( + "{} files changed across {} groups", + analysis.summary.total_files_changed, analysis.summary.total_groups, + ); + let request = refinement::build_refinement_request( + &analysis.groups, + analysis.infrastructure_group.as_ref(), + &analysis_json, + &diff_summary, + ); + + let response = llm::with_activity_callback(make_activity_callback(job.clone()), async { + provider.refine_groups(&request).await + }) + .await + .map_err(|e| CommandError::Llm(format!("{}", e)))?; + + if let Some(reasoning) = refinement_reasoning_excerpt(&response.reasoning) { + job.emit(ActivityEntry::info( + provider_name.clone(), + format!("Refinement rationale: {}", reasoning), + Some("refinement.reasoning".to_string()), + )) + .await; + } + + let provider_name = refinement_llm_config + .provider + .clone() + .unwrap_or_else(|| "anthropic".to_string()); + let model_name = refinement_llm_config + .model + .clone() + .unwrap_or_else(|| super::default_model_for_provider(&provider_name).to_string()); + + if !refinement::has_refinements(&response) { + emit_diffcore_activity(&job, "Refinement kept the current grouping").await; + return Ok(RefinementResult { + refined_groups: analysis.groups.clone(), + infrastructure_group: analysis.infrastructure_group.clone(), + refinement_response: response, + provider: provider_name, + model: model_name, + had_changes: false, + warnings: Vec::new(), + }); + } + + let (refined_groups, infra, warnings) = refinement::apply_refinement_lenient( + &analysis.groups, + analysis.infrastructure_group.as_ref(), + &response, + ); + + for warning in &warnings { + job.emit(ActivityEntry::info( + provider_name.clone(), + format!("Refinement repair: {}", warning.message), + Some("refinement.repair".to_string()), + )) + .await; + } + + emit_diffcore_activity( + &job, + format!( + "Refinement proposed {}", + refinement_operations_summary(&response) + ), + ) + .await; + + Ok(RefinementResult { + refined_groups, + infrastructure_group: infra, + refinement_response: response, + provider: provider_name, + model: model_name, + had_changes: true, + warnings, + }) +} + +#[tauri::command] +pub fn start_annotate_overview( + repo_path: Option, + llm_provider: Option, + llm_model: Option, + user_feedback: Option, + include_previous_output: Option, + previous_output: Option, + user_comments: Option>, + state: tauri::State<'_, AppState>, +) -> Result { + let analysis = super::load_cached_analysis(&state)?; + let (mut config, workdir) = super::load_config_from_path(repo_path.as_deref()); + if let Some(provider) = llm_provider { + config.llm.provider = Some(provider); + } + if let Some(model) = llm_model { + config.llm.model = Some(model); + } + + let provider_name = config + .llm + .provider + .clone() + .unwrap_or_else(|| "anthropic".to_string()); + let model_name = config + .llm + .model + .clone() + .unwrap_or_else(|| super::default_model_for_provider(&provider_name).to_string()); + let (job, start) = + state.create_llm_job("overview", &provider_name, &model_name, "Summarizing PR")?; + let llm_config = config.llm.clone(); + let reanalysis_context = build_overview_reanalysis_context( + user_feedback, + include_previous_output, + previous_output, + user_comments, + ); + let request = build_pass1_request(&analysis, reanalysis_context.as_deref()); + + tauri::async_runtime::spawn(async move { + match run_overview_with_activity(request, llm_config, workdir, job.clone()).await { + Ok(response) => match serde_json::to_value(&response) { + Ok(value) => job.complete("overview", value).await, + Err(error) => { + job.fail(format!("Failed to serialize overview response: {}", error)) + .await + } + }, + Err(error) => job.fail(error.to_string()).await, + } + }); + + Ok(start) +} + +#[tauri::command] +pub fn start_annotate_group( + group_id: String, + repo_path: String, + base: Option, + head: Option, + range: Option, + staged: bool, + unstaged: bool, + include_uncommitted: Option, + llm_provider: Option, + llm_model: Option, + state: tauri::State<'_, AppState>, +) -> Result { + let analysis = super::load_cached_analysis(&state)?; + let request = build_pass2_request( + &analysis, &group_id, &repo_path, base, head, range, staged, unstaged, include_uncommitted.unwrap_or(true), + )?; + let (mut config, workdir) = super::load_config_from_path(Some(&repo_path)); + if let Some(provider) = llm_provider { + config.llm.provider = Some(provider); + } + if let Some(model) = llm_model { + config.llm.model = Some(model); + } + + let provider_name = config + .llm + .provider + .clone() + .unwrap_or_else(|| "anthropic".to_string()); + let model_name = config + .llm + .model + .clone() + .unwrap_or_else(|| super::default_model_for_provider(&provider_name).to_string()); + let (job, start) = state.create_llm_job( + "group", + &provider_name, + &model_name, + &format!("Analyzing {}", group_id), + )?; + let llm_config = config.llm.clone(); + + tauri::async_runtime::spawn(async move { + match run_group_with_activity(request, llm_config, workdir, job.clone()).await { + Ok(response) => match serde_json::to_value(&response) { + Ok(value) => job.complete("group", value).await, + Err(error) => { + job.fail(format!("Failed to serialize group response: {}", error)) + .await + } + }, + Err(error) => job.fail(error.to_string()).await, + } + }); + + Ok(start) +} + +#[tauri::command] +pub fn start_refine_groups( + repo_path: Option, + llm_provider: Option, + llm_model: Option, + state: tauri::State<'_, AppState>, +) -> Result { + let analysis = super::load_cached_analysis(&state)?; + let (mut config, workdir) = super::load_config_from_path(repo_path.as_deref()); + if let Some(provider) = llm_provider { + config.llm.refinement.provider = Some(provider.clone()); + if config.llm.provider.is_none() { + config.llm.provider = Some(provider); + } + } + if let Some(model) = llm_model { + config.llm.refinement.model = Some(model.clone()); + if config.llm.model.is_none() { + config.llm.model = Some(model); + } + } + + let refinement_llm_config = diffcore_core::config::LlmConfig { + provider: config + .llm + .refinement + .provider + .clone() + .or(config.llm.provider.clone()), + model: config + .llm + .refinement + .model + .clone() + .or(config.llm.model.clone()), + key_cmd: config + .llm + .refinement + .key_cmd + .clone() + .or(config.llm.key_cmd.clone()), + key: config.llm.key.clone(), + annotations_enabled: config.llm.annotations_enabled, + refinement: config.llm.refinement.clone(), + }; + + let provider_name = refinement_llm_config + .provider + .clone() + .unwrap_or_else(|| "anthropic".to_string()); + let model_name = refinement_llm_config + .model + .clone() + .unwrap_or_else(|| super::default_model_for_provider(&provider_name).to_string()); + let (job, start) = + state.create_llm_job("refinement", &provider_name, &model_name, "Refining groups")?; + + tauri::async_runtime::spawn(async move { + match run_refinement_with_activity(analysis, refinement_llm_config, workdir, job.clone()) + .await + { + Ok(response) => match serde_json::to_value(&response) { + Ok(value) => job.complete("refinement", value).await, + Err(error) => { + job.fail(format!( + "Failed to serialize refinement response: {}", + error + )) + .await + } + }, + Err(error) => job.fail(error.to_string()).await, + } + }); + + Ok(start) +} + +/// Run LLM Pass 1 (overview annotation) on the cached analysis. +/// +/// Returns structured overview with per-group summaries, risk flags, +/// and suggested review order. The result is also stored in the cached +/// analysis output's `annotations` field. +#[tauri::command] +pub async fn annotate_overview( + repo_path: Option, + llm_provider: Option, + llm_model: Option, + user_feedback: Option, + include_previous_output: Option, + previous_output: Option, + user_comments: Option>, + state: tauri::State<'_, AppState>, +) -> Result { + // Get the cached analysis to build the request + let analysis = { + let last = state + .last_analysis + .lock() + .map_err(|e| CommandError::Analysis(format!("Lock poisoned: {}", e)))?; + last.clone().ok_or_else(|| { + CommandError::Analysis("No analysis available. Run analyze first.".into()) + })? + }; + + // Load config from the repo directory (not default) + let (mut config, workdir) = super::load_config_from_path(repo_path.as_deref()); + + // Apply frontend overrides if provided + if let Some(p) = llm_provider { + config.llm.provider = Some(p); + } + if let Some(m) = llm_model { + config.llm.model = Some(m); + } + + // Create LLM provider + let provider = llm::create_provider_for_workdir(&config.llm, workdir.as_deref()) + .map_err(|e| CommandError::Llm(format!("{}", e)))?; + + let reanalysis_context = build_overview_reanalysis_context( + user_feedback, + include_previous_output, + previous_output, + user_comments, + ); + let request = build_pass1_request(&analysis, reanalysis_context.as_deref()); + + let response = provider + .annotate_overview(&request) + .await + .map_err(|e| CommandError::Llm(format!("{}", e)))?; + + // Store the annotations in the cached analysis + match state.last_analysis.lock() { + Ok(mut last) => { + if let Some(ref mut a) = *last { + a.annotations = Some(serde_json::to_value(&response).map_err(|e| { + CommandError::Llm(format!("Failed to serialize response: {}", e)) + })?); + } + } + Err(e) => warn!( + "Failed to update last_analysis annotations (lock poisoned): {}", + e + ), + } + + Ok(response) +} + +/// Run LLM Pass 2 (deep analysis) on a specific group. +/// +/// Returns per-file annotations, flow narrative, and cross-cutting concerns. +#[tauri::command] +pub async fn annotate_group( + group_id: String, + repo_path: String, + base: Option, + head: Option, + range: Option, + staged: bool, + unstaged: bool, + include_uncommitted: Option, + llm_provider: Option, + llm_model: Option, + state: tauri::State<'_, AppState>, +) -> Result { + // Get the cached analysis to find the group + let analysis = { + let last = state + .last_analysis + .lock() + .map_err(|e| CommandError::Analysis(format!("Lock poisoned: {}", e)))?; + last.clone().ok_or_else(|| { + CommandError::Analysis("No analysis available. Run analyze first.".into()) + })? + }; + + let group = analysis + .groups + .iter() + .find(|g| g.id == group_id) + .ok_or_else(|| CommandError::Analysis(format!("Group '{}' not found", group_id)))? + .clone(); + + // Get file diffs for Pass 2 context + let repo_path_buf = PathBuf::from(&repo_path); + let repo_path_buf = std::fs::canonicalize(&repo_path_buf) + .map_err(|e| CommandError::Io(format!("Invalid repo path: {}", e)))?; + let repo = git2::Repository::discover(&repo_path_buf) + .map_err(|e| CommandError::Git(format!("Not a git repository: {}", e)))?; + + let (diff_result, _) = super::extract_diff(&repo, base, head, range, staged, unstaged, false, include_uncommitted.unwrap_or(true))?; + + // Build Pass 2 file inputs with diffs + let files: Vec = group + .files + .iter() + .map(|f| { + let file_diff = diff_result.files.iter().find(|d| d.path() == f.path); + let diff_text = file_diff + .map(|d| { + // Build a simple unified diff representation + let old = d.old_content.as_deref().unwrap_or(""); + let new = d.new_content.as_deref().unwrap_or(""); + format!( + "--- a/{}\n+++ b/{}\n{}", + f.path, + f.path, + super::simple_unified_diff(old, new) + ) + }) + .unwrap_or_default(); + let new_content = file_diff.and_then(|d| d.new_content.clone()); + + llm::schema::Pass2FileInput { + path: f.path.clone(), + diff: diff_text, + new_content, + role: format!("{:?}", f.role), + } + }) + .collect(); + + // Build graph context + let graph_context = group + .edges + .iter() + .map(|e| format!("{} --{:?}--> {}", e.from, e.edge_type, e.to)) + .collect::>() + .join("\n"); + + let (mut config, workdir) = super::load_config_from_path(Some(&repo_path)); + if let Some(p) = llm_provider { + config.llm.provider = Some(p); + } + if let Some(m) = llm_model { + config.llm.model = Some(m); + } + let provider = llm::create_provider_for_workdir(&config.llm, workdir.as_deref()) + .map_err(|e| CommandError::Llm(format!("{}", e)))?; + + let request = llm::schema::Pass2Request { + group_id: group.id.clone(), + group_name: group.name.clone(), + files, + graph_context, + }; + + let response = provider + .annotate_group(&request) + .await + .map_err(|e| CommandError::Llm(format!("{}", e)))?; + + Ok(response) +} + +/// Run LLM refinement pass on the cached analysis groups. +/// +/// Takes the deterministic groups (v1) and asks an LLM to suggest structural +/// improvements: splits, merges, re-ranks, and reclassifications. Applies the +/// refinement operations and returns the result containing both the refined +/// groups and the raw refinement response (for change indicators in the UI). +/// +/// Falls back to returning the original groups if refinement produces no changes +/// or validation fails. +#[tauri::command] +pub async fn refine_groups( + repo_path: Option, + llm_provider: Option, + llm_model: Option, + state: tauri::State<'_, AppState>, +) -> Result { + // Get the cached analysis + let analysis = { + let last = state + .last_analysis + .lock() + .map_err(|e| CommandError::Analysis(format!("Lock poisoned: {}", e)))?; + last.clone().ok_or_else(|| { + CommandError::Analysis("No analysis available. Run analyze first.".into()) + })? + }; + + // Load config, applying frontend overrides + let (mut config, workdir) = super::load_config_from_path(repo_path.as_deref()); + // Use refinement-specific provider/model if set, otherwise fall back to overrides + if let Some(p) = llm_provider { + config.llm.refinement.provider = Some(p.clone()); + if config.llm.provider.is_none() { + config.llm.provider = Some(p); + } + } + if let Some(m) = llm_model { + config.llm.refinement.model = Some(m.clone()); + if config.llm.model.is_none() { + config.llm.model = Some(m); + } + } + + // Build LLM config for the refinement provider + let refinement_llm_config = diffcore_core::config::LlmConfig { + provider: config + .llm + .refinement + .provider + .clone() + .or(config.llm.provider.clone()), + model: config + .llm + .refinement + .model + .clone() + .or(config.llm.model.clone()), + key_cmd: config + .llm + .refinement + .key_cmd + .clone() + .or(config.llm.key_cmd.clone()), + key: config.llm.key.clone(), + annotations_enabled: config.llm.annotations_enabled, + refinement: config.llm.refinement.clone(), + }; + + let provider = llm::create_provider_for_workdir(&refinement_llm_config, workdir.as_deref()) + .map_err(|e| CommandError::Llm(format!("{}", e)))?; + + // Serialize analysis for the refinement request + let analysis_json = serde_json::to_string_pretty(&analysis) + .map_err(|e| CommandError::Llm(format!("Failed to serialize analysis: {}", e)))?; + + let diff_summary = format!( + "{} files changed across {} groups", + analysis.summary.total_files_changed, analysis.summary.total_groups, + ); + + let request = refinement::build_refinement_request( + &analysis.groups, + analysis.infrastructure_group.as_ref(), + &analysis_json, + &diff_summary, + ); + + let response = provider + .refine_groups(&request) + .await + .map_err(|e| CommandError::Llm(format!("{}", e)))?; + + let provider_name = refinement_llm_config + .provider + .unwrap_or_else(|| "anthropic".to_string()); + let model_name = refinement_llm_config + .model + .unwrap_or_else(|| super::default_model_for_provider(&provider_name).to_string()); + + if !refinement::has_refinements(&response) { + return Ok(RefinementResult { + refined_groups: analysis.groups.clone(), + infrastructure_group: analysis.infrastructure_group.clone(), + refinement_response: response, + provider: provider_name, + model: model_name, + had_changes: false, + warnings: Vec::new(), + }); + } + + // Apply the refinement leniently: repair what we can, drop what we can't, + // surface warnings instead of erroring on individual hallucinated ops. + let (refined_groups, infra, warnings) = refinement::apply_refinement_lenient( + &analysis.groups, + analysis.infrastructure_group.as_ref(), + &response, + ); + + for w in &warnings { + warn!("Refinement repair: {}", w.message); + } + + // Update cached analysis with refined groups + match state.last_analysis.lock() { + Ok(mut last) => { + if let Some(ref mut a) = *last { + a.groups = refined_groups.clone(); + a.infrastructure_group = infra.clone(); + } + } + Err(e) => warn!( + "Failed to update last_analysis with refinement (lock poisoned): {}", + e + ), + } + + Ok(RefinementResult { + refined_groups, + infrastructure_group: infra, + refinement_response: response, + provider: provider_name, + model: model_name, + had_changes: true, + warnings, + }) +} + +/// Result of a refinement pass, including both the refined groups and +/// the raw refinement operations (for UI change indicators). +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct RefinementResult { + /// The refined flow groups (v2) — or original groups if no changes. + pub refined_groups: Vec, + /// The refined infrastructure group. + pub infrastructure_group: Option, + /// The raw refinement response with split/merge/re-rank/reclassify operations. + pub refinement_response: RefinementResponse, + /// Which provider performed the refinement. + pub provider: String, + /// Which model performed the refinement. + pub model: String, + /// Whether the refinement actually produced changes. + pub had_changes: bool, + /// Non-fatal warnings from the lenient apply path: repaired IDs and + /// dropped operations. Empty in the common case. + #[serde(default)] + pub warnings: Vec, +} + +/// Load cached refinement result for the current analysis. +/// +/// Tries two keys: (1) diff-hash key (exact match), (2) branch-based key (same branch +/// across worktrees, even with different uncommitted changes). +#[tauri::command] +pub fn get_cached_refinement( + repo_path: Option, + state: tauri::State<'_, AppState>, +) -> Result, CommandError> { + // Try diff-hash key first (exact content match) + let diff_key = state.last_cache_key.lock().ok().and_then(|k| k.clone()); + if let Some(ref key) = diff_key { + if let Some(json) = cache::load_cached_refinement(key) { + if let Ok(result) = serde_json::from_str::(&json) { + return Ok(Some(result)); + } + } + } + + // Fallback: try branch-based key (works across worktrees on same branch) + if let Some(ref repo) = repo_path { + if let Ok(branch_key) = super::comments::comment_cache_key(repo) { + let branch_refine_key = format!("branch_{}", branch_key); + if let Some(json) = cache::load_cached_refinement(&branch_refine_key) { + if let Ok(result) = serde_json::from_str::(&json) { + return Ok(Some(result)); + } + } + } + } + + Ok(None) +} + +/// Store a refinement result in the global cache (~/.diffcore/cache/refinements/). +/// +/// Stores under both diff-hash key and branch-based key for cross-worktree access. +#[tauri::command] +pub fn store_refinement_cache( + result: RefinementResult, + repo_path: Option, + state: tauri::State<'_, AppState>, +) -> Result<(), CommandError> { + let json = match serde_json::to_string(&result) { + Ok(j) => j, + Err(e) => { + warn!("Failed to serialize refinement for caching: {}", e); + return Ok(()); + } + }; + + // Store under diff-hash key + if let Some(cache_key) = state.last_cache_key.lock().ok().and_then(|k| k.clone()) { + cache::store_cached_refinement(&cache_key, &json); + } + + // Also store under branch-based key for cross-worktree access + if let Some(ref repo) = repo_path { + if let Ok(branch_key) = super::comments::comment_cache_key(repo) { + let branch_refine_key = format!("branch_{}", branch_key); + cache::store_cached_refinement(&branch_refine_key, &json); + } + } + + Ok(()) +} + +/// Background LLM job registration payload returned before SSE streaming begins. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct AsyncLlmJobStart { + pub job_id: String, + pub stream_url: String, + pub operation: String, + pub provider: String, + pub model: String, + pub title: String, +} diff --git a/crates/diffcore-tauri/src/commands/manifest.rs b/crates/diffcore-tauri/src/commands/manifest.rs new file mode 100644 index 0000000..143d1e9 --- /dev/null +++ b/crates/diffcore-tauri/src/commands/manifest.rs @@ -0,0 +1,110 @@ +//! Groups manifest import, export, and watch commands. + +use std::path::PathBuf; + +use tauri::Emitter; + +use diffcore_core::types::AnalysisOutput; + +use super::{AppState, CommandError}; + +/// Import a groups manifest JSON and apply it to the current analysis. +/// +/// Returns the updated `AnalysisOutput` with groups replaced by the manifest. +#[tauri::command] +pub fn import_groups_manifest( + manifest_path: String, + state: tauri::State<'_, AppState>, +) -> Result { + use diffcore_core::manifest; + + let manifest = manifest::read_manifest(std::path::Path::new(&manifest_path)) + .map_err(|e| CommandError::Io(e))?; + + let analysis = state + .last_analysis + .lock() + .map_err(|e| CommandError::Analysis(format!("Lock poisoned: {}", e)))? + .clone() + .ok_or_else(|| CommandError::Analysis("No analysis loaded".to_string()))?; + + let updated = manifest::import_manifest(&analysis, &manifest); + + // Update cached analysis + if let Ok(mut last) = state.last_analysis.lock() { + *last = Some(updated.clone()); + } + + Ok(updated) +} + +/// Export the current analysis groups as a manifest JSON file. +#[tauri::command] +pub fn export_groups_manifest( + output_path: String, + state: tauri::State<'_, AppState>, +) -> Result<(), CommandError> { + use diffcore_core::manifest; + + let analysis = state + .last_analysis + .lock() + .map_err(|e| CommandError::Analysis(format!("Lock poisoned: {}", e)))? + .clone() + .ok_or_else(|| CommandError::Analysis("No analysis loaded".to_string()))?; + + let groups_manifest = manifest::export_manifest(&analysis); + manifest::write_manifest(std::path::Path::new(&output_path), &groups_manifest) + .map_err(|e| CommandError::Io(e))?; + + Ok(()) +} + +/// Start watching a manifest file for changes. Emits "manifest-changed" events +/// to the frontend when the file is modified. +#[tauri::command] +pub fn watch_manifest( + manifest_path: String, + app_handle: tauri::AppHandle, + state: tauri::State<'_, AppState>, +) -> Result<(), CommandError> { + // Store the path for the watcher + if let Ok(mut path) = state.watched_manifest_path.lock() { + *path = Some(PathBuf::from(&manifest_path)); + } + + // Spawn a background thread that polls the file for changes + let path = PathBuf::from(manifest_path); + std::thread::spawn(move || { + let mut last_modified = std::fs::metadata(&path) + .and_then(|m| m.modified()) + .ok(); + + loop { + std::thread::sleep(std::time::Duration::from_millis(500)); + + let current_modified = std::fs::metadata(&path) + .and_then(|m| m.modified()) + .ok(); + + if current_modified != last_modified && current_modified.is_some() { + last_modified = current_modified; + // Emit event to frontend + let _ = app_handle.emit("manifest-changed", &path.to_string_lossy().to_string()); + } + } + }); + + Ok(()) +} + +/// Stop watching the manifest file. +#[tauri::command] +pub fn unwatch_manifest( + state: tauri::State<'_, AppState>, +) -> Result<(), CommandError> { + if let Ok(mut path) = state.watched_manifest_path.lock() { + *path = None; + } + Ok(()) +} diff --git a/crates/diffcore-tauri/src/commands/mod.rs b/crates/diffcore-tauri/src/commands/mod.rs new file mode 100644 index 0000000..8be4a15 --- /dev/null +++ b/crates/diffcore-tauri/src/commands/mod.rs @@ -0,0 +1,2034 @@ +//! Tauri IPC commands — bridge between the React frontend and diffcore-core. +//! +//! Each `#[tauri::command]` function is callable from the frontend via `invoke()`. + +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; + +use log::warn; + +use crate::activity_stream::{self, JobHandle}; + +use diffcore_core::cache; +use diffcore_core::cluster; +use diffcore_core::config::DiffcoreConfig; +use diffcore_core::entrypoint; +use diffcore_core::flow::{self, FlowConfig}; +use diffcore_core::git; +use diffcore_core::graph::SymbolGraph; +use diffcore_core::llm::BackendStatus; +use diffcore_core::output::{self, build_analysis_output}; +use diffcore_core::pipeline; +use diffcore_core::query_engine::QueryEngine; +use diffcore_core::rank; +use diffcore_core::types::{AnalysisOutput, GroupRankInput}; + +/// Application state shared across commands. +pub struct AppState { + /// The most recent analysis result, available for subsequent queries. + pub last_analysis: Mutex>, + /// Cached diff result from the most recent analysis, for instant file diff lookups. + pub last_diff: Mutex>, + /// Background LLM job manager for live SSE activity streams. + pub activity_manager: Arc, + /// Base URL for the embedded localhost SSE server. + pub activity_stream_base_url: Mutex>, + /// Cache key from the most recent analysis, for refinement cache lookups. + pub last_cache_key: Mutex>, + /// Path to the currently watched manifest file. + pub watched_manifest_path: Mutex>, + /// Long-lived QueryEngine instance for on-demand single-file parsing + /// (e.g. the source-explorer outline). Uses internal `OnceCell`s to + /// cache compiled tree-sitter queries per language across calls, so + /// the first parse of any given language pays the compilation cost + /// once for the whole app lifetime. + pub query_engine: Arc, +} + +/// Cached diff result with the parameters that produced it, for cache invalidation. +pub struct CachedDiff { + pub repo_path: PathBuf, + pub base: Option, + pub diff_result: git::DiffResult, +} + +impl AppState { + pub fn new() -> Self { + // Construct the QueryEngine eagerly so the field is non-Optional. + // QueryEngine::new() itself is cheap — per-language tree-sitter + // query compilation is deferred to the first parse of each + // language via internal OnceCells. We fall back to a fresh + // construction on error rather than panicking at startup; in + // practice QueryEngine::new() is infallible today, but the + // Result return type leaves room for future configuration loading. + let query_engine = Arc::new( + QueryEngine::new().unwrap_or_else(|e| { + log::error!("QueryEngine construction failed at startup: {e}"); + // Re-attempt; if this also fails the app cannot parse files + // but other commands continue to work, so we panic only as + // a last resort. (Today new() can't actually fail.) + QueryEngine::new().expect("QueryEngine::new() failed twice") + }), + ); + Self { + last_analysis: Mutex::new(None), + last_diff: Mutex::new(None), + activity_manager: Arc::new(activity_stream::ActivityManager::new()), + activity_stream_base_url: Mutex::new(None), + last_cache_key: Mutex::new(None), + watched_manifest_path: Mutex::new(None), + query_engine, + } + } + + pub fn init_activity_stream(&self) -> Result<(), CommandError> { + let mut base_url = self + .activity_stream_base_url + .lock() + .map_err(|e| CommandError::Analysis(format!("Lock poisoned: {}", e)))?; + if base_url.is_none() { + *base_url = Some( + activity_stream::spawn_sse_server(Arc::clone(&self.activity_manager)) + .map_err(|e| CommandError::Io(format!("Failed to start SSE server: {}", e)))?, + ); + } + Ok(()) + } + + pub fn create_llm_job( + &self, + operation: &str, + provider: &str, + model: &str, + title: &str, + ) -> Result<(JobHandle, AsyncLlmJobStart), CommandError> { + self.init_activity_stream()?; + let base_url = self + .activity_stream_base_url + .lock() + .map_err(|e| CommandError::Analysis(format!("Lock poisoned: {}", e)))? + .clone() + .ok_or_else(|| { + CommandError::Io("Activity SSE server was not initialized".to_string()) + })?; + + let manager = Arc::clone(&self.activity_manager); + let operation = operation.to_string(); + let provider = provider.to_string(); + let model = model.to_string(); + let title = title.to_string(); + let job_operation = operation.clone(); + let job_provider = provider.clone(); + let job_model = model.clone(); + let job_title = title.clone(); + + let handle = tauri::async_runtime::block_on(async move { + manager + .create_job(job_operation, job_provider, job_model, job_title) + .await + }); + + let start = AsyncLlmJobStart { + job_id: handle.job_id().to_string(), + stream_url: format!("{}/llm/jobs/{}/events", base_url, handle.job_id()), + operation, + provider, + model, + title, + }; + + Ok((handle, start)) + } +} + +/// Error type for Tauri commands — must implement `Into`. +#[derive(Debug, thiserror::Error)] +pub enum CommandError { + #[error("Git error: {0}")] + Git(String), + #[error("Analysis error: {0}")] + Analysis(String), + #[error("Config error: {0}")] + Config(String), + #[error("IO error: {0}")] + Io(String), + #[error("LLM error: {0}")] + Llm(String), + #[error("Network error: {0}")] + Network(String), +} + +impl serde::Serialize for CommandError { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(&self.to_string()) + } +} + +/// Analyze a git diff and return semantic flow groups. +/// +/// This is the primary IPC command — equivalent to `diffcore analyze` in the CLI. +/// When `pr_preview` is true, uses merge-base diff (shows what the branch introduces +/// relative to where it diverged from the base). +#[tauri::command] +pub fn analyze( + repo_path: String, + base: Option, + head: Option, + range: Option, + staged: bool, + unstaged: bool, + pr_preview: Option, + include_uncommitted: Option, + state: tauri::State<'_, AppState>, +) -> Result { + let repo_path = PathBuf::from(&repo_path); + let repo_path = std::fs::canonicalize(&repo_path) + .map_err(|e| CommandError::Io(format!("Invalid repo path: {}", e)))?; + + let repo = git2::Repository::discover(&repo_path) + .map_err(|e| CommandError::Git(format!("Not a git repository: {}", e)))?; + + let workdir = repo + .workdir() + .ok_or_else(|| CommandError::Git("Bare repositories are not supported".to_string()))? + .to_path_buf(); + + // Load config + let config = DiffcoreConfig::load_with_global_llm_from_dir(&workdir) + .map_err(|e| CommandError::Config(format!("{}", e)))?; + + // Resolve include_uncommitted: UI override > config > default (true) + let effective_include_uncommitted = include_uncommitted.unwrap_or(config.diff.include_uncommitted); + + // Extract diff + let (diff_result, diff_source) = extract_diff( + &repo, + base.clone(), + head, + range, + staged, + unstaged, + pr_preview.unwrap_or(false), + effective_include_uncommitted, + )?; + + // Cache the diff result for subsequent get_file_diff() calls + match state.last_diff.lock() { + Ok(mut cached) => { + *cached = Some(CachedDiff { + repo_path: repo_path.clone(), + base: base, + diff_result: diff_result.clone(), + }); + } + Err(e) => warn!("Failed to update last_diff state (lock poisoned): {}", e), + } + + if diff_result.files.is_empty() { + let empty_output = AnalysisOutput { + version: "1.0.0".to_string(), + diff_source, + summary: diffcore_core::types::AnalysisSummary { + total_files_changed: 0, + total_groups: 0, + languages_detected: vec![], + frameworks_detected: vec![], + }, + groups: vec![], + infrastructure_group: None, + annotations: None, + }; + match state.last_analysis.lock() { + Ok(mut last) => *last = Some(empty_output.clone()), + Err(e) => warn!( + "Failed to update last_analysis state (lock poisoned): {}", + e + ), + } + return Ok(empty_output); + } + + // Check cache for previously computed results + let cache_key = if staged || unstaged { + cache::compute_cache_key_working_dir(&diff_result, &workdir) + } else { + cache::compute_cache_key(&diff_result) + }; + if let Some(cached) = cache::load_cached(&workdir, &cache_key) { + match state.last_analysis.lock() { + Ok(mut last) => *last = Some(cached.clone()), + Err(e) => warn!( + "Failed to update last_analysis state (lock poisoned): {}", + e + ), + } + if let Ok(mut key) = state.last_cache_key.lock() { + *key = Some(cache_key); + } + return Ok(cached); + } + + // Parse all changed files in parallel + let file_inputs: Vec<(&str, &str)> = diff_result + .files + .iter() + .filter_map(|file_diff| { + let content = file_diff + .new_content + .as_deref() + .or(file_diff.old_content.as_deref())?; + let path = file_diff.path(); + if config.is_ignored(path) { + return None; + } + Some((path, content)) + }) + .collect(); + let parsed_files = pipeline::parse_files_parallel(&file_inputs); + + // Build workspace map for monorepo cross-package import resolution + let workspace_map = diffcore_core::graph::build_workspace_map(&workdir); + + // Build symbol graph + let mut graph = SymbolGraph::build_with_workspace(&parsed_files, &workspace_map); + + // Detect entrypoints + let entrypoints = entrypoint::detect_entrypoints(&parsed_files); + + // Run data flow analysis and enrich graph + let flow_analysis = flow::analyze_data_flow(&parsed_files, &FlowConfig::default()); + flow::enrich_graph(&mut graph, &flow_analysis); + + // Cluster changed files + let changed_files: Vec = diff_result + .files + .iter() + .filter(|f| !config.is_ignored(f.path())) + .map(|f| f.path().to_string()) + .collect(); + let cluster_result = cluster::cluster_files(&graph, &entrypoints, &changed_files); + + // Rank groups + let weights = config.ranking.clone(); + let rank_inputs: Vec = cluster_result + .groups + .iter() + .map(|group| { + let risk_flags = output::compute_group_risk_flags( + &group + .files + .iter() + .map(|f| f.path.as_str()) + .collect::>(), + ); + let total_add: u32 = group.files.iter().map(|f| f.changes.additions).sum(); + let total_del: u32 = group.files.iter().map(|f| f.changes.deletions).sum(); + + GroupRankInput { + group_id: group.id.clone(), + risk: rank::compute_risk_score( + risk_flags.has_schema_change, + risk_flags.has_api_change, + risk_flags.has_auth_change, + false, + ), + centrality: 0.5, + surface_area: rank::compute_surface_area(total_add, total_del, 1000), + uncertainty: if risk_flags.has_test_only { 0.1 } else { 0.5 }, + } + }) + .collect(); + + let ranked = rank::rank_groups(&rank_inputs, &weights); + + // Build output + let analysis_output = build_analysis_output( + &diff_result, + diff_source, + &parsed_files, + &cluster_result, + &ranked, + ); + + // Cache the deterministic analysis result + cache::store_cached(&workdir, &cache_key, &analysis_output); + + // Store cache key for refinement cache lookups + if let Ok(mut key) = state.last_cache_key.lock() { + *key = Some(cache_key); + } + + // Store for subsequent queries + match state.last_analysis.lock() { + Ok(mut last) => *last = Some(analysis_output.clone()), + Err(e) => warn!( + "Failed to update last_analysis state (lock poisoned): {}", + e + ), + } + + Ok(analysis_output) +} + +/// Get the most recent analysis result without re-running. +#[tauri::command] +pub fn get_last_analysis( + state: tauri::State<'_, AppState>, +) -> Result, CommandError> { + let last = state + .last_analysis + .lock() + .map_err(|e| CommandError::Analysis(format!("Lock poisoned: {}", e)))?; + Ok(last.clone()) +} + +/// Generate a Mermaid diagram for a specific group by ID. +#[tauri::command] +pub fn get_mermaid( + group_id: String, + state: tauri::State<'_, AppState>, +) -> Result { + let last = state + .last_analysis + .lock() + .map_err(|e| CommandError::Analysis(format!("Lock poisoned: {}", e)))?; + + let analysis = last.as_ref().ok_or_else(|| { + CommandError::Analysis("No analysis available. Run analyze first.".into()) + })?; + + let group = analysis + .groups + .iter() + .find(|g| g.id == group_id) + .ok_or_else(|| CommandError::Analysis(format!("Group '{}' not found", group_id)))?; + + Ok(output::generate_mermaid(group)) +} + +/// Get the diff content (old + new) for a specific file. +/// Returns the raw old and new content for the Monaco diff viewer. +/// Uses the cached DiffResult from the last `analyze()` call when parameters match, +/// avoiding redundant git diff extraction for every file navigation. +#[tauri::command] +pub fn get_file_diff( + repo_path: String, + file_path: String, + base: Option, + head: Option, + range: Option, + staged: bool, + unstaged: bool, + include_uncommitted: Option, + state: tauri::State<'_, AppState>, +) -> Result { + // Try to use cached diff from the last analyze() call + let cached_file = { + let repo_path_buf = PathBuf::from(&repo_path); + let repo_path_buf = std::fs::canonicalize(&repo_path_buf).ok(); + let cached = state.last_diff.lock().ok(); + cached.and_then(|guard| { + let c = guard.as_ref()?; + let rp = repo_path_buf.as_ref()?; + if &c.repo_path == rp && c.base == base { + c.diff_result + .files + .iter() + .find(|f| f.path() == file_path) + .map(|f| FileDiffContent { + path: file_path.clone(), + old_content: f.old_content.clone().unwrap_or_default(), + new_content: f.new_content.clone().unwrap_or_default(), + language: detect_language(&f.path()), + }) + } else { + None + } + }) + }; + + if let Some(content) = cached_file { + return Ok(content); + } + + // Cache miss — fall back to extracting from git + get_file_diff_uncached(repo_path, file_path, base, head, range, staged, unstaged, include_uncommitted.unwrap_or(true)) +} + +/// Core file diff logic without caching — also callable from integration tests. +pub fn get_file_diff_uncached( + repo_path: String, + file_path: String, + base: Option, + head: Option, + range: Option, + staged: bool, + unstaged: bool, + include_uncommitted: bool, +) -> Result { + // Security: reject paths with traversal components or absolute paths + // to prevent path traversal via IPC from a compromised frontend. + let fp = std::path::Path::new(&file_path); + if fp.is_absolute() + || fp + .components() + .any(|c| c == std::path::Component::ParentDir) + { + return Err(CommandError::Io(format!( + "Invalid file path (path traversal rejected): {}", + file_path + ))); + } + + let repo_path_buf = PathBuf::from(&repo_path); + let repo_path_buf = std::fs::canonicalize(&repo_path_buf) + .map_err(|e| CommandError::Io(format!("Invalid repo path: {}", e)))?; + + let repo = git2::Repository::discover(&repo_path_buf) + .map_err(|e| CommandError::Git(format!("Not a git repository: {}", e)))?; + + let (diff_result, _) = extract_diff(&repo, base, head, range, staged, unstaged, false, include_uncommitted)?; + + let file_diff = diff_result + .files + .iter() + .find(|f| f.path() == file_path) + .ok_or_else(|| CommandError::Analysis(format!("File '{}' not found in diff", file_path)))?; + + Ok(FileDiffContent { + path: file_path, + old_content: file_diff.old_content.clone().unwrap_or_default(), + new_content: file_diff.new_content.clone().unwrap_or_default(), + language: detect_language(&file_diff.path()), + }) +} + +pub(super) fn load_cached_analysis( + state: &tauri::State<'_, AppState>, +) -> Result { + let last = state + .last_analysis + .lock() + .map_err(|e| CommandError::Analysis(format!("Lock poisoned: {}", e)))?; + last.clone() + .ok_or_else(|| CommandError::Analysis("No analysis available. Run analyze first.".into())) +} + +pub(super) fn provider_supports_tool_activity(provider: &str) -> bool { + matches!(provider, "codex" | "claude") +} + + +pub(super) fn load_config_from_path(repo_path: Option<&str>) -> (DiffcoreConfig, Option) { + if let Some(path) = repo_path { + let repo_path = PathBuf::from(path); + if let Ok(canonical) = std::fs::canonicalize(&repo_path) { + if let Ok(repo) = git2::Repository::discover(&canonical) { + if let Some(workdir) = repo.workdir() { + let config = + DiffcoreConfig::load_with_global_llm_from_dir(workdir).unwrap_or_default(); + return (config, Some(workdir.to_path_buf())); + } + } + } + } + (DiffcoreConfig::load_global().unwrap_or_default(), None) +} + +/// Get the default model for a provider. +pub(super) fn default_model_for_provider(provider: &str) -> &str { + match provider { + "codex" => "default", + "claude" => "default", + "anthropic" => "claude-sonnet-4-6", + "openai" => "gpt-4.1", + "gemini" => "gemini-2.5-flash", + "openrouter" => "anthropic/claude-sonnet-4-6", + "github_copilot" => "gpt-4.1", + _ => "default", + } +} + +pub(super) fn default_provider_for_machine( + codex_status: &BackendStatus, + claude_status: &BackendStatus, +) -> &'static str { + if codex_status.authenticated { + "codex" + } else if claude_status.authenticated { + "claude" + } else { + "anthropic" + } +} + +pub(super) fn preferred_provider_for_runtime( + configured_provider: Option<&str>, + codex_status: &BackendStatus, + claude_status: &BackendStatus, +) -> String { + match configured_provider { + Some("codex") if codex_status.authenticated => "codex".to_string(), + Some("claude") if claude_status.authenticated => "claude".to_string(), + Some(provider) if provider_supports_tool_activity(provider) => { + default_provider_for_machine(codex_status, claude_status).to_string() + } + Some(provider) => { + if codex_status.authenticated || claude_status.authenticated { + default_provider_for_machine(codex_status, claude_status).to_string() + } else { + provider.to_string() + } + } + None => default_provider_for_machine(codex_status, claude_status).to_string(), + } +} + +pub(super) fn preferred_model_for_runtime( + configured_model: Option, + configured_provider: Option<&str>, + resolved_provider: &str, +) -> String { + if configured_provider == Some(resolved_provider) { + configured_model + .unwrap_or_else(|| default_model_for_provider(resolved_provider).to_string()) + } else { + default_model_for_provider(resolved_provider).to_string() + } +} + + +/// Open a repository from a path, with canonicalization and error handling. +pub(super) fn open_repo(repo_path: &str) -> Result { + let path = PathBuf::from(repo_path); + let path = std::fs::canonicalize(&path) + .map_err(|e| CommandError::Io(format!("Invalid repo path: {}", e)))?; + git2::Repository::discover(&path) + .map_err(|e| CommandError::Git(format!("Not a git repository: {}", e))) +} + +/// Build a simple unified diff from old and new content. +pub(super) fn simple_unified_diff(old: &str, new: &str) -> String { + let old_lines: Vec<&str> = old.lines().collect(); + let new_lines: Vec<&str> = new.lines().collect(); + let mut result = String::new(); + // Simple approach: show all old lines as removed, all new lines as added + // For a real implementation, use a proper diff algorithm + for line in &old_lines { + result.push_str(&format!("-{}\n", line)); + } + for line in &new_lines { + result.push_str(&format!("+{}\n", line)); + } + result +} + +/// File diff content for the Monaco diff viewer. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct FileDiffContent { + pub path: String, + pub old_content: String, + pub new_content: String, + pub language: String, +} + +// ── Internal helpers ── + +pub(super) fn extract_diff( + repo: &git2::Repository, + base: Option, + head: Option, + range: Option, + staged: bool, + unstaged: bool, + pr_preview: bool, + include_uncommitted: bool, +) -> Result<(git::DiffResult, diffcore_core::types::DiffSource), CommandError> { + if let Some(ref range) = range { + let diff = git::diff_range(repo, range).map_err(|e| CommandError::Git(format!("{}", e)))?; + let source = + output::diff_source_range(range, diff.base_sha.as_deref(), diff.head_sha.as_deref()); + Ok((diff, source)) + } else if staged { + let diff = git::diff_staged(repo).map_err(|e| CommandError::Git(format!("{}", e)))?; + let source = output::diff_source_staged(); + Ok((diff, source)) + } else if unstaged { + let diff = git::diff_unstaged(repo).map_err(|e| CommandError::Git(format!("{}", e)))?; + let source = output::diff_source_unstaged(); + Ok((diff, source)) + } else if pr_preview { + // PR preview mode: use merge-base diff + // Auto-detect default branch if no base ref provided + let detected_default = if base.is_none() { + git::detect_default_branch(repo).ok() + } else { + None + }; + let base_ref = base + .as_deref() + .or(detected_default.as_deref()) + .unwrap_or("main"); + let head_ref = head.as_deref().unwrap_or("HEAD"); + if include_uncommitted { + let diff = + git::diff_merge_base_to_workdir(repo, base_ref, head_ref).map_err(|e| { + CommandError::Git(format!( + "Failed to compute merge-base-to-workdir diff between '{}' and '{}': {}", + base_ref, head_ref, e + )) + })?; + let source = output::diff_source_branch_with_worktree( + base_ref, + diff.base_sha.as_deref(), + ); + Ok((diff, source)) + } else { + let selected = + git::diff_merge_base_with_worktree_fallback(repo, base_ref, head_ref).map_err( + |e| { + CommandError::Git(format!( + "Failed to compute merge-base diff between '{}' and '{}': {}", + base_ref, head_ref, e + )) + }, + )?; + let source = if selected.used_worktree_fallback { + output::diff_source_worktree( + Some(base_ref), + Some(head_ref), + selected.comparison_base_sha.as_deref(), + selected.comparison_head_sha.as_deref(), + ) + } else { + output::diff_source_branch( + base_ref, + head_ref, + selected.diff.base_sha.as_deref(), + selected.diff.head_sha.as_deref(), + ) + }; + Ok((selected.diff, source)) + } + } else { + let base_ref = base.as_deref().unwrap_or("main"); + let head_ref = head.as_deref().unwrap_or("HEAD"); + if include_uncommitted { + let diff = git::diff_branch_to_workdir(repo, base_ref) + .map_err(|e| CommandError::Git(format!("{}", e)))?; + let source = output::diff_source_branch_with_worktree( + base_ref, + diff.base_sha.as_deref(), + ); + Ok((diff, source)) + } else { + let selected = git::diff_refs_with_worktree_fallback(repo, base_ref, head_ref) + .map_err(|e| CommandError::Git(format!("{}", e)))?; + let source = if selected.used_worktree_fallback { + output::diff_source_worktree( + Some(base_ref), + Some(head_ref), + selected.comparison_base_sha.as_deref(), + selected.comparison_head_sha.as_deref(), + ) + } else { + output::diff_source_branch( + base_ref, + head_ref, + selected.diff.base_sha.as_deref(), + selected.diff.head_sha.as_deref(), + ) + }; + Ok((selected.diff, source)) + } + } +} + +pub(super) fn detect_language(path: &str) -> String { + match path.rsplit('.').next() { + // ── Core 13 ───────────────────────────────────────────── + Some("ts" | "tsx") => "typescript".to_string(), + Some("js" | "jsx" | "mjs" | "cjs") => "javascript".to_string(), + Some("py" | "pyi") => "python".to_string(), + Some("go") => "go".to_string(), + Some("rs") => "rust".to_string(), + Some("java") => "java".to_string(), + Some("cs") => "csharp".to_string(), + Some("php") => "php".to_string(), + Some("rb") => "ruby".to_string(), + Some("kt" | "kts") => "kotlin".to_string(), + Some("swift") => "swift".to_string(), + Some("c" | "h") => "c".to_string(), + Some("cpp" | "cc" | "cxx" | "c++" | "hpp" | "hxx" | "h++" | "hh") => "cpp".to_string(), + Some("scala" | "sc") => "scala".to_string(), + // ── Extras (matching the lang-* Cargo features) ───────── + Some("sh" | "bash" | "zsh") => "shell".to_string(), + Some("hs" | "lhs") => "haskell".to_string(), + Some("nix") => "nix".to_string(), + Some("lua") => "lua".to_string(), + Some("pl" | "pm" | "perl") => "perl".to_string(), + Some("ex" | "exs") => "elixir".to_string(), + Some("erl" | "hrl") => "erlang".to_string(), + Some("zig" | "zon") => "zig".to_string(), + Some("ml" | "mli") => "ocaml".to_string(), + Some("jl") => "julia".to_string(), + Some("dart") => "dart".to_string(), + Some("r" | "R") => "r".to_string(), + Some("fish") => "fish".to_string(), + Some("html" | "htm") => "html".to_string(), + Some("css") => "css".to_string(), + Some("scss" | "sass") => "scss".to_string(), + Some("vue") => "vue".to_string(), + Some("svelte") => "svelte".to_string(), + Some("graphql" | "gql") => "graphql".to_string(), + // ── Data formats ──────────────────────────────────────── + Some("json") => "json".to_string(), + Some("toml") => "toml".to_string(), + Some("yaml" | "yml") => "yaml".to_string(), + Some("md" | "markdown") => "markdown".to_string(), + Some("sql") => "sql".to_string(), + Some("prisma") => "prisma".to_string(), + _ => "plaintext".to_string(), + } +} + + +// ── Submodules ────────────────────────────────────────────────────────────── + +pub mod llm; +pub mod workspace; +pub mod settings; +pub mod editor; +pub mod app_state; +pub mod comments; +pub mod manifest; + +// Re-export all public items so `commands::X` still works from main.rs. +pub use llm::{ + start_annotate_overview, annotate_overview, start_annotate_group, annotate_group, + start_refine_groups, refine_groups, RefinementResult, AsyncLlmJobStart, + get_cached_refinement, store_refinement_cache, +}; +pub use workspace::{ + list_branches, list_commits, list_worktrees, get_branch_status, get_repo_info, + get_launch_directory, get_last_diff_file_statuses, cross_file_search, + get_workspace_file_content, parse_file_content, + FileShortStatus, CrossFileSearchMatch, CrossFileSearchResult, RepoInfo, +}; +pub use settings::{ + check_api_key, get_llm_settings, save_llm_settings, save_api_key, clear_api_key, + fetch_provider_models, get_ignore_paths, save_ignore_paths, LlmSettings, +}; +pub use editor::{open_in_editor, check_editors_available, save_file_content}; +pub use app_state::{save_app_state, load_last_app_state}; +pub use comments::{ + save_comment, delete_comment, load_comments, export_comments, + save_comment_cached, load_comments_cached, delete_comment_cached, update_comment_cached, + ReviewComment, CommentsFile, comment_cache_key, +}; +pub use manifest::{ + import_groups_manifest, export_groups_manifest, watch_manifest, unwatch_manifest, +}; + +#[cfg(test)] +#[allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::print_stdout, + clippy::print_stderr +)] +mod tests { + use super::*; + + #[test] + fn test_detect_language_typescript() { + assert_eq!(detect_language("src/app.ts"), "typescript"); + assert_eq!(detect_language("src/App.tsx"), "typescript"); + } + + #[test] + fn test_detect_language_javascript() { + assert_eq!(detect_language("index.js"), "javascript"); + assert_eq!(detect_language("App.jsx"), "javascript"); + } + + #[test] + fn test_detect_language_python() { + assert_eq!(detect_language("main.py"), "python"); + } + + #[test] + fn test_detect_language_rust() { + assert_eq!(detect_language("lib.rs"), "rust"); + } + + #[test] + fn test_detect_language_json() { + assert_eq!(detect_language("package.json"), "json"); + } + + #[test] + fn test_detect_language_unknown() { + assert_eq!(detect_language("Makefile"), "plaintext"); + assert_eq!(detect_language("noext"), "plaintext"); + } + + #[test] + fn test_detect_language_yaml() { + assert_eq!(detect_language("config.yaml"), "yaml"); + assert_eq!(detect_language("ci.yml"), "yaml"); + } + + #[test] + fn test_detect_language_shell() { + assert_eq!(detect_language("run.sh"), "shell"); + assert_eq!(detect_language("init.bash"), "shell"); + } + + #[test] + fn test_detect_language_various() { + assert_eq!(detect_language("main.go"), "go"); + assert_eq!(detect_language("App.java"), "java"); + assert_eq!(detect_language("app.rb"), "ruby"); + assert_eq!(detect_language("schema.prisma"), "prisma"); + assert_eq!(detect_language("query.sql"), "sql"); + assert_eq!(detect_language("style.css"), "css"); + assert_eq!(detect_language("page.html"), "html"); + assert_eq!(detect_language("README.md"), "markdown"); + assert_eq!(detect_language("config.toml"), "toml"); + } + + #[test] + fn test_app_state_new() { + let state = AppState::new(); + let last = state.last_analysis.lock().unwrap(); + assert!(last.is_none()); + } + + #[test] + fn test_command_error_display() { + let err = CommandError::Git("not found".to_string()); + assert_eq!(err.to_string(), "Git error: not found"); + + let err = CommandError::Analysis("no data".to_string()); + assert_eq!(err.to_string(), "Analysis error: no data"); + + let err = CommandError::Config("invalid".to_string()); + assert_eq!(err.to_string(), "Config error: invalid"); + + let err = CommandError::Io("permission denied".to_string()); + assert_eq!(err.to_string(), "IO error: permission denied"); + + let err = CommandError::Llm("no api key".to_string()); + assert_eq!(err.to_string(), "LLM error: no api key"); + } + + #[test] + fn test_command_error_serialize() { + let err = CommandError::Git("test error".to_string()); + let json = serde_json::to_string(&err).unwrap(); + assert_eq!(json, "\"Git error: test error\""); + + let err = CommandError::Llm("rate limited".to_string()); + let json = serde_json::to_string(&err).unwrap(); + assert_eq!(json, "\"LLM error: rate limited\""); + } + + #[test] + fn test_simple_unified_diff_basic() { + let diff = simple_unified_diff("old line", "new line"); + assert!(diff.contains("-old line")); + assert!(diff.contains("+new line")); + } + + #[test] + fn test_simple_unified_diff_empty() { + let diff = simple_unified_diff("", ""); + assert!(diff.is_empty()); + } + + #[test] + fn test_simple_unified_diff_multiline() { + let diff = simple_unified_diff("a\nb", "c\nd\ne"); + assert!(diff.contains("-a\n")); + assert!(diff.contains("-b\n")); + assert!(diff.contains("+c\n")); + assert!(diff.contains("+d\n")); + assert!(diff.contains("+e\n")); + } + + #[test] + fn test_repo_info_serde_roundtrip() { + let info = RepoInfo { + current_branch: Some("feature-branch".to_string()), + default_branch: "main".to_string(), + branches: vec![ + git::BranchInfo { + name: "main".to_string(), + is_current: false, + has_upstream: true, + }, + git::BranchInfo { + name: "feature-branch".to_string(), + is_current: true, + has_upstream: false, + }, + ], + worktrees: vec![git::WorktreeInfo { + path: "/tmp/repo".to_string(), + branch: Some("main".to_string()), + is_main: true, + }], + status: Some(git::BranchStatus { + branch: "feature-branch".to_string(), + upstream: None, + ahead: 0, + behind: 0, + }), + is_worktree: false, + }; + let json = serde_json::to_string(&info).unwrap(); + let back: RepoInfo = serde_json::from_str(&json).unwrap(); + assert_eq!(back.current_branch, Some("feature-branch".to_string())); + assert_eq!(back.default_branch, "main"); + assert_eq!(back.branches.len(), 2); + assert_eq!(back.worktrees.len(), 1); + assert!(back.status.is_some()); + } + + #[test] + fn test_repo_info_no_status() { + let info = RepoInfo { + current_branch: None, + default_branch: "main".to_string(), + branches: vec![], + worktrees: vec![], + status: None, + is_worktree: false, + }; + let json = serde_json::to_string(&info).unwrap(); + let back: RepoInfo = serde_json::from_str(&json).unwrap(); + assert!(back.current_branch.is_none()); + assert!(back.status.is_none()); + } + + #[test] + fn test_check_api_key_no_repo() { + // Without any env vars or config, should return false (no key configured) + // Note: this test may pass or fail depending on whether env vars are set, + // but it should never panic. + let result = check_api_key(None); + assert!(result.is_ok()); + } + + #[test] + fn test_check_api_key_invalid_path() { + // Invalid path should not panic, should return Ok(bool) + let result = check_api_key(Some("/nonexistent/path/to/repo".to_string())); + assert!(result.is_ok()); + } + + #[test] + fn test_llm_settings_serde_roundtrip() { + let settings = LlmSettings { + annotations_enabled: true, + refinement_enabled: false, + provider: "codex".to_string(), + model: "default".to_string(), + api_key_source: "Codex CLI login".to_string(), + has_api_key: true, + refinement_provider: "claude".to_string(), + refinement_model: "default".to_string(), + refinement_max_iterations: 2, + global_config_path: "~/.diffcore/config.toml".to_string(), + codex_available: true, + codex_authenticated: true, + claude_available: true, + claude_authenticated: true, + include_uncommitted: true, + }; + let json = serde_json::to_string(&settings).unwrap(); + let back: LlmSettings = serde_json::from_str(&json).unwrap(); + assert_eq!(back.provider, "codex"); + assert_eq!(back.model, "default"); + assert!(back.annotations_enabled); + assert!(!back.refinement_enabled); + assert!(back.has_api_key); + assert_eq!(back.refinement_provider, "claude"); + assert_eq!(back.refinement_model, "default"); + assert_eq!(back.refinement_max_iterations, 2); + assert!(back.codex_available); + assert!(back.claude_authenticated); + } + + #[test] + fn test_llm_settings_all_providers() { + for provider in &["codex", "claude", "anthropic", "openai", "gemini"] { + let expected = default_model_for_provider(provider); + assert!( + !expected.is_empty(), + "Provider '{}' should have a default model", + provider + ); + } + } + + #[test] + fn test_default_model_for_provider() { + assert_eq!(default_model_for_provider("codex"), "default"); + assert_eq!(default_model_for_provider("claude"), "default"); + assert_eq!(default_model_for_provider("anthropic"), "claude-sonnet-4-6"); + assert_eq!(default_model_for_provider("openai"), "gpt-4.1"); + assert_eq!(default_model_for_provider("gemini"), "gemini-2.5-flash"); + assert_eq!(default_model_for_provider("unknown"), "default"); + } + + #[test] + fn test_preferred_provider_for_runtime_prefers_authenticated_codex_over_direct_api() { + let codex = BackendStatus { + installed: true, + authenticated: true, + }; + let claude = BackendStatus { + installed: true, + authenticated: false, + }; + + assert_eq!( + preferred_provider_for_runtime(Some("openai"), &codex, &claude), + "codex" + ); + assert_eq!( + preferred_provider_for_runtime(Some("anthropic"), &codex, &claude), + "codex" + ); + } + + #[test] + fn test_preferred_model_for_runtime_resets_to_provider_default_when_backend_changes() { + assert_eq!( + preferred_model_for_runtime(Some("gpt-5.4".to_string()), Some("openai"), "codex"), + "default" + ); + assert_eq!( + preferred_model_for_runtime(Some("default".to_string()), Some("codex"), "codex"), + "default" + ); + } + + #[test] + fn test_get_llm_settings_no_repo() { + let result = get_llm_settings(None); + assert!(result.is_ok()); + let settings = result.unwrap(); + assert!(!settings.provider.is_empty()); + assert!(!settings.model.is_empty()); + assert!(!settings.global_config_path.is_empty()); + } + + #[test] + fn test_get_llm_settings_invalid_path() { + let result = get_llm_settings(Some("/nonexistent/path".to_string())); + assert!(result.is_ok()); + let settings = result.unwrap(); + assert!(!settings.provider.is_empty()); + } + + #[test] + fn test_load_config_from_path_none() { + let (_config, workdir) = load_config_from_path(None); + assert!(workdir.is_none()); + } + + #[test] + fn test_load_config_from_path_invalid() { + let (_config, workdir) = load_config_from_path(Some("/nonexistent/path")); + assert!(workdir.is_none()); + } + + #[test] + fn test_refinement_result_serde_roundtrip() { + use diffcore_core::llm::schema::RefinementResponse; + + let result = RefinementResult { + refined_groups: vec![], + infrastructure_group: None, + refinement_response: RefinementResponse { + splits: vec![], + merges: vec![], + re_ranks: vec![], + reclassifications: vec![], + reasoning: "No changes needed".to_string(), + }, + provider: "anthropic".to_string(), + model: "claude-sonnet-4-6".to_string(), + had_changes: false, + warnings: Vec::new(), + }; + let json = serde_json::to_string(&result).unwrap(); + let back: RefinementResult = serde_json::from_str(&json).unwrap(); + assert_eq!(back.provider, "anthropic"); + assert_eq!(back.model, "claude-sonnet-4-6"); + assert!(!back.had_changes); + assert!(back.refined_groups.is_empty()); + assert!(back.infrastructure_group.is_none()); + assert!(back.warnings.is_empty()); + } + + #[test] + fn test_refinement_result_with_changes() { + use diffcore_core::llm::schema::{RefinementNewGroup, RefinementResponse, RefinementSplit}; + use diffcore_core::types::{ChangeStats, FileChange, FileRole, FlowGroup}; + + let result = RefinementResult { + refined_groups: vec![FlowGroup { + id: "g1".to_string(), + name: "Refined group".to_string(), + entrypoint: None, + files: vec![FileChange { + path: "test.ts".to_string(), + flow_position: 0, + role: FileRole::Entrypoint, + changes: ChangeStats { + additions: 10, + deletions: 5, + }, + symbols_changed: vec![], + }], + edges: vec![], + risk_score: 0.5, + review_order: 1, + }], + infrastructure_group: None, + refinement_response: RefinementResponse { + splits: vec![RefinementSplit { + source_group_id: "g1".to_string(), + new_groups: vec![RefinementNewGroup { + name: "Sub A".to_string(), + files: vec!["test.ts".to_string()], + }], + reason: "test split".to_string(), + }], + merges: vec![], + re_ranks: vec![], + reclassifications: vec![], + reasoning: "Split for clarity".to_string(), + }, + provider: "openai".to_string(), + model: "gpt-4.1".to_string(), + had_changes: true, + warnings: Vec::new(), + }; + let json = serde_json::to_string(&result).unwrap(); + let back: RefinementResult = serde_json::from_str(&json).unwrap(); + assert!(back.had_changes); + assert_eq!(back.refined_groups.len(), 1); + assert_eq!(back.refinement_response.splits.len(), 1); + } + + #[test] + fn test_file_diff_content_serde_roundtrip() { + let content = FileDiffContent { + path: "src/main.ts".to_string(), + old_content: "const x = 1;".to_string(), + new_content: "const x = 2;".to_string(), + language: "typescript".to_string(), + }; + let json = serde_json::to_string(&content).unwrap(); + let back: FileDiffContent = serde_json::from_str(&json).unwrap(); + assert_eq!(back.path, "src/main.ts"); + assert_eq!(back.old_content, "const x = 1;"); + assert_eq!(back.new_content, "const x = 2;"); + assert_eq!(back.language, "typescript"); + } + + // ── Error handling edge case tests ──────────────────────────────── + + #[test] + fn test_command_error_all_variants_display() { + let variants = vec![ + CommandError::Git("git error".into()), + CommandError::Analysis("analysis error".into()), + CommandError::Config("config error".into()), + CommandError::Io("io error".into()), + CommandError::Llm("llm error".into()), + ]; + for err in &variants { + let msg = err.to_string(); + assert!(!msg.is_empty()); + // Verify serialization works for all variants (sent to frontend) + let json = serde_json::to_string(err).unwrap(); + assert!(!json.is_empty()); + } + } + + #[test] + fn test_detect_language_edge_cases() { + // Path with multiple dots + assert_eq!(detect_language("my.file.test.ts"), "typescript"); + // Hidden file + assert_eq!(detect_language(".hidden.js"), "javascript"); + // No extension + assert_eq!(detect_language("Makefile"), "plaintext"); + // Empty string + assert_eq!(detect_language(""), "plaintext"); + // Path with spaces + assert_eq!(detect_language("path with spaces/file.ts"), "typescript"); + } + + #[test] + fn test_simple_unified_diff_only_additions() { + let diff = simple_unified_diff("", "new line 1\nnew line 2"); + assert!(diff.contains("+new line 1")); + assert!(diff.contains("+new line 2")); + assert!(!diff.contains("-")); + } + + #[test] + fn test_simple_unified_diff_only_deletions() { + let diff = simple_unified_diff("old line 1\nold line 2", ""); + assert!(diff.contains("-old line 1")); + assert!(diff.contains("-old line 2")); + assert!(!diff.contains("+")); + } + + #[test] + fn test_app_state_mutex_not_poisoned() { + let state = AppState::new(); + // Lock, set, release + { + let mut last = state.last_analysis.lock().unwrap(); + *last = None; + } + // Lock again should succeed + let last = state.last_analysis.lock().unwrap(); + assert!(last.is_none()); + } + + #[test] + fn test_default_model_for_unknown_provider() { + // Unknown providers should get a reasonable default + let model = default_model_for_provider("nonexistent"); + assert!(!model.is_empty()); + } + + #[test] + fn test_open_in_editor_nonexistent_file() { + let result = open_in_editor( + "vscode".to_string(), + "/tmp/__nonexistent_file_12345__".to_string(), + ); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!( + err.contains("File not found"), + "Expected file-not-found error, got: {}", + err + ); + } + + #[test] + fn test_open_in_editor_unknown_editor() { + // Create a temporary file to pass the file-exists check + let tmp = std::env::temp_dir().join("diffcore_test_open_editor"); + std::fs::write(&tmp, "test").unwrap(); + let result = open_in_editor( + "unknown_editor".to_string(), + tmp.to_str().unwrap().to_string(), + ); + std::fs::remove_file(&tmp).ok(); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!( + err.contains("Unknown editor"), + "Expected unknown-editor error, got: {}", + err + ); + } + + // ── Review comment tests ──────────────────────────────────────── + + #[test] + fn test_review_comment_serde_roundtrip() { + let comment = ReviewComment { + id: "c1".to_string(), + comment_type: "code".to_string(), + group_id: "group_1".to_string(), + file_path: Some("src/auth.ts".to_string()), + start_line: Some(42), + end_line: Some(58), + selected_code: Some("function validate() {}".to_string()), + text: "Missing validation".to_string(), + created_at: "2026-03-20T14:30:00Z".to_string(), + }; + let json = serde_json::to_string(&comment).unwrap(); + let back: ReviewComment = serde_json::from_str(&json).unwrap(); + assert_eq!(back.id, "c1"); + assert_eq!(back.comment_type, "code"); + assert_eq!(back.group_id, "group_1"); + assert_eq!(back.file_path, Some("src/auth.ts".to_string())); + assert_eq!(back.start_line, Some(42)); + assert_eq!(back.end_line, Some(58)); + assert_eq!( + back.selected_code, + Some("function validate() {}".to_string()) + ); + assert_eq!(back.text, "Missing validation"); + } + + #[test] + fn test_review_comment_file_level() { + let comment = ReviewComment { + id: "c2".to_string(), + comment_type: "file".to_string(), + group_id: "group_1".to_string(), + file_path: Some("src/auth.ts".to_string()), + start_line: None, + end_line: None, + selected_code: None, + text: "Should we add rate limiting?".to_string(), + created_at: "2026-03-20T14:30:00Z".to_string(), + }; + let json = serde_json::to_string(&comment).unwrap(); + let back: ReviewComment = serde_json::from_str(&json).unwrap(); + assert_eq!(back.comment_type, "file"); + assert!(back.start_line.is_none()); + assert!(back.selected_code.is_none()); + } + + #[test] + fn test_review_comment_group_level() { + let comment = ReviewComment { + id: "c3".to_string(), + comment_type: "group".to_string(), + group_id: "group_1".to_string(), + file_path: None, + start_line: None, + end_line: None, + selected_code: None, + text: "Overall looks good".to_string(), + created_at: "2026-03-20T14:31:00Z".to_string(), + }; + let json = serde_json::to_string(&comment).unwrap(); + let back: ReviewComment = serde_json::from_str(&json).unwrap(); + assert_eq!(back.comment_type, "group"); + assert!(back.file_path.is_none()); + } + + #[test] + fn test_comments_file_serde_roundtrip() { + let comments_file = CommentsFile { + analysis_hash: "abc123".to_string(), + comments: vec![ + ReviewComment { + id: "c1".to_string(), + comment_type: "code".to_string(), + group_id: "group_1".to_string(), + file_path: Some("src/auth.ts".to_string()), + start_line: Some(42), + end_line: Some(58), + selected_code: Some("fn validate()".to_string()), + text: "Missing validation".to_string(), + created_at: "2026-03-20T14:30:00Z".to_string(), + }, + ReviewComment { + id: "c2".to_string(), + comment_type: "group".to_string(), + group_id: "group_1".to_string(), + file_path: None, + start_line: None, + end_line: None, + selected_code: None, + text: "Needs review".to_string(), + created_at: "2026-03-20T14:31:00Z".to_string(), + }, + ], + }; + let json = serde_json::to_string_pretty(&comments_file).unwrap(); + let back: CommentsFile = serde_json::from_str(&json).unwrap(); + assert_eq!(back.analysis_hash, "abc123"); + assert_eq!(back.comments.len(), 2); + assert_eq!(back.comments[0].comment_type, "code"); + assert_eq!(back.comments[1].comment_type, "group"); + } + + #[test] + fn test_review_comment_json_type_field() { + // Verify the "type" field is correctly renamed from comment_type + let comment = ReviewComment { + id: "c1".to_string(), + comment_type: "code".to_string(), + group_id: "g1".to_string(), + file_path: None, + start_line: None, + end_line: None, + selected_code: None, + text: "test".to_string(), + created_at: "2026-03-20T14:30:00Z".to_string(), + }; + let json = serde_json::to_string(&comment).unwrap(); + assert!( + json.contains("\"type\":\"code\""), + "JSON should use 'type' not 'comment_type': {}", + json + ); + // Verify deserialization from "type" field + let back: ReviewComment = serde_json::from_str(&json).unwrap(); + assert_eq!(back.comment_type, "code"); + } + + // ── Open-in-editor / editor detection tests ───────────────────── + + #[test] + fn test_check_editors_available_returns_all_editor_ids() { + let result = check_editors_available(); + // Should always contain all 5 editor IDs + for id in &["vscode", "cursor", "zed", "vim", "terminal"] { + assert!(result.contains_key(*id), "Missing editor id: {}", id); + } + // Terminal should always be available + assert_eq!(result["terminal"], true); + } + + /// Gated behind `DIFFCORE_RUN_EDITOR_TESTS=1` because it actually spawns editor processes. + #[test] + fn test_open_in_editor_all_known_editors_accept_temp_file() { + if std::env::var("DIFFCORE_RUN_EDITOR_TESTS").is_err() { + eprintln!("Skipped: set DIFFCORE_RUN_EDITOR_TESTS=1 to run (launches real editors)"); + return; + } + + // All known editor IDs should not return "Unknown editor" for a valid file + let tmp = std::env::temp_dir().join("diffcore_test_known_editors"); + std::fs::write(&tmp, "test").unwrap(); + let path = tmp.to_str().unwrap().to_string(); + + for editor in &["vscode", "cursor", "zed", "vim", "terminal"] { + let result = open_in_editor(editor.to_string(), path.clone()); + // Result may be Ok (if editor is installed) or Err (not installed), + // but should never be "Unknown editor" + if let Err(e) = &result { + let msg = e.to_string(); + assert!( + !msg.contains("Unknown editor"), + "Editor '{}' treated as unknown: {}", + editor, + msg + ); + } + } + + std::fs::remove_file(&tmp).ok(); + } + + // ── Update Comment Tests ── + + #[test] + fn test_update_comment_cached_changes_text() { + let dir = tempfile::tempdir().unwrap(); + let repo_path = init_test_repo(dir.path()); + + let comment = ReviewComment { + id: "test_update_1".to_string(), + comment_type: "code".to_string(), + group_id: "g1".to_string(), + file_path: Some("src/main.ts".to_string()), + start_line: Some(10), + end_line: Some(15), + selected_code: Some("const x = 1;".to_string()), + text: "Original text".to_string(), + created_at: "2026-01-01T00:00:00Z".to_string(), + }; + + // Save then update + save_comment_cached(repo_path.clone(), comment).unwrap(); + update_comment_cached(repo_path.clone(), "test_update_1".to_string(), "Updated text".to_string()).unwrap(); + + let loaded = load_comments_cached(repo_path).unwrap(); + assert_eq!(loaded.len(), 1); + assert_eq!(loaded[0].text, "Updated text"); + assert_eq!(loaded[0].id, "test_update_1"); + } + + #[test] + fn test_update_comment_preserves_other_fields() { + let dir = tempfile::tempdir().unwrap(); + let repo_path = init_test_repo(dir.path()); + + let comment = ReviewComment { + id: "test_preserve_1".to_string(), + comment_type: "code".to_string(), + group_id: "group-abc".to_string(), + file_path: Some("src/handler.ts".to_string()), + start_line: Some(42), + end_line: Some(50), + selected_code: Some("function handler() {}".to_string()), + text: "Before update".to_string(), + created_at: "2026-03-15T12:00:00Z".to_string(), + }; + + save_comment_cached(repo_path.clone(), comment).unwrap(); + update_comment_cached(repo_path.clone(), "test_preserve_1".to_string(), "After update".to_string()).unwrap(); + + let loaded = load_comments_cached(repo_path).unwrap(); + let c = &loaded[0]; + assert_eq!(c.text, "After update"); + assert_eq!(c.comment_type, "code"); + assert_eq!(c.group_id, "group-abc"); + assert_eq!(c.file_path, Some("src/handler.ts".to_string())); + assert_eq!(c.start_line, Some(42)); + assert_eq!(c.end_line, Some(50)); + assert_eq!(c.selected_code, Some("function handler() {}".to_string())); + assert_eq!(c.created_at, "2026-03-15T12:00:00Z"); + } + + #[test] + fn test_update_nonexistent_comment_is_noop() { + let dir = tempfile::tempdir().unwrap(); + let repo_path = init_test_repo(dir.path()); + + let comment = ReviewComment { + id: "existing_1".to_string(), + comment_type: "file".to_string(), + group_id: "g1".to_string(), + file_path: Some("test.ts".to_string()), + start_line: None, + end_line: None, + selected_code: None, + text: "Should not change".to_string(), + created_at: "2026-01-01T00:00:00Z".to_string(), + }; + + save_comment_cached(repo_path.clone(), comment).unwrap(); + // Update a non-existent ID + update_comment_cached(repo_path.clone(), "nonexistent_id".to_string(), "New text".to_string()).unwrap(); + + let loaded = load_comments_cached(repo_path).unwrap(); + assert_eq!(loaded.len(), 1); + assert_eq!(loaded[0].text, "Should not change"); + } + + #[test] + fn test_update_comment_among_multiple() { + let dir = tempfile::tempdir().unwrap(); + let repo_path = init_test_repo(dir.path()); + + for i in 1..=5 { + let comment = ReviewComment { + id: format!("multi_{}", i), + comment_type: "code".to_string(), + group_id: "g1".to_string(), + file_path: Some("src/main.ts".to_string()), + start_line: Some(i * 10), + end_line: Some(i * 10 + 5), + selected_code: None, + text: format!("Comment {}", i), + created_at: "2026-01-01T00:00:00Z".to_string(), + }; + save_comment_cached(repo_path.clone(), comment).unwrap(); + } + + // Update only the 3rd comment + update_comment_cached(repo_path.clone(), "multi_3".to_string(), "Updated comment 3".to_string()).unwrap(); + + let loaded = load_comments_cached(repo_path).unwrap(); + assert_eq!(loaded.len(), 5); + assert_eq!(loaded[0].text, "Comment 1"); + assert_eq!(loaded[1].text, "Comment 2"); + assert_eq!(loaded[2].text, "Updated comment 3"); + assert_eq!(loaded[3].text, "Comment 4"); + assert_eq!(loaded[4].text, "Comment 5"); + } + + #[test] + fn test_update_comment_with_empty_text() { + let dir = tempfile::tempdir().unwrap(); + let repo_path = init_test_repo(dir.path()); + + let comment = ReviewComment { + id: "empty_text_1".to_string(), + comment_type: "file".to_string(), + group_id: "g1".to_string(), + file_path: Some("file.ts".to_string()), + start_line: None, + end_line: None, + selected_code: None, + text: "Has text".to_string(), + created_at: "2026-01-01T00:00:00Z".to_string(), + }; + + save_comment_cached(repo_path.clone(), comment).unwrap(); + update_comment_cached(repo_path.clone(), "empty_text_1".to_string(), "".to_string()).unwrap(); + + let loaded = load_comments_cached(repo_path).unwrap(); + assert_eq!(loaded[0].text, ""); + } + + #[test] + fn test_update_comment_with_special_characters() { + let dir = tempfile::tempdir().unwrap(); + let repo_path = init_test_repo(dir.path()); + + let comment = ReviewComment { + id: "special_chars_1".to_string(), + comment_type: "code".to_string(), + group_id: "g1".to_string(), + file_path: Some("src/main.ts".to_string()), + start_line: Some(1), + end_line: Some(5), + selected_code: None, + text: "Plain text".to_string(), + created_at: "2026-01-01T00:00:00Z".to_string(), + }; + + save_comment_cached(repo_path.clone(), comment).unwrap(); + let special_text = "Contains \"quotes\", newlines\n\ttabs, unicode: 🦀, and & entities"; + update_comment_cached(repo_path.clone(), "special_chars_1".to_string(), special_text.to_string()).unwrap(); + + let loaded = load_comments_cached(repo_path).unwrap(); + assert_eq!(loaded[0].text, special_text); + } + + #[test] + fn test_update_then_delete_comment() { + let dir = tempfile::tempdir().unwrap(); + let repo_path = init_test_repo(dir.path()); + + let comment = ReviewComment { + id: "update_delete_1".to_string(), + comment_type: "file".to_string(), + group_id: "g1".to_string(), + file_path: Some("test.ts".to_string()), + start_line: None, + end_line: None, + selected_code: None, + text: "Will be updated then deleted".to_string(), + created_at: "2026-01-01T00:00:00Z".to_string(), + }; + + save_comment_cached(repo_path.clone(), comment).unwrap(); + update_comment_cached(repo_path.clone(), "update_delete_1".to_string(), "Updated".to_string()).unwrap(); + delete_comment_cached(repo_path.clone(), "update_delete_1".to_string()).unwrap(); + + let loaded = load_comments_cached(repo_path).unwrap(); + assert!(loaded.is_empty()); + } + + #[test] + fn test_multiple_updates_to_same_comment() { + let dir = tempfile::tempdir().unwrap(); + let repo_path = init_test_repo(dir.path()); + + let comment = ReviewComment { + id: "multi_update_1".to_string(), + comment_type: "code".to_string(), + group_id: "g1".to_string(), + file_path: Some("src/main.ts".to_string()), + start_line: Some(1), + end_line: Some(3), + selected_code: None, + text: "Version 1".to_string(), + created_at: "2026-01-01T00:00:00Z".to_string(), + }; + + save_comment_cached(repo_path.clone(), comment).unwrap(); + + for i in 2..=10 { + update_comment_cached(repo_path.clone(), "multi_update_1".to_string(), format!("Version {}", i)).unwrap(); + } + + let loaded = load_comments_cached(repo_path).unwrap(); + assert_eq!(loaded.len(), 1); + assert_eq!(loaded[0].text, "Version 10"); + } + + #[test] + fn test_update_comment_on_empty_cache() { + let dir = tempfile::tempdir().unwrap(); + let repo_path = init_test_repo(dir.path()); + + // Update on empty cache should succeed (no comment found, noop) + let result = update_comment_cached(repo_path.clone(), "no_such_id".to_string(), "text".to_string()); + assert!(result.is_ok()); + + let loaded = load_comments_cached(repo_path).unwrap(); + assert!(loaded.is_empty()); + } + + #[test] + fn test_update_comment_cached_invalid_repo() { + let result = update_comment_cached( + "/nonexistent/repo/path".to_string(), + "id".to_string(), + "text".to_string(), + ); + assert!(result.is_err()); + } + + // ── ReviewComment Serde Tests ── + + #[test] + fn test_review_comment_serde_all_fields() { + let comment = ReviewComment { + id: "c1".to_string(), + comment_type: "code".to_string(), + group_id: "g1".to_string(), + file_path: Some("src/main.ts".to_string()), + start_line: Some(10), + end_line: Some(20), + selected_code: Some("const x = 1;".to_string()), + text: "This needs refactoring".to_string(), + created_at: "2026-04-06T12:00:00Z".to_string(), + }; + + let json = serde_json::to_string(&comment).unwrap(); + let back: ReviewComment = serde_json::from_str(&json).unwrap(); + assert_eq!(back.id, "c1"); + assert_eq!(back.comment_type, "code"); + assert_eq!(back.group_id, "g1"); + assert_eq!(back.file_path, Some("src/main.ts".to_string())); + assert_eq!(back.start_line, Some(10)); + assert_eq!(back.end_line, Some(20)); + assert_eq!(back.selected_code, Some("const x = 1;".to_string())); + assert_eq!(back.text, "This needs refactoring"); + assert_eq!(back.created_at, "2026-04-06T12:00:00Z"); + } + + #[test] + fn test_review_comment_serde_minimal_fields() { + let comment = ReviewComment { + id: "c2".to_string(), + comment_type: "group".to_string(), + group_id: "g2".to_string(), + file_path: None, + start_line: None, + end_line: None, + selected_code: None, + text: "Group-level comment".to_string(), + created_at: "2026-01-01T00:00:00Z".to_string(), + }; + + let json = serde_json::to_string(&comment).unwrap(); + let back: ReviewComment = serde_json::from_str(&json).unwrap(); + assert_eq!(back.comment_type, "group"); + assert!(back.file_path.is_none()); + assert!(back.start_line.is_none()); + assert!(back.end_line.is_none()); + assert!(back.selected_code.is_none()); + } + + #[test] + fn test_review_comment_type_rename_in_json() { + // The `comment_type` field is serialized as `type` in JSON (via serde rename) + let comment = ReviewComment { + id: "c3".to_string(), + comment_type: "file".to_string(), + group_id: "g1".to_string(), + file_path: Some("test.ts".to_string()), + start_line: None, + end_line: None, + selected_code: None, + text: "File comment".to_string(), + created_at: "2026-01-01T00:00:00Z".to_string(), + }; + + let json = serde_json::to_string(&comment).unwrap(); + assert!(json.contains(r#""type":"file""#)); + assert!(!json.contains("comment_type")); + } + + // ── LLM Settings Annotations Tests ── + + #[test] + fn test_llm_settings_annotations_enabled_roundtrip_true() { + let settings = LlmSettings { + annotations_enabled: true, + refinement_enabled: true, + provider: "codex".to_string(), + model: "default".to_string(), + api_key_source: "test".to_string(), + has_api_key: true, + refinement_provider: "codex".to_string(), + refinement_model: "default".to_string(), + refinement_max_iterations: 1, + global_config_path: "~/.diffcore/config.toml".to_string(), + codex_available: false, + codex_authenticated: false, + claude_available: false, + claude_authenticated: false, + include_uncommitted: true, + }; + let json = serde_json::to_string(&settings).unwrap(); + let back: LlmSettings = serde_json::from_str(&json).unwrap(); + assert!(back.annotations_enabled); + assert!(back.refinement_enabled); + } + + #[test] + fn test_llm_settings_annotations_enabled_roundtrip_false() { + let settings = LlmSettings { + annotations_enabled: false, + refinement_enabled: false, + provider: "anthropic".to_string(), + model: "claude-sonnet-4-6".to_string(), + api_key_source: "env".to_string(), + has_api_key: false, + refinement_provider: "anthropic".to_string(), + refinement_model: "claude-sonnet-4-6".to_string(), + refinement_max_iterations: 3, + global_config_path: "/tmp/config.toml".to_string(), + codex_available: true, + codex_authenticated: true, + claude_available: true, + claude_authenticated: true, + include_uncommitted: false, + }; + let json = serde_json::to_string(&settings).unwrap(); + let back: LlmSettings = serde_json::from_str(&json).unwrap(); + assert!(!back.annotations_enabled); + assert!(!back.refinement_enabled); + } + + #[test] + fn test_llm_settings_all_fields_present_in_json() { + let settings = LlmSettings { + annotations_enabled: true, + refinement_enabled: true, + provider: "openai".to_string(), + model: "gpt-4.1".to_string(), + api_key_source: "config".to_string(), + has_api_key: true, + refinement_provider: "gemini".to_string(), + refinement_model: "gemini-2.5-flash".to_string(), + refinement_max_iterations: 2, + global_config_path: "~/.diffcore/config.toml".to_string(), + codex_available: true, + codex_authenticated: false, + claude_available: true, + claude_authenticated: true, + include_uncommitted: true, + }; + let json = serde_json::to_string(&settings).unwrap(); + assert!(json.contains("annotations_enabled")); + assert!(json.contains("refinement_enabled")); + assert!(json.contains("provider")); + assert!(json.contains("model")); + assert!(json.contains("has_api_key")); + assert!(json.contains("refinement_provider")); + assert!(json.contains("refinement_model")); + assert!(json.contains("refinement_max_iterations")); + assert!(json.contains("global_config_path")); + assert!(json.contains("include_uncommitted")); + } + + // ── Comment CRUD Integration Tests ── + + #[test] + fn test_full_comment_lifecycle_save_load_update_delete() { + let dir = tempfile::tempdir().unwrap(); + let repo_path = init_test_repo(dir.path()); + + // 1. Start empty + let loaded = load_comments_cached(repo_path.clone()).unwrap(); + assert!(loaded.is_empty()); + + // 2. Save two comments + let c1 = ReviewComment { + id: "lifecycle_1".to_string(), + comment_type: "code".to_string(), + group_id: "g1".to_string(), + file_path: Some("src/a.ts".to_string()), + start_line: Some(5), + end_line: Some(10), + selected_code: Some("let a = 1;".to_string()), + text: "First comment".to_string(), + created_at: "2026-01-01T00:00:00Z".to_string(), + }; + let c2 = ReviewComment { + id: "lifecycle_2".to_string(), + comment_type: "file".to_string(), + group_id: "g1".to_string(), + file_path: Some("src/b.ts".to_string()), + start_line: None, + end_line: None, + selected_code: None, + text: "Second comment".to_string(), + created_at: "2026-01-01T01:00:00Z".to_string(), + }; + save_comment_cached(repo_path.clone(), c1).unwrap(); + save_comment_cached(repo_path.clone(), c2).unwrap(); + + let loaded = load_comments_cached(repo_path.clone()).unwrap(); + assert_eq!(loaded.len(), 2); + + // 3. Update first comment + update_comment_cached(repo_path.clone(), "lifecycle_1".to_string(), "Edited first".to_string()).unwrap(); + let loaded = load_comments_cached(repo_path.clone()).unwrap(); + assert_eq!(loaded[0].text, "Edited first"); + assert_eq!(loaded[1].text, "Second comment"); + + // 4. Delete second comment + delete_comment_cached(repo_path.clone(), "lifecycle_2".to_string()).unwrap(); + let loaded = load_comments_cached(repo_path.clone()).unwrap(); + assert_eq!(loaded.len(), 1); + assert_eq!(loaded[0].id, "lifecycle_1"); + + // 5. Update the remaining comment again + update_comment_cached(repo_path.clone(), "lifecycle_1".to_string(), "Final edit".to_string()).unwrap(); + let loaded = load_comments_cached(repo_path.clone()).unwrap(); + assert_eq!(loaded[0].text, "Final edit"); + + // 6. Delete last comment + delete_comment_cached(repo_path.clone(), "lifecycle_1".to_string()).unwrap(); + let loaded = load_comments_cached(repo_path).unwrap(); + assert!(loaded.is_empty()); + } + + #[test] + fn test_comment_types_code_file_group() { + let dir = tempfile::tempdir().unwrap(); + let repo_path = init_test_repo(dir.path()); + + let types = vec!["code", "file", "group"]; + for (i, t) in types.iter().enumerate() { + let comment = ReviewComment { + id: format!("type_test_{}", i), + comment_type: t.to_string(), + group_id: "g1".to_string(), + file_path: if *t != "group" { Some("test.ts".to_string()) } else { None }, + start_line: if *t == "code" { Some(1) } else { None }, + end_line: if *t == "code" { Some(5) } else { None }, + selected_code: if *t == "code" { Some("code".to_string()) } else { None }, + text: format!("{} comment", t), + created_at: "2026-01-01T00:00:00Z".to_string(), + }; + save_comment_cached(repo_path.clone(), comment).unwrap(); + } + + let loaded = load_comments_cached(repo_path).unwrap(); + assert_eq!(loaded.len(), 3); + assert_eq!(loaded[0].comment_type, "code"); + assert_eq!(loaded[1].comment_type, "file"); + assert_eq!(loaded[2].comment_type, "group"); + } + + /// Helper to create a minimal git repo for comment cache tests. + fn init_test_repo(dir: &std::path::Path) -> String { + use std::process::Command; + Command::new("git") + .args(["init"]) + .current_dir(dir) + .output() + .unwrap(); + Command::new("git") + .args(["commit", "--allow-empty", "-m", "init"]) + .current_dir(dir) + .output() + .unwrap(); + dir.to_str().unwrap().to_string() + } +} diff --git a/crates/diffcore-tauri/src/commands/settings.rs b/crates/diffcore-tauri/src/commands/settings.rs new file mode 100644 index 0000000..14ff691 --- /dev/null +++ b/crates/diffcore-tauri/src/commands/settings.rs @@ -0,0 +1,279 @@ +//! LLM settings, API key, and configuration commands. + +use std::path::PathBuf; + +use diffcore_core::config::DiffcoreConfig; +use diffcore_core::llm; + +use super::CommandError; + +/// Check whether LLM access is configured and available. +/// +/// This includes API-key-based providers plus subscription-backed Codex/Claude CLIs. +#[tauri::command] +pub fn check_api_key(repo_path: Option) -> Result { + Ok(get_llm_settings(repo_path)?.has_api_key) +} + +/// Get LLM settings from the shared global config plus repo-local overrides. +/// +/// Reads `~/.diffcore/config.toml`, merges in any repo-local `[llm]` overrides, resolves +/// CLI/API availability, and returns a unified `LlmSettings` struct for the settings panel. +#[tauri::command] +pub fn get_llm_settings(repo_path: Option) -> Result { + let (config, workdir) = super::load_config_from_path(repo_path.as_deref()); + let codex_status = llm::codex_cli::detect_status(); + let claude_status = llm::claude_cli::detect_status(); + + let configured_provider = config.llm.provider.as_deref(); + let provider = + super::preferred_provider_for_runtime(configured_provider, &codex_status, &claude_status); + let model = + super::preferred_model_for_runtime(config.llm.model.clone(), configured_provider, &provider); + + let has_api_key = match provider.as_str() { + "codex" => codex_status.authenticated, + "claude" => claude_status.authenticated, + _ => llm::resolve_api_key(&config.llm, &provider).is_ok(), + }; + + let api_key_source = match provider.as_str() { + "codex" => match (codex_status.installed, codex_status.authenticated) { + (true, true) => "Codex CLI login".to_string(), + (true, false) => "Codex CLI installed, not logged in".to_string(), + (false, _) => "Codex CLI not installed".to_string(), + }, + "claude" => match (claude_status.installed, claude_status.authenticated) { + (true, true) => "Claude Code subscription".to_string(), + (true, false) => "Claude Code installed, not logged in".to_string(), + (false, _) => "Claude Code not installed".to_string(), + }, + _ if config.llm.key_cmd.is_some() => "key_cmd".to_string(), + _ if config.llm.key.as_ref().is_some_and(|k| !k.is_empty()) => { + "~/.diffcore/config.toml".to_string() + } + _ if std::env::var("DIFFCORE_API_KEY").is_ok() => "DIFFCORE_API_KEY".to_string(), + _ => { + let env_var = match provider.as_str() { + "anthropic" => "ANTHROPIC_API_KEY", + "openai" => "OPENAI_API_KEY", + "gemini" => "GEMINI_API_KEY", + "openrouter" => "OPENROUTER_API_KEY", + "github_copilot" => "GITHUB_COPILOT_TOKEN", + _ => "none", + }; + if std::env::var(env_var).is_ok() { + env_var.to_string() + } else if workdir.is_some() { + "none (configure in ~/.diffcore/config.toml or env)".to_string() + } else { + "none".to_string() + } + } + }; + + let configured_refinement_provider = config + .llm + .refinement + .provider + .as_deref() + .or(configured_provider); + let refinement_provider = super::preferred_provider_for_runtime( + configured_refinement_provider, + &codex_status, + &claude_status, + ); + let refinement_model = super::preferred_model_for_runtime( + config + .llm + .refinement + .model + .clone() + .or(config.llm.model.clone()), + configured_refinement_provider, + &refinement_provider, + ); + + Ok(LlmSettings { + annotations_enabled: config.llm.annotations_enabled, + refinement_enabled: config.llm.refinement.enabled, + provider, + model, + api_key_source, + has_api_key, + refinement_provider, + refinement_model, + refinement_max_iterations: config.llm.refinement.max_iterations, + global_config_path: display_global_config_path(), + codex_available: codex_status.installed, + codex_authenticated: codex_status.authenticated, + claude_available: claude_status.installed, + claude_authenticated: claude_status.authenticated, + include_uncommitted: config.diff.include_uncommitted, + }) +} + +/// Save LLM settings to the shared global config. +/// +/// Loads the existing global config, updates the `[llm]` section with the provided +/// settings, and writes back to `~/.diffcore/config.toml`. +#[tauri::command] +pub fn save_llm_settings(_repo_path: String, settings: LlmSettings) -> Result<(), CommandError> { + let mut config = + DiffcoreConfig::load_global().map_err(|e| CommandError::Config(format!("{}", e)))?; + + // Update LLM section + config.llm.provider = Some(settings.provider); + config.llm.model = Some(settings.model); + // Don't overwrite key_cmd — that's managed manually + config.llm.refinement.enabled = settings.refinement_enabled; + config.llm.refinement.provider = Some(settings.refinement_provider); + config.llm.refinement.model = Some(settings.refinement_model); + config.llm.refinement.max_iterations = settings.refinement_max_iterations; + config.llm.annotations_enabled = settings.annotations_enabled; + + // Update diff behavior + config.diff.include_uncommitted = settings.include_uncommitted; + + config + .save_global() + .map_err(|e| CommandError::Config(format!("Failed to save config: {}", e)))?; + + Ok(()) +} + +/// Save an API key to `~/.diffcore/config.toml` under `[llm] key = "..."`. +/// +/// The key is stored directly in the config file. Precedence is maintained: +/// `key_cmd` > `key` (config) > env vars. +#[tauri::command] +pub fn save_api_key(_repo_path: String, api_key: String) -> Result<(), CommandError> { + let mut config = + DiffcoreConfig::load_global().map_err(|e| CommandError::Config(format!("{}", e)))?; + + config.llm.key = Some(api_key); + + config + .save_global() + .map_err(|e| CommandError::Config(format!("Failed to save config: {}", e)))?; + + Ok(()) +} + +/// Remove the stored API key from `~/.diffcore/config.toml`. +#[tauri::command] +pub fn clear_api_key(_repo_path: String) -> Result<(), CommandError> { + let mut config = + DiffcoreConfig::load_global().map_err(|e| CommandError::Config(format!("{}", e)))?; + + config.llm.key = None; + + config + .save_global() + .map_err(|e| CommandError::Config(format!("Failed to save config: {}", e)))?; + + Ok(()) +} + +/// Re-export the shared `ModelInfo` type for the Tauri frontend. +pub use llm::models::ModelInfo; + +/// Fetch available models from a provider's API. +/// +/// Delegates to the shared `diffcore-core` model listing module, which handles +/// caching, API key resolution, and provider-specific fetching. +/// Pass `force_refresh: true` to bypass the 24-hour cache. +#[tauri::command] +pub async fn fetch_provider_models( + provider: String, + force_refresh: bool, +) -> Result, CommandError> { + llm::models::fetch_provider_models(&provider, force_refresh) + .await + .map_err(|e| match e { + llm::models::ModelListError::Network(msg) => CommandError::Network(msg), + llm::models::ModelListError::Config(msg) => CommandError::Config(msg), + llm::models::ModelListError::UnknownProvider(p) => { + CommandError::Config(format!("Unknown provider: {}", p)) + } + }) +} + +/// Get the current ignore paths from `.diffcore.toml`. +#[tauri::command] +pub fn get_ignore_paths(repo_path: Option) -> Result, CommandError> { + let (config, _workdir) = super::load_config_from_path(repo_path.as_deref()); + Ok(config.ignore.paths) +} + +/// Save ignore paths to `.diffcore.toml`. +/// +/// Loads the existing config (preserving other sections), updates the ignore +/// paths, and writes back. +#[tauri::command] +pub fn save_ignore_paths(repo_path: String, paths: Vec) -> Result<(), CommandError> { + let repo_path_buf = PathBuf::from(&repo_path); + let repo_path_buf = std::fs::canonicalize(&repo_path_buf) + .map_err(|e| CommandError::Io(format!("Invalid repo path: {}", e)))?; + let repo = git2::Repository::discover(&repo_path_buf) + .map_err(|e| CommandError::Git(format!("Not a git repository: {}", e)))?; + let workdir = repo + .workdir() + .ok_or_else(|| CommandError::Git("Bare repositories are not supported".to_string()))?; + + let mut config = DiffcoreConfig::load_from_dir(workdir) + .map_err(|e| CommandError::Config(format!("{}", e)))?; + + config.ignore.paths = paths; + + config + .save_to_dir(workdir) + .map_err(|e| CommandError::Config(format!("Failed to save config: {}", e)))?; + + Ok(()) +} + +/// LLM settings for the UI — surface for the settings panel. +/// +/// Contains the current provider/model configuration, API key status, +/// and annotation/refinement toggle states. Returned by `get_llm_settings` +/// and accepted by `save_llm_settings`. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct LlmSettings { + /// Whether LLM annotations are enabled (controls visibility of Summarize PR / Analyze buttons). + pub annotations_enabled: bool, + /// Whether LLM refinement is enabled. + pub refinement_enabled: bool, + /// Selected LLM backend: subscription-backed CLI or direct API provider. + pub provider: String, + /// Selected model identifier. + pub model: String, + /// How the API key is configured. + pub api_key_source: String, + /// Whether an API key is actually available (resolvable). + pub has_api_key: bool, + /// Refinement provider (can differ from annotation provider). + pub refinement_provider: String, + /// Refinement model. + pub refinement_model: String, + /// Maximum refinement iterations. + pub refinement_max_iterations: u32, + /// Where shared LLM settings are stored. + pub global_config_path: String, + /// Whether Codex CLI is installed. + pub codex_available: bool, + /// Whether Codex CLI is logged in and ready. + pub codex_authenticated: bool, + /// Whether Claude Code is installed. + pub claude_available: bool, + /// Whether Claude Code is logged in and ready. + pub claude_authenticated: bool, + /// Whether to include uncommitted working tree changes in branch comparisons. + pub include_uncommitted: bool, +} + +fn display_global_config_path() -> String { + DiffcoreConfig::global_config_path() + .map(|path| path.to_string_lossy().to_string()) + .unwrap_or_else(|| "~/.diffcore/config.toml".to_string()) +} diff --git a/crates/diffcore-tauri/src/commands/workspace.rs b/crates/diffcore-tauri/src/commands/workspace.rs new file mode 100644 index 0000000..ad61e0c --- /dev/null +++ b/crates/diffcore-tauri/src/commands/workspace.rs @@ -0,0 +1,338 @@ +//! Workspace, git, and file content commands. + +use std::collections::HashSet; +use std::path::PathBuf; + +use grep_regex::RegexMatcherBuilder; +use grep_searcher::{sinks, SearcherBuilder}; +use ignore::WalkBuilder; + +use diffcore_core::git; + +use super::{AppState, CommandError, FileDiffContent}; + +/// List all local branches in the repository. +/// +/// Returns branches sorted with current branch first, then alphabetically. +#[tauri::command] +pub fn list_branches(repo_path: String) -> Result, CommandError> { + let repo = super::open_repo(&repo_path)?; + git::list_branches(&repo).map_err(|e| CommandError::Git(format!("{}", e))) +} + +/// List recent commits for commit-level ref selection in the UI. +#[tauri::command] +pub fn list_commits(repo_path: String, limit: Option) -> Result, CommandError> { + let repo = super::open_repo(&repo_path)?; + let bounded_limit = limit.unwrap_or(50).clamp(1, 200); + git::list_recent_commits(&repo, bounded_limit) + .map_err(|e| CommandError::Git(format!("{}", e))) +} + +/// List all git worktrees for the repository. +#[tauri::command] +pub fn list_worktrees(repo_path: String) -> Result, CommandError> { + let repo = super::open_repo(&repo_path)?; + git::list_worktrees(&repo).map_err(|e| CommandError::Git(format!("{}", e))) +} + +/// Get the current branch's tracking status (ahead/behind upstream). +#[tauri::command] +pub fn get_branch_status(repo_path: String) -> Result { + let repo = super::open_repo(&repo_path)?; + git::get_branch_status(&repo).map_err(|e| CommandError::Git(format!("{}", e))) +} + +/// Auto-detect the default branch and current branch for a repository. +/// +/// Returns a summary useful for the UI to set up initial state. +#[tauri::command] +pub fn get_repo_info(repo_path: String) -> Result { + let repo = super::open_repo(&repo_path)?; + + let current = git::current_branch(&repo); + let default_branch = git::detect_default_branch(&repo).unwrap_or_else(|_| "main".to_string()); + let branches = git::list_branches(&repo).map_err(|e| CommandError::Git(format!("{}", e)))?; + let worktrees = git::list_worktrees(&repo).map_err(|e| CommandError::Git(format!("{}", e)))?; + let status = git::get_branch_status(&repo).ok(); + let is_worktree = git::is_linked_worktree(&repo); + + Ok(RepoInfo { + current_branch: current, + default_branch, + branches, + worktrees, + status, + is_worktree, + }) +} + +/// Return the first directory argument passed at app launch, if any. +#[tauri::command] +pub fn get_launch_directory() -> Option { + std::env::args_os() + .skip(1) + .map(PathBuf::from) + .find(|path| path.is_dir()) + .and_then(|path| std::fs::canonicalize(path).ok()) + .map(|path| path.to_string_lossy().to_string()) +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct FileShortStatus { + pub path: String, + pub status: String, +} + +#[tauri::command] +pub fn get_last_diff_file_statuses( + state: tauri::State<'_, AppState>, +) -> Result, CommandError> { + let guard = state + .last_diff + .lock() + .map_err(|e| CommandError::Analysis(format!("Lock poisoned: {}", e)))?; + + let Some(cached) = guard.as_ref() else { + return Ok(vec![]); + }; + + let mut out = Vec::with_capacity(cached.diff_result.files.len()); + for file in &cached.diff_result.files { + let status = match file.status { + diffcore_core::git::FileStatus::Added => "A", + diffcore_core::git::FileStatus::Modified => "M", + diffcore_core::git::FileStatus::Deleted => "D", + diffcore_core::git::FileStatus::Renamed => "R", + diffcore_core::git::FileStatus::Copied => "C", + }; + out.push(FileShortStatus { + path: file.path().to_string(), + status: status.to_string(), + }); + } + + Ok(out) +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct CrossFileSearchMatch { + pub line_number: u32, + pub line_text: String, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct CrossFileSearchResult { + pub file_path: String, + pub matches: Vec, +} + +fn changed_files_from_state(state: &AppState) -> HashSet { + let mut files = HashSet::new(); + + if let Ok(guard) = state.last_analysis.lock() { + if let Some(analysis) = guard.as_ref() { + for group in &analysis.groups { + for file in &group.files { + files.insert(file.path.clone()); + } + } + if let Some(infra) = &analysis.infrastructure_group { + for file in &infra.files { + files.insert(file.clone()); + } + } + } + } + + if files.is_empty() { + if let Ok(guard) = state.last_diff.lock() { + if let Some(cached) = guard.as_ref() { + for file in &cached.diff_result.files { + files.insert(file.path().to_string()); + } + } + } + } + + files +} + +fn workspace_files(workdir: &std::path::Path) -> Vec { + let mut builder = WalkBuilder::new(workdir); + builder + .hidden(false) + .ignore(true) + .git_ignore(true) + .git_exclude(true) + .parents(true); + + builder + .build() + .into_iter() + .filter_map(Result::ok) + .filter(|entry| entry.file_type().map(|ft| ft.is_file()).unwrap_or(false)) + .filter_map(|entry| { + let rel = entry.path().strip_prefix(workdir).ok()?; + let rel_str = rel.to_string_lossy().replace('\\', "/"); + if rel_str.starts_with(".git/") { + return None; + } + Some(rel_str) + }) + .collect() +} + +#[tauri::command] +pub fn cross_file_search( + repo_path: String, + query: String, + show_unchanged_files: bool, + max_results: Option, + state: tauri::State<'_, AppState>, +) -> Result, CommandError> { + let query = query.trim(); + if query.is_empty() { + return Ok(vec![]); + } + + let repo = super::open_repo(&repo_path)?; + let workdir = repo + .workdir() + .ok_or_else(|| CommandError::Git("Bare repositories are not supported".to_string()))? + .to_path_buf(); + + let mut candidates: Vec = if show_unchanged_files { + workspace_files(&workdir) + } else { + changed_files_from_state(&state).into_iter().collect() + }; + candidates.sort(); + + let matcher = RegexMatcherBuilder::new() + .case_insensitive(true) + .fixed_strings(true) + .build(query) + .map_err(|e| CommandError::Analysis(format!("Invalid search query: {}", e)))?; + + let mut searcher = SearcherBuilder::new() + .line_number(true) + .multi_line(false) + .binary_detection(grep_searcher::BinaryDetection::quit(b'\x00')) + .build(); + + let max_file_results = max_results.unwrap_or(200).max(1); + let mut results = Vec::new(); + let mut total_matches = 0usize; + + for relative_path in candidates { + if results.len() >= max_file_results || total_matches >= 1000 { + break; + } + + let absolute_path = workdir.join(&relative_path); + let metadata = match std::fs::metadata(&absolute_path) { + Ok(meta) => meta, + Err(_) => continue, + }; + if metadata.len() > 2 * 1024 * 1024 { + continue; + } + + let mut file_matches = Vec::new(); + + let sink = sinks::UTF8(|line_number: u64, line: &str| { + if total_matches >= 1000 || file_matches.len() >= 50 { + return Ok(false); + } + let clean = line.trim_end_matches(&['\r', '\n'][..]).to_string(); + file_matches.push(CrossFileSearchMatch { + line_number: line_number as u32, + line_text: clean, + }); + total_matches += 1; + Ok(true) + }); + + if searcher.search_path(&matcher, &absolute_path, sink).is_err() { + continue; + } + + if !file_matches.is_empty() { + results.push(CrossFileSearchResult { + file_path: relative_path, + matches: file_matches, + }); + } + } + + Ok(results) +} + +#[tauri::command] +pub fn get_workspace_file_content( + repo_path: String, + file_path: String, +) -> Result { + let repo = super::open_repo(&repo_path)?; + let workdir = repo + .workdir() + .ok_or_else(|| CommandError::Git("Bare repositories are not supported".to_string()))? + .to_path_buf(); + + let absolute = workdir.join(&file_path); + if !absolute.exists() || !absolute.is_file() { + return Err(CommandError::Io(format!("File not found: {}", file_path))); + } + + let content = std::fs::read_to_string(&absolute) + .map_err(|e| CommandError::Io(format!("Failed to read file '{}': {}", file_path, e)))?; + + Ok(FileDiffContent { + path: file_path.clone(), + old_content: content.clone(), + new_content: content, + language: super::detect_language(&file_path), + }) +} + +/// Parse a single file's source via the diffcore-core query engine and +/// return the language-agnostic IR (definitions, imports, exports, call +/// sites). Used by the source-explorer outline panel so it can show +/// symbols for any language the engine supports — replacing the +/// hand-written per-language regex parsers that used to live in +/// `SourceExplorer.tsx` and only covered TS/JS/Python/Go/Rust. +/// +/// `path` is used only for language detection (via file extension); no +/// disk access happens. `source` is the raw text to parse. The shared +/// `QueryEngine` instance held on `AppState` caches per-language +/// tree-sitter query compilation across calls, so repeated outline +/// updates for the same language are cheap. +/// +/// Returns an empty `ParsedFile` (with `Language::Unknown`) when the +/// path's extension is not recognised — the caller is expected to +/// degrade gracefully rather than treat that as an error. +#[tauri::command] +pub fn parse_file_content( + path: String, + source: String, + state: tauri::State<'_, AppState>, +) -> Result { + state + .query_engine + .parse_file(&path, &source) + .map_err(|e| CommandError::Analysis(format!("parse_file failed: {e}"))) +} + + +/// Summary of repository state for the UI. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct RepoInfo { + pub current_branch: Option, + pub default_branch: String, + pub branches: Vec, + pub worktrees: Vec, + pub status: Option, + /// Whether the opened path is a linked worktree (not the main worktree). + pub is_worktree: bool, +} diff --git a/crates/diffcore-tauri/src/main.rs b/crates/diffcore-tauri/src/main.rs index c54eed0..d9dda60 100644 --- a/crates/diffcore-tauri/src/main.rs +++ b/crates/diffcore-tauri/src/main.rs @@ -51,53 +51,61 @@ fn main() { Ok(()) }) .invoke_handler(tauri::generate_handler![ + // Core commands (defined in commands/mod.rs) commands::analyze, commands::get_last_analysis, commands::get_mermaid, commands::get_file_diff, - commands::start_annotate_overview, - commands::annotate_overview, - commands::start_annotate_group, - commands::annotate_group, - commands::start_refine_groups, - commands::list_branches, - commands::list_worktrees, - commands::get_branch_status, - commands::get_repo_info, - commands::get_launch_directory, - commands::get_last_diff_file_statuses, - commands::cross_file_search, - commands::get_workspace_file_content, - commands::parse_file_content, - commands::check_api_key, - commands::get_llm_settings, - commands::save_llm_settings, - commands::save_api_key, - commands::clear_api_key, - commands::fetch_provider_models, - commands::refine_groups, - commands::list_commits, - commands::open_in_editor, - commands::save_file_content, - commands::check_editors_available, - commands::save_comment, - commands::delete_comment, - commands::load_comments, - commands::export_comments, - commands::get_ignore_paths, - commands::save_ignore_paths, - commands::get_cached_refinement, - commands::store_refinement_cache, - commands::save_comment_cached, - commands::load_comments_cached, - commands::delete_comment_cached, - commands::update_comment_cached, - commands::save_app_state, - commands::load_last_app_state, - commands::import_groups_manifest, - commands::export_groups_manifest, - commands::watch_manifest, - commands::unwatch_manifest, + // LLM annotation and refinement (commands/llm.rs) + commands::llm::start_annotate_overview, + commands::llm::annotate_overview, + commands::llm::start_annotate_group, + commands::llm::annotate_group, + commands::llm::start_refine_groups, + commands::llm::refine_groups, + commands::llm::get_cached_refinement, + commands::llm::store_refinement_cache, + // Git / workspace (commands/workspace.rs) + commands::workspace::list_branches, + commands::workspace::list_commits, + commands::workspace::list_worktrees, + commands::workspace::get_branch_status, + commands::workspace::get_repo_info, + commands::workspace::get_launch_directory, + commands::workspace::get_last_diff_file_statuses, + commands::workspace::cross_file_search, + commands::workspace::get_workspace_file_content, + commands::workspace::parse_file_content, + // Settings / API keys (commands/settings.rs) + commands::settings::check_api_key, + commands::settings::get_llm_settings, + commands::settings::save_llm_settings, + commands::settings::save_api_key, + commands::settings::clear_api_key, + commands::settings::fetch_provider_models, + commands::settings::get_ignore_paths, + commands::settings::save_ignore_paths, + // Editor integration (commands/editor.rs) + commands::editor::open_in_editor, + commands::editor::save_file_content, + commands::editor::check_editors_available, + // Review comments (commands/comments.rs) + commands::comments::save_comment, + commands::comments::delete_comment, + commands::comments::load_comments, + commands::comments::export_comments, + commands::comments::save_comment_cached, + commands::comments::load_comments_cached, + commands::comments::delete_comment_cached, + commands::comments::update_comment_cached, + // App state persistence (commands/app_state.rs) + commands::app_state::save_app_state, + commands::app_state::load_last_app_state, + // Groups manifest (commands/manifest.rs) + commands::manifest::import_groups_manifest, + commands::manifest::export_groups_manifest, + commands::manifest::watch_manifest, + commands::manifest::unwatch_manifest, ]) .run(tauri::generate_context!()) { From 5cb754e288abb5265816fe7a54bfff23d3a2b5b9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Apr 2026 22:13:47 +0000 Subject: [PATCH 08/15] refactor: address code review feedback on commands split - Remove duplicate provider_supports_tool_activity from llm.rs, call super:: instead - Move RepoInfo struct before its first use in workspace.rs Agent-Logs-Url: https://github.com/mikenrafter/diff-core/sessions/22a705a7-65f2-4133-8d93-e9e3d306a8ca Co-authored-by: mikenrafter <88250914+mikenrafter@users.noreply.github.com> --- crates/diffcore-tauri/src/commands/llm.rs | 6 +---- .../diffcore-tauri/src/commands/workspace.rs | 27 ++++++++++--------- 2 files changed, 15 insertions(+), 18 deletions(-) diff --git a/crates/diffcore-tauri/src/commands/llm.rs b/crates/diffcore-tauri/src/commands/llm.rs index 33f117e..ddd9fb9 100644 --- a/crates/diffcore-tauri/src/commands/llm.rs +++ b/crates/diffcore-tauri/src/commands/llm.rs @@ -197,12 +197,8 @@ async fn emit_diffcore_activity(job: &JobHandle, message: impl Into) { .await; } -fn provider_supports_tool_activity(provider: &str) -> bool { - matches!(provider, "codex" | "claude") -} - async fn emit_direct_api_activity_notice(job: &JobHandle, provider: &str) { - if provider_supports_tool_activity(provider) { + if super::provider_supports_tool_activity(provider) { return; } diff --git a/crates/diffcore-tauri/src/commands/workspace.rs b/crates/diffcore-tauri/src/commands/workspace.rs index ad61e0c..960067e 100644 --- a/crates/diffcore-tauri/src/commands/workspace.rs +++ b/crates/diffcore-tauri/src/commands/workspace.rs @@ -11,6 +11,20 @@ use diffcore_core::git; use super::{AppState, CommandError, FileDiffContent}; + + +/// Summary of repository state for the UI. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct RepoInfo { + pub current_branch: Option, + pub default_branch: String, + pub branches: Vec, + pub worktrees: Vec, + pub status: Option, + /// Whether the opened path is a linked worktree (not the main worktree). + pub is_worktree: bool, +} + /// List all local branches in the repository. /// /// Returns branches sorted with current branch first, then alphabetically. @@ -323,16 +337,3 @@ pub fn parse_file_content( .parse_file(&path, &source) .map_err(|e| CommandError::Analysis(format!("parse_file failed: {e}"))) } - - -/// Summary of repository state for the UI. -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct RepoInfo { - pub current_branch: Option, - pub default_branch: String, - pub branches: Vec, - pub worktrees: Vec, - pub status: Option, - /// Whether the opened path is a linked worktree (not the main worktree). - pub is_worktree: bool, -} From 5bae1a122a26ed0822f5852519c2d5cf83c86a09 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Apr 2026 23:39:23 +0000 Subject: [PATCH 09/15] refactor: extract App.tsx into components and hooks (phase 2) - Create src/utils/ with constants, pathUtils, gitUtils, groupUtils, activityUtils, llmUtils - Create src/hooks/AppContext.tsx with comprehensive AppContextValue interface - Extract JSX sections into: - src/components/CrashTest.tsx - src/components/panels/HeaderBar.tsx - src/components/panels/LeftPane.tsx - src/components/panels/CenterPane.tsx - src/components/panels/RightPane.tsx - src/components/modals/AISetupModal.tsx - src/components/modals/SettingsPanel.tsx - src/components/modals/CommentInputOverlay.tsx - src/components/modals/RegenDialog.tsx - App.tsx now uses AppContext.Provider + component composition - App.tsx reduced from 6685 to ~3530 lines (hooks extraction pending) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: mikenrafter <88250914+mikenrafter@users.noreply.github.com> --- crates/diffcore-tauri/ui/src/App.tsx | 3609 +---------------- .../ui/src/components/CrashTest.tsx | 10 + .../ui/src/components/modals/AISetupModal.tsx | 182 + .../components/modals/CommentInputOverlay.tsx | 62 + .../ui/src/components/modals/RegenDialog.tsx | 60 + .../src/components/modals/SettingsPanel.tsx | 321 ++ .../ui/src/components/panels/CenterPane.tsx | 466 +++ .../ui/src/components/panels/HeaderBar.tsx | 341 ++ .../ui/src/components/panels/LeftPane.tsx | 520 +++ .../ui/src/components/panels/RightPane.tsx | 841 ++++ .../ui/src/extract_components.py | 373 ++ .../ui/src/hooks/AppContext.tsx | 429 ++ crates/diffcore-tauri/ui/src/update_app.py | 270 ++ .../ui/src/utils/activityUtils.ts | 413 ++ .../diffcore-tauri/ui/src/utils/constants.ts | 114 + .../diffcore-tauri/ui/src/utils/gitUtils.ts | 50 + .../diffcore-tauri/ui/src/utils/groupUtils.ts | 67 + .../diffcore-tauri/ui/src/utils/llmUtils.ts | 29 + .../diffcore-tauri/ui/src/utils/pathUtils.ts | 123 + 19 files changed, 4894 insertions(+), 3386 deletions(-) create mode 100644 crates/diffcore-tauri/ui/src/components/CrashTest.tsx create mode 100644 crates/diffcore-tauri/ui/src/components/modals/AISetupModal.tsx create mode 100644 crates/diffcore-tauri/ui/src/components/modals/CommentInputOverlay.tsx create mode 100644 crates/diffcore-tauri/ui/src/components/modals/RegenDialog.tsx create mode 100644 crates/diffcore-tauri/ui/src/components/modals/SettingsPanel.tsx create mode 100644 crates/diffcore-tauri/ui/src/components/panels/CenterPane.tsx create mode 100644 crates/diffcore-tauri/ui/src/components/panels/HeaderBar.tsx create mode 100644 crates/diffcore-tauri/ui/src/components/panels/LeftPane.tsx create mode 100644 crates/diffcore-tauri/ui/src/components/panels/RightPane.tsx create mode 100644 crates/diffcore-tauri/ui/src/extract_components.py create mode 100644 crates/diffcore-tauri/ui/src/hooks/AppContext.tsx create mode 100644 crates/diffcore-tauri/ui/src/update_app.py create mode 100644 crates/diffcore-tauri/ui/src/utils/activityUtils.ts create mode 100644 crates/diffcore-tauri/ui/src/utils/constants.ts create mode 100644 crates/diffcore-tauri/ui/src/utils/gitUtils.ts create mode 100644 crates/diffcore-tauri/ui/src/utils/groupUtils.ts create mode 100644 crates/diffcore-tauri/ui/src/utils/llmUtils.ts create mode 100644 crates/diffcore-tauri/ui/src/utils/pathUtils.ts diff --git a/crates/diffcore-tauri/ui/src/App.tsx b/crates/diffcore-tauri/ui/src/App.tsx index 350bd69..34abfb4 100644 --- a/crates/diffcore-tauri/ui/src/App.tsx +++ b/crates/diffcore-tauri/ui/src/App.tsx @@ -19,18 +19,13 @@ import type { RefinementResponse, ReviewComment, CommentInput, - InfraSubGroup, } from "./types"; import { LLM_PROVIDERS, DEFAULT_MODELS_BY_PROVIDER } from "./types"; import type { ModelInfo } from "./types"; -import DiffViewer, { type DiffViewerHandle, type EditedHunk } from "./components/DiffViewer"; -import FlowGraph from "./components/FlowGraph"; -import SourceExplorer, { type SourceFocusRequest } from "./components/SourceExplorer"; -import Dropdown from "./components/Dropdown"; -import FileDisplay from "./components/FileDisplay"; +import { type DiffViewerHandle, type EditedHunk } from "./components/DiffViewer"; +import { type SourceFocusRequest } from "./components/SourceExplorer"; // RiskHeatmap hidden (Phase 9.4) — component kept for future re-enablement // import RiskHeatmap from "./components/RiskHeatmap"; -import ErrorBoundary from "./components/ErrorBoundary"; import { buildManifestPrompt } from "./buildManifestPrompt"; import { MOCK_ANALYSIS, MOCK_DIFFS, MOCK_PASS1, MOCK_PASS2, MOCK_REPO_INFO, MOCK_LLM_SETTINGS, MOCK_REFINEMENT } from "./mock"; @@ -43,118 +38,45 @@ async function tauriInvoke(cmd: string, args?: Record): Prom return invoke(cmd, args); } -const PROVIDER_LABELS: Record = { - codex: "Codex CLI", - claude: "Claude Code", - anthropic: "Anthropic API", - openai: "OpenAI API", - gemini: "Gemini API", - openrouter: "OpenRouter", - github_copilot: "GitHub Copilot", -}; - -type OnboardingStep = "recommended" | "api"; -type SubscriptionProvider = "codex" | "claude"; -type RightPanelTab = "activity" | "annotations" | "source" | "comments"; -type ActivityViewMode = "stream" | "all"; -type ReplayHunk = { - id: string; - filePath: string; - startLine: number; - endLine: number; - originalStartLine: number; - originalEndLine: number; - isDeletionOnly: boolean; - selectedCode: string | null; -}; -type ActivityKind = - | "system" - | "search" - | "read" - | "command" - | "reasoning" - | "result" - | "warning" - | "error"; - -const API_PROVIDER_OPTIONS: LlmProvider[] = ["openai", "anthropic", "gemini", "openrouter", "github_copilot"]; -const ACTIVITY_STREAM_LIMIT = 10; -const COMPARE_TARGET_UNSTAGED = "__DIFFCORE_UNSTAGED__"; -const COMPARE_TARGET_STAGED = "__DIFFCORE_STAGED__"; -// TODO: re-enable app state save/restore after UX and reliability pass. -const STATE_SAVE_RESTORE_ENABLED = false; - -type CompareMode = "branch" | "unstaged_to_staged" | "invalid"; - -type PersistedAppState = { - version: number; - repoPath: string; - baseRef: string; - headRef: string | null; - includeUncommitted: boolean; - showUnchangedFiles: boolean; - analysis: AnalysisOutput | null; - selectedGroupId: string | null; - selectedFile: string | null; - fileDiff: FileDiffContent | null; - openTabs: Array<{ path: string; groupId: string }>; - comments: ReviewComment[]; - reviewedGroupIds: string[]; - rightPanelTab: RightPanelTab; - annotationSubTab: "info" | "graph" | "edges"; - graphGranularity: "file" | "module_class_method"; - replayActive: boolean; - replayStep: number; - replayVisited: string[]; - replayHunkIndex: number; - replayViewedHunkIds: string[]; - overview: Pass1Response | null; - deepAnalyses: Record; - activityEntries: LlmActivityEntry[]; - activityError: string | null; - activityViewMode: ActivityViewMode; - diffViewMode: DiffViewMode; - recentRepoPaths: string[]; - favoriteRepoPaths: string[]; -}; - -type CrossFileSearchMatch = { - line_number: number; - line_text: string; -}; - -type CrossFileSearchResult = { - file_path: string; - matches: CrossFileSearchMatch[]; -}; - -type FileShortStatus = { - path: string; - status: "A" | "M" | "D" | "R" | "C" | string; -}; - -const SUBSCRIPTION_BACKENDS: Array<{ - provider: SubscriptionProvider; - title: string; - description: string; - installCommand: string; - loginCommand: string; -}> = [ - { - provider: "codex", - title: "Codex CLI", - description: "Best path if you already use Codex. diffcore can reuse that login and let Codex inspect the repo directly.", - installCommand: "npm install -g @openai/codex", - loginCommand: "codex login", - }, - { - provider: "claude", - title: "Claude Code", - description: "Use your Claude Code subscription instead of pasting a separate Anthropic key into every repo.", - installCommand: "brew install claude-code", - loginCommand: "claude auth login", - }, -]; +import { + PROVIDER_LABELS, + ACTIVITY_STREAM_LIMIT, + COMPARE_TARGET_UNSTAGED, + COMPARE_TARGET_STAGED, + STATE_SAVE_RESTORE_ENABLED, +} from "./utils/constants"; +import type { + OnboardingStep, + SubscriptionProvider, + RightPanelTab, + ActivityViewMode, + ReplayHunk, + CompareMode, + PersistedAppState, + CrossFileSearchMatch, + CrossFileSearchResult, + FileShortStatus, +} from "./utils/constants"; +import { parseSymbolEndpoint, findLineContainingSymbol } from "./utils/pathUtils"; +import { formatBranchStatus, formatCompareTargetLabel } from "./utils/gitUtils"; +import { + providerSupportsToolActivity, + buildMockActivityEntries, + describeActivityEntry, + summarizeActivityTimeline, +} from "./utils/activityUtils"; +import { resolveInteractiveProvider, resolveInteractiveModel } from "./utils/llmUtils"; +import { AppContext } from "./hooks/AppContext"; +import type { AppContextValue } from "./hooks/AppContext"; +import { HeaderBar } from "./components/panels/HeaderBar"; +import { AISetupModal } from "./components/modals/AISetupModal"; +import { SettingsPanel } from "./components/modals/SettingsPanel"; +import { LeftPane } from "./components/panels/LeftPane"; +import { CenterPane } from "./components/panels/CenterPane"; +import { RightPane } from "./components/panels/RightPane"; +import { CommentInputOverlay } from "./components/modals/CommentInputOverlay"; +import { RegenDialog } from "./components/modals/RegenDialog"; + function isApiProvider(provider: string): boolean { return ( @@ -1504,6 +1426,7 @@ export default function App() { const resolvedPrimaryProvider = resolveInteractiveProvider( llmSettings?.provider ?? null, recommendedSubscriptionProvider, + isApiProvider, ); const resolvedPrimaryModel = resolveInteractiveModel( llmSettings?.model ?? null, @@ -1513,6 +1436,7 @@ export default function App() { const resolvedRefinementProvider = resolveInteractiveProvider( llmSettings?.refinement_provider ?? llmSettings?.provider ?? null, recommendedSubscriptionProvider, + isApiProvider, ); const resolvedRefinementModel = resolveInteractiveModel( llmSettings?.refinement_model ?? llmSettings?.model ?? null, @@ -2679,6 +2603,20 @@ export default function App() { [commentsByFileMap], ); + // Group comments by file for the Comments tab + const commentsByFile = useMemo(() => { + const map = new Map(); + // Group-level comments go under a special key + for (const c of comments) { + const key = c.file_path ?? `__group__${c.group_id}`; + const arr = map.get(key); + if (arr) arr.push(c); + else map.set(key, [c]); + } + return map; + }, [comments]); + + /** * Compute whether to render side-by-side for the current file. * @@ -3249,658 +3187,163 @@ export default function App() { // Status display const statusText = formatBranchStatus(repoInfo); - const annotationsTabContent = selectedGroup ? ( -
- {/* Annotation sub-tabs */} -
- - - -
- - {annotationSubTab === "info" && ( - <> -
-

Flow Group

-

{selectedGroup.name}

- {selectedGroup.entrypoint && ( -

- Entrypoint: {selectedGroup.entrypoint.symbol} ( - {selectedGroup.entrypoint.entrypoint_type}) -

- )} -

- Risk: {selectedGroup.risk_score.toFixed(2)}{" "} - | Files: {selectedGroup.files.length} | - Review order: #{selectedGroup.review_order} -

- {llmSettings && ( - <> -
- -
- updateSetting("model", value)} - options={modelsForProvider(llmSettings.provider).map((m) => ({ value: m, label: m }))} - placeholder="Select model" - /> -
-
- - - )} - {selectedGroup.files.length > 1 && !replayActive && ( - - )} - {replayActive && ( - - )} -
- - {refinementVerdict && ( -
-

Refinement Verdict

-

{refinementVerdict.title}

-

- {PROVIDER_LABELS[refinementVerdict.provider as LlmProvider] ?? refinementVerdict.provider}/{refinementVerdict.model} -

- {refinementVerdict.reasoning && ( -

{refinementVerdict.reasoning}

- )} -
- )} - - {overview && !groupAnnotation && ( -
-

LLM Overview

-

{overview.overall_summary}

-
- )} - - {groupAnnotation && ( -
-

LLM Summary

-

{groupAnnotation.summary}

-

- Review rationale: {groupAnnotation.review_order_rationale} -

- {groupAnnotation.risk_flags.length > 0 && ( -
- {groupAnnotation.risk_flags.map((flag, i) => ( - {flag} - ))} -
- )} -
- )} - - {overview && groupAnnotation && ( -
-

Overall Summary

-

{overview.overall_summary}

-
- )} - - {groupDeepAnalysis && ( - <> -
-

Flow Narrative

-

{groupDeepAnalysis.flow_narrative}

-
- - {groupDeepAnalysis.file_annotations.length > 0 && ( -
-

File Annotations

- {groupDeepAnalysis.file_annotations.map((fa, i) => ( -
-
- {shortPath(fa.file)} - {fa.role_in_flow} -
-

{fa.changes_summary}

- {fa.risks.length > 0 && ( -
- Risks: -
    - {fa.risks.map((r, j) => ( -
  • {r}
  • - ))} -
-
- )} - {fa.suggestions.length > 0 && ( -
- Suggestions: -
    - {fa.suggestions.map((s, j) => ( -
  • {s}
  • - ))} -
-
- )} -
- ))} -
- )} - - {groupDeepAnalysis.cross_cutting_concerns.length > 0 && ( -
-

Cross-Cutting Concerns

-
    - {groupDeepAnalysis.cross_cutting_concerns.map((c, i) => ( -
  • {c}
  • - ))} -
-
- )} - - )} - - )} - - {annotationSubTab === "graph" && selectedGroup.edges.length > 0 && ( -
-
- -
- - value={graphGranularity} - onChange={(value) => setGraphGranularity(value)} - options={[ - { value: "file", label: "file" }, - { value: "module_class_method", label: "module/class/method", description: "preview" }, - ]} - /> -
-
- {graphGranularity === "module_class_method" && ( -

- Preview mode: symbol-level graph is not available yet; rendering file-level graph as fallback. -

- )} - - - - -
- )} - - {annotationSubTab === "edges" && selectedGroup.edges.length > 0 && ( -
-
    - {selectedGroup.edges.map((edge, i) => { - const fromFile = symbolFilePath(edge.from); - const fromSymbol = shortSymbol(edge.from); - const toLabel = shortSymbol(edge.to); - return ( -
  • - - - {fromSymbol} - - - - - )} - /> -
  • - ); - })} -
-
- )} -
- ) : ( -
- Select a group to see annotations. -
- ); - - const activityTabContent = ( -
-
-
-
-
-

AI Activity

-

- {activityJob - ? activityJob.title - : activityTimeline.length > 0 - ? "Latest AI run" - : "No AI activity yet"} -

-

- {activityJob - ? `${PROVIDER_LABELS[activityJob.provider as LlmProvider] ?? activityJob.provider}/${activityJob.model}` - : activityTimeline.length > 0 && activityEventProvider - ? `Latest stream captured from ${PROVIDER_LABELS[activityEventProvider as LlmProvider] ?? activityEventProvider}${activitySupportsToolStreaming ? " with live repo access." : "."}` - : activityEventProvider - ? `${PROVIDER_LABELS[activityEventProvider as LlmProvider] ?? activityEventProvider}${activitySupportsToolStreaming ? " can stream repo activity live." : " is running in direct API mode."}` - : "Run Summarize PR, Analyze This Flow, or Refine to inspect AI work."} -

-
- {activityJob ? ( - Live - ) : activityTimeline.length > 0 ? ( - Saved - ) : null} -
- - {(activityJob || activityTimeline.length > 0) && ( -
-
- {activityStats.total} - events -
- {/* - Search / reads / commands tiles are hidden in direct-API mode - because hosted APIs (OpenAI / Anthropic / Gemini) don't emit - tool events — leaving them visible just shows three stale 0s. - */} - {!activityIsDirectApi && ( - <> -
- {activityStats.search} - search -
-
- {activityStats.read} - reads -
-
- {activityStats.command} - commands -
- - )} -
- )} - - {activitySupportsToolStreaming && activityEventProvider && ( -
-

Live repo activity enabled

-

- {PROVIDER_LABELS[activityEventProvider as LlmProvider] ?? activityEventProvider} can inspect the repo directly, - so file reads, searches, and shell commands stream here while you wait. -

-
- )} - - {refinementVerdict && ( -
-

{refinementVerdict.title}

-

- {PROVIDER_LABELS[refinementVerdict.provider as LlmProvider] ?? refinementVerdict.provider}/{refinementVerdict.model} -

- {refinementVerdict.reasoning && ( -

{refinementVerdict.reasoning}

- )} -
- )} -
-
- - - ); - - const sourceTabContent = ( - { - diffViewerRef.current?.scrollToLine(startLine, endLine); - }} - /> - ); - - // Group comments by file for the Comments tab - const commentsByFile = useMemo(() => { - const map = new Map(); - // Group-level comments go under a special key - for (const c of comments) { - const key = c.file_path ?? `__group__${c.group_id}`; - const arr = map.get(key); - if (arr) arr.push(c); - else map.set(key, [c]); - } - return map; - }, [comments]); - - const commentsTabContent = ( -
-
- - {comments.length > 0 && ( - - )} -
- {comments.length === 0 ? ( -
-

No comments yet

-

Press c to comment on a file or select code lines in the diff viewer

-
- ) : ( -
- {Array.from(commentsByFile.entries()).map(([fileKey, fileComments]) => ( -
-
- - {fileKey.startsWith("__group__") ? "Group comments" : shortPath(fileKey)} - - {fileComments.length} -
- {fileComments.map((comment) => ( -
{ - setActiveCommentId(comment.id); - if (comment.file_path) { - const group = analysis?.groups.find((g: FlowGroup) => g.id === comment.group_id); - if (group) openFileInTab(comment.file_path, group.id); - } - if (comment.start_line != null) { - // If file is already loaded, scroll immediately; otherwise queue - if (comment.file_path === selectedFile && fileDiff) { - setTimeout(() => { - diffViewerRef.current?.scrollToLine(comment.start_line!, comment.end_line ?? undefined); - }, 50); - } else if (comment.file_path) { - pendingScrollToCommentRef.current = { - startLine: comment.start_line, - endLine: comment.end_line ?? undefined, - commentId: comment.id, - }; - } - } - }} - role="button" - tabIndex={0} - > -
- {comment.type} - {comment.start_line != null && comment.end_line != null && ( - L{comment.start_line}-{comment.end_line} - )} - - -
- {comment.selected_code && ( -
{comment.selected_code}
- )} - {editingCommentId === comment.id ? ( -
e.stopPropagation()}> -