use std::fs;
#[cfg(unix)]
use std::os::unix::fs::symlink;
use tempfile::TempDir;
use tokensave::config::{load_config, save_config};
use tokensave::tokensave::TokenSave;
use tokensave::types::{Edge, EdgeKind, Node, NodeKind};
async fn canonical_graph(cg: &TokenSave) -> (Vec<Node>, Vec<Edge>) {
let mut nodes = cg.db().get_all_nodes().await.unwrap();
for node in &mut nodes {
node.updated_at = 0;
}
nodes.sort_by(|a, b| a.id.cmp(&b.id));
let mut edges = cg.db().get_all_edges().await.unwrap();
edges.sort_by(|a, b| {
(&a.source, &a.target, a.kind.as_str(), a.line).cmp(&(
&b.source,
&b.target,
b.kind.as_str(),
b.line,
))
});
(nodes, edges)
}
#[test]
fn test_ignore_crate_nested_gitignore_direct() {
let dir = TempDir::new().unwrap();
let project = dir.path();
fs::create_dir_all(project.join("src/vendor")).unwrap();
fs::write(project.join("src/lib.rs"), "kept").unwrap();
fs::write(project.join("src/vendor/gen.rs"), "generated").unwrap();
fs::write(project.join("src/vendor/.gitignore"), "*\n").unwrap();
let files: Vec<String> = ignore::WalkBuilder::new(project)
.hidden(true)
.git_ignore(true)
.git_global(false)
.git_exclude(false)
.follow_links(true)
.add_custom_ignore_filename(".gitignore")
.build()
.filter_map(|e| e.ok())
.filter(|e| e.file_type().is_some_and(|ft| ft.is_file()))
.filter_map(|e| {
e.path()
.strip_prefix(project)
.ok()
.map(|p| p.to_string_lossy().replace('\\', "/"))
})
.collect();
assert!(
files.contains(&"src/lib.rs".to_string()),
"lib.rs must be found"
);
assert!(
!files.iter().any(|f| f.contains("vendor")),
"nested .gitignore (*) must exclude vendor/gen.rs; got: {files:?}"
);
}
#[tokio::test]
async fn test_full_pipeline() {
let dir = TempDir::new().unwrap();
let project = dir.path();
fs::create_dir_all(project.join("src")).unwrap();
fs::write(
project.join("src/main.rs"),
r#"
use crate::utils::helper;
mod utils;
fn main() {
let result = helper();
println!("{}", result);
}
"#,
)
.unwrap();
fs::write(
project.join("src/utils.rs"),
r#"
/// Returns a greeting string.
pub fn helper() -> String {
format_greeting("world")
}
fn format_greeting(name: &str) -> String {
format!("Hello, {}!", name)
}
"#,
)
.unwrap();
let cg = TokenSave::init(project).await.unwrap();
let index_result = cg.index_all().await.unwrap();
assert!(index_result.file_count > 0, "should index files");
assert!(index_result.node_count > 0, "should extract nodes");
let stats = cg.get_stats().await.unwrap();
assert!(stats.node_count > 0);
assert!(stats.file_count >= 2);
let results = cg.search("helper", 10).await.unwrap();
assert!(!results.is_empty(), "should find 'helper'");
assert!(results.iter().any(|r| r.node.name == "helper"));
let stats = cg.get_stats().await.unwrap();
assert!(stats.edge_count > 0, "should have edges");
}
#[tokio::test]
async fn test_incremental_sync() {
let dir = TempDir::new().unwrap();
let project = dir.path();
fs::create_dir_all(project.join("src")).unwrap();
fs::write(project.join("src/lib.rs"), "pub fn original() {}\n").unwrap();
let cg = TokenSave::init(project).await.unwrap();
cg.index_all().await.unwrap();
let results = cg.search("original", 10).await.unwrap();
assert!(!results.is_empty());
fs::write(
project.join("src/lib.rs"),
"pub fn modified() {}\npub fn added() {}\n",
)
.unwrap();
let sync_result = cg.sync().await.unwrap();
assert!(
sync_result.files_modified > 0 || sync_result.files_added > 0,
"sync should detect changes: modified={}, added={}",
sync_result.files_modified,
sync_result.files_added
);
let results = cg.search("modified", 10).await.unwrap();
assert!(!results.is_empty(), "should find 'modified' after sync");
}
#[tokio::test]
async fn test_indexes_source_with_invalid_utf8_in_comment() {
let dir = TempDir::new().unwrap();
let project = dir.path();
fs::write(
project.join("latin1_repro.c"),
b"/* by W\xfcrkner */\nint latin1_symbol(void) { return 42; }\n",
)
.unwrap();
let cg = TokenSave::init(project).await.unwrap();
cg.index_all().await.unwrap();
let results = cg.search("latin1_symbol", 10).await.unwrap();
assert!(
results
.iter()
.any(|result| result.node.name == "latin1_symbol"),
"function in source containing invalid UTF-8 should be indexed"
);
}
#[tokio::test]
async fn test_init_and_open() {
let dir = TempDir::new().unwrap();
let project = dir.path();
assert!(!TokenSave::is_initialized(project));
TokenSave::init(project).await.unwrap();
assert!(TokenSave::is_initialized(project));
let cg = TokenSave::open(project).await;
assert!(cg.is_ok());
}
#[tokio::test]
async fn test_search_empty_index() {
let dir = TempDir::new().unwrap();
let project = dir.path();
let cg = TokenSave::init(project).await.unwrap();
let results = cg.search("anything", 10).await.unwrap();
assert!(results.is_empty());
}
#[tokio::test]
async fn test_stats_empty_index() {
let dir = TempDir::new().unwrap();
let project = dir.path();
let cg = TokenSave::init(project).await.unwrap();
let stats = cg.get_stats().await.unwrap();
assert_eq!(stats.node_count, 0);
assert_eq!(stats.edge_count, 0);
assert_eq!(stats.file_count, 0);
}
#[tokio::test]
async fn test_context_building() {
let dir = TempDir::new().unwrap();
let project = dir.path();
fs::create_dir_all(project.join("src")).unwrap();
fs::write(
project.join("src/lib.rs"),
r#"
/// Processes incoming data.
pub fn process_data(input: &str) -> String {
input.to_uppercase()
}
"#,
)
.unwrap();
let cg = TokenSave::init(project).await.unwrap();
cg.index_all().await.unwrap();
let options = tokensave::types::BuildContextOptions::default();
let context = cg
.build_context("process_data function", &options)
.await
.unwrap();
assert!(
!context.entry_points.is_empty(),
"should find entry points for 'process_data'"
);
}
#[tokio::test]
async fn test_struct_and_impl_extraction() {
let dir = TempDir::new().unwrap();
let project = dir.path();
fs::create_dir_all(project.join("src")).unwrap();
fs::write(
project.join("src/lib.rs"),
r#"
pub struct Point {
pub x: f64,
pub y: f64,
}
impl Point {
pub fn new(x: f64, y: f64) -> Self {
Point { x, y }
}
pub fn distance(&self, other: &Point) -> f64 {
((self.x - other.x).powi(2) + (self.y - other.y).powi(2)).sqrt()
}
}
"#,
)
.unwrap();
let cg = TokenSave::init(project).await.unwrap();
let result = cg.index_all().await.unwrap();
assert!(
result.node_count >= 5,
"should extract Point, x, y, new, distance (got {})",
result.node_count
);
let results = cg.search("Point", 10).await.unwrap();
assert!(!results.is_empty(), "should find 'Point'");
let results = cg.search("distance", 10).await.unwrap();
assert!(!results.is_empty(), "should find 'distance'");
}
#[tokio::test]
async fn test_file_removal_sync() {
let dir = TempDir::new().unwrap();
let project = dir.path();
fs::create_dir_all(project.join("src")).unwrap();
fs::write(project.join("src/lib.rs"), "pub fn keep() {}\n").unwrap();
fs::write(project.join("src/remove_me.rs"), "pub fn gone() {}\n").unwrap();
let cg = TokenSave::init(project).await.unwrap();
cg.index_all().await.unwrap();
let stats = cg.get_stats().await.unwrap();
assert!(
stats.file_count >= 2,
"should have at least 2 files indexed"
);
fs::remove_file(project.join("src/remove_me.rs")).unwrap();
let sync_result = cg.sync().await.unwrap();
assert_eq!(sync_result.files_removed, 1, "should detect 1 removed file");
let results = cg.search("gone", 10).await.unwrap();
assert!(results.is_empty(), "'gone' should no longer be found");
}
#[tokio::test]
async fn test_index_all_is_idempotent() {
let dir = TempDir::new().unwrap();
let project = dir.path();
fs::create_dir_all(project.join("src")).unwrap();
fs::write(
project.join("src/lib.rs"),
"pub fn alpha() {}\npub fn beta() {}\n",
)
.unwrap();
let cg = TokenSave::init(project).await.unwrap();
let result1 = cg.index_all().await.unwrap();
let stats1 = cg.get_stats().await.unwrap();
let result2 = cg.index_all().await.unwrap();
let stats2 = cg.get_stats().await.unwrap();
assert_eq!(
result1.file_count, result2.file_count,
"re-indexing should produce the same file count"
);
assert_eq!(
stats1.node_count, stats2.node_count,
"re-indexing should produce the same node count"
);
}
#[tokio::test]
async fn test_sync_no_changes() {
let dir = TempDir::new().unwrap();
let project = dir.path();
fs::create_dir_all(project.join("src")).unwrap();
fs::write(project.join("src/lib.rs"), "pub fn stable() {}\n").unwrap();
let cg = TokenSave::init(project).await.unwrap();
cg.index_all().await.unwrap();
let sync_result = cg.sync().await.unwrap();
assert_eq!(sync_result.files_added, 0);
assert_eq!(sync_result.files_modified, 0);
assert_eq!(sync_result.files_removed, 0);
}
#[tokio::test]
async fn test_search_by_docstring() {
let dir = TempDir::new().unwrap();
let project = dir.path();
fs::create_dir_all(project.join("src")).unwrap();
fs::write(
project.join("src/lib.rs"),
r#"
/// Calculates the fibonacci sequence.
pub fn fibonacci(n: u64) -> u64 {
if n <= 1 { n } else { fibonacci(n - 1) + fibonacci(n - 2) }
}
"#,
)
.unwrap();
let cg = TokenSave::init(project).await.unwrap();
cg.index_all().await.unwrap();
let results = cg.search("fibonacci", 10).await.unwrap();
assert!(
!results.is_empty(),
"should find node via docstring/name search"
);
}
#[tokio::test]
async fn test_multiple_files_cross_reference() {
let dir = TempDir::new().unwrap();
let project = dir.path();
fs::create_dir_all(project.join("src")).unwrap();
fs::write(
project.join("src/lib.rs"),
r#"
pub mod models;
pub mod services;
"#,
)
.unwrap();
fs::write(
project.join("src/models.rs"),
r#"
pub struct User {
pub name: String,
pub email: String,
}
"#,
)
.unwrap();
fs::write(
project.join("src/services.rs"),
r#"
use crate::models::User;
pub fn create_user(name: &str, email: &str) -> String {
format!("{}:{}", name, email)
}
"#,
)
.unwrap();
let cg = TokenSave::init(project).await.unwrap();
let result = cg.index_all().await.unwrap();
assert_eq!(result.file_count, 3, "should index all 3 files");
let results = cg.search("User", 10).await.unwrap();
assert!(!results.is_empty(), "should find 'User' struct");
let results = cg.search("create_user", 10).await.unwrap();
assert!(!results.is_empty(), "should find 'create_user' function");
}
#[cfg(unix)]
#[tokio::test]
async fn test_index_follows_symlinked_directories() {
let dir = TempDir::new().unwrap();
let project = dir.path();
let external = TempDir::new().unwrap();
fs::create_dir_all(external.path()).unwrap();
fs::write(
external.path().join("lib.rs"),
"pub fn through_symlink() {}\n",
)
.unwrap();
symlink(external.path(), project.join("src")).unwrap();
let cg = TokenSave::init(project).await.unwrap();
let result = cg.index_all().await.unwrap();
assert_eq!(
result.file_count, 1,
"should index the file behind the symlink"
);
let files = cg.get_all_files().await.unwrap();
let paths: Vec<&str> = files.iter().map(|f| f.path.as_str()).collect();
assert!(paths.contains(&"src/lib.rs"));
let results = cg.search("through_symlink", 10).await.unwrap();
assert!(
!results.is_empty(),
"should extract symbols from symlinked source"
);
}
async fn setup_gitignore_project(project: &std::path::Path) -> TokenSave {
TokenSave::init(project).await.unwrap();
let mut config = load_config(project).unwrap();
config.git_ignore = true;
save_config(project, &config).unwrap();
TokenSave::open(project).await.unwrap()
}
#[tokio::test]
async fn test_nested_gitignore_excludes_files_in_subdir() {
let dir = TempDir::new().unwrap();
let project = dir.path();
fs::create_dir_all(project.join("src/vendor")).unwrap();
fs::write(project.join("src/lib.rs"), "pub fn kept() {}\n").unwrap();
fs::write(project.join("src/vendor/gen.rs"), "pub fn generated() {}\n").unwrap();
fs::write(project.join("src/vendor/.gitignore"), "*\n").unwrap();
let cg = setup_gitignore_project(project).await;
let result = cg.index_all().await.unwrap();
assert_eq!(
result.file_count, 1,
"vendor/ should be excluded by nested .gitignore"
);
let files = cg.get_all_files().await.unwrap();
let paths: Vec<&str> = files.iter().map(|f| f.path.as_str()).collect();
assert!(paths.contains(&"src/lib.rs"), "src/lib.rs must be indexed");
assert!(
!paths.iter().any(|p| p.contains("vendor")),
"vendor files must be excluded by nested .gitignore"
);
}
#[tokio::test]
async fn test_nested_gitignore_scope_is_limited_to_its_directory() {
let dir = TempDir::new().unwrap();
let project = dir.path();
fs::create_dir_all(project.join("src/internal")).unwrap();
fs::write(project.join("src/lib.rs"), "pub fn public_api() {}\n").unwrap();
fs::write(
project.join("src/internal/secret.rs"),
"pub fn secret() {}\n",
)
.unwrap();
fs::write(project.join("src/internal/.gitignore"), "*.rs\n").unwrap();
let cg = setup_gitignore_project(project).await;
cg.index_all().await.unwrap();
let files = cg.get_all_files().await.unwrap();
let paths: Vec<&str> = files.iter().map(|f| f.path.as_str()).collect();
assert!(
paths.contains(&"src/lib.rs"),
"src/lib.rs must not be affected by nested .gitignore in src/internal/"
);
assert!(
!paths.iter().any(|p| p.contains("secret")),
"src/internal/secret.rs must be excluded by its own directory's .gitignore"
);
}
#[tokio::test]
async fn test_nested_gitignore_negation_overrides_parent_rule() {
let dir = TempDir::new().unwrap();
let project = dir.path();
fs::create_dir_all(project.join("src/exceptions")).unwrap();
fs::write(project.join(".gitignore"), "*.rs\n").unwrap();
fs::write(project.join("src/exceptions/.gitignore"), "!important.rs\n").unwrap();
fs::write(
project.join("src/exceptions/important.rs"),
"pub fn must_be_indexed() {}\n",
)
.unwrap();
fs::write(
project.join("src/exceptions/ignored.rs"),
"pub fn ignored() {}\n",
)
.unwrap();
let cg = setup_gitignore_project(project).await;
cg.index_all().await.unwrap();
let results = cg.search("must_be_indexed", 10).await.unwrap();
assert!(
!results.is_empty(),
"nested .gitignore negation must un-ignore important.rs even though root rule excludes *.rs"
);
let files = cg.get_all_files().await.unwrap();
let paths: Vec<&str> = files.iter().map(|f| f.path.as_str()).collect();
assert!(
!paths.iter().any(|p| p.ends_with("ignored.rs")),
"ignored.rs must remain excluded by root .gitignore"
);
}
#[tokio::test]
async fn test_nested_gitignore_applies_to_deeper_descendants() {
let dir = TempDir::new().unwrap();
let project = dir.path();
fs::create_dir_all(project.join("src/mid/deep")).unwrap();
fs::write(project.join("src/lib.rs"), "pub fn top() {}\n").unwrap();
fs::write(project.join("src/mid/.gitignore"), "deep/\n").unwrap();
fs::write(project.join("src/mid/mid.rs"), "pub fn mid() {}\n").unwrap();
fs::write(project.join("src/mid/deep/leaf.rs"), "pub fn leaf() {}\n").unwrap();
let cg = setup_gitignore_project(project).await;
cg.index_all().await.unwrap();
let files = cg.get_all_files().await.unwrap();
let paths: Vec<&str> = files.iter().map(|f| f.path.as_str()).collect();
assert!(paths.contains(&"src/lib.rs"), "src/lib.rs must be indexed");
assert!(
paths.contains(&"src/mid/mid.rs"),
"src/mid/mid.rs must be indexed"
);
assert!(
!paths.iter().any(|p| p.contains("deep")),
"src/mid/deep/leaf.rs must be excluded by mid-level .gitignore"
);
}
#[cfg(unix)]
#[tokio::test]
async fn test_gitignore_scan_follows_symlinked_directories() {
let dir = TempDir::new().unwrap();
let project = dir.path();
let external = TempDir::new().unwrap();
fs::create_dir_all(external.path()).unwrap();
fs::write(
external.path().join("lib.rs"),
"pub fn through_gitignore_symlink() {}\n",
)
.unwrap();
symlink(external.path(), project.join("src")).unwrap();
TokenSave::init(project).await.unwrap();
let mut config = load_config(project).unwrap();
config.git_ignore = true;
save_config(project, &config).unwrap();
let cg = TokenSave::open(project).await.unwrap();
let result = cg.index_all().await.unwrap();
assert_eq!(
result.file_count, 1,
"gitignore-aware scan should follow symlinks"
);
let results = cg.search("through_gitignore_symlink", 10).await.unwrap();
assert!(
!results.is_empty(),
"should extract symbols through symlink with gitignore-aware walker"
);
}
#[cfg(unix)]
#[tokio::test]
async fn test_gitignore_scan_prunes_excluded_dir_with_symlink() {
let dir = TempDir::new().unwrap();
let project = dir.path();
fs::create_dir_all(project.join("src")).unwrap();
fs::write(project.join("src/main.rs"), "pub fn real_symbol() {}\n").unwrap();
let external = TempDir::new().unwrap();
fs::write(
external.path().join("escaped.rs"),
"pub fn escaped_symbol() {}\n",
)
.unwrap();
let excluded = project.join("build-output/nested");
fs::create_dir_all(&excluded).unwrap();
symlink(external.path(), excluded.join("link")).unwrap();
TokenSave::init(project).await.unwrap();
let mut config = load_config(project).unwrap();
config.git_ignore = true;
config.exclude.push("build-output/**".to_string());
save_config(project, &config).unwrap();
let cg = TokenSave::open(project).await.unwrap();
cg.index_all().await.unwrap();
let real = cg.search("real_symbol", 10).await.unwrap();
assert!(!real.is_empty(), "the real source file should be indexed");
let escaped = cg.search("escaped_symbol", 10).await.unwrap();
assert!(
escaped.is_empty(),
"symbols behind a symlink inside an excluded dir must not be indexed"
);
}
async fn setup_call_edge_project() -> (TempDir, TokenSave) {
let dir = TempDir::new().unwrap();
let project = dir.path();
fs::create_dir_all(project.join("src")).unwrap();
fs::write(
project.join("src/lib.rs"),
r#"
pub mod caller_mod;
pub mod callee_mod;
"#,
)
.unwrap();
fs::write(
project.join("src/callee_mod.rs"),
r#"
/// The target function that should be found via call edges.
pub fn target_fn() -> u32 {
42
}
"#,
)
.unwrap();
fs::write(
project.join("src/caller_mod.rs"),
r#"
use crate::callee_mod::target_fn;
pub fn caller_fn() -> u32 {
target_fn()
}
"#,
)
.unwrap();
let cg = TokenSave::init(project).await.unwrap();
(dir, cg)
}
async fn find_node_id(cg: &TokenSave, name: &str) -> String {
let results = cg.search(name, 10).await.unwrap();
results
.iter()
.find(|r| r.node.name == name)
.unwrap_or_else(|| panic!("node '{name}' not found in index"))
.node
.id
.clone()
}
#[tokio::test]
async fn test_index_all_produces_call_edges() {
let (_dir, cg) = setup_call_edge_project().await;
cg.index_all().await.unwrap();
let target_id = find_node_id(&cg, "target_fn").await;
let callers = cg.get_callers(&target_id, 3).await.unwrap();
assert!(
callers
.iter()
.any(|(node, edge)| node.name == "caller_fn" && edge.kind == EdgeKind::Calls),
"index_all should produce a Calls edge from caller_fn -> target_fn"
);
}
#[cfg(feature = "lang-ruby")]
#[tokio::test]
async fn test_index_all_resolves_static_ruby_receiver_calls() {
let dir = TempDir::new().unwrap();
let project = dir.path();
fs::write(
project.join("receiver_calls.rb"),
r#"
class Publisher
def publish
end
class << self
def publish
end
def run(worker)
Publisher.publish
::Publisher.publish
self.publish
worker.publish
InstanceOnly.publish
end
end
self.publish
target.instance_eval { self.publish }
Other.class_eval { self.publish }
end
class InstanceOnly
def publish
end
end
"#,
)
.unwrap();
let cg = TokenSave::init(project).await.unwrap();
cg.index_all().await.unwrap();
let nodes = cg.db().get_all_nodes().await.unwrap();
let edges = cg.db().get_all_edges().await.unwrap();
let caller = nodes
.iter()
.find(|node| {
node.kind == NodeKind::SingletonMethod
&& node.signature.as_deref() == Some("def run(worker)")
})
.unwrap();
let singleton_publish = nodes
.iter()
.find(|node| {
node.kind == NodeKind::SingletonMethod
&& node.signature.as_deref() == Some("def publish")
})
.unwrap();
let publisher = nodes
.iter()
.find(|node| node.kind == NodeKind::Class && node.name == "Publisher")
.unwrap();
let instance_targets: Vec<_> = nodes
.iter()
.filter(|node| {
node.kind == NodeKind::Method && node.signature.as_deref() == Some("def publish")
})
.map(|node| node.id.as_str())
.collect();
let calls: Vec<_> = edges
.iter()
.filter(|edge| edge.source == caller.id && edge.kind == EdgeKind::Calls)
.collect();
assert_eq!(calls.len(), 3, "nodes: {nodes:#?}\nedges: {edges:#?}");
assert!(calls.iter().all(|edge| edge.target == singleton_publish.id));
assert!(calls
.iter()
.all(|edge| !instance_targets.contains(&edge.target.as_str())));
assert_eq!(
edges
.iter()
.filter(|edge| {
edge.source == publisher.id
&& edge.target == singleton_publish.id
&& edge.kind == EdgeKind::Calls
})
.count(),
1,
"self calls in blocks that retarget self must not be attributed to Publisher"
);
}
#[cfg(feature = "lang-ruby")]
#[tokio::test]
async fn test_incremental_sync_resolves_calls_to_legacy_ruby_singleton_methods() {
let dir = TempDir::new().unwrap();
let project = dir.path();
fs::write(
project.join("publisher.rb"),
"class Publisher\n class << self\n def publish; end\n end\nend\n",
)
.unwrap();
fs::write(project.join("caller.rb"), "def run; end\n").unwrap();
let cg = TokenSave::init(project).await.unwrap();
cg.index_all().await.unwrap();
cg.db()
.conn()
.execute(
"UPDATE nodes SET kind = 'method' WHERE name = 'publish'",
(),
)
.await
.unwrap();
cg.db()
.conn()
.execute(
"DELETE FROM metadata WHERE key = 'ruby_singleton_method_kind_v1'",
(),
)
.await
.unwrap();
fs::write(
project.join("caller.rb"),
"def run\n Publisher.publish\nend\n",
)
.unwrap();
let sync = cg.sync().await.unwrap();
assert!(sync.modified_paths.contains(&"publisher.rb".to_string()));
let nodes = cg.db().get_all_nodes().await.unwrap();
let publish = nodes.iter().find(|node| node.name == "publish").unwrap();
assert_eq!(publish.kind, NodeKind::SingletonMethod);
assert!(cg
.db()
.get_all_edges()
.await
.unwrap()
.iter()
.any(|edge| { edge.kind == EdgeKind::Calls && edge.target == publish.id }));
let settled = cg.sync().await.unwrap();
assert!(settled.modified_paths.is_empty());
}
#[tokio::test]
async fn test_sync_produces_call_edges() {
let (_dir, cg) = setup_call_edge_project().await;
cg.sync().await.unwrap();
let target_id = find_node_id(&cg, "target_fn").await;
let callers = cg.get_callers(&target_id, 3).await.unwrap();
assert!(
callers
.iter()
.any(|(node, edge)| node.name == "caller_fn" && edge.kind == EdgeKind::Calls),
"sync should produce a Calls edge from caller_fn -> target_fn"
);
}
#[tokio::test]
async fn test_sync_produces_call_edges_after_file_modification() {
let dir = TempDir::new().unwrap();
let project = dir.path();
fs::create_dir_all(project.join("src")).unwrap();
fs::write(
project.join("src/lib.rs"),
r#"
pub fn base_fn() -> u32 { 1 }
pub fn consumer() -> u32 { base_fn() }
"#,
)
.unwrap();
let cg = TokenSave::init(project).await.unwrap();
cg.index_all().await.unwrap();
fs::write(
project.join("src/lib.rs"),
r#"
pub fn base_fn() -> u32 { 1 }
pub fn middle_fn() -> u32 { base_fn() }
pub fn top_fn() -> u32 { middle_fn() }
"#,
)
.unwrap();
cg.sync().await.unwrap();
let base_id = find_node_id(&cg, "base_fn").await;
let middle_id = find_node_id(&cg, "middle_fn").await;
let base_callers = cg.get_callers(&base_id, 1).await.unwrap();
assert!(
base_callers
.iter()
.any(|(node, _)| node.name == "middle_fn"),
"sync should resolve middle_fn -> base_fn call edge after modification"
);
let middle_callers = cg.get_callers(&middle_id, 1).await.unwrap();
assert!(
middle_callers.iter().any(|(node, _)| node.name == "top_fn"),
"sync should resolve top_fn -> middle_fn call edge after modification"
);
let transitive_callers = cg.get_callers(&base_id, 3).await.unwrap();
assert!(
transitive_callers
.iter()
.any(|(node, _)| node.name == "top_fn"),
"sync should support transitive call edge traversal"
);
}
#[tokio::test]
async fn test_sync_resolves_cross_file_call_edges_for_new_files() {
let dir = TempDir::new().unwrap();
let project = dir.path();
fs::create_dir_all(project.join("src")).unwrap();
fs::write(
project.join("src/lib.rs"),
r#"
pub mod engine;
pub fn entry_point() -> u32 { 0 }
"#,
)
.unwrap();
let cg = TokenSave::init(project).await.unwrap();
cg.index_all().await.unwrap();
fs::write(
project.join("src/engine.rs"),
r#"
use crate::entry_point;
pub fn run_engine() -> u32 {
entry_point()
}
"#,
)
.unwrap();
cg.sync().await.unwrap();
let entry_id = find_node_id(&cg, "entry_point").await;
let callers = cg.get_callers(&entry_id, 3).await.unwrap();
assert!(
callers.iter().any(|(node, _)| node.name == "run_engine"),
"sync should resolve cross-file call edges when a new file is added"
);
}
#[tokio::test]
async fn test_sync_does_not_duplicate_edges() {
let dir = TempDir::new().unwrap();
let project = dir.path();
fs::create_dir_all(project.join("src")).unwrap();
fs::write(
project.join("src/callee.rs"),
"pub fn target_fn() -> u32 { 42 }\n",
)
.unwrap();
fs::write(
project.join("src/caller_a.rs"),
"pub fn caller_a() -> u32 { target_fn() }\n",
)
.unwrap();
fs::write(
project.join("src/caller_b.rs"),
"pub fn caller_b() -> u32 { target_fn() }\n",
)
.unwrap();
let cg = TokenSave::init(project).await.unwrap();
cg.index_all().await.unwrap();
let stats_before = cg.get_stats().await.unwrap();
let edges_before = stats_before.edge_count;
fs::write(
project.join("src/caller_a.rs"),
"pub fn caller_a() -> u32 { target_fn() + 1 }\n",
)
.unwrap();
cg.sync().await.unwrap();
let stats_after = cg.get_stats().await.unwrap();
assert_eq!(
edges_before, stats_after.edge_count,
"sync must not create duplicate edges (before={edges_before}, after={})",
stats_after.edge_count
);
cg.sync().await.unwrap();
let stats_final = cg.get_stats().await.unwrap();
assert_eq!(
edges_before, stats_final.edge_count,
"repeated sync must not grow edges (before={edges_before}, final={})",
stats_final.edge_count
);
}
#[tokio::test]
async fn test_concurrent_sync_is_rejected() {
let dir = TempDir::new().unwrap();
let project = dir.path();
fs::create_dir_all(project.join("src")).unwrap();
fs::write(project.join("src/lib.rs"), "pub fn f() {}\n").unwrap();
let cg = TokenSave::init(project).await.unwrap();
let lock_path = project.join(".tokensave/sync.lock");
fs::write(&lock_path, format!("{}", std::process::id())).unwrap();
let err = cg.sync().await.unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("another sync is already in progress"),
"expected sync lock error, got: {msg}"
);
fs::remove_file(&lock_path).unwrap();
cg.sync().await.unwrap();
}
#[tokio::test]
async fn test_sync_reresolves_inbound_edges_after_callee_change() {
let dir = TempDir::new().unwrap();
let project = dir.path();
fs::create_dir_all(project.join("src")).unwrap();
fs::write(
project.join("src/callee.rs"),
"pub fn target_fn() -> u32 { 42 }\n",
)
.unwrap();
fs::write(
project.join("src/caller_a.rs"),
"pub fn caller_a() -> u32 { target_fn() }\n",
)
.unwrap();
fs::write(
project.join("src/caller_b.rs"),
"pub fn caller_b() -> u32 { target_fn() }\n",
)
.unwrap();
let cg = TokenSave::init(project).await.unwrap();
cg.index_all().await.unwrap();
async fn calls_into_target_fn(cg: &TokenSave) -> usize {
let nodes = cg.db().get_all_nodes().await.unwrap();
let edges = cg.db().get_all_edges().await.unwrap();
let Some(target_id) = nodes
.iter()
.find(|n| n.name == "target_fn")
.map(|n| n.id.clone())
else {
return 0;
};
edges
.iter()
.filter(|e| e.kind == EdgeKind::Calls && e.target == target_id)
.count()
}
assert_eq!(
calls_into_target_fn(&cg).await,
2,
"full index should resolve both cross-file calls into target_fn"
);
fs::write(
project.join("src/callee.rs"),
"pub fn target_fn() -> u32 { 43 }\npub fn unrelated() -> u32 { 0 }\n",
)
.unwrap();
let sync_result = cg.sync().await.unwrap();
assert_eq!(
sync_result.files_modified, 1,
"only callee.rs should be detected as stale"
);
assert_eq!(
calls_into_target_fn(&cg).await,
2,
"sync must re-resolve inbound call edges from untouched callers \
after the callee they reference is reindexed — a full reindex \
would keep both edges, so incremental sync must too"
);
}
#[tokio::test]
async fn test_incremental_sync_graph_matches_full_reindex() {
let synced_dir = TempDir::new().unwrap();
let synced_project = synced_dir.path();
let full_dir = TempDir::new().unwrap();
let full_project = full_dir.path();
fs::create_dir_all(synced_project.join("src")).unwrap();
fs::create_dir_all(full_project.join("src")).unwrap();
let callee_v1 = "pub fn target_fn() -> u32 { 42 }\n";
let caller_a_v1 = "pub fn caller_a() -> u32 { target_fn() }\n";
let caller_b_v1 = "pub fn caller_b() -> u32 { target_fn() }\n";
let caller_c_v1 = "pub fn caller_c() -> u32 { target_fn() }\n";
fs::write(synced_project.join("src/callee.rs"), callee_v1).unwrap();
fs::write(synced_project.join("src/caller_a.rs"), caller_a_v1).unwrap();
fs::write(synced_project.join("src/caller_b.rs"), caller_b_v1).unwrap();
fs::write(synced_project.join("src/caller_c.rs"), caller_c_v1).unwrap();
let cg = TokenSave::init(synced_project).await.unwrap();
cg.index_all().await.unwrap();
let callee_v2 = "pub fn target_fn() -> u32 { 43 }\npub fn extra_1() {}\n";
fs::write(synced_project.join("src/callee.rs"), callee_v2).unwrap();
cg.sync().await.unwrap();
let caller_a_v2 = "pub fn caller_a() -> u32 { target_fn() + 1 }\n";
fs::write(synced_project.join("src/caller_a.rs"), caller_a_v2).unwrap();
cg.sync().await.unwrap();
let caller_b_v2 = "pub fn caller_b() -> u32 { target_fn() + 2 }\n";
fs::write(synced_project.join("src/caller_b.rs"), caller_b_v2).unwrap();
cg.sync().await.unwrap();
fs::write(full_project.join("src/callee.rs"), callee_v2).unwrap();
fs::write(full_project.join("src/caller_a.rs"), caller_a_v2).unwrap();
fs::write(full_project.join("src/caller_b.rs"), caller_b_v2).unwrap();
fs::write(full_project.join("src/caller_c.rs"), caller_c_v1).unwrap();
let cg_full = TokenSave::init(full_project).await.unwrap();
cg_full.index_all().await.unwrap();
let (synced_nodes, synced_edges) = canonical_graph(&cg).await;
let (full_nodes, full_edges) = canonical_graph(&cg_full).await;
assert_eq!(synced_nodes, full_nodes);
assert_eq!(synced_edges, full_edges);
let target_id = synced_nodes
.iter()
.find(|n| n.name == "target_fn")
.map(|n| n.id.clone())
.expect("target_fn node must exist");
let calls_into_target = synced_edges
.iter()
.filter(|e| e.kind == EdgeKind::Calls && e.target == target_id)
.count();
assert_eq!(
calls_into_target, 3,
"all three callers (including untouched caller_c) must resolve \
into target_fn after incremental sync"
);
}
#[cfg(feature = "lang-ruby")]
#[tokio::test]
async fn test_ruby_incremental_sync_graph_matches_full_reindex() {
let synced_dir = TempDir::new().unwrap();
let synced_project = synced_dir.path();
let full_dir = TempDir::new().unwrap();
let full_project = full_dir.path();
for project in [synced_project, full_project] {
fs::create_dir_all(project.join("app/models/concerns")).unwrap();
fs::create_dir_all(project.join("app/services")).unwrap();
}
let application_record = r#"class ApplicationRecord
def persist
end
end
"#;
let auditable_v1 = r#"module Auditable
def audit
normalize_audit()
end
def normalize_audit
end
end
"#;
let auditable_v2 = r#"module Auditable
def audit
sanitize_audit()
end
def sanitize_audit
end
end
"#;
let report_v1 = r#"class Report < ApplicationRecord
include Auditable
def publish
audit()
persist()
end
end
"#;
let report_v2 = r#"class Report < ApplicationRecord
include Auditable
def publish
audit()
persist()
end
def archive
audit()
end
end
"#;
let publisher_v1 = r#"class ReportPublisher
def call
publish()
end
end
"#;
let publisher_v2 = r#"class ReportPublisher
def call
archive()
end
end
"#;
fs::write(
synced_project.join("app/models/application_record.rb"),
application_record,
)
.unwrap();
fs::write(
synced_project.join("app/models/concerns/auditable.rb"),
auditable_v1,
)
.unwrap();
fs::write(synced_project.join("app/models/report.rb"), report_v1).unwrap();
fs::write(
synced_project.join("app/services/report_publisher.rb"),
publisher_v1,
)
.unwrap();
let cg = TokenSave::init(synced_project).await.unwrap();
cg.index_all().await.unwrap();
fs::write(
synced_project.join("app/models/concerns/auditable.rb"),
auditable_v2,
)
.unwrap();
assert_eq!(cg.sync().await.unwrap().files_modified, 1);
fs::write(synced_project.join("app/models/report.rb"), report_v2).unwrap();
assert_eq!(cg.sync().await.unwrap().files_modified, 1);
fs::write(
synced_project.join("app/services/report_publisher.rb"),
publisher_v2,
)
.unwrap();
assert_eq!(cg.sync().await.unwrap().files_modified, 1);
fs::write(
full_project.join("app/models/application_record.rb"),
application_record,
)
.unwrap();
fs::write(
full_project.join("app/models/concerns/auditable.rb"),
auditable_v2,
)
.unwrap();
fs::write(full_project.join("app/models/report.rb"), report_v2).unwrap();
fs::write(
full_project.join("app/services/report_publisher.rb"),
publisher_v2,
)
.unwrap();
let cg_full = TokenSave::init(full_project).await.unwrap();
cg_full.index_all().await.unwrap();
let (synced_nodes, synced_edges) = canonical_graph(&cg).await;
let (full_nodes, full_edges) = canonical_graph(&cg_full).await;
let node_id = |name: &str| {
let matches: Vec<_> = synced_nodes
.iter()
.filter(|node| node.name == name)
.collect();
assert_eq!(matches.len(), 1, "expected one node named {name}");
matches[0].id.as_str()
};
let has_edge = |source: &str, target: &str, kind: EdgeKind| {
synced_edges
.iter()
.any(|edge| edge.source == source && edge.target == target && edge.kind == kind)
};
let application_record_id = node_id("ApplicationRecord");
let auditable_id = node_id("Auditable");
let report_id = node_id("Report");
let audit_id = node_id("audit");
let sanitize_audit_id = node_id("sanitize_audit");
let archive_id = node_id("archive");
let call_id = node_id("call");
let archive = synced_nodes
.iter()
.find(|node| node.id == archive_id)
.expect("archive node must exist");
assert_eq!(archive.parent_id.as_deref(), Some(report_id));
assert!(has_edge(
report_id,
application_record_id,
EdgeKind::Extends
));
assert!(has_edge(report_id, auditable_id, EdgeKind::Implements));
assert!(has_edge(audit_id, sanitize_audit_id, EdgeKind::Calls));
assert!(has_edge(call_id, archive_id, EdgeKind::Calls));
assert_eq!(synced_nodes, full_nodes);
assert_eq!(synced_edges, full_edges);
}
#[tokio::test]
async fn test_verbose_sync_reports_skipped_extensions() {
let dir = TempDir::new().unwrap();
let project = dir.path();
fs::create_dir_all(project.join("src")).unwrap();
fs::write(project.join("src/lib.rs"), "pub fn f() {}\n").unwrap();
fs::write(project.join("a.coolscript"), "print 1\n").unwrap();
fs::write(project.join("b.coolscript"), "print 2\n").unwrap();
fs::write(project.join("logo.png"), [0x89u8, 0x50, 0x4e, 0x47]).unwrap();
fs::write(project.join("deps.lock"), "lockfile\n").unwrap();
let cg = TokenSave::init(project).await.unwrap();
cg.index_all().await.unwrap();
let lines = std::sync::Mutex::new(Vec::<String>::new());
let result = cg
.sync_with_progress_verbose(
|_, _, _| {},
|msg| lines.lock().unwrap().push(msg.to_string()),
)
.await
.unwrap();
let lines = lines.into_inner().unwrap();
assert!(
lines
.iter()
.any(|l| l.contains(".coolscript: 2 file(s) skipped (no registered extractor)")),
"verbose output must summarize the skipped extension; got: {lines:?}"
);
assert!(
!lines
.iter()
.any(|l| l.contains(".png") || l.contains(".lock")),
"binary/asset extensions must not be reported; got: {lines:?}"
);
assert_eq!(
result.skipped_extensions,
vec![("coolscript".to_string(), 2)],
"skipped_extensions: {:?}",
result.skipped_extensions
);
}
#[tokio::test]
async fn test_index_all_reports_skipped_extensions() {
let dir = TempDir::new().unwrap();
let project = dir.path();
fs::write(project.join("README.md"), "# readme\n").unwrap();
fs::write(
project.join("example.vhd"),
"entity example is\nend example;\n",
)
.unwrap();
let cg = TokenSave::init(project).await.unwrap();
let result = cg.index_all().await.unwrap();
assert_eq!(
result.skipped_extensions,
vec![("vhd".to_string(), 1)],
"skipped_extensions: {:?}",
result.skipped_extensions
);
}