crates/monetize-server/src/serving.rs
//! ★ **What this monetize serves, said where its ONE box reads it** (A32-4): every product its
//! config declares, the ones SERVED (a product the registry holds: its guest loaded, its plugin
//! linked) and the ones refused, by name — written to `<data_dir>/`[`SERVING_FILE`] once the
//! gRPC door listens, and again on every look of the guest watcher (a feed taken, a guest
//! loaded). The ONE box's self-update trial confirms on this word (monetize-admin
//! `self_update::serves_whole`), never on a bare TCP connect: an app that refused every product
//! guest answered TCP and was confirmed (R3+R4, 2026-10-26).
use crate::registry::{GuestChange, Registry};
/// The file under `data_dir` this monetize says what it serves in.
pub const SERVING_FILE: &str = "serving.json";
/// **What this monetize serves** — as written to [`SERVING_FILE`].
#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct Serving {
/// The process that wrote it.
pub pid: u32,
/// When it was written (unix ms): a word from before this start is not this app's.
pub at_unix_ms: u64,
/// Every product the config declares (`[products.<id>]`).
pub declared: Vec<String>,
/// The declared products served now.
pub served: Vec<String>,
/// The declared products NOT served, each `<id>: <why>` (its start refusal when one was said).
pub refused: Vec<String>,
}
/// Pure: **what `reg` serves of `declared`**, at `now_unix_ms`.
pub fn of(reg: &Registry, declared: &[String], now_unix_ms: u64) -> Serving {
let mut s = Serving {
pid: std::process::id(),
at_unix_ms: now_unix_ms,
declared: declared.to_vec(),
..Serving::default()
};
for id in declared {
if reg.find_product(id).is_some() {
s.served.push(id.clone());
continue;
}
let why = reg
.start_refusals()
.iter()
.find_map(|r| match r {
GuestChange::Refused {
id: rid,
clause,
why,
} if rid == id => Some(format!("{clause} ({why})")),
_ => None,
})
.unwrap_or_else(|| "not served (its guest is not loaded)".to_string());
s.refused.push(format!("{id}: {why}"));
}
s
}
/// **Write `s` to `path`** whole (a temporary file renamed over it): a reader never sees half.
pub fn write(path: &std::path::Path, s: &Serving) -> std::io::Result<()> {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
let tmp = path.with_extension("json.tmp");
std::fs::write(
&tmp,
serde_json::to_vec_pretty(s).map_err(std::io::Error::other)?,
)?;
std::fs::rename(&tmp, path)
}
/// **Say what `state` serves of `cfg`'s products now**, to `cfg`'s [`SERVING_FILE`]. A write that
/// fails is said: the box's trial then waits (and rolls back), which is what it should do.
pub fn say(state: &crate::state::AppState, cfg: &crate::config::Config) {
let declared: Vec<String> = cfg.products.keys().cloned().collect();
let s = of(&state.registry, &declared, state.now());
if let Err(e) = write(&cfg.serving_path(), &s) {
eprintln!(
"monetize-server: WARNING the serving word could not be written to {}: {e}",
cfg.serving_path().display()
);
}
}