use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use super::{ResolutionError, ResolutionResult, Sha256Hash};
pub const RESOLUTION_INDEX_VERSION: &str = "1.0";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResolutionIndex {
pub version: String,
pub hashes: HashMap<PathBuf, String>,
pub mappings: HashMap<String, PathBuf>,
pub rules: HashMap<PathBuf, ResolutionRules>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ResolutionRules {
pub base_url: Option<String>,
pub paths: HashMap<String, Vec<String>>,
}
impl Default for ResolutionIndex {
fn default() -> Self {
Self::new()
}
}
impl ResolutionIndex {
pub fn new() -> Self {
Self {
version: RESOLUTION_INDEX_VERSION.to_string(),
hashes: HashMap::new(),
mappings: HashMap::new(),
rules: HashMap::new(),
}
}
pub fn needs_rebuild(&self, config_path: &Path, current_sha: &Sha256Hash) -> bool {
self.hashes
.get(config_path)
.map(|stored_sha| stored_sha != current_sha.as_str())
.unwrap_or(true)
}
pub fn update_sha(&mut self, config_path: &Path, sha: &Sha256Hash) {
self.hashes
.insert(config_path.to_path_buf(), sha.as_str().to_string());
}
pub fn add_mapping(&mut self, pattern: &str, config_path: &Path) {
self.mappings
.insert(pattern.to_string(), config_path.to_path_buf());
}
pub fn set_rules(&mut self, config_path: &Path, rules: ResolutionRules) {
self.rules.insert(config_path.to_path_buf(), rules);
}
pub fn get_config_for_file(&self, file_path: &Path) -> Option<&PathBuf> {
let resolved_file = file_path
.canonicalize()
.unwrap_or_else(|_| file_path.to_path_buf());
let file_str = resolved_file.to_str()?;
let mut matches: Vec<(&String, &PathBuf)> = self
.mappings
.iter()
.filter(|(pattern, _)| {
let pattern_prefix = if let Some(pos) = pattern.find("/**/*") {
&pattern[..pos]
} else {
pattern.trim_end_matches('/')
};
let pattern_path = std::path::Path::new(pattern_prefix);
let canon_pattern = pattern_path
.canonicalize()
.unwrap_or_else(|_| pattern_path.to_path_buf());
let Some(canon_pattern_str) = canon_pattern.to_str() else {
return false;
};
file_str.starts_with(canon_pattern_str)
})
.collect();
matches.sort_by_key(|(pattern, _)| -(pattern.len() as i32));
matches.first().map(|(_, config)| *config)
}
}
pub struct ResolutionPersistence {
base_dir: PathBuf,
}
impl ResolutionPersistence {
pub fn new(codanna_dir: &Path) -> Self {
Self {
base_dir: codanna_dir.join("index").join("resolvers"),
}
}
fn index_path(&self, language_id: &str) -> PathBuf {
self.base_dir.join(format!("{language_id}_resolution.json"))
}
pub fn load(&self, language_id: &str) -> ResolutionResult<ResolutionIndex> {
let path = self.index_path(language_id);
if !path.exists() {
return Ok(ResolutionIndex::new());
}
let content = fs::read_to_string(&path).map_err(|e| ResolutionError::IoError {
path: path.clone(),
cause: e.to_string(),
})?;
let index: ResolutionIndex =
serde_json::from_str(&content).map_err(|e| ResolutionError::ParseError {
message: format!("Failed to parse resolution index: {e}"),
})?;
if index.version != RESOLUTION_INDEX_VERSION {
return Err(ResolutionError::ParseError {
message: format!(
"Incompatible index version: expected {}, got {}",
RESOLUTION_INDEX_VERSION, index.version
),
});
}
Ok(index)
}
pub fn save(&self, language_id: &str, index: &ResolutionIndex) -> ResolutionResult<()> {
fs::create_dir_all(&self.base_dir).map_err(|e| ResolutionError::IoError {
path: self.base_dir.clone(),
cause: e.to_string(),
})?;
let path = self.index_path(language_id);
let content =
serde_json::to_string_pretty(index).map_err(|e| ResolutionError::ParseError {
message: format!("Failed to serialize resolution index: {e}"),
})?;
fs::write(&path, content).map_err(|e| ResolutionError::IoError {
path,
cause: e.to_string(),
})?;
Ok(())
}
pub fn clear(&self, language_id: &str) -> ResolutionResult<()> {
let path = self.index_path(language_id);
if path.exists() {
fs::remove_file(&path).map_err(|e| ResolutionError::IoError {
path,
cause: e.to_string(),
})?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{LanguageConfig, Settings};
use crate::parsing::typescript::tsconfig::read_tsconfig;
use tempfile::TempDir;
fn create_test_settings_with_tsconfigs() -> Settings {
let mut settings = Settings::default();
let ts_config = LanguageConfig {
enabled: true,
config_files: vec![
PathBuf::from("examples/typescript/tsconfig.json"),
PathBuf::from("examples/typescript/packages/web/tsconfig.json"),
],
extensions: vec![".ts".to_string(), ".tsx".to_string()],
parser_options: Default::default(),
projects: Vec::new(),
};
settings
.languages
.insert("typescript".to_string(), ts_config);
settings
}
#[test]
#[ignore = "Requires examples/ directory with tsconfig files - run with: cargo test -- --ignored"]
fn test_resolution_index_with_settings() {
let settings = create_test_settings_with_tsconfigs();
let ts_configs = settings
.languages
.get("typescript")
.expect("Should have TypeScript config")
.config_files
.clone();
if !ts_configs.iter().all(|p| p.exists()) {
println!("Skipping test - example tsconfig files not found");
return;
}
let mut index = ResolutionIndex::new();
for config_path in &ts_configs {
let sha = crate::project_resolver::sha::compute_file_sha(config_path)
.expect("Should compute SHA");
assert!(index.needs_rebuild(config_path, &sha));
index.update_sha(config_path, &sha);
assert!(!index.needs_rebuild(config_path, &sha));
let config = read_tsconfig(config_path).expect("Should parse tsconfig from settings");
index.set_rules(
config_path,
ResolutionRules {
base_url: config.compilerOptions.baseUrl,
paths: config.compilerOptions.paths,
},
);
}
index.add_mapping("examples/typescript/src/**/*.ts", &ts_configs[0]);
index.add_mapping("examples/typescript/packages/web/**/*.ts", &ts_configs[1]);
assert_eq!(
index.get_config_for_file(&PathBuf::from(
"examples/typescript/src/components/Button.ts"
)),
Some(&ts_configs[0])
);
}
#[test]
#[ignore = "Requires examples/ directory with tsconfig files - run with: cargo test -- --ignored"]
fn test_persistence_with_settings() {
let settings = create_test_settings_with_tsconfigs();
let ts_configs = &settings
.languages
.get("typescript")
.expect("Should have TypeScript config")
.config_files;
if ts_configs.is_empty() || !ts_configs[0].exists() {
println!("Skipping test - no tsconfig in settings");
return;
}
let temp_dir = TempDir::new().unwrap();
let persistence = ResolutionPersistence::new(temp_dir.path());
let mut index = ResolutionIndex::new();
let config_path = &ts_configs[0];
let sha = crate::project_resolver::sha::compute_file_sha(config_path)
.expect("Should compute SHA");
let config = read_tsconfig(config_path).expect("Should parse tsconfig");
index.update_sha(config_path, &sha);
index.add_mapping("examples/typescript/**/*.ts", config_path);
index.set_rules(
config_path,
ResolutionRules {
base_url: config.compilerOptions.baseUrl,
paths: config.compilerOptions.paths,
},
);
persistence.save("typescript", &index).unwrap();
let loaded = persistence.load("typescript").unwrap();
assert_eq!(loaded.version, RESOLUTION_INDEX_VERSION);
assert_eq!(loaded.hashes.len(), 1);
assert_eq!(loaded.mappings.len(), 1);
assert_eq!(loaded.rules.len(), 1);
assert!(
!loaded.needs_rebuild(config_path, &sha),
"SHA should match after load"
);
assert_eq!(
loaded.get_config_for_file(&PathBuf::from("examples/typescript/src/main.ts")),
Some(config_path)
);
assert!(
loaded.rules.contains_key(config_path),
"Rules should exist for config"
);
}
}