use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::time::SystemTime;
use glob::{MatchOptions, Pattern};
use serde::{Deserialize, Serialize};
use crate::config::get_tokensave_dir;
use crate::errors::{Result, TokenSaveError};
use crate::extraction::LanguageRegistry;
pub const PROJECT_MANIFEST_FILENAME: &str = "project.json";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectManifest {
#[serde(default = "default_version")]
pub version: u32,
#[serde(default)]
pub entries: Vec<ManifestEntry>,
}
fn default_version() -> u32 {
1
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ManifestEntry {
pub path: String,
#[serde(default)]
pub language: Option<String>,
#[serde(default)]
pub comment: Option<String>,
}
#[derive(Debug, Clone)]
struct CompiledEntry {
raw: String,
pattern: Pattern,
language: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct CompiledManifest {
local: Vec<CompiledEntry>,
external: Vec<CompiledEntry>,
}
const MATCH_OPTS: MatchOptions = MatchOptions {
case_sensitive: true,
require_literal_separator: false,
require_literal_leading_dot: false,
};
pub fn get_manifest_path(project_root: &Path) -> PathBuf {
get_tokensave_dir(project_root).join(PROJECT_MANIFEST_FILENAME)
}
fn expand_tilde(path: &str) -> String {
if path == "~" {
return dirs::home_dir()
.map_or_else(|| path.to_string(), |h| h.to_string_lossy().to_string());
}
if let Some(rest) = path.strip_prefix("~/") {
if let Some(home) = dirs::home_dir() {
return format!("{}/{}", home.to_string_lossy().trim_end_matches('/'), rest);
}
}
path.to_string()
}
impl CompiledManifest {
pub fn compile(manifest: &ProjectManifest, registry: &LanguageRegistry) -> Result<Self> {
let mut local = Vec::new();
let mut external = Vec::new();
for entry in &manifest.entries {
if let Some(ref lang) = entry.language {
if registry.extractor_for_language(lang).is_none() {
let mut known: Vec<&str> = registry
.extractors()
.iter()
.map(|e| e.language_name())
.collect();
known.sort_unstable();
known.dedup();
return Err(TokenSaveError::Config {
message: format!(
"project.json: unknown language '{}' for path '{}'; \
valid languages: {}",
lang,
entry.path,
known.join(", ")
),
});
}
}
let raw = expand_tilde(entry.path.trim()).replace('\\', "/");
if raw.is_empty() || raw == "." {
continue;
}
let pattern = Pattern::new(&raw).map_err(|e| TokenSaveError::Config {
message: format!("project.json: invalid glob '{}': {e}", entry.path),
})?;
let compiled = CompiledEntry {
raw: raw.clone(),
pattern,
language: entry.language.clone(),
};
if Path::new(&raw).is_absolute() {
external.push(compiled);
} else {
local.push(compiled);
}
}
Ok(Self { local, external })
}
pub fn is_empty(&self) -> bool {
self.local.is_empty() && self.external.is_empty()
}
pub fn language_for(&self, path: &str) -> Option<&str> {
self.local
.iter()
.chain(self.external.iter())
.find(|e| e.language.is_some() && e.pattern.matches_with(path, MATCH_OPTS))
.and_then(|e| e.language.as_deref())
}
pub fn matches_local_file(&self, rel_path: &str) -> bool {
self.local
.iter()
.any(|e| e.pattern.matches_with(rel_path, MATCH_OPTS))
}
pub fn local_dir_may_contain(&self, rel_dir: &str) -> bool {
let prefix = format!("{}/", rel_dir.trim_end_matches('/'));
self.local.iter().any(|e| {
e.raw.starts_with(&prefix)
|| e.pattern.matches_with(rel_dir, MATCH_OPTS)
|| e.pattern.matches_with(&format!("{prefix}_"), MATCH_OPTS)
})
}
pub fn expand_external_files(&self, max_file_size: u64) -> Vec<String> {
let mut out = Vec::new();
for entry in &self.external {
let mut push = |p: &Path| {
if let Ok(meta) = std::fs::metadata(p) {
if meta.is_file() && meta.len() <= max_file_size {
out.push(p.to_string_lossy().replace('\\', "/"));
}
}
};
let has_glob_meta = entry.raw.contains(['*', '?', '[']);
if has_glob_meta {
if let Ok(paths) = glob::glob_with(&entry.raw, MATCH_OPTS) {
for p in paths.flatten() {
push(&p);
}
}
} else {
push(Path::new(&entry.raw));
}
}
out.sort();
out.dedup();
out
}
}
pub fn load_manifest(
project_root: &Path,
registry: &LanguageRegistry,
) -> Result<Option<CompiledManifest>> {
let path = get_manifest_path(project_root);
let Ok(contents) = std::fs::read_to_string(&path) else {
return Ok(None);
};
let manifest: ProjectManifest =
serde_json::from_str(&contents).map_err(|e| TokenSaveError::Config {
message: format!("failed to parse '{}': {e}", path.display()),
})?;
Ok(Some(CompiledManifest::compile(&manifest, registry)?))
}
type CacheEntry = (Option<SystemTime>, Option<std::sync::Arc<CompiledManifest>>);
static MANIFEST_CACHE: Mutex<Option<std::collections::HashMap<PathBuf, CacheEntry>>> =
Mutex::new(None);
pub fn manifest_for(
project_root: &Path,
registry: &LanguageRegistry,
) -> Option<std::sync::Arc<CompiledManifest>> {
let path = get_manifest_path(project_root);
let mtime = std::fs::metadata(&path).and_then(|m| m.modified()).ok();
let mut guard = MANIFEST_CACHE.lock().ok()?;
let cache = guard.get_or_insert_with(std::collections::HashMap::new);
if let Some((cached_mtime, cached)) = cache.get(project_root) {
if *cached_mtime == mtime {
return cached.clone();
}
}
let loaded = load_manifest(project_root, registry)
.ok()
.flatten()
.filter(|m| !m.is_empty())
.map(std::sync::Arc::new);
cache.insert(project_root.to_path_buf(), (mtime, loaded.clone()));
loaded
}
pub fn resolve_extractor<'r>(
registry: &'r LanguageRegistry,
project_root: &Path,
file_path: &str,
) -> Option<&'r dyn crate::extraction::LanguageExtractor> {
if let Some(manifest) = manifest_for(project_root, registry) {
if let Some(lang) = manifest.language_for(file_path) {
return registry.extractor_for_language(lang);
}
}
registry.extractor_for_file(file_path)
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
fn registry() -> LanguageRegistry {
LanguageRegistry::new()
}
fn compile(entries: Vec<ManifestEntry>) -> CompiledManifest {
CompiledManifest::compile(
&ProjectManifest {
version: 1,
entries,
},
®istry(),
)
.unwrap()
}
fn entry(path: &str, language: Option<&str>) -> ManifestEntry {
ManifestEntry {
path: path.to_string(),
language: language.map(str::to_string),
comment: None,
}
}
#[test]
fn unknown_language_is_a_hard_error() {
let m = ProjectManifest {
version: 1,
entries: vec![entry("foo/*.x", Some("klingon"))],
};
let err = CompiledManifest::compile(&m, ®istry()).unwrap_err();
assert!(err.to_string().contains("klingon"), "{err}");
assert!(err.to_string().contains("valid languages"), "{err}");
}
#[test]
fn language_override_matches_globs_and_literals() {
let m = compile(vec![
entry("homedir/.bash_profile", Some("bash")),
entry("homedir/.bashrc.d/*.shrc", Some("bash")),
]);
assert_eq!(m.language_for("homedir/.bash_profile"), Some("bash"));
assert_eq!(
m.language_for("homedir/.bashrc.d/prompt.shrc"),
Some("bash")
);
assert_eq!(m.language_for("src/main.rs"), None);
}
#[test]
fn local_file_and_dir_matching() {
let m = compile(vec![entry("homedir/.bashrc.d/*.shrc", Some("bash"))]);
assert!(m.matches_local_file("homedir/.bashrc.d/a.shrc"));
assert!(!m.matches_local_file("homedir/.bashrc.d/a.txt"));
assert!(m.local_dir_may_contain("homedir"));
assert!(m.local_dir_may_contain("homedir/.bashrc.d"));
assert!(!m.local_dir_may_contain("src"));
}
#[test]
fn dot_entry_is_a_no_op() {
let m = compile(vec![entry(".", None)]);
assert!(m.is_empty());
}
#[test]
fn resolve_extractor_honors_override() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join(".tokensave")).unwrap();
std::fs::write(
dir.path().join(".tokensave/project.json"),
r#"{"version":1,"entries":[{"path":"dotfiles/.bash_profile","language":"bash"}]}"#,
)
.unwrap();
let reg = registry();
let e = resolve_extractor(®, dir.path(), "dotfiles/.bash_profile").unwrap();
assert_eq!(e.language_name(), "Bash");
assert!(resolve_extractor(®, dir.path(), "x.unknownext").is_none());
assert!(resolve_extractor(®, dir.path(), "x.rs").is_some());
}
#[test]
fn expand_external_files_finds_concrete_files() {
let dir = tempfile::tempdir().unwrap();
let f = dir.path().join(".bash_profile");
std::fs::write(&f, "export A=1\n").unwrap();
let m = compile(vec![entry(&f.to_string_lossy(), Some("bash"))]);
let files = m.expand_external_files(1_000_000);
assert_eq!(files.len(), 1);
assert!(files[0].ends_with(".bash_profile"));
assert_eq!(m.language_for(&files[0]), Some("bash"));
}
}