//! Who applies a typo fix, and when (Yvv, 2026-10-30, L13). What a typo is //! does not change: `edit` (letters and spaces, judged against the voted text). //! //! « Corriger coquille → ping auteur pour valid → idem 3j ça passe auto, mais //! auteur a toujours la main tant que c'est pas le texte en vote. Sinon seule //! une coquille validée corrige le texte en vote. » //! //! - The person who **answers for the version** — its author; for a text //! without author (a starting text), an administrator of trustinWallet //! (`trustin_access::answers_typo`) — applies a fix at once (`route`). //! - Anyone else's fix **waits**: the one who answers is told by a message, //! validates or refuses, and **without an answer within 3 days it applies by //! itself** (`tick`). On a text in force, only a validated fix (or one passed //! at the deadline) corrects it. //! - **The author keeps the hand while the version is not in force**: a fix //! applied (validated or passed at the deadline) may still be refused — the //! text it was written over comes back —, and a refused one validated after //! all. Once in force, an answer is final. //! - A fix is written only over the text it was written over (`fits`): two //! fixes waiting on one text are each judged against the voted text; once one //! applies, the other no longer fits and is **stale** (« recharge, puis //! recommence »). Slips never add up. //! - Translations stay out of this: editorial, without validation. use serde::{Deserialize, Serialize}; /// Days the one who answers for the version has, before a fix applies by itself. pub const ANSWER_DAYS: i64 = 3; /// Where a fix goes when it is written. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Route { /// Applied at once: the writer answers for the version. Apply, /// Waits for the one who answers for the version, or for the deadline. Await, } pub fn route(writer_answers: bool) -> Route { if writer_answers { Route::Apply } else { Route::Await } } #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum TypoStatus { /// Written, waiting for an answer or the deadline. Pending, /// In the text. Applied, /// Not in the text: refused (or taken back by the author before the text was in force). Refused, /// The text changed before it could apply: written over a text that is no longer there. Stale, } impl TypoStatus { pub fn as_str(self) -> &'static str { match self { Self::Pending => "pending", Self::Applied => "applied", Self::Refused => "refused", Self::Stale => "stale", } } pub fn parse(raw: &str) -> Option { [Self::Pending, Self::Applied, Self::Refused, Self::Stale].into_iter().find(|s| s.as_str() == raw) } } #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Answer { Validate, Refuse, } /// What a step does to the text of the version. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum TextChange { /// Write the fix (only where `fits`). Write, /// Put back the text the fix was written over (only where the fix is still the text). Restore, Keep, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct Settle { pub status: TypoStatus, pub text: TextChange, /// Holds only while the version is not in force: the author's change of mind, /// decided on a version read open, must not land on one that entered meanwhile. pub while_open: bool, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum TypoRefusal { /// Already answered (or stale): nothing left to decide. Settled, } /// An answer of the one who answers for the version. `in_force`: the version is in force now. pub fn answer(status: TypoStatus, answer: Answer, in_force: bool) -> Result { let to = |status, text, while_open| Ok(Settle { status, text, while_open }); match (status, answer) { (TypoStatus::Pending, Answer::Validate) => to(TypoStatus::Applied, TextChange::Write, false), (TypoStatus::Pending, Answer::Refuse) => to(TypoStatus::Refused, TextChange::Keep, false), // The author keeps the hand while the version is not in force. (TypoStatus::Applied, Answer::Refuse) if !in_force => to(TypoStatus::Refused, TextChange::Restore, true), (TypoStatus::Refused, Answer::Validate) if !in_force => to(TypoStatus::Applied, TextChange::Write, true), _ => Err(TypoRefusal::Settled), } } /// The clock (sortable stamps): a pending fix applies by itself at its deadline. pub fn tick(status: TypoStatus, due: &str, now: &str) -> Option { (status == TypoStatus::Pending && due <= now).then_some(Settle { status: TypoStatus::Applied, text: TextChange::Write, while_open: false, }) } /// A text as a fix sees it: the revision it belongs to, its title and body. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct Text<'a> { pub revision: i64, pub title: &'a str, pub body: &'a str, } /// A fix is written only over the very text it was written over: same revision /// (no change of substance since), same title and body (no other fix since). pub fn fits(base: Text<'_>, current: Text<'_>) -> bool { base == current } #[cfg(test)] mod tests { use super::*; use crate::edit::{EditKind, classify}; #[test] fn the_one_who_answers_applies_at_once_anyone_else_waits() { assert_eq!(route(true), Route::Apply, "the author, or an administrator for a starting text"); assert_eq!(route(false), Route::Await); } #[test] fn a_pending_fix_is_validated_or_refused() { for in_force in [false, true] { assert_eq!( answer(TypoStatus::Pending, Answer::Validate, in_force), Ok(Settle { status: TypoStatus::Applied, text: TextChange::Write, while_open: false }) ); assert_eq!( answer(TypoStatus::Pending, Answer::Refuse, in_force), Ok(Settle { status: TypoStatus::Refused, text: TextChange::Keep, while_open: false }) ); } } #[test] fn the_author_keeps_the_hand_until_the_text_is_in_force() { // A proposal: an applied fix may be taken back, a refused one validated after all. assert_eq!( answer(TypoStatus::Applied, Answer::Refuse, false), Ok(Settle { status: TypoStatus::Refused, text: TextChange::Restore, while_open: true }) ); assert_eq!( answer(TypoStatus::Refused, Answer::Validate, false), Ok(Settle { status: TypoStatus::Applied, text: TextChange::Write, while_open: true }) ); // In force: an answer is final. assert_eq!(answer(TypoStatus::Applied, Answer::Refuse, true), Err(TypoRefusal::Settled)); assert_eq!(answer(TypoStatus::Refused, Answer::Validate, true), Err(TypoRefusal::Settled)); // Nothing to answer twice the same way, and never a stale fix. assert_eq!(answer(TypoStatus::Applied, Answer::Validate, false), Err(TypoRefusal::Settled)); assert_eq!(answer(TypoStatus::Refused, Answer::Refuse, false), Err(TypoRefusal::Settled)); for a in [Answer::Validate, Answer::Refuse] { assert_eq!(answer(TypoStatus::Stale, a, false), Err(TypoRefusal::Settled)); } } #[test] fn without_an_answer_it_applies_at_the_deadline() { let due = "2026-11-03T09:00:00Z"; assert_eq!(tick(TypoStatus::Pending, due, "2026-11-03T08:59:59Z"), None); assert_eq!( tick(TypoStatus::Pending, due, due), Some(Settle { status: TypoStatus::Applied, text: TextChange::Write, while_open: false }) ); // A deadline passed while the server was stopped is taken at the next tick. assert!(tick(TypoStatus::Pending, due, "2027-02-01T00:00:00Z").is_some()); for s in [TypoStatus::Applied, TypoStatus::Refused, TypoStatus::Stale] { assert_eq!(tick(s, due, "2027-02-01T00:00:00Z"), None, "{s:?}"); } } #[test] fn two_fixes_waiting_on_one_text_the_second_goes_stale() { let voted = Text { revision: 1, title: "Réciprocité", body: "des persones qui respectent" }; let first = Text { revision: 1, title: "Réciprocité", body: "des personnes qui respectent" }; let second = Text { revision: 1, title: "Réciprocité", body: "des persones qui repectent" }; // Each is judged against the voted text, as before: each is a typo there. assert_eq!(classify(voted.body, first.body), EditKind::Typographic); assert_eq!(classify(voted.body, second.body), EditKind::Typographic); // Both were written over the voted text; the first applies. assert!(fits(voted, voted)); // Then the text is the first fix: the second no longer fits — stale, never merged. assert!(!fits(voted, first)); // A change of substance in between (a new revision) does the same, even back to the same words. assert!(!fits(voted, Text { revision: 2, ..voted })); } #[test] fn words_round_trip() { for s in [TypoStatus::Pending, TypoStatus::Applied, TypoStatus::Refused, TypoStatus::Stale] { assert_eq!(TypoStatus::parse(s.as_str()), Some(s)); } assert_eq!(TypoStatus::parse("typo"), None); } }