use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
fn default_true() -> bool {
true
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct LibraryEntry {
pub name: String,
pub version: Option<String>,
#[serde(default)]
pub version_indexed: Option<String>,
#[serde(default)]
pub db_file: Option<String>,
#[serde(default)]
pub nudge_dismissed: bool,
pub path: PathBuf,
pub language: String,
pub discovered_via: DiscoveryMethod,
pub indexed: bool,
#[serde(default = "default_true")]
pub source_available: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum DiscoveryMethod {
LspFollowThrough,
Manual,
ManifestScan,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct LibraryRegistry {
entries: Vec<LibraryEntry>,
}
impl LibraryRegistry {
pub fn new() -> Self {
Self {
entries: Vec::new(),
}
}
pub fn load(path: &Path) -> Result<Self> {
if !path.exists() {
return Ok(Self::new());
}
let data = std::fs::read_to_string(path)?;
let registry: LibraryRegistry = serde_json::from_str(&data)?;
Ok(registry)
}
pub fn save(&self, path: &Path) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let data = serde_json::to_string_pretty(self)?;
crate::util::fs::atomic_write(path, &data)?;
Ok(())
}
pub fn register(
&mut self,
name: String,
path: PathBuf,
language: String,
discovered_via: DiscoveryMethod,
source_available: bool,
) {
if let Some(existing) = self.entries.iter_mut().find(|e| e.name == name) {
if existing.discovered_via == DiscoveryMethod::Manual
&& discovered_via == DiscoveryMethod::ManifestScan
{
return;
}
let path_changed = existing.path != path;
existing.path = path;
existing.language = language;
existing.discovered_via = discovered_via;
existing.source_available = source_available;
if path_changed {
existing.indexed = false;
}
} else {
self.entries.push(LibraryEntry {
name,
version: None,
version_indexed: None,
db_file: None,
nudge_dismissed: false,
path,
language,
indexed: false,
discovered_via,
source_available,
});
}
}
pub fn lookup(&self, name: &str) -> Option<&LibraryEntry> {
self.entries.iter().find(|e| e.name == name)
}
pub fn lookup_mut(&mut self, name: &str) -> Option<&mut LibraryEntry> {
self.entries.iter_mut().find(|e| e.name == name)
}
pub fn all(&self) -> &[LibraryEntry] {
&self.entries
}
pub fn resolve_path(&self, name: &str, relative: &str) -> Result<PathBuf> {
let entry = self
.lookup(name)
.ok_or_else(|| anyhow::anyhow!("Unknown library: {}", name))?;
let rel = std::path::Path::new(relative);
for component in rel.components() {
if matches!(component, std::path::Component::ParentDir) {
anyhow::bail!(
"Path traversal rejected: '{}' contains '..' component",
relative
);
}
}
Ok(entry.path.join(relative))
}
pub fn is_library_path(&self, absolute_path: &Path) -> Option<&LibraryEntry> {
self.entries
.iter()
.find(|e| absolute_path.starts_with(&e.path))
}
pub fn stale_libraries(&self) -> Vec<&LibraryEntry> {
self.entries
.iter()
.filter(|e| {
e.indexed
&& e.version.is_some()
&& e.version_indexed.is_some()
&& e.version != e.version_indexed
})
.collect()
}
pub fn update_version(&mut self, name: &str, new_version: &str) {
if let Some(entry) = self.entries.iter_mut().find(|e| e.name == name) {
let changed = entry.version.as_deref() != Some(new_version);
entry.version = Some(new_version.to_string());
if changed {
entry.nudge_dismissed = false;
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn json_roundtrip_library_entry() {
let entry = LibraryEntry {
name: "serde".into(),
version: Some("1.0.200".into()),
version_indexed: None,
db_file: None,
nudge_dismissed: false,
path: PathBuf::from("/home/user/.cargo/registry/serde-1.0.200"),
language: "rust".into(),
discovered_via: DiscoveryMethod::LspFollowThrough,
indexed: true,
source_available: true,
};
let json = serde_json::to_string(&entry).unwrap();
let restored: LibraryEntry = serde_json::from_str(&json).unwrap();
assert_eq!(entry, restored);
}
#[test]
fn discovery_method_serializes_as_snake_case() {
let lsp = serde_json::to_string(&DiscoveryMethod::LspFollowThrough).unwrap();
assert_eq!(lsp, "\"lsp_follow_through\"");
let manual = serde_json::to_string(&DiscoveryMethod::Manual).unwrap();
assert_eq!(manual, "\"manual\"");
}
#[test]
fn register_and_lookup() {
let mut reg = LibraryRegistry::new();
reg.register(
"tokio".into(),
PathBuf::from("/libs/tokio"),
"rust".into(),
DiscoveryMethod::Manual,
true,
);
let entry = reg.lookup("tokio").unwrap();
assert_eq!(entry.name, "tokio");
assert_eq!(entry.path, PathBuf::from("/libs/tokio"));
assert_eq!(entry.language, "rust");
assert!(!entry.indexed);
assert_eq!(entry.discovered_via, DiscoveryMethod::Manual);
assert!(reg.lookup("nonexistent").is_none());
}
#[test]
fn register_updates_existing_entry() {
let mut reg = LibraryRegistry::new();
reg.register(
"serde".into(),
PathBuf::from("/libs/serde-1.0"),
"rust".into(),
DiscoveryMethod::Manual,
true,
);
reg.lookup_mut("serde").unwrap().indexed = true;
reg.register(
"serde".into(),
PathBuf::from("/libs/serde-1.0"),
"rust".into(),
DiscoveryMethod::LspFollowThrough,
true,
);
let entry = reg.lookup("serde").unwrap();
assert!(
entry.indexed,
"indexed should be preserved when path unchanged"
);
assert_eq!(entry.discovered_via, DiscoveryMethod::LspFollowThrough);
reg.register(
"serde".into(),
PathBuf::from("/libs/serde-2.0"),
"rust".into(),
DiscoveryMethod::Manual,
true,
);
let entry = reg.lookup("serde").unwrap();
assert!(!entry.indexed, "indexed should reset when path changes");
assert_eq!(entry.path, PathBuf::from("/libs/serde-2.0"));
}
#[test]
fn is_library_path_matches_and_misses() {
let mut reg = LibraryRegistry::new();
reg.register(
"tokio".into(),
PathBuf::from("/libs/tokio"),
"rust".into(),
DiscoveryMethod::Manual,
true,
);
reg.register(
"serde".into(),
PathBuf::from("/libs/serde"),
"rust".into(),
DiscoveryMethod::Manual,
true,
);
let found = reg.is_library_path(Path::new("/libs/tokio/src/runtime.rs"));
assert_eq!(found.unwrap().name, "tokio");
let found = reg.is_library_path(Path::new("/libs/serde/src/de.rs"));
assert_eq!(found.unwrap().name, "serde");
assert!(reg.is_library_path(Path::new("/other/file.rs")).is_none());
}
#[test]
fn resolve_path_works() {
let mut reg = LibraryRegistry::new();
reg.register(
"tokio".into(),
PathBuf::from("/libs/tokio"),
"rust".into(),
DiscoveryMethod::Manual,
true,
);
let resolved = reg.resolve_path("tokio", "src/runtime.rs").unwrap();
assert_eq!(resolved, PathBuf::from("/libs/tokio/src/runtime.rs"));
}
#[test]
fn resolve_path_errors_for_unknown_library() {
let reg = LibraryRegistry::new();
let result = reg.resolve_path("nonexistent", "src/lib.rs");
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Unknown library"));
}
#[test]
fn persistence_roundtrip() {
let dir = tempdir().unwrap();
let path = dir.path().join("libraries.json");
let mut reg = LibraryRegistry::new();
reg.register(
"tokio".into(),
PathBuf::from("/libs/tokio"),
"rust".into(),
DiscoveryMethod::LspFollowThrough,
true,
);
reg.register(
"serde".into(),
PathBuf::from("/libs/serde"),
"rust".into(),
DiscoveryMethod::Manual,
true,
);
reg.lookup_mut("serde").unwrap().indexed = true;
reg.lookup_mut("serde").unwrap().version = Some("1.0.200".into());
reg.save(&path).unwrap();
let loaded = LibraryRegistry::load(&path).unwrap();
assert_eq!(loaded.all().len(), 2);
let tokio = loaded.lookup("tokio").unwrap();
assert_eq!(tokio.discovered_via, DiscoveryMethod::LspFollowThrough);
assert!(!tokio.indexed);
let serde = loaded.lookup("serde").unwrap();
assert!(serde.indexed);
assert_eq!(serde.version, Some("1.0.200".into()));
}
#[test]
fn load_missing_file_returns_empty() {
let reg = LibraryRegistry::load(Path::new("/nonexistent/path/libraries.json")).unwrap();
assert!(reg.all().is_empty());
}
#[test]
fn all_returns_all_entries() {
let mut reg = LibraryRegistry::new();
assert!(reg.all().is_empty());
reg.register(
"a".into(),
PathBuf::from("/a"),
"rust".into(),
DiscoveryMethod::Manual,
true,
);
reg.register(
"b".into(),
PathBuf::from("/b"),
"python".into(),
DiscoveryMethod::LspFollowThrough,
true,
);
assert_eq!(reg.all().len(), 2);
}
#[test]
fn library_entry_serializes_version_fields() {
let entry = LibraryEntry {
name: "tokio".to_string(),
version: Some("1.38.0".to_string()),
version_indexed: Some("1.37.0".to_string()),
db_file: Some("tokio.db".to_string()),
nudge_dismissed: false,
path: PathBuf::from("/tmp/tokio"),
language: "rust".to_string(),
indexed: true,
discovered_via: DiscoveryMethod::LspFollowThrough,
source_available: true,
};
let json = serde_json::to_string(&entry).unwrap();
assert!(json.contains("version_indexed"));
assert!(json.contains("db_file"));
assert!(json.contains("nudge_dismissed"));
}
#[test]
fn library_entry_deserializes_without_new_fields() {
let json = r#"{"name":"serde","version":null,"path":"/tmp/serde","language":"rust","indexed":false,"discovered_via":"lsp_follow_through"}"#;
let entry: LibraryEntry = serde_json::from_str(json).unwrap();
assert_eq!(entry.version_indexed, None);
assert_eq!(entry.db_file, None);
assert!(!entry.nudge_dismissed);
}
#[test]
fn stale_libraries_detects_version_mismatch() {
let mut registry = LibraryRegistry::new();
registry.register(
"tokio".into(),
PathBuf::from("/tmp"),
"rust".into(),
DiscoveryMethod::LspFollowThrough,
true,
);
let entry = registry.lookup_mut("tokio").unwrap();
entry.version = Some("1.38.0".to_string());
entry.version_indexed = Some("1.37.0".to_string());
entry.indexed = true;
let stale = registry.stale_libraries();
assert_eq!(stale.len(), 1);
assert_eq!(stale[0].name, "tokio");
}
#[test]
fn nudge_dismissed_resets_on_version_change() {
let mut registry = LibraryRegistry::new();
registry.register(
"tokio".into(),
PathBuf::from("/tmp"),
"rust".into(),
DiscoveryMethod::LspFollowThrough,
true,
);
let entry = registry.lookup_mut("tokio").unwrap();
entry.version = Some("1.37.0".to_string());
entry.nudge_dismissed = true;
registry.update_version("tokio", "1.38.0");
let entry = registry.lookup("tokio").unwrap();
assert!(!entry.nudge_dismissed);
assert_eq!(entry.version.as_deref(), Some("1.38.0"));
}
#[test]
fn source_available_defaults_to_true_on_deserialize() {
let json = r#"{"name":"foo","path":"/tmp/foo","language":"rust",
"discovered_via":"manual","indexed":false,"nudge_dismissed":false}"#;
let entry: LibraryEntry = serde_json::from_str(json).unwrap();
assert!(
entry.source_available,
"missing field should default to true"
);
}
#[test]
fn register_with_source_available_false() {
let mut reg = LibraryRegistry::new();
reg.register(
"jackson".into(),
PathBuf::new(),
"java".into(),
DiscoveryMethod::ManifestScan,
false,
);
let entry = reg.lookup("jackson").unwrap();
assert!(!entry.source_available);
assert!(entry.path.as_os_str().is_empty());
}
#[test]
fn manifest_scan_does_not_overwrite_manual() {
let mut reg = LibraryRegistry::new();
reg.register(
"foo".into(),
PathBuf::from("/manual/path"),
"rust".into(),
DiscoveryMethod::Manual,
true,
);
reg.register(
"foo".into(),
PathBuf::from("/scan/path"),
"rust".into(),
DiscoveryMethod::ManifestScan,
true,
);
let entry = reg.lookup("foo").unwrap();
assert_eq!(
entry.path,
PathBuf::from("/manual/path"),
"ManifestScan must not overwrite Manual"
);
}
#[test]
fn manifest_scan_updates_existing_manifest_scan() {
let mut reg = LibraryRegistry::new();
reg.register(
"foo".into(),
PathBuf::from("/old"),
"rust".into(),
DiscoveryMethod::ManifestScan,
true,
);
reg.register(
"foo".into(),
PathBuf::from("/new"),
"rust".into(),
DiscoveryMethod::ManifestScan,
true,
);
let entry = reg.lookup("foo").unwrap();
assert_eq!(entry.path, PathBuf::from("/new"));
}
#[test]
fn discovery_method_manifest_scan_serializes() {
let json = serde_json::to_string(&DiscoveryMethod::ManifestScan).unwrap();
assert_eq!(json, "\"manifest_scan\"");
}
}