crates/monetize-server/src/services/accounts.rs
//! **`Accounts`: the accounts ONE monetize holds**, read side.
//!
//! An account is `(product, tenant)` (`monetize::ledger`). Its rows are the
//! subscription (the signed fact monetize holds), every purchase attempt folded
//! (ledger sales and paid signups, the same lines `Ledger.Sales` answers), and
//! the private instances it owns (their `Instance` rows on the ledger, G5
//! 5.4 — the run file only adds what the job says now). Each account names the vendor
//! instance its product bills through: the product's `[products.<id>] vendor`.
//!
//! Nothing here decides anything. It is assembled on every call from the
//! stores that already hold the truth, so there is no second copy to drift.
use std::collections::BTreeMap;
use std::sync::Arc;
use monetize_pay::Money;
use monetize_product::EntitlementFact;
use monetize_proto::accounts_server::Accounts;
use monetize_proto::{self as pb};
use tonic::{Request, Response, Status};
use super::{blocking, ledger_err, product_or_not_found};
use crate::convert::{fact_to_pb, money_to_pb};
use crate::estate_order::{self, phase, EstateRun};
use crate::state::AppState;
/// The most accounts one `List` answers, whatever the caller asks for.
pub const LIST_CEILING: usize = 500;
pub struct AccountsService(pub Arc<AppState>);
#[tonic::async_trait]
impl Accounts for AccountsService {
async fn list(
&self,
req: Request<pb::ListAccountsRequest>,
) -> Result<Response<pb::ListAccountsResponse>, Status> {
let r = req.into_inner();
blocking(&self.0, move |s| {
if !r.product.is_empty() {
product_or_not_found(s, &r.product)?;
}
let want = if r.limit == 0 {
LIST_CEILING
} else {
(r.limit as usize).min(LIST_CEILING)
};
let book = gather(s, &r.product)?;
let mut accounts: Vec<pb::AccountSummary> = book
.iter()
.map(|((p, t), rows)| summarise_on(s, p, t, rows))
.collect();
accounts.sort_by(|a, b| {
b.last_unix_ms
.cmp(&a.last_unix_ms)
.then_with(|| (&a.product, &a.tenant).cmp(&(&b.product, &b.tenant)))
});
accounts.truncate(want);
let vendors = s
.registry
.product_ids()
.into_iter()
.filter(|p| r.product.is_empty() || *p == r.product)
.filter_map(|p| s.registry.route_of(p).map(|v| (p.to_string(), v)))
.collect();
Ok(Response::new(pb::ListAccountsResponse {
accounts,
vendors,
}))
})
.await
}
async fn rows(
&self,
req: Request<pb::AccountRowsRequest>,
) -> Result<Response<pb::AccountRowsResponse>, Status> {
let r = req.into_inner();
if r.product.is_empty() || r.tenant.is_empty() {
return Err(Status::invalid_argument(
"an account is (product, tenant): name both",
));
}
blocking(&self.0, move |s| {
product_or_not_found(s, &r.product)?;
let mut book = gather(s, &r.product)?;
let rows = book
.remove(&(r.product.clone(), r.tenant.clone()))
.ok_or_else(|| {
Status::not_found(format!(
"no account ({}, {}) here: no fact, no purchase, no instance",
r.product, r.tenant
))
})?;
let account = summarise_on(s, &r.product, &r.tenant, &rows);
Ok(Response::new(pb::AccountRowsResponse {
account: Some(account),
rows,
}))
})
.await
}
async fn instance_log(
&self,
req: Request<pb::InstanceLogRequest>,
) -> Result<Response<pb::InstanceLogResponse>, Status> {
let company = req.into_inner().company;
blocking(&self.0, move |s| {
let run = s.estates.get(&company).ok_or_else(|| {
Status::not_found(format!("no private instance {company:?} here"))
})?;
let kept = s
.estates
.run_dir(&company)
.map(|d| estate_order::setup_log::read_tail(&d, LOG_LINES))
.unwrap_or_default();
Ok(Response::new(pb::InstanceLogResponse {
lines: log_lines(&run.history, kept),
}))
})
.await
}
}
/// How many of the installer's kept lines an instance's log shows (its newest).
pub const LOG_LINES: usize = 400;
/// ★ UW-13: **an instance's setup, as narrated** — the order's phases (`order`, `phase:
/// note`) and the installer's kept lines, merged oldest first.
pub fn log_lines(
history: &[estate_order::Transition],
kept: Vec<estate_order::setup_log::KeptLine>,
) -> Vec<pb::InstanceLogLine> {
let phases = history.iter().map(|t| pb::InstanceLogLine {
at_unix_ms: t.at_ms,
source: "order".into(),
line: if t.note.is_empty() {
t.phase.clone()
} else if t.phase.is_empty() {
t.note.clone()
} else {
format!("{}: {}", t.phase, t.note)
},
});
let said = kept.into_iter().map(|l| pb::InstanceLogLine {
at_unix_ms: l.at_unix_ms,
source: l.source,
line: l.line,
});
let mut lines: Vec<pb::InstanceLogLine> = phases.chain(said).collect();
lines.sort_by_key(|l| l.at_unix_ms);
lines
}
/// The vendor instance `product` bills through, empty for a free product.
fn route(s: &AppState, product: &str) -> String {
s.registry.route_of(product).unwrap_or_default()
}
/// ★ ALICA-5 (2026-10-26): **the vendor instance that bills `tenant` of `product`** — its
/// SITE's own (`@<estate>/<name>`: that estate's `vendor`; a plain name: the product's home
/// estate's), the route every order and signup of that site takes
/// ([`crate::registry::Registry::route_for`]). The product's own route only where no site
/// says one. Live 2026-10-26: `@test2-gunnar-rs/alica` paid through stripe/gunnar-test2 and
/// the page said stripe/gunnar, the product's.
pub(crate) fn vendor_of(s: &AppState, product: &str, tenant: &str) -> String {
let estate = monetize_product::TenantId(tenant.to_owned())
.estate()
.map(|(e, _)| e.to_owned())
.or_else(|| s.registry.home_of(product));
s.registry
.route_for(product, estate.as_deref())
.ok()
.filter(|v| !v.trim().is_empty())
.unwrap_or_else(|| route(s, product))
}
/// Pure: **the vendor a signup sale's reference names** (`signup:<vendor>:<session>`,
/// [`crate::signup::signup_sales`]) — the vendor that took that money; `None` for another
/// reference.
pub(crate) fn signup_vendor(reference: &str) -> Option<&str> {
let (vendor, session) = reference.strip_prefix("signup:")?.rsplit_once(':')?;
(!vendor.trim().is_empty() && !session.trim().is_empty()).then_some(vendor)
}
/// Pure: **the account's money mode** — what its drawn sales said (live wins; all test is
/// test), else, when they say nothing, the mode of the key its vendor holds (ALICA-5: an
/// account with only an instance or a given fact read "mode not said" though the box knows
/// its key is live).
pub(crate) fn mode_of(drawn: &[Option<bool>], key: Option<bool>) -> Option<bool> {
if drawn.contains(&Some(true)) {
Some(true)
} else if !drawn.is_empty() && drawn.iter().all(|l| *l == Some(false)) {
Some(false)
} else {
key
}
}
/// [`summarise`] with the account's own vendor ([`vendor_of`]) and its key's mode.
fn summarise_on(
s: &AppState,
product: &str,
tenant: &str,
rows: &[pb::AccountRow],
) -> pb::AccountSummary {
let vendor = vendor_of(s, product, tenant);
let key = s.registry.find_vendor(&vendor).and_then(|v| v.livemode());
summarise(product, tenant, &vendor, key, rows)
}
type Book = BTreeMap<(String, String), Vec<pb::AccountRow>>;
/// Every account of `product` (or of every product, when empty), each with
/// its rows in the order `Rows` answers them.
fn gather(s: &AppState, product: &str) -> Result<Book, Status> {
let wanted = |p: &str| product.is_empty() || p == product;
let mut book = Book::new();
let mut put = |p: &str, t: &str, row: pb::AccountRow| {
book.entry((p.to_string(), t.to_string()))
.or_default()
.push(row)
};
for p in s.registry.product_ids().into_iter().filter(|p| wanted(p)) {
for fact in s.ledger.entitlements(p).map_err(ledger_err)? {
let vendor = vendor_of(s, p, &fact.tenant.0);
put(p, &fact.tenant.0, subscription_row(&fact, &vendor));
}
}
for sale in s
.ledger
.sales()
.map_err(ledger_err)?
.iter()
.filter(|x| wanted(&x.product))
{
let vendor = sale
.vendor
.clone()
.filter(|v| !v.trim().is_empty())
.unwrap_or_else(|| vendor_of(s, &sale.product, &sale.tenant));
put(
&sale.product,
&sale.tenant,
purchase_row(super::ledger::sale_to_pb(sale), vendor),
);
}
// A paid signup is a purchase too, though it writes no ledger row. Its vendor is the one
// its own reference names: the vendor that took that money.
for sale in crate::signup::signup_sales(s, product)
.map_err(|e| Status::internal(format!("signup store: {e}")))?
{
let vendor = signup_vendor(&sale.reference)
.map(str::to_owned)
.unwrap_or_else(|| vendor_of(s, &sale.product, &sale.tenant));
let (p, t) = (sale.product.clone(), sale.tenant.clone());
put(&p, &t, purchase_row(sale, vendor));
}
// **The products a private instance belongs to**: the box's own estate product's lifecycle
// kind (`gunnar` on a box that sells Gunnar) and every other product it serves (T-OWNERFLOW
// 2026-10-26: a Holger instance bought on a Holger site is booked on the ledger under
// `holger`, and the page read `gunnar`'s alone, so it never showed).
let base = estate_order::instance_product(s);
let mut kinds = vec![base.clone()];
for p in s.registry.product_ids() {
if !kinds.iter().any(|k| k == p) {
kinds.push(p.to_string());
}
}
let base_apex = s
.estates
.cfg
.as_ref()
.map(|c| c.apex.clone())
.unwrap_or_default();
for instance in kinds.iter().filter(|p| wanted(p)) {
// The LEDGER's rows (G5): an instance is on the account its order was
// proven for, whether or not its run file is read here.
for booked in s.ledger.instances(instance).map_err(ledger_err)? {
// ★ ST-4/ST-5: booked on its own site's account, named on that site.
let live = s.estates.get(&booked.company).filter(|run| {
crate::estate_order::ledger_account(s, run) == booked.tenant
&& run.ordered_at_ms == booked.ordered_unix_ms
});
let terms = live
.as_ref()
.and_then(|run| crate::estate_order::run_terms(s, run));
let apex = terms
.as_ref()
.map(|c| c.apex.clone())
.unwrap_or_else(|| base_apex.clone());
// The run's own terms name its product; the box's own for its own product.
let words = terms.as_ref().or(if *instance == base {
s.estates.cfg.as_ref()
} else {
None
});
let title = estate_order::lifecycle::words_of(words).title;
let line = Instance::of(
&booked,
live.as_ref(),
&apex,
&vendor_of(s, instance, &booked.tenant),
&title,
);
let tenant = line.tenant.clone();
put(instance, &tenant, line.row());
}
}
for rows in book.values_mut() {
order(rows);
}
Ok(book)
}
/// The subscription first, then the rest newest first.
fn order(rows: &mut [pb::AccountRow]) {
rows.sort_by(|a, b| {
(b.kind == "subscription")
.cmp(&(a.kind == "subscription"))
.then_with(|| b.at_unix_ms.cmp(&a.at_unix_ms))
.then_with(|| a.id.cmp(&b.id))
});
}
fn summarise(
product: &str,
tenant: &str,
vendor: &str,
key_mode: Option<bool>,
rows: &[pb::AccountRow],
) -> pb::AccountSummary {
let fact = rows.iter().find_map(|r| r.fact.as_ref());
let drawn: Vec<Option<bool>> = rows
.iter()
.filter_map(|r| r.sale.as_ref())
.filter(|s| s.drawn)
.map(|s| s.livemode)
.collect();
let livemode = mode_of(&drawn, key_mode);
pb::AccountSummary {
product: product.to_string(),
tenant: tenant.to_string(),
vendor: vendor.to_string(),
state: fact
.map(|f| f.state)
.unwrap_or(pb::State::Unspecified as i32),
paid_until_unix_ms: fact.and_then(|f| f.paid_until_unix_ms),
rows: rows.len() as u32,
instances: rows.iter().filter(|r| r.kind == "instance").count() as u32,
last_unix_ms: rows.iter().map(|r| r.at_unix_ms).max().unwrap_or(0),
needs_you: rows.iter().any(|r| r.needs_you),
livemode,
}
}
fn subscription_row(fact: &EntitlementFact, vendor: &str) -> pb::AccountRow {
let until = fact
.paid_until_unix_ms
.map(|ms| format!(" until {}", estate_order::iso_date(ms)))
.unwrap_or_default();
let caps = caps_words(&fact.caps);
pb::AccountRow {
kind: "subscription".into(),
id: String::new(),
vendor: vendor.to_string(),
words: format!(
"{:?}{until}{}",
fact.state,
if caps.is_empty() {
String::new()
} else {
format!(" · {caps}")
}
),
at_unix_ms: fact.issued_unix_ms.unwrap_or(0),
needs_you: false,
fact: Some(fact_to_pb(fact)),
..Default::default()
}
}
fn purchase_row(sale: pb::Sale, vendor: String) -> pb::AccountRow {
let price = sale.price.as_ref().map(pb_money_words).unwrap_or_default();
let caps = caps_words(&sale.caps);
pb::AccountRow {
kind: "purchase".into(),
id: sale.reference.clone(),
vendor,
words: [sale.outcome.as_str(), caps.as_str(), price.as_str()]
.iter()
.filter(|w| !w.is_empty())
.copied()
.collect::<Vec<_>>()
.join(" · "),
at_unix_ms: sale.at_unix_ms,
needs_you: sale.needs_you,
sale: Some(sale),
..Default::default()
}
}
/// **One private instance, as an account row.** Built from its ledger line
/// ([`monetize::ledger::InstanceLine`]), with the live run's state and why
/// when this monetize holds it.
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct Instance {
/// The account it lands on: the ledger row's tenant — the account the
/// order was proven for. Never the company.
pub tenant: String,
pub company: String,
pub host: String,
pub phase: String,
pub state: String,
pub monthly: Money,
pub paid_until_ms: u64,
pub why: String,
pub vendor: String,
pub last_ms: u64,
/// The operator's own buy: an operator test, never revenue.
pub operator_test: bool,
/// What the buyer calls the product (its lifecycle's words: `Gunnar`).
pub title: String,
}
impl Instance {
pub(crate) fn of(
line: &monetize::ledger::InstanceLine,
live: Option<&EstateRun>,
apex: &str,
fallback_vendor: &str,
title: &str,
) -> Instance {
let (state, why) = match live {
// The operator's console: a setup that failed after payment reads `owed`.
Some(run) => (
run.operator_view_as(
apex,
&crate::estate_order::lifecycle::ProductWords::neutral(),
)
.0,
run.why.clone(),
),
None => (line.phase.clone(), String::new()),
};
Instance {
tenant: line.tenant.clone(),
company: line.company.clone(),
host: if line.host.is_empty() {
format!("{}.{apex}", line.company)
} else {
line.host.clone()
},
phase: line.phase.clone(),
state,
monthly: line.monthly,
paid_until_ms: line.paid_until_unix_ms,
why,
vendor: if line.vendor.is_empty() {
fallback_vendor.to_string()
} else {
line.vendor.clone()
},
last_ms: line.last_unix_ms,
operator_test: line.operator_test,
title: title.to_string(),
}
}
pub(crate) fn row(&self) -> pb::AccountRow {
let monthly_words = if self.operator_test {
format!(
"{} a month · operator test, not revenue",
self.monthly.in_words()
)
} else {
format!("{} a month", self.monthly.in_words())
};
pb::AccountRow {
kind: "instance".into(),
id: self.company.clone(),
vendor: self.vendor.clone(),
words: format!(
"Private {} {} · {} · {monthly_words}",
self.title, self.company, self.phase
),
at_unix_ms: self.last_ms,
// The two phases an operator has to act on: a job that stopped,
// and money taken for an estate that does not run yet.
needs_you: self.phase == phase::FAILED || self.phase == phase::OWED,
instance: Some(pb::AccountInstance {
kind: "estate".into(),
company: self.company.clone(),
host: self.host.clone(),
phase: self.phase.clone(),
state: self.state.clone(),
monthly: Some(money_to_pb(self.monthly)),
monthly_words,
paid_until: if self.paid_until_ms == 0 {
String::new()
} else {
estate_order::iso_date(self.paid_until_ms)
},
why: self.why.clone(),
}),
..Default::default()
}
}
}
/// `pack_bytes = 20 GiB` reads `20 GiB`; any other meter reads `meter value`.
fn caps_words(caps: &BTreeMap<String, u64>) -> String {
const GIB: u64 = 1 << 30;
caps.iter()
.map(|(meter, v)| {
if meter.ends_with("_bytes") {
if v % GIB == 0 {
format!("{} GiB", v / GIB)
} else {
format!("{:.2} GiB", *v as f64 / GIB as f64)
}
} else {
format!("{meter} {v}")
}
})
.collect::<Vec<_>>()
.join(", ")
}
fn pb_money_words(m: &pb::Money) -> String {
let code = match m.currency() {
pb::Currency::Sek => "SEK",
pb::Currency::Eur => "EUR",
pb::Currency::Dkk => "DKK",
pb::Currency::Unspecified => "",
};
format!("{}.{:02} {code}", m.minor / 100, m.minor % 100)
.trim_end()
.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use monetize_pay::Currency;
fn instance(company: &str, tenant: &str, phase: &str, last_ms: u64) -> Instance {
Instance {
tenant: tenant.into(),
company: company.into(),
host: format!("{company}.gunnar.rs"),
phase: phase.into(),
state: "ready".into(),
monthly: Money {
minor: 13_200,
currency: Currency::Eur,
},
paid_until_ms: 0,
why: String::new(),
vendor: "stripe/gunnar".into(),
last_ms,
operator_test: false,
title: "Gunnar".into(),
}
}
/// ★ ALICA-5 (live 2026-10-26): **an account on a site names THAT site's vendor**. The
/// owner paid on test2.gunnar.rs through stripe/gunnar-test2; the Accounts page said
/// stripe/gunnar, the product's route. The home site's account keeps the home's vendor; a
/// signup sale names the vendor in its own reference.
#[tokio::test]
async fn an_account_on_a_site_names_that_sites_vendor() {
use monetize_proto::accounts_server::Accounts as _;
let wrap = |id: &str| {
monetize_pay::instance::Instance::wrap(id, Box::new(monetize::testing::MockPay::new()))
.unwrap()
};
let site = |vendor: &str, site: &str| crate::config::EstateConfig {
product: "gunnar".into(),
vendor: vendor.into(),
site: site.into(),
..Default::default()
};
let reg = crate::registry::Registry::new()
.product(monetize::testing::MockProduct::gunnar())
.vendor(wrap("stripe/gunnar"))
.vendor(wrap("stripe/gunnar-test2"))
.route("gunnar", "stripe/gunnar")
.home("gunnar", "gunnar-rs")
.estate("gunnar-rs", site("stripe/gunnar", "gunnar.rs"))
.estate(
"test2-gunnar-rs",
site("stripe/gunnar-test2", "test2.gunnar.rs"),
);
let s = Arc::new(AppState::in_memory(reg));
for tenant in ["@test2-gunnar-rs/alica", "bob"] {
let fact = monetize_product::EntitlementFact {
tenant: monetize_product::TenantId(tenant.into()),
plan: "gunnar/10gb-1m-fi".into(),
state: monetize_product::State::Paid,
paid_until_unix_ms: Some(1_795_000_000_000),
caps: [("pack_bytes".to_string(), 10 << 30)].into(),
source: "operator".into(),
signature: vec![],
issued_unix_ms: None,
issued_signature: vec![],
};
s.ledger
.set_entitlement("gunnar", &s.signer_for("gunnar").sign_fact(fact))
.unwrap();
}
let out = AccountsService(s.clone())
.list(Request::new(pb::ListAccountsRequest::default()))
.await
.unwrap()
.into_inner();
let vendor_of = |t: &str| {
out.accounts
.iter()
.find(|a| a.tenant == t)
.map(|a| a.vendor.clone())
.unwrap_or_default()
};
assert_eq!(
vendor_of("@test2-gunnar-rs/alica"),
"stripe/gunnar-test2",
"test2's account is billed through test2's vendor: {out:?}"
);
assert_eq!(
vendor_of("bob"),
"stripe/gunnar",
"the home's account keeps the home's vendor"
);
let rows = AccountsService(s.clone())
.rows(Request::new(pb::AccountRowsRequest {
product: "gunnar".into(),
tenant: "@test2-gunnar-rs/alica".into(),
}))
.await
.unwrap()
.into_inner();
assert_eq!(
rows.rows[0].vendor, "stripe/gunnar-test2",
"its SaaS row too"
);
assert_eq!(rows.account.unwrap().vendor, "stripe/gunnar-test2");
}
/// ★ ALICA-5: a signup sale's vendor is the one its reference names (the vendor that took
/// the money), never guessed from the product.
#[test]
fn a_signup_sale_names_the_vendor_in_its_reference() {
assert_eq!(
signup_vendor("signup:stripe/gunnar-test2:cs_live_a1"),
Some("stripe/gunnar-test2")
);
assert_eq!(signup_vendor("signup:mock:cs_1"), Some("mock"));
for other in [
"estate/acme",
"signup:",
"signup:stripe/gunnar:",
"signup::cs_1",
"purchase:gunnar:x",
] {
assert_eq!(signup_vendor(other), None, "{other}");
}
}
/// ★ ALICA-5: **the mode is said whenever the box knows it**: the drawn sales' word first
/// (live wins), else the vendor key's own mode — "mode not said" only where neither says.
#[test]
fn an_account_says_its_mode_from_its_sales_else_its_vendors_key() {
assert_eq!(
mode_of(&[Some(false), Some(true)], Some(false)),
Some(true),
"one live sale is live money"
);
assert_eq!(
mode_of(&[Some(false)], Some(true)),
Some(false),
"test sales are test, whatever the key now"
);
assert_eq!(
mode_of(&[], Some(true)),
Some(true),
"no sale: the key's mode"
);
assert_eq!(
mode_of(&[None], Some(false)),
Some(false),
"sales that say nothing: the key's mode"
);
assert_eq!(mode_of(&[], None), None, "nobody says");
let sub = pb::AccountRow {
kind: "subscription".into(),
..Default::default()
};
assert_eq!(
summarise(
"gunnar",
"@test2-gunnar-rs/alica",
"stripe/gunnar-test2",
Some(true),
&[sub]
)
.livemode,
Some(true),
"an account with no drawn sale says its key's mode"
);
}
/// A Private Gunnar is a row on its owner's gunnar account, beside the
/// subscription, and the account counts it as an instance.
#[test]
fn an_instance_is_a_row_on_its_owners_account() {
let sub = pb::AccountRow {
kind: "subscription".into(),
at_unix_ms: 5,
..Default::default()
};
let mut rows = vec![
instance("acme", "acme", phase::CAPTURED, 10).row(),
sub,
instance("acme2", "acme", phase::FAILED, 20).row(),
];
order(&mut rows);
let kinds: Vec<(&str, &str)> = rows
.iter()
.map(|r| (r.kind.as_str(), r.id.as_str()))
.collect();
assert_eq!(
kinds,
vec![
("subscription", ""),
("instance", "acme2"),
("instance", "acme")
]
);
let a = summarise("gunnar", "acme", "stripe/gunnar", None, &rows);
assert_eq!(
(a.rows, a.instances, a.last_unix_ms, a.needs_you),
(3, 2, 20, true),
"a failed job waits on the operator"
);
let row = &rows[2];
assert_eq!(row.vendor, "stripe/gunnar");
assert_eq!(
row.words,
"Private Gunnar acme · captured · 132.00 EUR a month"
);
let line = row.instance.as_ref().unwrap();
assert_eq!(
(
line.kind.as_str(),
line.host.as_str(),
line.monthly_words.as_str()
),
("estate", "acme.gunnar.rs", "132.00 EUR a month")
);
}
/// ★ UW-13: the order's phases and the installer's kept lines, one log, oldest first;
/// a phase change says its phase and its note.
#[test]
fn an_instances_log_is_its_phases_and_its_installers_lines_in_order() {
use crate::estate_order::setup_log::KeptLine;
use crate::estate_order::Transition;
let history = vec![
Transition {
phase: "held".into(),
at_ms: 10,
note: "the money is held".into(),
},
Transition {
phase: "laid".into(),
at_ms: 30,
note: String::new(),
},
];
let kept = vec![
KeptLine {
at_unix_ms: 20,
source: "installer".into(),
phase: "laying".into(),
line: "front up".into(),
},
KeptLine {
at_unix_ms: 40,
source: "acme-main".into(),
phase: "installing".into(),
line: "serving".into(),
},
];
let lines = log_lines(&history, kept);
assert_eq!(
lines
.iter()
.map(|l| (l.at_unix_ms, l.source.as_str(), l.line.as_str()))
.collect::<Vec<_>>(),
[
(10, "order", "held: the money is held"),
(20, "installer", "front up"),
(30, "order", "laid"),
(40, "acme-main", "serving")
]
);
}
#[test]
fn caps_read_in_gib() {
let caps = BTreeMap::from([
("pack_bytes".to_string(), 20u64 << 30),
("seats".to_string(), 3),
]);
assert_eq!(caps_words(&caps), "20 GiB, seats 3");
}
}