feat: add smolboid daemon: mail loop, feed store and HTTP listener
This commit is contained in:
parent
fd3f9bcf24
commit
8330b65ff9
8 changed files with 1981 additions and 0 deletions
595
daemon/src/mailloop.rs
Normal file
595
daemon/src/mailloop.rs
Normal file
|
|
@ -0,0 +1,595 @@
|
|||
//! The mail loop: fetch from the daemon's mailbox, admit by ownership, apply
|
||||
//! each mail to its feed, and send rate-limited error replies. The apply and
|
||||
//! retention logic is pure and unit-tested; network glue stays thin.
|
||||
|
||||
use fumi_core::account::Account;
|
||||
use fumi_core::address::Address;
|
||||
use fumi_core::client;
|
||||
use fumi_core::store::{now, Store};
|
||||
use fumi_core::error::Error as FumiError;
|
||||
use smolboid_core::feed::{Feed, MailLine, Record};
|
||||
use smolboid_core::envelope;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::dispatch::{self, Action, Command, Incoming};
|
||||
use crate::feedstore::FeedStore;
|
||||
use crate::state::{payload_key, reply_allowed, FeedState, PruneMode, State};
|
||||
|
||||
use std::sync::{Mutex, MutexGuard};
|
||||
|
||||
/// Everything needed to publish one record, before retention gets a say.
|
||||
pub struct PendingRecord {
|
||||
pub timestamp: String,
|
||||
pub text: String,
|
||||
pub repost: bool,
|
||||
/// The Reply-To the mail carried, when the ordinary-send form was used.
|
||||
pub reply_to: Option<String>,
|
||||
pub mail: MailLine,
|
||||
}
|
||||
|
||||
/// Failures that draw an error reply (PLAN.md, Management mail).
|
||||
#[derive(Debug)]
|
||||
pub enum ApplyError {
|
||||
OverQuota(bool),
|
||||
Collision(String),
|
||||
FeedFull,
|
||||
/// The author's Reply-To changed after the feed bound one: published
|
||||
/// records would stop reconstructing (PLAN.md, smol:reply_to).
|
||||
ReplyToChanged,
|
||||
}
|
||||
|
||||
/// Applies a published record to the feed: quota, uniqueness, retention
|
||||
/// (pruning in Fifo mode, rejecting in Off mode), then the append.
|
||||
/// Returns the rendered size after appending, for the 90% warning.
|
||||
pub fn apply_record(
|
||||
feed: &mut Feed,
|
||||
fs: &mut FeedState,
|
||||
cfg: &Config,
|
||||
record: PendingRecord,
|
||||
now_unix: i64,
|
||||
) -> Result<(), ApplyError> {
|
||||
State::roll_counters(fs, now_unix);
|
||||
let (counter, limit) = if record.repost {
|
||||
(&mut fs.reposts, cfg.reposts_per_day)
|
||||
} else {
|
||||
(&mut fs.posts, cfg.posts_per_day)
|
||||
};
|
||||
if *counter >= limit {
|
||||
return Err(ApplyError::OverQuota(record.repost));
|
||||
}
|
||||
if feed.has_timestamp(&record.timestamp) {
|
||||
// The signature pins the time, so a bump is impossible; the author
|
||||
// resends a second later (post rule 2).
|
||||
return Err(ApplyError::Collision(record.timestamp));
|
||||
}
|
||||
|
||||
// The Reply-To must be the author's own address (key = sender), and the
|
||||
// verifier derives it from `smol:author` plus the record's sender key. The
|
||||
// first publishing mail adopts the author's own spelling of the address;
|
||||
// a later change cannot be adopted without invalidating old records.
|
||||
if let Some(reply_to) = &record.reply_to {
|
||||
let Ok(addr) = Address::parse(reply_to) else {
|
||||
return Err(ApplyError::ReplyToChanged);
|
||||
};
|
||||
if addr.identity != Some(record.mail.sender) || addr.short() != feed.author().unwrap_or_default() {
|
||||
if feed.entries().is_empty() {
|
||||
feed.set_meta("smol:author", &addr.short());
|
||||
} else {
|
||||
return Err(ApplyError::ReplyToChanged);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let entry = if record.repost {
|
||||
Record::Repost {
|
||||
timestamp: record.timestamp.clone(),
|
||||
args: record.text.clone(),
|
||||
}
|
||||
} else {
|
||||
Record::Post {
|
||||
timestamp: record.timestamp.clone(),
|
||||
text: record.text.clone(),
|
||||
}
|
||||
};
|
||||
let rendered = entry.render();
|
||||
let addition = rendered.len() + 1 + record.mail.render().len() + 1;
|
||||
let mut size = feed.render().len() + addition;
|
||||
if size > cfg.cap_bytes {
|
||||
if fs.prune == PruneMode::Off {
|
||||
return Err(ApplyError::FeedFull);
|
||||
}
|
||||
// FIFO: drop oldest records until the new one fits. If even an empty
|
||||
// feed cannot hold it, the record simply exceeds the cap.
|
||||
while size > cfg.cap_bytes {
|
||||
let Some(oldest) = feed.entries().first().map(|e| e.record.timestamp().to_owned())
|
||||
else {
|
||||
return Err(ApplyError::FeedFull);
|
||||
};
|
||||
feed.delete(&oldest);
|
||||
size = feed.render().len() + addition;
|
||||
}
|
||||
}
|
||||
|
||||
feed.append(entry, Some(record.mail));
|
||||
*counter += 1;
|
||||
|
||||
// The 90% warning fires once per growth episode (PLAN.md, Retention).
|
||||
let size = feed.render().len();
|
||||
if size * 10 >= cfg.cap_bytes * 9 {
|
||||
if !fs.warned {
|
||||
fs.warned = true;
|
||||
}
|
||||
} else if fs.warned {
|
||||
fs.warned = false;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Applies command fields to feed and state. Deletion failure is reported so
|
||||
/// the caller can reply.
|
||||
pub fn apply_commands(
|
||||
feed: &mut Feed,
|
||||
fs: &mut FeedState,
|
||||
commands: &[Command],
|
||||
) -> Result<usize, String> {
|
||||
for command in commands {
|
||||
match command {
|
||||
Command::Nick(nick) => {
|
||||
fs.nick = nick.clone();
|
||||
feed.set_meta("nick", nick);
|
||||
}
|
||||
Command::Delete(timestamp) => {
|
||||
if !feed.delete(timestamp) {
|
||||
return Err(format!("no record at {timestamp}"));
|
||||
}
|
||||
}
|
||||
Command::Prune(mode) => fs.prune = *mode,
|
||||
}
|
||||
}
|
||||
Ok(commands.len())
|
||||
}
|
||||
|
||||
pub struct Daemon {
|
||||
pub cfg: Config,
|
||||
pub store: Store,
|
||||
pub account: Account,
|
||||
pub state: Mutex<State>,
|
||||
pub feeds: FeedStore,
|
||||
}
|
||||
|
||||
impl Daemon {
|
||||
/// One poll cycle. Returns a human summary line.
|
||||
pub fn poll(&self) -> Result<String, String> {
|
||||
let summary = client::fetch(&self.store, &self.account, false, false, self.cfg.timeout)
|
||||
.map_err(|e| format!("fetch: {e}"))?;
|
||||
if summary.stored == 0 {
|
||||
return Ok("no new mail".into());
|
||||
}
|
||||
let mut applied = 0;
|
||||
let mut dropped = 0;
|
||||
let mut replies: Vec<(String, String, String)> = Vec::new(); // (address, subject, body)
|
||||
|
||||
let inbox = self
|
||||
.store
|
||||
.mail("inbox")
|
||||
.map_err(|e| format!("inbox: {e}"))?;
|
||||
for stored in &inbox {
|
||||
let outcome = self.process(stored.id, &stored.envelope, &mut replies);
|
||||
let _ = self.store.delete_local("inbox", &stored.id);
|
||||
match outcome {
|
||||
Ok(true) => applied += 1,
|
||||
Ok(false) => dropped += 1,
|
||||
Err(e) => eprintln!("mail: {e}"),
|
||||
}
|
||||
}
|
||||
self.send_replies(replies)?;
|
||||
Ok(format!(
|
||||
"{} applied, {} dropped",
|
||||
applied, dropped
|
||||
))
|
||||
}
|
||||
|
||||
/// Processes one stored envelope end to end. Ok(true) when the feed or
|
||||
/// state changed.
|
||||
fn process(
|
||||
&self,
|
||||
id: [u8; 32],
|
||||
envelope: &[u8],
|
||||
replies: &mut Vec<(String, String, String)>,
|
||||
) -> Result<bool, String> {
|
||||
let opened = envelope::open(self.account.keys(), envelope, now())
|
||||
.map_err(|e| format!("open {}: {e}", &fumi_core::crypto::b32(&id)[..8]))?;
|
||||
let incoming = Incoming {
|
||||
sender: opened.sender,
|
||||
epk: opened.epk,
|
||||
sig: opened.sig,
|
||||
time: opened.time,
|
||||
body: opened.body,
|
||||
};
|
||||
|
||||
let mut state = self.state.lock().unwrap();
|
||||
// Admission: a feed must exist and its owner must still be accepted.
|
||||
// Requests-tier or blocked mail has neither and is dropped without a
|
||||
// reply (PLAN.md, Admission and Error replies) — main-tier inbox rows
|
||||
// are all `mail("inbox")` ever returns.
|
||||
let index = match state.feed_of(&opened.sender) {
|
||||
Some(index) => index,
|
||||
None => match self.follow_rotation(&incoming, &mut state)? {
|
||||
Some(index) => index,
|
||||
None => return Ok(false),
|
||||
},
|
||||
};
|
||||
let feed_state = state.feeds[index].clone();
|
||||
if !self.accepted(&feed_state.address)? {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
self.apply(&incoming, index, &mut state, replies)
|
||||
}
|
||||
|
||||
fn accepted(&self, address: &str) -> Result<bool, String> {
|
||||
match self.store.accepted_state(address) {
|
||||
Ok(Some(true)) => Ok(true),
|
||||
Ok(_) => Ok(false),
|
||||
Err(e) => Err(format!("accepted_state: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// An unknown sender may be a rotated owner: a signed Reply-To names the
|
||||
/// address, and a verified chain from the key we hold to this one
|
||||
/// (SPEC.md §7) moves the feed to the new key, after which the very mail
|
||||
/// that announced the rotation applies as the rotated owner. Anything
|
||||
/// else is dropped.
|
||||
fn follow_rotation(
|
||||
&self,
|
||||
incoming: &Incoming,
|
||||
state: &mut MutexGuard<'_, State>,
|
||||
) -> Result<Option<usize>, String> {
|
||||
let Ok(text) = std::str::from_utf8(&incoming.body) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let (fields, _) = fumi_core::message::parse_frontmatter(text);
|
||||
let Some(reply_to) = fields.get("reply-to") else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Ok(addr) = Address::parse(reply_to) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(user_key) = addr.identity else {
|
||||
return Ok(None);
|
||||
};
|
||||
if user_key != incoming.sender {
|
||||
return Ok(None);
|
||||
}
|
||||
let Some(index) = state.feeds.iter().position(|f| f.address == addr.short()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(old) = state.feeds[index].latest_owner() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let mut session = client::connect(&self.store, &addr, false, self.cfg.timeout)
|
||||
.map_err(|e| format!("rotation connect: {e}"))?;
|
||||
let resolved = client::resolve(session.transport(), &addr.user)
|
||||
.map_err(|e| format!("rotation resolve: {e}"))?;
|
||||
session.close();
|
||||
let (current, chain) = resolved;
|
||||
if !client::walk_chain(&addr.user, &old, ¤t, &chain) {
|
||||
return Ok(None);
|
||||
}
|
||||
if state.feeds[index].owner_keys.last().map(String::as_str)
|
||||
!= Some(fumi_core::crypto::b32(¤t).as_str())
|
||||
{
|
||||
state.feeds[index]
|
||||
.owner_keys
|
||||
.push(fumi_core::crypto::b32(¤t));
|
||||
}
|
||||
Ok(Some(index))
|
||||
}
|
||||
|
||||
fn apply(
|
||||
&self,
|
||||
incoming: &Incoming,
|
||||
index: usize,
|
||||
state: &mut MutexGuard<'_, State>,
|
||||
replies: &mut Vec<(String, String, String)>,
|
||||
) -> Result<bool, String> {
|
||||
let username = state.feeds[index].username.clone();
|
||||
let address = state.feeds[index].address.clone();
|
||||
let mut feed = self.feeds.load(&username)?;
|
||||
let before = feed.render();
|
||||
|
||||
let outcome = match dispatch::dispatch(incoming) {
|
||||
Ok(Action::Post { timestamp, text, reply_to, mail }) => {
|
||||
self.publish(
|
||||
state, index, &mut feed, &address, incoming, replies,
|
||||
PendingRecord { timestamp, text, reply_to, repost: false, mail },
|
||||
)?
|
||||
}
|
||||
Ok(Action::Repost { timestamp, args, reply_to, mail }) => {
|
||||
self.publish(
|
||||
state, index, &mut feed, &address, incoming, replies,
|
||||
PendingRecord { timestamp, text: args, reply_to, repost: true, mail },
|
||||
)?
|
||||
}
|
||||
Ok(Action::Commands(commands)) => {
|
||||
match apply_commands(&mut feed, &mut state.feeds[index], &commands) {
|
||||
Ok(n) => n > 0,
|
||||
Err(reason) => {
|
||||
self.queue_reply(state, incoming, &address, &reason, replies);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Action::Ignore) => false,
|
||||
Err(reason) => {
|
||||
self.queue_reply(state, incoming, &address, &reason, replies);
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
let after = feed.render();
|
||||
if after != before {
|
||||
self.feeds.save(&username, &feed)?;
|
||||
}
|
||||
// Counters, ledgers and prune modes change even when no record did.
|
||||
state
|
||||
.save(&self.cfg.data.join("state.json"))
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn publish(
|
||||
&self,
|
||||
state: &mut MutexGuard<'_, State>,
|
||||
index: usize,
|
||||
feed: &mut Feed,
|
||||
address: &str,
|
||||
incoming: &Incoming,
|
||||
replies: &mut Vec<(String, String, String)>,
|
||||
record: PendingRecord,
|
||||
) -> Result<bool, String> {
|
||||
let was_warned = state.feeds[index].warned;
|
||||
match apply_record(feed, &mut state.feeds[index], &self.cfg, record, incoming.time) {
|
||||
Ok(()) => {
|
||||
// The transition false -> true sends the cap warning once per
|
||||
// growth episode (PLAN.md, Retention).
|
||||
if state.feeds[index].warned && !was_warned {
|
||||
replies.push((
|
||||
address.to_owned(),
|
||||
"twtxt: feed at 90% of its size cap".into(),
|
||||
format!(
|
||||
"Your feed has reached 90% of the {}-byte cap.\n\n\
|
||||
In hard-limit mode (the default) a full feed rejects new records; \
|
||||
delete some with Twt-Delete, or choose FIFO pruning with \
|
||||
Twt-Prune: fifo, which drops the oldest records to make room.",
|
||||
self.cfg.cap_bytes
|
||||
),
|
||||
));
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
Err(e) => {
|
||||
let reason = match &e {
|
||||
ApplyError::OverQuota(repost) => format!(
|
||||
"daily {} quota exceeded; try tomorrow",
|
||||
if *repost { "repost" } else { "post" }
|
||||
),
|
||||
ApplyError::Collision(ts) => {
|
||||
format!("timestamp {ts} already exists; wait a second and resend")
|
||||
}
|
||||
ApplyError::FeedFull => "feed is at its size cap; delete records with Twt-Delete or choose Twt-Prune: fifo".to_owned(),
|
||||
ApplyError::ReplyToChanged => format!(
|
||||
"your Reply-To does not match the feed's bound author {}; \
|
||||
send from that address or mail the operator",
|
||||
feed.author().unwrap_or_default()
|
||||
),
|
||||
};
|
||||
self.queue_reply(state, incoming, address, &reason, replies);
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One error reply per rejected payload, within the hourly budget.
|
||||
fn queue_reply(
|
||||
&self,
|
||||
state: &mut MutexGuard<'_, State>,
|
||||
incoming: &Incoming,
|
||||
address: &str,
|
||||
reason: &str,
|
||||
replies: &mut Vec<(String, String, String)>,
|
||||
) {
|
||||
let key = payload_key(&incoming.sender, incoming.time, &incoming.body);
|
||||
if !reply_allowed(&mut **state, &incoming.sender, &key, now()) {
|
||||
return;
|
||||
}
|
||||
let text = std::str::from_utf8(&incoming.body)
|
||||
.map(|t| t.chars().take(200).collect::<String>())
|
||||
.unwrap_or_default();
|
||||
replies.push((
|
||||
address.to_owned(),
|
||||
format!("twtxt: {reason}"),
|
||||
format!(
|
||||
"Your mail could not be applied.\n\nReason: {reason}\n\nQuoted mail:\n{text}\n"
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
fn send_replies(&self, replies: Vec<(String, String, String)>) -> Result<(), String> {
|
||||
for (address, subject, body) in replies {
|
||||
let addr = Address::parse(&address).map_err(|e| format!("reply address: {e}"))?;
|
||||
client::send(
|
||||
&self.store,
|
||||
&self.account,
|
||||
&client::SendDraft {
|
||||
address: &addr,
|
||||
text: body,
|
||||
subject: Some(&subject),
|
||||
reply_to: None,
|
||||
headers: &[],
|
||||
anonymous: false,
|
||||
no_pad: true,
|
||||
},
|
||||
self.cfg.timeout,
|
||||
)
|
||||
.map_err(|e: FumiError| format!("reply to {address}: {e}"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::Config;
|
||||
use crate::state::State;
|
||||
use smolboid_core::verify;
|
||||
|
||||
fn feed_state() -> FeedState {
|
||||
FeedState {
|
||||
username: "tobias".into(),
|
||||
address: "tobias@smol.place".into(),
|
||||
owner_keys: vec![fumi_core::crypto::b32(&[1; 32])],
|
||||
nick: "tobias".into(),
|
||||
prune: PruneMode::Off,
|
||||
warned: false,
|
||||
day: State::day(0),
|
||||
posts: 0,
|
||||
reposts: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// A record renders at roughly 260 bytes: the post line plus a
|
||||
/// `# smol:mail` line of three base32 values.
|
||||
fn config(cap: usize) -> Config {
|
||||
Config {
|
||||
cap_bytes: cap,
|
||||
..Config::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn record(ts: &str, text: &str) -> PendingRecord {
|
||||
PendingRecord {
|
||||
timestamp: ts.into(),
|
||||
text: text.into(),
|
||||
repost: false,
|
||||
reply_to: None,
|
||||
mail: MailLine {
|
||||
sender: [1; 32],
|
||||
epk: [2; 32],
|
||||
sig: [3; 64],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn posts_publish_and_verify() {
|
||||
let mut feed = smolboid_core::Feed::new();
|
||||
let mut fs = feed_state();
|
||||
apply_record(&mut feed, &mut fs, &config(1 << 20), record("2026-10-01T12:47:00Z", "hello"), 0).unwrap();
|
||||
assert_eq!(fs.posts, 1);
|
||||
let report = verify(&feed);
|
||||
assert_eq!(report.states, vec![smolboid_core::State::Unsigned]);
|
||||
assert_eq!(feed.entries().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reposts_publish_as_repost_records() {
|
||||
let mut feed = smolboid_core::Feed::new();
|
||||
let mut fs = feed_state();
|
||||
let record = PendingRecord {
|
||||
timestamp: "2026-10-01T12:47:00Z".into(),
|
||||
text: "@<dana https://x/d.txt> #abc1234".into(),
|
||||
repost: true,
|
||||
reply_to: None,
|
||||
mail: MailLine { sender: [1; 32], epk: [2; 32], sig: [3; 64] },
|
||||
};
|
||||
apply_record(&mut feed, &mut fs, &config(1 << 20), record, 0).unwrap();
|
||||
let entry = &feed.entries()[0];
|
||||
assert!(matches!(&entry.record, Record::Repost { args, .. } if args == "@<dana https://x/d.txt> #abc1234"));
|
||||
assert_eq!(fs.reposts, 1);
|
||||
assert_eq!(fs.posts, 0);
|
||||
assert!(feed.render().starts_with("# smol:repost 2026-10-01T12:47:00Z @<dana https://x/d.txt> #abc1234\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collisions_and_quotas_reject_without_appending() {
|
||||
let mut feed = smolboid_core::Feed::new();
|
||||
let mut fs = feed_state();
|
||||
let cfg = config(1 << 20);
|
||||
apply_record(&mut feed, &mut fs, &cfg, record("2026-10-01T12:47:00Z", "one"), 0).unwrap();
|
||||
assert!(matches!(
|
||||
apply_record(&mut feed, &mut fs, &cfg, record("2026-10-01T12:47:00Z", "two"), 0),
|
||||
Err(ApplyError::Collision(_))
|
||||
));
|
||||
assert_eq!(feed.entries().len(), 1);
|
||||
|
||||
let mut fs = feed_state();
|
||||
fs.posts = 50;
|
||||
assert!(matches!(
|
||||
apply_record(&mut feed, &mut fs, &config(1 << 20), record("2026-10-01T12:48:00Z", "x"), 0),
|
||||
Err(ApplyError::OverQuota(_))
|
||||
));
|
||||
// A new UTC day rolls the counters.
|
||||
let mut fs = feed_state();
|
||||
fs.posts = 50;
|
||||
apply_record(&mut feed, &mut fs, &config(1 << 20), record("2026-10-01T12:48:00Z", "x"), 1_760_000_000).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hard_limit_rejects_and_fifo_prunes_oldest() {
|
||||
let mut feed = smolboid_core::Feed::new();
|
||||
let mut fs = feed_state();
|
||||
let cfg = config(300);
|
||||
apply_record(&mut feed, &mut fs, &cfg, record("2026-10-01T12:47:00Z", "first post here"), 0).unwrap();
|
||||
assert!(matches!(
|
||||
apply_record(&mut feed, &mut fs, &cfg, record("2026-10-01T12:48:00Z", "second post"), 0),
|
||||
Err(ApplyError::FeedFull)
|
||||
));
|
||||
assert_eq!(feed.entries().len(), 1, "off mode removes nothing");
|
||||
|
||||
fs.prune = PruneMode::Fifo;
|
||||
apply_record(&mut feed, &mut fs, &cfg, record("2026-10-01T12:48:00Z", "second post"), 0).unwrap();
|
||||
let entries = feed.entries();
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert_eq!(entries[0].record.timestamp(), "2026-10-01T12:48:00Z");
|
||||
// The quota counts published posts; pruning does not refund it.
|
||||
assert_eq!(fs.posts, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warning_flag_flips_at_ninety_percent() {
|
||||
let mut feed = smolboid_core::Feed::new();
|
||||
let mut fs = feed_state();
|
||||
let cfg = config(1300);
|
||||
apply_record(&mut feed, &mut fs, &cfg, record("2026-10-01T12:47:00Z", "x"), 0).unwrap();
|
||||
assert!(!fs.warned);
|
||||
apply_record(&mut feed, &mut fs, &cfg, record("2026-10-01T12:47:01Z", &"y".repeat(700)), 0).unwrap();
|
||||
assert!(fs.warned, "crossing 90% sets the flag once");
|
||||
feed.delete("2026-10-01T12:47:01Z");
|
||||
apply_record(&mut feed, &mut fs, &cfg, record("2026-10-01T12:47:02Z", "z"), 0).unwrap();
|
||||
assert!(!fs.warned, "shrinking below the threshold resets it");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_touch_feed_and_state() {
|
||||
let mut feed = smolboid_core::Feed::new();
|
||||
let mut fs = feed_state();
|
||||
apply_record(&mut feed, &mut fs, &config(1 << 20), record("2026-10-01T12:47:00Z", "gone soon"), 0).unwrap();
|
||||
|
||||
apply_commands(
|
||||
&mut feed,
|
||||
&mut fs,
|
||||
&[Command::Nick("toby".into()), Command::Prune(PruneMode::Fifo)],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(fs.nick, "toby");
|
||||
assert_eq!(feed.meta("nick"), Some("toby"));
|
||||
assert_eq!(fs.prune, PruneMode::Fifo);
|
||||
|
||||
apply_commands(&mut feed, &mut fs, &[Command::Delete("2026-10-01T12:47:00Z".into())]).unwrap();
|
||||
assert_eq!(feed.entries().len(), 0);
|
||||
assert!(apply_commands(&mut feed, &mut fs, &[Command::Delete("2026-10-01T12:47:00Z".into())]).is_err());
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue