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::gh;
use crate::git_status;
use crate::state::{Repo, Shelf};
use teravars::Engine;
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;
let mut capture_errors = 0;
let mut gh_errors = 0;
let mut engine = Engine::new();
let gh_client = match gh::resolve_token().await {
Some(token) => match gh::build_client(&token) {
Ok(c) => Some(c),
Err(e) => {
tracing::warn!(target: "shoka", "gh client init failed: {e:#}");
None
}
},
None => None,
};
for repo in &shelf.repos {
if !tags.is_empty() && !has_all_tags(repo, &tags) {
skipped_filter += 1;
continue;
}
let path = match resolved.clone_path_for(repo, &mut engine) {
Ok(p) => p,
Err(e) => {
tracing::warn!(
target: "shoka",
"skipping {} for refresh: path resolve failed ({e:#})",
repo.slug()
);
capture_errors += 1;
continue;
}
};
let entry = cache.upsert(repo);
if !force && !entry.is_stale(threshold, now) {
skipped_threshold += 1;
continue;
}
match git_status::capture(&path) {
Ok(snapshot) => {
entry.git_status = Some(snapshot);
}
Err(e) => {
tracing::warn!(
target: "shoka",
"git status capture failed for {} at {}: {e:#}",
repo.slug(),
path.display()
);
capture_errors += 1;
}
}
if let Some(client) = gh_client.as_ref() {
if repo.host == "github.com" {
match gh::capture_snapshot(client, &repo.owner, &repo.name).await {
Ok(snap) => entry.gh = Some(snap),
Err(e) => {
tracing::warn!(
target: "shoka",
"gh snapshot failed for {}: {e:#}",
repo.slug()
);
gh_errors += 1;
}
}
}
}
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, {capture_errors} capture errors, {gh_errors} gh errors ({} 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
);
}
if capture_errors > 0 {
println!(
" {} {} capture errors (previous snapshot kept; see SHOKA_LOG=warn)",
"!".red(),
capture_errors
);
}
if gh_errors > 0 {
println!(
" {} {} gh API errors (rate limit / 404? previous snapshot kept)",
"!".red(),
gh_errors
);
}
if gh_client.is_none() {
println!(
" {} no GITHUB_TOKEN / gh CLI — PR + CI columns will stay empty",
"↩".dimmed()
);
}
}
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;
cmd.creation_flags(bg_refresh_creation_flags());
}
cmd.spawn()
.with_context(|| format!("spawning {} cache refresh --background", exe.display()))?;
Ok(())
}
#[cfg(windows)]
pub(crate) const fn bg_refresh_creation_flags() -> u32 {
const DETACHED_PROCESS: u32 = 0x0000_0008;
const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW
}
#[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", None)
.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", None)
.unwrap();
assert!(!entry.is_stale(60, 1_700_000_010));
let entry = cache
.find_mut("github.com", "yukimemi", "shoka", None)
.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");
}
#[cfg(windows)]
#[test]
fn bg_refresh_creation_flags_compose_all_three() {
const DETACHED_PROCESS: u32 = 0x0000_0008;
const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
let flags = bg_refresh_creation_flags();
assert_eq!(flags & DETACHED_PROCESS, DETACHED_PROCESS);
assert_eq!(flags & CREATE_NEW_PROCESS_GROUP, CREATE_NEW_PROCESS_GROUP);
assert_eq!(flags & CREATE_NO_WINDOW, CREATE_NO_WINDOW);
assert_eq!(
flags,
DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW
);
}
}