lib.rsannotatedlib.rssource722 lines · 28.0 KB · raw
1//! The archive: every request lmjtfy has had answered, and the questions
2//! people asked. These are the messages the Worker and the archive's Durable
3//! Object exchange. No I/O and no clock.
4//!
5//! The rule (the user, 2026-10-02): the same request is never sent to the
6//! same model twice. A request is the exact body, and it is kept with its
7//! response under the model it went to. Asking again returns that response.
8//! The archive is also what makes the call, so two visitors asking the same
9//! new thing at once share one request.
10#![forbid(unsafe_code)]
11
12use ask::{Sent, Wanted};
13use serde::{Deserialize, Serialize};
14
15pub mod event;
16pub use event::{Event, Origin, Seen as Report};
17
18/// What the Worker asks the archive.
19#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
20pub enum Ask {
21    /// Jev's answer to `wanted` about `input`. `request` is the body the
22    /// Worker prepared and shows on the page; the archive prepares it again
23    /// from `wanted`, and refuses if the two differ.
24    Jev { input: String, wanted: Wanted, request: String, #[serde(default)] pick: Pick },
25    /// The LLM's reply to `request`, from `model`.
26    Llm { model: String, request: String, #[serde(default)] pick: Pick },
27    /// `input` was asked and got `answers`, one per question Jev answered.
28    /// `llm` is whether an LLM had to be asked; `listed` is whether the
29    /// question may be shown on the public feed. `who` is the asking
30    /// browser for this question only (`Asker`): a browser that asked it
31    /// before is not counted again. `None` when the browser sent no id.
32    /// `browser` is that browser's id itself, kept beside `who` so the
33    /// owner can see whose row it is.
34    Asked {
35        input: String,
36        answers: Vec<Answer>,
37        llm: bool,
38        listed: bool,
39        #[serde(default)]
40        who: Option<Asker>,
41        #[serde(default)]
42        browser: String,
43    },
44    /// Someone cloned or pulled `repo` (`lmjtfy.git`) through the site.
45    /// Counted, and told to every open page.
46    Fetched { repo: String, fetch: Fetch },
47    /// `who` thinks Jev's answer to `input` right (`Up`) or wrong (`Down`).
48    /// The vote is for the answer as it is kept now; the same vote again
49    /// takes it back. Answered with the `Rating` after it.
50    Rate {
51        input: String,
52        who: Asker,
53        vote: Vote,
54        #[serde(default)]
55        browser: String,
56    },
57    /// The votes on Jev's answer to `input` as it is kept now, and `who`'s.
58    Rating { input: String, who: Option<Asker> },
59    /// `who` says more about the vote they have on Jev's answer to `input`
60    /// (`comment` is already `clean_comment`ed). Kept beside the vote, as a
61    /// row of its own that is never changed; answered with `Commented`.
62    Comment {
63        input: String,
64        who: Asker,
65        browser: String,
66        comment: String,
67    },
68    /// What the home page shows from the archive.
69    Home,
70    /// The next `FEED` listed questions asked before `after`, newest first:
71    /// the feed scrolled to its end.
72    Older { after: Cursor },
73    /// What Jev said to `input`, if it was ever asked and answered. Nothing
74    /// is sent to find out.
75    Answer { input: String },
76    /// `input` was a question Jev cannot take (the gate said it is not one,
77    /// or no question could be made of it). Kept apart from what was
78    /// answered: it is not counted, listed or toasted, and exists so a link
79    /// to it can say so without asking anyone.
80    Declined { input: String },
81    /// Whether `input` was declined and never answered. Sends nothing.
82    WasDeclined { input: String },
83    /// The questions on the feed that what has been typed so far could be
84    /// the start of. It reads the archive and asks nobody anything.
85    Suggest { typed: String },
86    /// Something happened, to be kept whole (`event.rs`).
87    Event(Box<Event>),
88}
89
90/// The archive object's name: there is one, for the whole site. A Worker
91/// that binds the archive (the site's, the admin's) reaches it by this.
92pub const OBJECT: &str = "everything";
93
94/// The path of the archive's door for the owner's admin backend. The site's
95/// own Worker never sends a request there; the admin's Worker, which binds
96/// the same object from the owner's account, does.
97pub const ADMIN: &str = "/admin";
98
99/// What the admin backend asks the archive.
100#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
101pub enum Admin {
102    /// Rows of the archive's tables: one statement that only reads
103    /// (`reads_only`), with a value for each `?`.
104    Select {
105        sql: String,
106        #[serde(default)]
107        values: Vec<serde_json::Value>,
108    },
109    /// The owner's say on whether `input` may be shown on the feed: yes, no,
110    /// or `None` to leave it to the rules again. Jev's own verdict
111    /// (`asked.listed`) is kept beside it, untouched.
112    Moderate { input: String, listed: Option<bool> },
113    /// The name of a moment in the archive's last thirty days, to restore
114    /// to: `at_ms`, or now. Asking changes nothing.
115    Bookmark { at_ms: Option<f64> },
116    /// Puts the whole archive back as it was at `bookmark`, losing all
117    /// that was kept since. Answered with the bookmark of the moment just
118    /// before, which undoes it.
119    Restore { bookmark: String },
120}
121
122/// What the archive answers the admin backend.
123#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
124pub enum Answered {
125    Rows {
126        columns: Vec<String>,
127        rows: Vec<Vec<serde_json::Value>>,
128        /// How many rows SQLite read to answer, which is what Cloudflare
129        /// meters: a statement that returns ten rows may have read every
130        /// row of a table to find them.
131        #[serde(default)]
132        read: u64,
133    },
134    Done,
135    Refused(String),
136    /// A moment in the archive's history, by name.
137    Bookmark(String),
138}
139
140/// Whether `sql` is one statement that can only read. The admin's door
141/// changes the archive through `Admin`'s own messages, never through SQL:
142/// the schema changes only by a migration, and a response that was kept is
143/// never edited. Text in quotes is not looked at, so a question that says
144/// "delete" can still be searched for; pass such text as a value.
145pub fn reads_only(sql: &str) -> bool {
146    let mut bare = String::new();
147    let mut quoted = None;
148    for c in sql.chars() {
149        match quoted {
150            Some(quote) if c == quote => quoted = None,
151            Some(_) => {}
152            None if c == '\'' || c == '"' => quoted = Some(c),
153            None => bare.push(c.to_ascii_lowercase()),
154        }
155    }
156    let bare = bare.trim().trim_end_matches(';').trim_end();
157    let words: Vec<&str> = bare.split(|c: char| !c.is_ascii_alphanumeric() && c != '_').filter(|word| !word.is_empty()).collect();
158    const WRITES: [&str; 12] = ["insert", "update", "delete", "drop", "alter", "create", "attach", "detach", "pragma", "vacuum", "reindex", "analyze"];
159    // `replace` is a statement that writes (`REPLACE INTO`) and a function
160    // that does not (`replace(path, '/', ' ')`): only the function is
161    // followed by its arguments.
162    let replaces = bare.match_indices("replace").any(|(at, word)| {
163        let alone = !bare[..at].ends_with(|c: char| c.is_ascii_alphanumeric() || c == '_');
164        let after = &bare[at + word.len()..];
165        alone && !after.starts_with(|c: char| c.is_ascii_alphanumeric() || c == '_') && !after.trim_start().starts_with('(')
166    });
167    quoted.is_none() && !bare.contains(';') && matches!(words.first(), Some(&"select") | Some(&"with")) && !replaces && !words.iter().any(|word| WRITES.contains(word))
168}
169
170/// Which of a request's kept responses an ask wants. A request is sent
171/// again only when a visitor asks for that (`Fresh`, the page's ↻); every
172/// response it ever got is kept, numbered from 1, newest last.
173#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
174pub enum Pick {
175    /// The newest kept, or sent if there is none.
176    #[default]
177    Latest,
178    /// Sent now, whatever is kept, and kept as the newest.
179    Fresh,
180    /// That one, if kept; the newest kept otherwise.
181    Version(u32),
182    /// The newest kept, and never sent: `Called::NotKept` if there is none.
183    /// What a page that is stepping through old versions uses for the calls
184    /// after the one it stepped, so looking costs nothing.
185    KeptOnly,
186}
187
188/// Which version of each call a page wants, by `call_id`: the page's
189/// `$pins` signal, `id:3;id:fresh`, later entries winning. A call it does
190/// not name gets the newest kept.
191#[derive(Clone, Debug, Default, PartialEq, Eq)]
192pub struct Pins(pub Vec<(String, Pick)>);
193
194impl Pins {
195    pub fn parse(text: &str) -> Pins {
196        let mut pins: Vec<(String, Pick)> = Vec::new();
197        for entry in text.split(';') {
198            let Some((id, what)) = entry.trim().split_once(':') else { continue };
199            if id.len() != 12 || !id.bytes().all(|b| b.is_ascii_hexdigit()) {
200                continue;
201            }
202            let pick = match what {
203                "fresh" => Pick::Fresh,
204                number => match number.parse::<u32>() {
205                    Ok(version) if version > 0 => Pick::Version(version),
206                    _ => continue,
207                },
208            };
209            pins.retain(|(seen, _)| seen != id);
210            pins.push((id.to_owned(), pick));
211        }
212        Pins(pins)
213    }
214
215    /// Stepping through old versions, and asking nothing new: a version is
216    /// pinned and nothing is to be sent again. Then a call that is not pinned
217    /// is only read, never sent, so looking costs nothing.
218    pub fn browsing(&self) -> bool {
219        self.0.iter().any(|(_, pick)| matches!(pick, Pick::Version(_))) && !self.0.iter().any(|(_, pick)| *pick == Pick::Fresh)
220    }
221
222    /// What to ask the archive for, for the call `id`.
223    pub fn pick(&self, id: &str) -> Pick {
224        match self.0.iter().find(|(seen, _)| seen == id) {
225            Some((_, pick)) => *pick,
226            None if self.browsing() => Pick::KeptOnly,
227            None => Pick::Latest,
228        }
229    }
230}
231
232/// A call, as the page names it for ↻ and ◀ ▶: the first 12 hex digits of
233/// the SHA-256 of where it went and what was sent.
234pub fn call_id(sent_to: &str, request: &str) -> String {
235    use sha2::{Digest, Sha256};
236    let digest = Sha256::new().chain_update(sent_to.as_bytes()).chain_update([0]).chain_update(request.as_bytes()).finalize();
237    digest.iter().take(6).map(|byte| format!("{byte:02x}")).collect()
238}
239
240/// A response the archive holds, and the call that got it.
241#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
242pub struct Record {
243    /// The response body exactly as it arrived.
244    pub response: String,
245    pub request_id: Option<String>,
246    pub attempts: u32,
247    /// The round trip of the call that was sent, in milliseconds.
248    pub took_ms: f64,
249    /// When that call was answered, in Unix milliseconds.
250    pub answered_ms: f64,
251    /// Whether this ask is the one that sent it.
252    pub sent_now: bool,
253    /// Which of the request's kept responses this is, from 1, and how many
254    /// there are.
255    #[serde(default = "first")]
256    pub version: u32,
257    #[serde(default = "first")]
258    pub versions: u32,
259}
260
261fn first() -> u32 {
262    1
263}
264
265impl Record {
266    pub fn sent(&self) -> Sent {
267        if self.sent_now { Sent::Now } else { Sent::Before { at_ms: self.answered_ms } }
268    }
269
270    /// The same record, as someone who did not send it sees it.
271    pub fn kept(mut self) -> Self {
272        self.sent_now = false;
273        self
274    }
275}
276
277/// A browser, for one question: the hex SHA-256 of the browser's random id
278/// and the question. It says whether this browser asked this question
279/// before, and nothing else: two questions from one browser give unrelated
280/// values, and the id cannot be had back from one.
281#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
282pub struct Asker(pub String);
283
284/// A browser's view of Jev's answer.
285#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
286pub enum Vote {
287    Up,
288    Down,
289}
290
291/// The votes on one answer, and the asking browser's own, if it voted.
292#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
293pub struct Rating {
294    pub up: u32,
295    pub down: u32,
296    pub mine: Option<Vote>,
297    /// Whether these are votes on Jev declining the question (up: right to
298    /// decline) and not on an answer (up: right).
299    #[serde(default)]
300    pub declined: bool,
301}
302
303/// The most a comment on a vote may be, in characters.
304pub const MOST_COMMENT: usize = 1000;
305
306/// Why a comment was not kept.
307#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
308pub enum Unsaid {
309    /// Nothing but whitespace.
310    Empty,
311    /// More than `MOST_COMMENT` characters. Refused, not cut: the visitor
312    /// keeps what they wrote and shortens it.
313    TooLong,
314    /// The browser has no vote on this answer to say more about (or no id).
315    NoVote,
316    /// Too many from this visitor this minute.
317    Limit,
318    /// The archive could not be asked or could not write it.
319    Failed,
320}
321
322/// A comment as it is kept: trimmed, line ends made `\n`, and every other
323/// control character dropped. It is the visitor's own words, so it stays
324/// untrusted wherever it is shown: this does not make it safe to put in a
325/// page, and nothing is done to it for that.
326pub fn clean_comment(raw: &str) -> Result<String, Unsaid> {
327    let text: String = raw.replace("\r\n", "\n").replace('\r', "\n").chars().filter(|c| *c == '\n' || *c == '\t' || !c.is_control()).collect();
328    let text = text.trim();
329    match text.chars().count() {
330        0 => Err(Unsaid::Empty),
331        n if n > MOST_COMMENT => Err(Unsaid::TooLong),
332        _ => Ok(text.to_owned()),
333    }
334}
335
336/// Which shared budget refused a call.
337#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
338pub enum Pot {
339    Jev,
340    Llm,
341}
342
343/// What became of a call.
344#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
345pub enum Called {
346    Answered(Record),
347    /// Sent, or not sendable, and there is no response. Nothing is kept, so
348    /// the same request may be sent again.
349    Failed { error: String, request_id: Option<String>, took_ms: f64 },
350    /// Not sent: today's budget for it is used up.
351    Spent(Pot),
352    /// Not sent, because the ask was `Pick::KeptOnly` and nothing is kept.
353    NotKept,
354}
355
356/// What Jev said to one question, as it is kept for the feed and for link
357/// previews: the answer in a few words, and the numbers behind it.
358#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
359pub struct Answer {
360    /// What the page prints large: "No?", "Pepperoni.", "28%.".
361    pub headline: String,
362    pub detail: Detail,
363}
364
365/// The numbers behind an answer, by the type of question it answered.
366#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
367pub enum Detail {
368    /// Kept before details were. Only the headline is known.
369    Unknown,
370    /// A yes-or-no: the probability of yes.
371    Noul { p_yes: f64 },
372    /// A how-likely, which Jev answers as a Noul about the thing itself: the
373    /// probability that it is so.
374    Chance { p: f64 },
375    /// A pick: every option with its probability, most likely first.
376    Choice { confidence: f64, options: Vec<(String, f64)> },
377    /// A how-much: every level with its probability, lowest first.
378    Score { score: f64, confidence: f64, levels: Vec<(String, f64)> },
379}
380
381impl Answer {
382    /// The type and the numbers in one line, as the page prints them under
383    /// an answer. `None` when only the headline was kept.
384    pub fn note(&self) -> Option<String> {
385        Some(match &self.detail {
386            Detail::Unknown => return None,
387            Detail::Noul { p_yes } => format!("Noul · p(yes) = {p_yes:.2}"),
388            Detail::Chance { p } => format!("Noul · p = {p:.2} · Jev thinks that's {}", ask::likelihood(*p)),
389            Detail::Choice { confidence, .. } => format!("Choice · confidence {confidence:.2}"),
390            Detail::Score { score, confidence, levels } => {
391                format!("Score · {score:.1} of {} · confidence {confidence:.2}", levels.len().saturating_sub(1))
392            }
393        })
394    }
395
396    /// The `most` likeliest options or levels, likeliest first, for a line
397    /// of text. Empty for a Noul, whose one number is in the note.
398    pub fn spread(&self, most: usize) -> Vec<(&str, f64)> {
399        let mut spread: Vec<(&str, f64)> = match &self.detail {
400            Detail::Choice { options, .. } => options.iter().map(|(label, p)| (label.as_str(), *p)).collect(),
401            Detail::Score { levels, .. } => levels.iter().map(|(label, p)| (label.as_str(), *p)).collect(),
402            _ => Vec::new(),
403        };
404        spread.sort_by(|a, b| b.1.total_cmp(&a.1));
405        spread.truncate(most);
406        spread
407    }
408
409    /// Everything in one line: the headline, the note and the spread.
410    pub fn line(&self) -> String {
411        let mut parts = vec![self.headline.clone()];
412        parts.extend(self.note());
413        let spread: Vec<String> = self.spread(3).iter().map(|(label, p)| format!("{label} {:.0}%", p * 100.0)).collect();
414        if !spread.is_empty() {
415            parts.push(spread.join(", "));
416        }
417        parts.join(" · ")
418    }
419}
420
421/// A kept answer as it is stored: with its numbers, or, from before they
422/// were kept, as the headline alone.
423#[derive(Deserialize)]
424#[serde(untagged)]
425enum Stored {
426    Whole(Answer),
427    Headline(String),
428}
429
430/// Reads the answers a question was stored with, old form or new.
431pub fn stored(text: &str) -> Vec<Answer> {
432    let stored: Vec<Stored> = serde_json::from_str(text).unwrap_or_default();
433    stored
434        .into_iter()
435        .map(|stored| match stored {
436            Stored::Whole(answer) => answer,
437            Stored::Headline(headline) => Answer { headline, detail: Detail::Unknown },
438        })
439        .collect()
440}
441
442/// A question somebody asked, and what Jev said. Nothing about who asked.
443#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
444pub struct Entry {
445    pub input: String,
446    pub answers: Vec<Answer>,
447    /// When it was last asked.
448    pub asked_ms: f64,
449    /// How many times it has been asked and answered.
450    pub times: u32,
451}
452
453impl Entry {
454    /// The answers in a few words each, as the feed prints them.
455    pub fn headlines(&self) -> String {
456        let headlines: Vec<&str> = self.answers.iter().map(|answer| answer.headline.as_str()).collect();
457        headlines.join(" ")
458    }
459}
460
461/// How many questions are suggested for what has been typed, and how many
462/// characters must be typed before any are.
463pub const SUGGESTED: usize = 5;
464pub const SUGGEST_FROM: usize = 3;
465
466/// Every question the feed may show, ready to be matched against what a
467/// visitor is typing. Only those: a suggestion is shown to strangers, as
468/// the feed is.
469#[derive(Clone, Debug, Default, PartialEq)]
470pub struct Listed(Vec<(String, Entry)>);
471
472impl Listed {
473    pub fn new(entries: impl IntoIterator<Item = Entry>) -> Listed {
474        Listed(entries.into_iter().map(|entry| (entry.input.to_lowercase(), entry)).collect())
475    }
476
477    /// The questions `typed` could be the start of, whatever its case:
478    /// those that begin with it, then those with a word that begins there,
479    /// the most asked first, and no more than `SUGGESTED`. Nothing until
480    /// `SUGGEST_FROM` characters are typed.
481    pub fn suggest(&self, typed: &str) -> Vec<Entry> {
482        let typed = typed.trim().to_lowercase();
483        if typed.chars().count() < SUGGEST_FROM {
484            return Vec::new();
485        }
486        let within = format!(" {typed}");
487        let mut found: Vec<(u8, &Entry)> = self
488            .0
489            .iter()
490            .filter_map(|(lower, entry)| if lower.starts_with(&typed) { Some((0, entry)) } else if lower.contains(&within) { Some((1, entry)) } else { None })
491            .collect();
492        found.sort_by(|a, b| a.0.cmp(&b.0).then(b.1.times.cmp(&a.1.times)).then(b.1.asked_ms.total_cmp(&a.1.asked_ms)).then(a.1.input.cmp(&b.1.input)));
493        found.into_iter().take(SUGGESTED).map(|(_, entry)| entry.clone()).collect()
494    }
495}
496
497/// How many questions the feed shows as asked lately, and as most asked.
498pub const FEED: u32 = 50;
499pub const MOST: u32 = 5;
500
501/// The archive in numbers.
502#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
503pub struct Stats {
504    /// Different questions answered.
505    pub questions: u32,
506    /// Times a question was asked and answered, repeats included.
507    pub asks: u32,
508    /// Of the different questions, those Jev answered with no LLM.
509    pub no_llm: u32,
510    /// Requests that went out and were answered.
511    pub sent: u32,
512    /// Requests that did not go out, because their response was kept.
513    pub kept: u32,
514    /// Clones and pulls of the code through the site.
515    #[serde(default)]
516    pub clones: u32,
517    #[serde(default)]
518    pub pulls: u32,
519}
520
521impl Stats {
522    /// The share of questions answered with no LLM, as a whole percentage.
523    pub fn no_llm_percent(&self) -> Option<u32> {
524        (self.questions > 0).then(|| (f64::from(self.no_llm) * 100.0 / f64::from(self.questions)).round() as u32)
525    }
526}
527
528/// What the home page shows from the archive. The lists hold only questions
529/// that may be shown publicly; the numbers count every question.
530#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
531pub struct Home {
532    /// Newest first.
533    pub lately: Vec<Entry>,
534    /// Asked more than once, most asked first.
535    pub most: Vec<Entry>,
536    pub stats: Stats,
537}
538
539/// Where the feed was scrolled to: the last question shown. Questions are
540/// listed newest first, ties broken by the question itself, so the next page
541/// starts strictly after this one and none is shown twice or skipped.
542#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
543pub struct Cursor {
544    pub asked_ms: f64,
545    pub input: String,
546}
547
548impl Entry {
549    /// Where the feed is once this entry is the last shown.
550    pub fn cursor(&self) -> Cursor {
551        Cursor { asked_ms: self.asked_ms, input: self.input.clone() }
552    }
553}
554
555/// What a fetch through the clone proxy was. Git's upload-pack request
556/// names the commits wanted; a pull also names the ones it has.
557#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
558pub enum Fetch {
559    Clone,
560    Pull,
561}
562
563impl Fetch {
564    /// Its name in the archive's `counts`, for one repository.
565    pub fn counted(self, repo: &str) -> String {
566        match self {
567            Fetch::Clone => format!("clone:{repo}"),
568            Fetch::Pull => format!("pull:{repo}"),
569        }
570    }
571}
572
573/// What the archive pushes to every open page over its socket, as JSON:
574/// `{"online": 3}` or `{"toast": "<html>"}`.
575#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
576#[serde(rename_all = "lowercase")]
577pub enum Live {
578    /// Pages open now.
579    Online(u32),
580    /// Something happened, as markup to show for a moment.
581    Toast(String),
582    /// The build the site is now (`LMJTFY_BUILD`), told to each page as it
583    /// connects. A page built from another offers a reload: a deploy
584    /// restarts the object, every page reconnects, and each hears this.
585    Build(String),
586    /// Elements that changed, each with an `id`: a page that has an element
587    /// with that id replaces it. An element with `data-prepend="<id>"`
588    /// instead puts its children at the top of that element, each removing
589    /// any element with its id first: a question asked again moves to the
590    /// top of the feed, and what a page has scrolled in below stays. The
591    /// activity feeds stay current this way, with no refresh.
592    Patch(String),
593}
594
595/// Where an open page connected from, as Cloudflare placed the request: a
596/// country code and a city, either unknown. It is kept on the page's socket
597/// while it is open, for the online list.
598#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
599pub struct Place {
600    pub country: Option<String>,
601    pub city: Option<String>,
602}
603
604impl Place {
605    /// From what the Worker passed on, kept only if it looks like a place:
606    /// a country is two letters, a city a short line of text.
607    pub fn new(country: Option<&str>, city: Option<&str>) -> Place {
608        let country = country
609            .map(str::trim)
610            .filter(|code| code.len() == 2 && code.bytes().all(|b| b.is_ascii_alphabetic()))
611            .map(str::to_ascii_uppercase)
612            // Cloudflare's own codes for Tor and for unknown are not places.
613            .filter(|code| code != "T1" && code != "XX");
614        let city = city
615            .map(str::trim)
616            .filter(|city| !city.is_empty() && city.chars().count() <= 64 && !city.chars().any(char::is_control))
617            .map(str::to_owned);
618        Place { country, city }
619    }
620
621    /// The country's flag: its two letters as regional indicator symbols.
622    pub fn flag(&self) -> Option<String> {
623        let code = self.country.as_deref()?;
624        code.chars().map(|letter| char::from_u32(0x1F1E6 + (letter as u32 - 'A' as u32))).collect()
625    }
626
627    /// `Austin, US`, `US`, or `somewhere`.
628    pub fn label(&self) -> String {
629        match (&self.city, &self.country) {
630            (Some(city), Some(country)) => format!("{city}, {country}"),
631            (Some(city), None) => city.clone(),
632            (None, Some(country)) => country.clone(),
633            (None, None) => "somewhere".to_owned(),
634        }
635    }
636}
637
638/// A header value as the Worker percent-encoded it (`+` for a space), back
639/// to text. `None` if it is not UTF-8 or a `%` is not followed by two hex
640/// digits.
641pub fn percent_decoded(text: &str) -> Option<String> {
642    let mut bytes = Vec::with_capacity(text.len());
643    let mut rest = text.bytes();
644    while let Some(byte) = rest.next() {
645        match byte {
646            b'+' => bytes.push(b' '),
647            b'%' => {
648                let hex = [rest.next()?, rest.next()?];
649                bytes.push(u8::from_str_radix(std::str::from_utf8(&hex).ok()?, 16).ok()?);
650            }
651            byte => bytes.push(byte),
652        }
653    }
654    String::from_utf8(bytes).ok()
655}
656
657/// What the archive keeps on an open page's socket: where it is, and whether
658/// a Worker that passes places on sent it. A page that reconnects while a
659/// deploy is still reaching Cloudflare's edge can come through the Worker
660/// from before, which passes no place; `passed` is false for it, and the
661/// archive asks it to reconnect once the deploy has settled.
662#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
663pub struct Seen {
664    pub place: Place,
665    pub passed: bool,
666    /// When it connected, in Unix milliseconds.
667    pub at_ms: f64,
668    /// The row of `events` that says it connected, which the row that says
669    /// it left copies who it was from.
670    #[serde(default)]
671    pub event: Option<f64>,
672}
673
674/// How long after connecting an unplaced page is asked to reconnect: long
675/// enough for a deploy to reach the whole edge.
676pub const REPLACE_AFTER_MS: f64 = 10_000.0;
677
678impl Seen {
679    /// Whether this page should reconnect to be placed: it came through a
680    /// Worker that passes no place, long enough ago. A socket with nothing
681    /// kept on it (`None`) was accepted by code from before places.
682    pub fn stale(seen: Option<&Seen>, now_ms: f64) -> bool {
683        seen.is_none_or(|seen| !seen.passed && now_ms - seen.at_ms >= REPLACE_AFTER_MS)
684    }
685}
686
687/// How many open pages are in each place, most first, then by name.
688pub fn places(open: impl IntoIterator<Item = Place>) -> Vec<(Place, u32)> {
689    let mut counted: std::collections::BTreeMap<Place, u32> = std::collections::BTreeMap::new();
690    for place in open {
691        *counted.entry(place).or_default() += 1;
692    }
693    let mut counted: Vec<(Place, u32)> = counted.into_iter().collect();
694    counted.sort_by(|(a, n), (b, m)| m.cmp(n).then_with(|| a.label().cmp(&b.label())));
695    counted
696}
697
698/// What the archive says back.
699#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
700pub enum Told {
701    Called(Called),
702    Noted,
703    Home(Home),
704    Answer(Option<Entry>),
705    WasDeclined(bool),
706    Older(Vec<Entry>),
707    Suggested(Vec<Entry>),
708    /// `None` if the question was never answered, so there is nothing to
709    /// vote on.
710    Rating(Option<Rating>),
711    /// What became of a `Comment`.
712    Commented(Result<(), Unsaid>),
713}
714
715/// A moment as `YYYY-MM-DD HH:MM UTC`.
716pub fn when(unix_ms: f64) -> String {
717    let minutes = (unix_ms / 60_000.0).floor().max(0.0) as u64;
718    format!("{} {:02}:{:02} UTC", budget::date(budget::day(unix_ms)), minutes / 60 % 24, minutes % 60)
719}
720
721#[cfg(test)]
722mod tests;