use std::path::Path;
use std::process::{Command, Stdio};
use anyhow::{Context, Result};
use owo_colors::OwoColorize;
use crate::cache::{Cache, current_unix_secs};
use crate::cli::CacheCommand;
use crate::commands::ShokaContext;
use crate::config::ShokaConfig;
use crate::state::{Repo, Shelf};
pub async fn dispatch(ctx: &ShokaContext, cmd: CacheCommand) -> Result<()> {
match cmd {
CacheCommand::Refresh {
force,
tags,
background,
} => {
if background {
if let Err(e) = refresh(ctx, force, tags, true).await {
tracing::warn!(target: "shoka", "background refresh failed: {e:#}");
}
Ok(())
} else {
refresh(ctx, force, tags, false).await
}
}
CacheCommand::Show => show(ctx).await,
CacheCommand::Clear => clear(ctx).await,
}
}
async fn refresh(
ctx: &ShokaContext,
force: bool,
tags: Vec<String>,
background: bool,
) -> Result<()> {
let cfg = ShokaConfig::load(&ctx.paths)?;
let resolved = cfg.resolve(ctx.profile_override.as_deref())?;
let shelf = Shelf::load(&ctx.paths)?;
let mut cache = Cache::load(&ctx.paths)?;
let now = current_unix_secs();
let threshold = resolved.cache.refresh_threshold_secs;
let mut refreshed = 0;
let mut skipped_threshold = 0;
let mut skipped_filter = 0;
for repo in &shelf.repos {
if !tags.is_empty() && !has_all_tags(repo, &tags) {
skipped_filter += 1;
continue;
}
let entry = cache.upsert(repo);
if !force && !entry.is_stale(threshold, now) {
skipped_threshold += 1;
continue;
}
entry.last_refreshed = Some(now);
refreshed += 1;
}
cache.save(&ctx.paths)?;
if background {
tracing::info!(
target: "shoka",
"background cache refresh: {refreshed} updated, {skipped_threshold} fresh, {skipped_filter} filtered ({} on shelf)",
shelf.len()
);
} else {
println!(
"{} refreshed {}{} repo{} ({} on the shelf)",
"cache:".bold(),
refreshed,
if force { " (forced)" } else { "" },
if refreshed == 1 { "" } else { "s" },
shelf.len()
);
if skipped_threshold > 0 {
println!(
" {} {} fresh (within {}s threshold)",
"↩".dimmed(),
skipped_threshold,
threshold
);
}
if skipped_filter > 0 {
println!(
" {} {} filtered out by --tag",
"↩".dimmed(),
skipped_filter
);
}
}
Ok(())
}
async fn show(ctx: &ShokaContext) -> Result<()> {
let cache = Cache::load(&ctx.paths)?;
let body = toml::to_string_pretty(&cache)?;
print!("{body}");
Ok(())
}
async fn clear(ctx: &ShokaContext) -> Result<()> {
let cache = Cache::default();
cache.save(&ctx.paths)?;
println!("{} cleared", "cache:".bold());
Ok(())
}
fn has_all_tags(repo: &Repo, wanted: &[String]) -> bool {
wanted.iter().all(|w| repo.tags.iter().any(|t| t == w))
}
pub fn try_spawn_bg_refresh(ctx: &ShokaContext) -> Result<()> {
let cfg = ShokaConfig::load(&ctx.paths)?;
let resolved = cfg.resolve(ctx.profile_override.as_deref())?;
if !resolved.cache.background_refresh {
tracing::debug!(target: "shoka", "background refresh disabled by config");
return Ok(());
}
let exe = std::env::current_exe().context("locating current shoka executable")?;
spawn_detached(
&exe,
ctx.paths.config_file(),
ctx.profile_override.as_deref(),
)
.context("spawning background refresh")?;
Ok(())
}
fn spawn_detached(exe: &Path, config_file: &Path, profile: Option<&str>) -> Result<()> {
let mut cmd = Command::new(exe);
cmd.args(["cache", "refresh", "--background"])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.env("SHOKA_CONFIG", config_file);
if let Some(p) = profile {
cmd.env("SHOKA_PROFILE", p);
}
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
unsafe {
cmd.pre_exec(|| {
libc::setsid();
Ok(())
});
}
}
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
const DETACHED_PROCESS: u32 = 0x0000_0008;
const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
cmd.creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP);
}
cmd.spawn()
.with_context(|| format!("spawning {} cache refresh --background", exe.display()))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cache::Cache;
use crate::state::{Repo, Shelf};
use std::path::Path;
use tempfile::TempDir;
fn paths_at(tmp: &Path) -> crate::paths::ShokaPaths {
crate::paths::ShokaPaths::resolve(Some(&tmp.join("config.toml")))
.expect("ShokaPaths::resolve")
}
fn sample(name: &str) -> Repo {
Repo::new("github.com", "yukimemi", name)
}
#[test]
fn refresh_walks_shelf_and_marks_entries() {
let mut shelf = Shelf::default();
shelf.add(sample("shoka")).unwrap();
shelf.add(sample("renri")).unwrap();
let mut cache = Cache::default();
let now = 1_700_000_000;
let threshold = 60;
for repo in &shelf.repos {
let entry = cache.upsert(repo);
if entry.is_stale(threshold, now) {
entry.last_refreshed = Some(now);
}
}
assert_eq!(cache.len(), 2);
assert_eq!(
cache
.find("github.com", "yukimemi", "shoka")
.unwrap()
.last_refreshed,
Some(now)
);
}
#[test]
fn refresh_respects_threshold_without_force() {
let mut cache = Cache::default();
let repo = sample("shoka");
cache.upsert(&repo).last_refreshed = Some(1_700_000_000);
let entry = cache.find_mut("github.com", "yukimemi", "shoka").unwrap();
assert!(!entry.is_stale(60, 1_700_000_010));
let entry = cache.find_mut("github.com", "yukimemi", "shoka").unwrap();
assert!(entry.is_stale(60, 1_700_000_120));
}
#[test]
fn clear_empties_cache_file_atomically() {
let tmp = TempDir::new().unwrap();
let paths = paths_at(tmp.path());
let mut cache = Cache::default();
cache.upsert(&sample("shoka")).last_refreshed = Some(1);
cache.save(&paths).unwrap();
assert!(paths.cache_file().exists());
Cache::default().save(&paths).unwrap();
let loaded = Cache::load(&paths).unwrap();
assert!(loaded.is_empty());
}
#[test]
fn has_all_tags_and_semantics() {
let mut repo = sample("shoka");
repo.tags = vec!["rust".into(), "cli".into()];
assert!(has_all_tags(&repo, &["rust".into()]));
assert!(has_all_tags(&repo, &["rust".into(), "cli".into()]));
assert!(!has_all_tags(
&repo,
&["rust".into(), "cli".into(), "tui".into()]
));
assert!(has_all_tags(&repo, &[]));
}
#[test]
fn try_spawn_bg_refresh_short_circuits_when_disabled() {
use std::fs;
let tmp = TempDir::new().unwrap();
fs::write(
tmp.path().join("config.toml"),
r#"
[global]
root = "/r"
[global.cache]
background_refresh = false
"#,
)
.unwrap();
let paths = paths_at(tmp.path());
let ctx = ShokaContext {
paths,
profile_override: None,
};
try_spawn_bg_refresh(&ctx).expect("opt-out path returns Ok");
}
}