use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant};
use crate::jobs::{kind, JobRecord};
pub const REFETCH_EVERY: Duration = Duration::from_millis(2000);
fn jobs_cell() -> &'static Mutex<Vec<JobRecord>> {
static JOBS: OnceLock<Mutex<Vec<JobRecord>>> = OnceLock::new();
JOBS.get_or_init(|| Mutex::new(Vec::new()))
}
pub fn publish_active_jobs(records: &[JobRecord]) {
if let Ok(mut g) = jobs_cell().lock() {
*g = records.to_vec();
}
}
fn is_populating_kind(k: &str) -> bool {
matches!(
k,
kind::WORKSPACE_POPULATE
| kind::WORKSPACE_CLONE
| kind::WORKSPACE_FETCH
| kind::WORKSPACE_REPUBLISH
| kind::KNOWLEDGE_SCAN
| kind::DEEPSCAN
| kind::SYMBOL_SCAN
| kind::SNAPSHOT
| kind::ARCH_GENERATE
| kind::INDEX_BUILD
)
}
fn job_targets_repo(r: &JobRecord, workspace: &str, repo: &str) -> bool {
if !workspace.is_empty() && r.workspace != workspace {
return false;
}
if repo.is_empty() {
return true;
}
if r.target == repo {
return true;
}
matches!(
r.kind.as_str(),
kind::WORKSPACE_POPULATE | kind::WORKSPACE_FETCH | kind::WORKSPACE_REPUBLISH
) || r.target == workspace
}
pub fn populate_active_for(workspace: &str, repo: &str) -> bool {
let Ok(g) = jobs_cell().lock() else { return false };
g.iter().any(|r| {
!r.is_terminal() && is_populating_kind(&r.kind) && job_targets_repo(r, workspace, repo)
})
}
#[doc(hidden)]
pub fn clear_active_jobs_for_test() {
if let Ok(mut g) = jobs_cell().lock() {
g.clear();
}
}
pub fn should_refetch(
loaded: bool,
last_fetch_at: Option<Instant>,
is_empty: bool,
populate_active: bool,
) -> bool {
if !loaded {
return true;
}
if !is_empty {
return false;
}
if populate_active {
return true;
}
match last_fetch_at {
Some(t) => t.elapsed() >= REFETCH_EVERY,
None => true,
}
}
#[derive(Debug, Clone, Copy)]
pub struct RefetchGate {
pub loaded: bool,
pub last_fetch_at: Option<Instant>,
}
impl Default for RefetchGate {
fn default() -> Self {
Self { loaded: false, last_fetch_at: None }
}
}
impl RefetchGate {
pub fn should_fetch(&self, is_empty: bool, populate_active: bool) -> bool {
should_refetch(self.loaded, self.last_fetch_at, is_empty, populate_active)
}
pub fn mark_fetched(&mut self) {
self.loaded = true;
self.last_fetch_at = Some(Instant::now());
}
pub fn reset(&mut self) {
self.loaded = false;
self.last_fetch_at = None;
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EmptyState {
Populated,
InRoute,
NotScanned,
}
impl EmptyState {
pub fn id(self) -> &'static str {
match self {
EmptyState::Populated => "populated",
EmptyState::InRoute => "in_route",
EmptyState::NotScanned => "not_scanned",
}
}
pub fn message(self, repo: &str) -> Option<String> {
match self {
EmptyState::Populated => None,
EmptyState::InRoute => Some(if repo.is_empty() {
"β³ in route for populateβ¦".to_string()
} else {
format!("Ⳡin route for populate⦠({repo})")
}),
EmptyState::NotScanned => Some(if repo.is_empty() {
"not scanned yet β run populate / deep-scan".to_string()
} else {
format!("not scanned yet β run populate / deep-scan for {repo}")
}),
}
}
pub fn is_empty(self) -> bool {
!matches!(self, EmptyState::Populated)
}
}
pub fn classify_empty(is_empty: bool, workspace: &str, repo: &str) -> EmptyState {
if !is_empty {
return EmptyState::Populated;
}
if populate_active_for(workspace, repo) {
EmptyState::InRoute
} else {
EmptyState::NotScanned
}
}
#[cfg(feature = "viz")]
pub fn render_empty(
ui: &mut eframe::egui::Ui,
is_empty: bool,
workspace: &str,
repo: &str,
text_color: eframe::egui::Color32,
) -> EmptyState {
let st = classify_empty(is_empty, workspace, repo);
if let Some(msg) = st.message(repo) {
ui.add_space(16.0);
ui.vertical_centered(|ui| {
ui.colored_label(text_color, msg);
});
}
st
}
#[cfg(test)]
mod tests {
use super::*;
fn rec(kind: &str, target: &str, workspace: &str, status: &str) -> JobRecord {
JobRecord {
job_id: format!("{kind}-{target}-{status}"),
kind: kind.to_string(),
target: target.to_string(),
workspace: workspace.to_string(),
status: status.to_string(),
ts_start_micros: 0,
ts_end_micros: None,
elapsed_ms: None,
detail_json: String::new(),
result_ref: String::new(),
parent_id: None,
}
}
#[test]
fn never_fetched_always_fetches() {
assert!(should_refetch(false, None, false, false));
assert!(should_refetch(false, Some(Instant::now()), false, true));
}
#[test]
fn populated_latches_no_refetch() {
assert!(!should_refetch(true, Some(Instant::now()), false, true));
assert!(!should_refetch(true, Some(Instant::now()), false, false));
}
#[test]
fn empty_with_active_job_refetches_eagerly() {
assert!(should_refetch(true, Some(Instant::now()), true, true));
}
#[test]
fn empty_without_job_throttles() {
assert!(!should_refetch(true, Some(Instant::now()), true, false));
let old = Instant::now() - (REFETCH_EVERY + Duration::from_millis(50));
assert!(should_refetch(true, Some(old), true, false));
}
#[test]
fn gate_lifecycle() {
let mut g = RefetchGate::default();
assert!(g.should_fetch(false, false), "fresh gate fetches");
g.mark_fetched();
assert!(!g.should_fetch(false, false), "populated gate latches");
assert!(g.should_fetch(true, true));
g.reset();
assert!(g.should_fetch(true, false), "reset gate fetches");
}
#[test]
fn classify_empty_picks_in_route_when_job_active() {
clear_active_jobs_for_test();
publish_active_jobs(&[rec(kind::WORKSPACE_POPULATE, "ws", "ws", "running")]);
assert_eq!(classify_empty(true, "ws", "a"), EmptyState::InRoute);
assert_eq!(classify_empty(false, "ws", "a"), EmptyState::Populated);
clear_active_jobs_for_test();
}
#[test]
fn classify_empty_not_scanned_when_no_job() {
clear_active_jobs_for_test();
assert_eq!(classify_empty(true, "ws", "a"), EmptyState::NotScanned);
publish_active_jobs(&[rec(kind::KNOWLEDGE_SCAN, "a", "other-ws", "running")]);
assert_eq!(classify_empty(true, "ws", "a"), EmptyState::NotScanned);
clear_active_jobs_for_test();
}
#[test]
fn classify_empty_terminal_job_is_not_in_route() {
clear_active_jobs_for_test();
publish_active_jobs(&[rec(kind::WORKSPACE_POPULATE, "ws", "ws", "done")]);
assert_eq!(classify_empty(true, "ws", "a"), EmptyState::NotScanned);
clear_active_jobs_for_test();
}
#[test]
fn per_member_scan_targets_that_repo_only() {
clear_active_jobs_for_test();
publish_active_jobs(&[rec(kind::KNOWLEDGE_SCAN, "a", "ws", "running")]);
assert_eq!(classify_empty(true, "ws", "a"), EmptyState::InRoute);
assert_eq!(classify_empty(true, "ws", "b"), EmptyState::NotScanned);
clear_active_jobs_for_test();
}
#[test]
fn empty_state_messages_are_the_law2_strings() {
assert!(EmptyState::InRoute.message("nornir").unwrap().contains("in route for populate"));
assert!(EmptyState::NotScanned
.message("nornir")
.unwrap()
.contains("not scanned yet"));
assert!(EmptyState::Populated.message("nornir").is_none());
}
}