use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::time::Instant;
use walkdir::WalkDir;
use crate::config::{get_tokensave_dir, is_excluded, load_config, save_config, TokenSaveConfig};
use crate::context::ContextBuilder;
use crate::db::Database;
use crate::errors::{TokenSaveError, Result};
use crate::extraction::LanguageRegistry;
use crate::graph::{GraphQueryManager, GraphTraverser};
use crate::resolution::ReferenceResolver;
use crate::sync;
use crate::types::*;
pub struct TokenSave {
db: Database,
config: TokenSaveConfig,
project_root: PathBuf,
registry: LanguageRegistry,
}
pub struct IndexResult {
pub file_count: usize,
pub node_count: usize,
pub edge_count: usize,
pub duration_ms: u64,
}
pub struct SyncResult {
pub files_added: usize,
pub files_modified: usize,
pub files_removed: usize,
pub duration_ms: u64,
}
fn current_timestamp() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64
}
impl TokenSave {
pub async fn init(project_root: &Path) -> Result<Self> {
let config = TokenSaveConfig {
root_dir: project_root.to_string_lossy().to_string(),
..TokenSaveConfig::default()
};
save_config(project_root, &config)?;
let db_path = get_tokensave_dir(project_root).join("tokensave.db");
let db = Database::initialize(&db_path).await?;
Ok(Self {
db,
config,
project_root: project_root.to_path_buf(),
registry: LanguageRegistry::new(),
})
}
pub async fn open(project_root: &Path) -> Result<Self> {
let config = load_config(project_root)?;
let db_path = get_tokensave_dir(project_root).join("tokensave.db");
if !db_path.exists() {
return Err(TokenSaveError::Config {
message: format!(
"no TokenSave database found at '{}'; run 'tokensave sync' first",
db_path.display()
),
});
}
let db = Database::open(&db_path).await?;
Ok(Self {
db,
config,
project_root: project_root.to_path_buf(),
registry: LanguageRegistry::new(),
})
}
pub fn is_initialized(project_root: &Path) -> bool {
get_tokensave_dir(project_root)
.join("tokensave.db")
.exists()
}
}
impl TokenSave {
pub async fn index_all(&self) -> Result<IndexResult> {
self.index_all_with_progress(|_| {}).await
}
pub async fn index_all_with_progress<F>(&self, on_file: F) -> Result<IndexResult>
where
F: Fn(&str),
{
let start = Instant::now();
self.db.clear().await?;
let files = self.scan_files()?;
let mut total_nodes = 0;
let mut total_edges = 0;
for file_path in &files {
on_file(file_path);
let abs_path = self.project_root.join(file_path);
let source = match std::fs::read_to_string(&abs_path) {
Ok(s) => s,
Err(_) => continue,
};
let extractor = match self.registry.extractor_for_file(file_path) {
Some(e) => e,
None => continue,
};
let result = extractor.extract(file_path, &source);
self.db.insert_nodes(&result.nodes).await?;
self.db.insert_edges(&result.edges).await?;
if !result.unresolved_refs.is_empty() {
self.db.insert_unresolved_refs(&result.unresolved_refs).await?;
}
let file_record = FileRecord {
path: file_path.clone(),
content_hash: sync::content_hash(&source),
size: source.len() as u64,
modified_at: current_timestamp(),
indexed_at: current_timestamp(),
node_count: result.nodes.len() as u32,
};
self.db.upsert_file(&file_record).await?;
total_nodes += result.nodes.len();
total_edges += result.edges.len();
}
let unresolved = self.db.get_unresolved_refs().await?;
if !unresolved.is_empty() {
let resolver = ReferenceResolver::new(&self.db).await;
let resolution = resolver.resolve_all(&unresolved);
let edges = resolver.create_edges(&resolution.resolved);
if !edges.is_empty() {
self.db.insert_edges(&edges).await?;
total_edges += edges.len();
}
}
Ok(IndexResult {
file_count: files.len(),
node_count: total_nodes,
edge_count: total_edges,
duration_ms: start.elapsed().as_millis() as u64,
})
}
pub async fn sync(&self) -> Result<SyncResult> {
self.sync_with_progress(|_, _| {}).await
}
pub async fn sync_with_progress<F>(&self, on_progress: F) -> Result<SyncResult>
where
F: Fn(&str, &str),
{
let start = Instant::now();
on_progress("scanning files", "");
let current_files = self.scan_files()?;
on_progress("hashing files", "");
let mut current_hashes = Vec::new();
for path in ¤t_files {
let abs_path = self.project_root.join(path);
if let Ok(source) = std::fs::read_to_string(&abs_path) {
current_hashes.push((path.clone(), sync::content_hash(&source)));
}
}
on_progress("detecting changes", "");
let stale = sync::find_stale_files(&self.db, ¤t_hashes).await?;
let new = sync::find_new_files(&self.db, ¤t_files).await?;
let removed = sync::find_removed_files(&self.db, ¤t_files).await?;
for path in &removed {
on_progress("removing", path);
self.db.delete_file(path).await?;
}
let to_index: Vec<String> = stale.iter().chain(new.iter()).cloned().collect();
for file_path in &to_index {
on_progress("syncing", file_path);
self.db.delete_nodes_by_file(file_path).await?;
let abs_path = self.project_root.join(file_path);
let source = match std::fs::read_to_string(&abs_path) {
Ok(s) => s,
Err(_) => continue,
};
let extractor = match self.registry.extractor_for_file(file_path) {
Some(e) => e,
None => continue,
};
let result = extractor.extract(file_path, &source);
self.db.insert_nodes(&result.nodes).await?;
self.db.insert_edges(&result.edges).await?;
if !result.unresolved_refs.is_empty() {
self.db.insert_unresolved_refs(&result.unresolved_refs).await?;
}
let file_record = FileRecord {
path: file_path.clone(),
content_hash: sync::content_hash(&source),
size: source.len() as u64,
modified_at: current_timestamp(),
indexed_at: current_timestamp(),
node_count: result.nodes.len() as u32,
};
self.db.upsert_file(&file_record).await?;
}
if !to_index.is_empty() {
on_progress("resolving references", "");
let unresolved = self.db.get_unresolved_refs().await?;
if !unresolved.is_empty() {
let resolver = ReferenceResolver::new(&self.db).await;
let resolution = resolver.resolve_all(&unresolved);
let edges = resolver.create_edges(&resolution.resolved);
if !edges.is_empty() {
self.db.insert_edges(&edges).await?;
}
}
}
Ok(SyncResult {
files_added: new.len(),
files_modified: stale.len(),
files_removed: removed.len(),
duration_ms: start.elapsed().as_millis() as u64,
})
}
fn scan_files(&self) -> Result<Vec<String>> {
let supported_exts = self.registry.supported_extensions();
let mut files = Vec::new();
for entry in WalkDir::new(&self.project_root)
.follow_links(false)
.into_iter()
.filter_entry(|e| {
if e.depth() == 0 {
return true;
}
let name = e.file_name().to_string_lossy();
!name.starts_with('.') && name != "target"
})
{
let entry = match entry {
Ok(e) => e,
Err(_) => continue,
};
if !entry.file_type().is_file() {
continue;
}
let path = entry.path();
let ext = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("");
if !supported_exts.contains(&ext) {
continue;
}
if let Ok(relative) = path.strip_prefix(&self.project_root) {
let rel_str = relative.to_string_lossy().to_string();
if !is_excluded(&rel_str, &self.config) {
if let Ok(metadata) = std::fs::metadata(path) {
if metadata.len() <= self.config.max_file_size {
files.push(rel_str);
}
}
}
}
}
Ok(files)
}
}
impl TokenSave {
pub async fn search(&self, query: &str, limit: usize) -> Result<Vec<SearchResult>> {
self.db.search_nodes(query, limit).await
}
pub async fn get_stats(&self) -> Result<GraphStats> {
self.db.get_stats().await
}
pub async fn get_node(&self, id: &str) -> Result<Option<Node>> {
self.db.get_node_by_id(id).await
}
pub async fn get_callers(&self, node_id: &str, max_depth: usize) -> Result<Vec<(Node, Edge)>> {
let traverser = GraphTraverser::new(&self.db);
traverser.get_callers(node_id, max_depth).await
}
pub async fn get_callees(&self, node_id: &str, max_depth: usize) -> Result<Vec<(Node, Edge)>> {
let traverser = GraphTraverser::new(&self.db);
traverser.get_callees(node_id, max_depth).await
}
pub async fn get_impact_radius(&self, node_id: &str, max_depth: usize) -> Result<Subgraph> {
let traverser = GraphTraverser::new(&self.db);
traverser.get_impact_radius(node_id, max_depth).await
}
pub async fn find_dead_code(&self, kinds: &[NodeKind]) -> Result<Vec<Node>> {
let qm = GraphQueryManager::new(&self.db);
qm.find_dead_code(kinds).await
}
pub async fn build_context(&self, task: &str, options: &BuildContextOptions) -> Result<TaskContext> {
let builder = ContextBuilder::new(&self.db, &self.project_root);
builder.build_context(task, options).await
}
pub async fn get_file_token_map(&self) -> Result<HashMap<String, u64>> {
let files = self.db.get_all_files().await?;
Ok(files.into_iter().map(|f| (f.path, f.size / 4)).collect())
}
pub async fn get_tokens_saved(&self) -> Result<u64> {
match self.db.get_metadata("tokens_saved").await? {
Some(v) => Ok(v.parse::<u64>().unwrap_or(0)),
None => Ok(0),
}
}
pub async fn set_tokens_saved(&self, value: u64) -> Result<()> {
self.db
.set_metadata("tokens_saved", &value.to_string())
.await
}
pub fn get_config(&self) -> &TokenSaveConfig {
&self.config
}
pub fn project_root(&self) -> &Path {
&self.project_root
}
}