use super::discovery::JustfilePath;
use super::discovery::find_justfiles;
use super::parser::ParsedRecipe;
use super::parser::parse_justfile;
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::Mutex;
use std::time::SystemTime;
#[derive(Clone)]
struct CachedJustfile {
mtime: SystemTime,
recipes: Vec<ParsedRecipe>,
}
#[derive(Default)]
struct Inner {
files: HashMap<String, CachedJustfile>,
last_paths: Vec<JustfilePath>,
}
#[derive(Clone, Default)]
pub struct JustRegistry {
inner: Arc<Mutex<Inner>>,
}
impl JustRegistry {
pub fn new() -> Self {
Self::default()
}
#[expect(
clippy::unwrap_used,
reason = "Mutex poisoning indicates a prior panic while holding the lock. \
For this cache, we fail fast rather than risk inconsistent state."
)]
fn lock_inner(&self) -> std::sync::MutexGuard<'_, Inner> {
self.inner.lock().unwrap()
}
pub async fn refresh(&self, repo_root: &str) -> Result<(), String> {
let root = repo_root.to_string();
let paths = tokio::task::spawn_blocking(move || find_justfiles(&root))
.await
.map_err(|e| format!("spawn_blocking failed: {e}"))??;
let mut inner = self.lock_inner();
inner.last_paths = paths;
Ok(())
}
pub async fn get_all_recipes(
&self,
repo_root: &str,
) -> Result<Vec<(String, ParsedRecipe)>, String> {
let needs_refresh = {
let inner = self.lock_inner();
inner.last_paths.is_empty()
};
if needs_refresh {
self.refresh(repo_root).await?;
}
let paths = self.lock_inner().last_paths.clone();
let mut results = Vec::new();
for jf in paths {
let path_clone = jf.path.clone();
let mtime = tokio::task::spawn_blocking(move || {
std::fs::metadata(&path_clone).and_then(|m| m.modified())
})
.await
.map_err(|e| format!("spawn_blocking failed: {e}"))?
.map_err(|e| format!("stat failed for {}: {e}", jf.path))?;
let need_parse = {
let inner = self.lock_inner();
inner.files.get(&jf.path).is_none_or(|c| c.mtime < mtime)
};
if need_parse {
let recipes = parse_justfile(&jf.path).await?;
let mut inner = self.lock_inner();
inner
.files
.insert(jf.path.clone(), CachedJustfile { mtime, recipes });
}
let inner = self.lock_inner();
if let Some(cached) = inner.files.get(&jf.path) {
for r in &cached.recipes {
results.push((jf.dir.clone(), r.clone()));
}
}
}
Ok(results)
}
#[cfg(test)]
pub fn clear(&self) {
let mut inner = self.lock_inner();
inner.files.clear();
inner.last_paths.clear();
}
}
#[cfg(test)]
#[expect(clippy::unwrap_used)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[tokio::test]
async fn caches_across_calls() {
if tokio::process::Command::new("just")
.arg("--version")
.output()
.await
.is_err()
{
eprintln!("Skipping test: just not installed");
return;
}
let tmp = TempDir::new().unwrap();
let root = tmp.path();
fs::write(root.join("justfile"), "build:\n echo building").unwrap();
let registry = JustRegistry::new();
let recipes1 = registry
.get_all_recipes(root.to_str().unwrap())
.await
.unwrap();
assert_eq!(recipes1.len(), 1);
let recipes2 = registry
.get_all_recipes(root.to_str().unwrap())
.await
.unwrap();
assert_eq!(recipes2.len(), 1);
}
#[tokio::test]
async fn invalidates_on_mtime_change() {
if tokio::process::Command::new("just")
.arg("--version")
.output()
.await
.is_err()
{
eprintln!("Skipping test: just not installed");
return;
}
let tmp = TempDir::new().unwrap();
let root = tmp.path();
let jf = root.join("justfile");
fs::write(&jf, "build:\n echo building").unwrap();
let registry = JustRegistry::new();
let recipes1 = registry
.get_all_recipes(root.to_str().unwrap())
.await
.unwrap();
assert_eq!(recipes1.len(), 1);
assert_eq!(recipes1[0].1.name, "build");
std::thread::sleep(std::time::Duration::from_millis(10));
fs::write(&jf, "test:\n echo testing\n\ncheck:\n echo checking").unwrap();
filetime::set_file_mtime(
&jf,
filetime::FileTime::from_system_time(std::time::SystemTime::now()),
)
.unwrap();
registry.refresh(root.to_str().unwrap()).await.unwrap();
let recipes2 = registry
.get_all_recipes(root.to_str().unwrap())
.await
.unwrap();
assert_eq!(recipes2.len(), 2);
}
}