use std::io;
use std::path::{Path, PathBuf};
use std::sync::{Arc, mpsc};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use choreo_ai_protocols::{
ProviderEntry, RefreshError, RefreshOutcome, fetch_modelsdev, load_bundled_base,
normalize_modelsdev, write_file_atomic,
};
use choreo_proto::RefreshStatus;
use crossbeam_channel::{Receiver, Sender, after, select};
use tracing::{debug, info, warn};
use crate::config_watch::ConfigChange;
use crate::daemon::DaemonCommand;
use crate::db::{get_catalog_etag, get_catalog_last_attempt_ms, set_catalog_last_attempt_ms};
const REFRESH_ATTEMPT_INTERVAL: Duration = Duration::from_secs(25 * 60 * 60);
const CATALOG_BIN_NAME: &str = "catalog.bin";
pub const USER_OVERLAY_NAME: &str = "models-overlay.toml";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RefreshReport {
pub providers: usize,
pub models: usize,
pub status: RefreshStatus,
}
#[derive(Debug)]
pub struct RefreshRequester {
pub force: bool,
pub tx: mpsc::Sender<Result<RefreshReport, String>>,
}
#[derive(Debug)]
pub enum MaintenanceEvent {
RefreshNow {
force: bool,
reply: mpsc::Sender<Result<RefreshReport, String>>,
},
}
#[derive(Debug, Clone, Default)]
pub struct CatalogPaths {
pub bin: PathBuf,
pub overlay: PathBuf,
}
impl CatalogPaths {
pub fn from_dirs() -> Self {
let data_dir = dirs::data_dir().map(|d| d.join("choreographr"));
let config_dir = dirs::config_dir().map(|d| d.join("choreographr"));
match (&data_dir, &config_dir) {
(Some(data), Some(config)) => Self {
bin: data.join(CATALOG_BIN_NAME),
overlay: config.join(USER_OVERLAY_NAME),
},
_ => {
warn!(
?data_dir,
?config_dir,
"could not resolve catalog cache/overlay dirs; using the embedded catalog only",
);
Self::default()
}
}
}
}
pub(crate) fn load_cached_base(path: &Path) -> Option<Vec<ProviderEntry>> {
let bytes = match std::fs::read(path) {
Ok(bytes) => bytes,
Err(e) if e.kind() == io::ErrorKind::NotFound => return None,
Err(e) => {
warn!(path = %path.display(), error = %e, "failed to read catalog cache");
return None;
}
};
match postcard::from_bytes(&bytes) {
Ok(base) => Some(base),
Err(e) => {
warn!(
path = %path.display(),
error = %e,
"catalog cache failed to deserialize; falling back to the embedded catalog",
);
None
}
}
}
pub(crate) fn read_user_overlay(path: &Path) -> io::Result<Option<String>> {
match std::fs::read_to_string(path) {
Ok(contents) => Ok(Some(contents)),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e),
}
}
pub(crate) fn overlay_fingerprint_changed(last_applied: Option<&str>, fresh: Option<&str>) -> bool {
last_applied != fresh
}
pub(crate) fn write_catalog_cache(base: &[ProviderEntry], bin_path: &Path) -> io::Result<()> {
let bytes = postcard::to_allocvec(base).map_err(io::Error::other)?;
write_file_atomic(bin_path, &bytes)
}
fn ensure_runtime_dirs(paths: &CatalogPaths) {
let Some(dir) = paths.bin.parent() else {
return;
};
match std::fs::create_dir_all(dir) {
Ok(()) => debug!(dir = %dir.display(), "catalog data dir ready"),
Err(e) => warn!(
dir = %dir.display(),
error = %e,
"failed to create the catalog data dir; cache persistence may be unavailable",
),
}
}
pub(crate) fn spawn_catalog_maintenance(
daemon_tx: mpsc::Sender<DaemonCommand>,
db: Arc<redb::Database>,
paths: CatalogPaths,
overlay_rx: Receiver<ConfigChange>,
) -> Sender<MaintenanceEvent> {
let (tx, rx) = crossbeam_channel::unbounded::<MaintenanceEvent>();
let _ = std::thread::Builder::new()
.name("catalog-maintenance".into())
.spawn(move || maintenance_loop(daemon_tx, db, paths, rx, overlay_rx));
tx
}
struct MaintenanceState {
base: Vec<ProviderEntry>,
etag: Option<String>,
last_attempt_ms: Option<u64>,
last_applied_user_overlay: Option<String>,
next_retry_at: Option<Instant>,
}
fn maintenance_loop(
daemon_tx: mpsc::Sender<DaemonCommand>,
db: Arc<redb::Database>,
paths: CatalogPaths,
rx: Receiver<MaintenanceEvent>,
overlay_rx: Receiver<ConfigChange>,
) {
ensure_runtime_dirs(&paths);
let (base, etag, cache_valid) = match load_cached_base(&paths.bin) {
Some(base) => {
let etag = match get_catalog_etag(&db) {
Ok(etag) => etag,
Err(e) => {
warn!(error = %e, "failed to read the catalog etag from the DB; \
the next refresh will be a plain GET");
None
}
};
info!(
providers = base.len(),
"loaded catalog cache from disk ({} bytes)",
std::fs::metadata(&paths.bin).map(|m| m.len()).unwrap_or(0),
);
(base, etag, true)
}
None => {
let base = load_bundled_base();
info!(
providers = base.len(),
"no valid catalog cache; using the embedded catalog.bin",
);
(base, None, false)
}
};
let last_attempt_ms = match get_catalog_last_attempt_ms(&db) {
Ok(last_attempt) => last_attempt,
Err(e) => {
warn!(
error = %e,
"failed to read the catalog last-attempt timestamp; treating it as stale",
);
None
}
};
let user_overlay = match read_user_overlay(&paths.overlay) {
Ok(Some(contents)) => Some(contents),
Ok(None) => None,
Err(e) => {
warn!(
path = %paths.overlay.display(),
error = %e,
"failed to read user overlay; starting without it",
);
None
}
};
let mut state = MaintenanceState {
base,
etag,
last_attempt_ms,
last_applied_user_overlay: user_overlay.clone(),
next_retry_at: None,
};
let _ = daemon_tx.send(DaemonCommand::CatalogBaseChanged {
base: state.base.clone(),
etag: state.etag.clone(),
user_overlay,
persist: false,
reply: Vec::new(),
});
if should_fetch_at_startup(cache_valid, state.last_attempt_ms, wall_now_ms()) {
record_attempt(&db, &mut state);
run_refresh(&daemon_tx, &mut state, false, Vec::new());
} else if let Some(deadline) =
next_retry_deadline(state.last_attempt_ms, Instant::now(), wall_now_ms())
{
state.next_retry_at = Some(deadline);
info!(
?deadline,
"catalog cache is fresh; skipping the startup fetch and arming the \
revalidation timer for the remaining time",
);
}
loop {
let timeout = state
.next_retry_at
.map(|at| at.saturating_duration_since(Instant::now()))
.unwrap_or(REFRESH_ATTEMPT_INTERVAL);
select! {
recv(rx) -> msg => match msg {
Ok(MaintenanceEvent::RefreshNow { force, reply }) => {
reload_user_overlay(&daemon_tx, &mut state, &paths.overlay);
let (any_force, replies) = fold_refresh_nows(&rx, force, reply);
record_attempt(&db, &mut state);
run_refresh(&daemon_tx, &mut state, any_force, replies);
}
Err(_) => {
info!("catalog maintenance channel closed; exiting");
break;
}
},
recv(overlay_rx) -> evt => match evt {
Ok(_change) => {
reload_user_overlay(&daemon_tx, &mut state, &paths.overlay);
}
Err(_) => {
warn!("config transport channel closed; overlay auto-reload unavailable");
}
},
recv(after(timeout)) -> _ => {
if let Some(at) = state.next_retry_at
&& Instant::now() >= at
{
state.next_retry_at = None;
record_attempt(&db, &mut state);
run_refresh(&daemon_tx, &mut state, false, Vec::new());
}
},
}
}
}
fn should_fetch_at_startup(cache_valid: bool, last_attempt_ms: Option<u64>, now_ms: u64) -> bool {
if !cache_valid {
return true;
}
match last_attempt_ms {
None => true,
Some(at) => now_ms.saturating_sub(at) >= REFRESH_ATTEMPT_INTERVAL.as_millis() as u64,
}
}
fn next_retry_deadline(last_attempt_ms: Option<u64>, now: Instant, now_ms: u64) -> Option<Instant> {
let at = last_attempt_ms?;
let elapsed = Duration::from_millis(now_ms.saturating_sub(at));
let remaining = REFRESH_ATTEMPT_INTERVAL.saturating_sub(elapsed);
Some(now + remaining)
}
fn wall_now_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
fn record_attempt(db: &redb::Database, state: &mut MaintenanceState) {
let now_ms = wall_now_ms();
state.last_attempt_ms = Some(now_ms);
if let Err(e) = set_catalog_last_attempt_ms(db, now_ms) {
warn!(
error = %e,
"failed to persist the catalog attempt timestamp; the cooldown will \
not survive a restart (the next startup re-fetches)",
);
}
}
fn run_refresh(
daemon_tx: &mpsc::Sender<DaemonCommand>,
state: &mut MaintenanceState,
force: bool,
reply: Vec<RefreshRequester>,
) {
run_refresh_impl(daemon_tx, state, force, reply, |etag, force| {
fetch_modelsdev(etag, force)
});
}
fn run_refresh_impl<F>(
daemon_tx: &mpsc::Sender<DaemonCommand>,
state: &mut MaintenanceState,
force: bool,
reply: Vec<RefreshRequester>,
fetch: F,
) where
F: FnOnce(Option<&str>, bool) -> Result<RefreshOutcome, RefreshError>,
{
match fetch(state.etag.as_deref(), force) {
Ok(RefreshOutcome::NotModified) => {
info!(
force,
"models.dev catalog unchanged (304); keeping the current catalog",
);
if !reply.is_empty() {
let _ = daemon_tx.send(DaemonCommand::CatalogNotModified { reply });
}
state.next_retry_at = Some(Instant::now() + REFRESH_ATTEMPT_INTERVAL);
}
Ok(RefreshOutcome::Fetched { json, etag }) => {
let new_base = normalize_modelsdev(&json);
if new_base.is_empty() {
warn!(
"models.dev response did not normalize into a non-empty catalog; \
keeping the current catalog",
);
for r in reply {
let _ = r.tx.send(Err(
"models.dev response did not parse into a non-empty catalog".to_string(),
));
}
state.next_retry_at = Some(Instant::now() + REFRESH_ATTEMPT_INTERVAL);
return;
}
info!(
providers = new_base.len(),
?etag,
force,
"models.dev refresh fetched a new catalog",
);
state.base = new_base;
state.etag = etag;
state.next_retry_at = Some(Instant::now() + REFRESH_ATTEMPT_INTERVAL);
let _ = daemon_tx.send(DaemonCommand::CatalogBaseChanged {
base: state.base.clone(),
etag: state.etag.clone(),
user_overlay: state.last_applied_user_overlay.clone(),
persist: true,
reply,
});
}
Err(e) => {
warn!(error = %e, "models.dev refresh failed; will retry later");
for r in reply {
let _ = r.tx.send(Err(e.to_string()));
}
state.next_retry_at = Some(Instant::now() + REFRESH_ATTEMPT_INTERVAL);
}
}
}
fn fold_refresh_nows(
rx: &Receiver<MaintenanceEvent>,
force: bool,
first_reply: mpsc::Sender<Result<RefreshReport, String>>,
) -> (bool, Vec<RefreshRequester>) {
let mut any_force = force;
let mut replies = vec![RefreshRequester {
force,
tx: first_reply,
}];
while let Ok(MaintenanceEvent::RefreshNow { force, reply }) = rx.try_recv() {
any_force |= force;
replies.push(RefreshRequester { force, tx: reply });
}
(any_force, replies)
}
fn reload_user_overlay(
daemon_tx: &mpsc::Sender<DaemonCommand>,
state: &mut MaintenanceState,
overlay_path: &Path,
) {
match read_user_overlay(overlay_path) {
Ok(contents) => {
if overlay_fingerprint_changed(
state.last_applied_user_overlay.as_deref(),
contents.as_deref(),
) {
debug!(
path = %overlay_path.display(),
present = contents.is_some(),
"user overlay changed; reloading",
);
state.last_applied_user_overlay = contents.clone();
let _ = daemon_tx.send(DaemonCommand::CatalogBaseChanged {
base: state.base.clone(),
etag: state.etag.clone(),
user_overlay: contents,
persist: false,
reply: Vec::new(),
});
}
}
Err(e) => {
warn!(
path = %overlay_path.display(),
error = %e,
"failed to re-read the user overlay after a change; keeping the \
last-applied value",
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use choreo_ai_protocols::{ModelEntry, ProviderProtocol};
use choreo_proto::CatalogProvider;
fn tiny_base() -> Vec<ProviderEntry> {
vec![
ProviderEntry {
slug: "acme".into(),
display_name: "Acme".into(),
protocol: ProviderProtocol::OpenAi {
max_tokens_field: choreo_ai_protocols::MaxTokensField::MaxCompletionTokens,
},
base_url: "https://api.acme.dev/v1".into(),
default_model: "acme-1".into(),
models: vec![ModelEntry {
model: "acme-1".into(),
context_window: 8192,
reasoning_supported: true,
openai_reasoning_levels: vec!["off".into(), "high".into()],
max_output_tokens: 4096,
..Default::default()
}],
},
ProviderEntry {
slug: "zoocorp".into(),
display_name: "Zoo Corp".into(),
protocol: ProviderProtocol::AnthropicMessages,
base_url: "https://api.zoocorp.dev".into(),
default_model: "zoo-1".into(),
models: Vec::new(),
},
]
}
#[test]
fn fingerprint_compare_ignores_unchanged_contents() {
assert!(!overlay_fingerprint_changed(Some("a"), Some("a")));
assert!(!overlay_fingerprint_changed(None, None));
assert!(overlay_fingerprint_changed(Some("a"), Some("b")));
assert!(overlay_fingerprint_changed(None, Some("a")));
assert!(overlay_fingerprint_changed(Some("a"), None));
}
#[test]
fn cached_base_round_trips_through_postcard() {
let dir = tempfile::tempdir().unwrap();
let bin = dir.path().join("catalog.bin");
assert!(load_cached_base(&bin).is_none());
write_catalog_cache(&tiny_base(), &bin).unwrap();
let loaded = load_cached_base(&bin).expect("cache loads");
assert_eq!(loaded.len(), tiny_base().len());
assert_eq!(loaded[0].slug, "acme");
assert_eq!(loaded[1].slug, "zoocorp");
}
#[test]
fn corrupted_cache_falls_back_to_none() {
let dir = tempfile::tempdir().unwrap();
let bin = dir.path().join("catalog.bin");
std::fs::write(&bin, b"not postcard data").unwrap();
assert!(load_cached_base(&bin).is_none());
}
#[test]
fn user_overlay_read_distinguishes_missing_from_unreadable() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("models-overlay.toml");
assert_eq!(read_user_overlay(&path).unwrap(), None);
std::fs::write(&path, "[provider.acme]\nbase_url = \"x\"\n").unwrap();
assert_eq!(
read_user_overlay(&path).unwrap().as_deref(),
Some("[provider.acme]\nbase_url = \"x\"\n")
);
}
#[test]
fn catalog_paths_resolve_under_choreographr_dirs() {
let paths = CatalogPaths::from_dirs();
assert!(paths.bin.ends_with("choreographr/catalog.bin"));
assert!(paths.overlay.ends_with("choreographr/models-overlay.toml"));
}
#[test]
fn catalog_base_changed_payload_is_complete() {
let (reply, _rx) = mpsc::channel();
let _ = DaemonCommand::CatalogBaseChanged {
base: tiny_base(),
etag: Some("\"v9\"".into()),
user_overlay: Some("[provider.acme]\nbase_url = \"x\"\n".into()),
persist: true,
reply: vec![RefreshRequester {
force: true,
tx: reply,
}],
};
}
const SNAPSHOT_JSON: &str = r#"{
"acme": {
"name": "Acme",
"npm": "@ai-sdk/openai-compatible",
"models": {
"acme-1": {"reasoning": false, "limit": {"context": 8192}}
}
}
}"#;
fn maintenance_state() -> MaintenanceState {
MaintenanceState {
base: tiny_base(),
etag: Some("\"v1\"".into()),
last_attempt_ms: Some(1_700_000_000_000),
last_applied_user_overlay: None,
next_retry_at: None,
}
}
#[test]
fn run_refresh_fetched_arms_revalidation_and_sends_base_changed() {
let (daemon_tx, daemon_rx) = mpsc::channel::<DaemonCommand>();
let mut state = maintenance_state();
let (reply_tx, _reply_rx) = mpsc::channel();
run_refresh_impl(
&daemon_tx,
&mut state,
false,
vec![RefreshRequester {
force: false,
tx: reply_tx,
}],
|_etag, _force| {
Ok(RefreshOutcome::Fetched {
json: SNAPSHOT_JSON.into(),
etag: Some("\"v2\"".into()),
})
},
);
assert_eq!(state.etag.as_deref(), Some("\"v2\""));
assert_eq!(state.base.len(), 1, "snapshot normalizes to one provider");
assert!(
state.next_retry_at.is_some(),
"a successful fetch must schedule the next revalidation"
);
match daemon_rx.recv().unwrap() {
DaemonCommand::CatalogBaseChanged { persist, reply, .. } => {
assert!(persist, "a live fetch must persist the cache");
assert_eq!(
reply.len(),
1,
"the /refresh-models reply must be routed through the command"
);
assert!(
!reply[0].force,
"a plain requester must not be marked forced"
);
}
other => panic!(
"expected CatalogBaseChanged, got {:?}",
std::mem::discriminant(&other)
),
}
}
#[test]
fn run_refresh_forced_fetch_marks_the_requester_forced() {
let (daemon_tx, daemon_rx) = mpsc::channel::<DaemonCommand>();
let mut state = maintenance_state();
let (reply_tx, _reply_rx) = mpsc::channel();
run_refresh_impl(
&daemon_tx,
&mut state,
true,
vec![RefreshRequester {
force: true,
tx: reply_tx,
}],
|_etag, _force| {
Ok(RefreshOutcome::Fetched {
json: SNAPSHOT_JSON.into(),
etag: Some("\"v3\"".into()),
})
},
);
match daemon_rx.recv().unwrap() {
DaemonCommand::CatalogBaseChanged { reply, .. } => {
assert_eq!(reply.len(), 1);
assert!(reply[0].force, "a --force requester keeps its forced flag");
}
other => panic!(
"expected CatalogBaseChanged, got {:?}",
std::mem::discriminant(&other)
),
}
}
#[test]
fn run_refresh_not_modified_routes_reply_through_daemon_and_revalidates() {
let (daemon_tx, daemon_rx) = mpsc::channel::<DaemonCommand>();
let mut state = maintenance_state();
let (reply_tx, reply_rx) = mpsc::channel();
run_refresh_impl(
&daemon_tx,
&mut state,
false,
vec![RefreshRequester {
force: false,
tx: reply_tx,
}],
|etag, force| {
assert_eq!(etag, Some("\"v1\""));
assert!(!force);
Ok(RefreshOutcome::NotModified)
},
);
match daemon_rx.recv().unwrap() {
DaemonCommand::CatalogNotModified { reply } => {
assert_eq!(reply.len(), 1, "the requester's sender is routed");
let mut reply = reply;
let requester = reply.pop().expect("one requester");
let _ = requester.tx.send(Ok(RefreshReport {
providers: 2,
models: 1,
status: RefreshStatus::UpToDate,
}));
}
other => panic!(
"expected CatalogNotModified, got {:?}",
std::mem::discriminant(&other)
),
}
let report = reply_rx.recv().unwrap().expect("reply is Ok");
assert_eq!(report.status, RefreshStatus::UpToDate);
assert!(
state.next_retry_at.is_some(),
"a 304 must schedule the next revalidation"
);
}
#[test]
fn run_refresh_error_replies_and_schedules_retry() {
let (daemon_tx, _daemon_rx) = mpsc::channel::<DaemonCommand>();
let mut state = maintenance_state();
let (reply_tx, reply_rx) = mpsc::channel();
run_refresh_impl(
&daemon_tx,
&mut state,
false,
vec![RefreshRequester {
force: false,
tx: reply_tx,
}],
|_etag, _force| Err(choreo_ai_protocols::RefreshError::Network("boom".into())),
);
let err = reply_rx.recv().unwrap().expect_err("reply is Err");
assert!(err.contains("boom"), "unexpected error: {err}");
assert!(
state.next_retry_at.is_some(),
"a failure must schedule a retry"
);
}
#[test]
fn run_refresh_empty_normalization_keeps_current_catalog() {
let (daemon_tx, daemon_rx) = mpsc::channel::<DaemonCommand>();
let mut state = maintenance_state();
let (reply_tx, reply_rx) = mpsc::channel();
run_refresh_impl(
&daemon_tx,
&mut state,
false,
vec![RefreshRequester {
force: false,
tx: reply_tx,
}],
|_etag, _force| {
Ok(RefreshOutcome::Fetched {
json: "not json at all".into(),
etag: Some("\"v4\"".into()),
})
},
);
assert!(
daemon_rx.try_recv().is_err(),
"no swap for an empty normalize"
);
let err = reply_rx.recv().unwrap().expect_err("reply is Err");
assert!(err.contains("non-empty"), "unexpected error: {err}");
assert!(state.next_retry_at.is_some());
}
const NOW_MS: u64 = 1_700_000_000_000;
const INTERVAL_MS: u64 = 25 * 60 * 60 * 1000;
#[test]
fn startup_gate_fetches_without_a_valid_cache() {
assert!(should_fetch_at_startup(false, None, NOW_MS));
assert!(should_fetch_at_startup(false, Some(NOW_MS - 1_000), NOW_MS));
assert!(should_fetch_at_startup(false, Some(NOW_MS), NOW_MS));
}
#[test]
fn startup_gate_fetches_without_a_recorded_attempt() {
assert!(should_fetch_at_startup(true, None, NOW_MS));
}
#[test]
fn startup_gate_skips_fetch_while_attempt_is_fresh() {
assert!(!should_fetch_at_startup(true, Some(NOW_MS - 1_000), NOW_MS));
assert!(!should_fetch_at_startup(
true,
Some(NOW_MS - INTERVAL_MS / 2),
NOW_MS
));
}
#[test]
fn startup_gate_fetches_at_or_after_the_interval() {
assert!(should_fetch_at_startup(
true,
Some(NOW_MS - INTERVAL_MS),
NOW_MS
));
assert!(should_fetch_at_startup(
true,
Some(NOW_MS - INTERVAL_MS - 60_000),
NOW_MS
));
assert!(!should_fetch_at_startup(
true,
Some(NOW_MS + 3_600_000),
NOW_MS
));
}
#[test]
fn retry_deadline_is_none_without_a_recorded_attempt() {
assert_eq!(next_retry_deadline(None, Instant::now(), NOW_MS), None);
}
#[test]
fn retry_deadline_is_remaining_time_after_a_fresh_attempt() {
let now = Instant::now();
let deadline = next_retry_deadline(Some(NOW_MS - 3_600_000), now, NOW_MS)
.expect("a fresh attempt yields a deadline");
let expected = Duration::from_millis(INTERVAL_MS - 3_600_000);
assert_eq!(deadline.duration_since(now), expected);
}
#[test]
fn retry_deadline_saturates_at_now_when_already_due() {
let now = Instant::now();
let deadline = next_retry_deadline(Some(NOW_MS - INTERVAL_MS - 60_000), now, NOW_MS)
.expect("a stale attempt still yields a deadline");
assert_eq!(deadline, now);
}
#[test]
fn catalog_updated_payload_round_trips_catalog_provider() {
let p = CatalogProvider {
slug: "openai".into(),
display_name: "OpenAI".into(),
};
assert_eq!(p.slug, "openai");
assert_eq!(p.display_name, "OpenAI");
}
#[test]
fn fold_refresh_nows_folds_queued_bursts() {
let (tx, rx) = crossbeam_channel::unbounded::<MaintenanceEvent>();
let (reply_a, _ra) = mpsc::channel();
let (reply_b, _rb) = mpsc::channel();
let (reply_c, _rc) = mpsc::channel();
tx.send(MaintenanceEvent::RefreshNow {
force: false,
reply: reply_b,
})
.unwrap();
tx.send(MaintenanceEvent::RefreshNow {
force: true,
reply: reply_c,
})
.unwrap();
let (force, replies) = fold_refresh_nows(&rx, false, reply_a);
assert!(
force,
"a --force anywhere in the burst must force the fetch"
);
assert_eq!(replies.len(), 3, "every requester's reply sender is kept");
assert!(!replies[0].force);
assert!(!replies[1].force);
assert!(replies[2].force, "the --force requester keeps its flag");
assert!(rx.try_recv().is_err(), "the burst is fully drained");
}
#[test]
fn fold_refresh_nows_keeps_first_reply_when_queue_empty() {
let (_tx, rx) = crossbeam_channel::unbounded::<MaintenanceEvent>();
let (reply, _r) = mpsc::channel();
let (force, replies) = fold_refresh_nows(&rx, true, reply);
assert!(force);
assert_eq!(replies.len(), 1);
assert!(replies[0].force, "the first requester's flag is preserved");
}
#[test]
fn reload_user_overlay_fingerprint_gates_the_daemon_command() {
let dir = tempfile::tempdir().unwrap();
let overlay = dir.path().join("models-overlay.toml");
let (daemon_tx, daemon_rx) = mpsc::channel::<DaemonCommand>();
let mut state = maintenance_state();
reload_user_overlay(&daemon_tx, &mut state, &overlay);
assert!(daemon_rx.try_recv().is_err(), "absent file sends nothing");
assert!(state.last_applied_user_overlay.is_none());
std::fs::write(&overlay, "[provider.acme]\nbase_url = \"x\"\n").unwrap();
reload_user_overlay(&daemon_tx, &mut state, &overlay);
match daemon_rx.try_recv().unwrap() {
DaemonCommand::CatalogBaseChanged {
user_overlay,
persist,
reply,
..
} => {
assert_eq!(
user_overlay.as_deref(),
Some("[provider.acme]\nbase_url = \"x\"\n")
);
assert!(!persist, "an overlay reload must not persist the cache");
assert!(reply.is_empty());
}
other => panic!(
"expected CatalogBaseChanged, got {:?}",
std::mem::discriminant(&other)
),
}
reload_user_overlay(&daemon_tx, &mut state, &overlay);
assert!(
daemon_rx.try_recv().is_err(),
"unchanged contents must not trigger a reload"
);
std::fs::write(&overlay, "[provider.acme]\nbase_url = \"y\"\n").unwrap();
reload_user_overlay(&daemon_tx, &mut state, &overlay);
match daemon_rx.try_recv().unwrap() {
DaemonCommand::CatalogBaseChanged { user_overlay, .. } => {
assert_eq!(
user_overlay.as_deref(),
Some("[provider.acme]\nbase_url = \"y\"\n")
);
}
other => panic!(
"expected CatalogBaseChanged, got {:?}",
std::mem::discriminant(&other)
),
}
std::fs::remove_file(&overlay).unwrap();
reload_user_overlay(&daemon_tx, &mut state, &overlay);
match daemon_rx.try_recv().unwrap() {
DaemonCommand::CatalogBaseChanged { user_overlay, .. } => {
assert_eq!(
user_overlay, None,
"a deleted overlay falls back to bundled-only"
);
}
other => panic!(
"expected CatalogBaseChanged, got {:?}",
std::mem::discriminant(&other)
),
}
}
#[test]
fn ensure_runtime_dirs_creates_the_data_dir() {
let dir = tempfile::tempdir().unwrap();
let paths = CatalogPaths {
bin: dir.path().join("data/choreographr/catalog.bin"),
overlay: dir.path().join("config/choreographr/models-overlay.toml"),
};
ensure_runtime_dirs(&paths);
assert!(
paths.bin.parent().unwrap().is_dir(),
"data dir must exist after ensure_runtime_dirs"
);
assert!(
!paths.overlay.parent().unwrap().exists(),
"ensure_runtime_dirs must not create the config dir (the transport owns it)"
);
ensure_runtime_dirs(&paths);
}
#[test]
fn ensure_runtime_dirs_tolerates_empty_paths() {
ensure_runtime_dirs(&CatalogPaths::default());
}
}