use serde::Serialize;
use serde_json::Value;
pub const KEEP: usize = 6;
const ID_MAX: usize = 32;
const LABEL_MAX: usize = 32;
const TEXT_MAX: usize = 80;
const URL_MAX: usize = 2048;
const LIFE_MIN: i64 = 10;
const LIFE_MAX: i64 = 3600;
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct Card {
pub id: String,
pub kind: &'static str,
pub label: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub url: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub host: String,
#[serde(skip_serializing_if = "str::is_empty")]
pub tone: &'static str,
pub at: i64,
#[serde(rename = "for", skip_serializing_if = "is_zero")]
pub life: i64,
}
fn is_zero(n: &i64) -> bool {
*n == 0
}
impl Card {
pub fn live(&self, now: i64) -> bool {
self.life == 0 || now < self.at + self.life
}
}
#[derive(Debug, PartialEq)]
pub enum Op {
Put(Card),
Clear(String),
}
pub fn parse(b: &Value, now: i64) -> Result<Op, String> {
let s = |k: &str| b.get(k).and_then(Value::as_str).unwrap_or("").trim();
let id = s("id");
if id.len() > ID_MAX
|| !id
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
{
return Err(format!("id is at most {ID_MAX} letters, digits, - and _"));
}
if b.get("clear").and_then(Value::as_bool) == Some(true) {
return if id.is_empty() {
Err("clear needs the card's id".into())
} else {
Ok(Op::Clear(id.into()))
};
}
let kind = match s("kind") {
"link" => "link",
"text" | "" => "text",
_ => return Err("kind is link or text".into()),
};
let label = one_line(s("label"));
let max = if kind == "link" { LABEL_MAX } else { TEXT_MAX };
if label.is_empty() {
return Err("a card needs a label".into());
}
if label.chars().count() > max {
return Err(format!("a {kind} card's label is at most {max} characters"));
}
let (url, host) = if kind == "link" {
link(s("url"))?
} else {
(String::new(), String::new())
};
let tone = match s("tone") {
"ok" => "ok",
"warn" => "warn",
"bad" => "bad",
"run" => "run",
"" | "none" => "",
_ => return Err("tone is ok, warn, bad, run or none".into()),
};
let life = match b.get("for") {
None | Some(Value::Null) => 0,
Some(Value::String(p)) if p == "panel" => 0,
Some(v) => match v.as_i64() {
Some(n) if (LIFE_MIN..=LIFE_MAX).contains(&n) => n,
_ => {
return Err(format!(
"for is seconds, {LIFE_MIN} to {LIFE_MAX}, or \"panel\""
))
}
},
};
Ok(Op::Put(Card {
id: id.into(),
kind,
label,
url,
host,
tone,
at: now,
life,
}))
}
pub(crate) fn link(raw: &str) -> Result<(String, String), String> {
if raw.is_empty() {
return Err("a link card needs a url".into());
}
if raw.len() > URL_MAX || raw.chars().any(char::is_control) || raw.contains(char::is_whitespace)
{
return Err("the url is one line, without spaces".into());
}
let rest = raw
.strip_prefix("https://")
.or_else(|| raw.strip_prefix("http://"))
.ok_or("a card links to http or https only")?;
let auth = rest.split(['/', '?', '#']).next().unwrap_or("");
if auth.contains('@') {
return Err("the url may not carry a user or password".into());
}
if auth.is_empty() {
return Err("the url has no host".into());
}
Ok((raw.into(), auth.to_ascii_lowercase()))
}
pub(crate) fn one_line(s: &str) -> String {
s.split(|c: char| c.is_control() || c == '\u{2028}' || c == '\u{2029}')
.filter(|p| !p.is_empty())
.collect::<Vec<_>>()
.join(" ")
}
pub fn apply(cards: &mut Vec<Card>, op: Op, now: i64) -> bool {
let before = cards.clone();
cards.retain(|c| c.live(now));
match op {
Op::Clear(id) => cards.retain(|c| c.id != id),
Op::Put(mut c) => {
if c.id.is_empty() {
c.id = (1..)
.map(|n| format!("c{n}"))
.find(|id| !cards.iter().any(|o| &o.id == id))
.unwrap_or_default();
}
match cards.iter_mut().find(|o| o.id == c.id) {
Some(o) => {
if c.life == 0 {
c.at = o.at;
c.life = o.life;
}
*o = c;
}
None => {
cards.push(c);
let over = cards.len().saturating_sub(KEEP);
cards.drain(..over);
}
}
}
}
*cards != before
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn put(b: Value) -> Card {
match parse(&b, 100).unwrap() {
Op::Put(c) => c,
o => panic!("{o:?}"),
}
}
#[test]
fn a_link_card_goes_to_the_web_or_this_machine_and_nowhere_else() {
let c =
put(json!({ "kind": "link", "label": "preview", "url": "http://127.0.0.1:7801/x" }));
assert_eq!(c.host, "127.0.0.1:7801");
assert_eq!(
put(json!({ "kind": "link", "label": "PR", "url": "https://GitHub.com/o/r/pull/86" }))
.host,
"github.com"
);
for bad in [
"javascript:alert(1)",
"data:text/html,hi",
"file:///etc/passwd",
"https://user:pw@example.com/",
"https://",
"https://a.b/x y",
"https://a.b/\nx",
"ftp://a.b",
] {
assert!(
parse(&json!({ "kind": "link", "label": "x", "url": bad }), 0).is_err(),
"{bad}"
);
}
}
#[test]
fn a_card_is_one_short_line_with_a_kind_a_tone_and_a_lifetime() {
assert_eq!(
put(json!({ "label": "building\n3/7" })).label,
"building 3/7"
);
assert!(parse(&json!({ "label": "" }), 0).is_err());
assert!(parse(
&json!({ "kind": "link", "label": "x".repeat(33), "url": "https://a.b" }),
0
)
.is_err());
assert!(parse(&json!({ "label": "x".repeat(81) }), 0).is_err());
assert!(parse(&json!({ "kind": "box", "label": "x" }), 0).is_err());
assert!(parse(&json!({ "label": "x", "tone": "loud" }), 0).is_err());
assert!(parse(&json!({ "label": "x", "for": 5 }), 0).is_err());
assert!(parse(&json!({ "label": "x", "for": 7200 }), 0).is_err());
assert!(parse(&json!({ "id": "a b", "label": "x" }), 0).is_err());
assert_eq!(put(json!({ "label": "x", "for": 30 })).life, 30);
assert_eq!(put(json!({ "label": "x", "for": "panel" })).life, 0);
assert_eq!(
parse(&json!({ "id": "b", "clear": true }), 0).unwrap(),
Op::Clear("b".into())
);
assert!(parse(&json!({ "clear": true }), 0).is_err());
}
#[test]
fn the_same_id_updates_in_place_and_the_oldest_leave_past_six() {
let mut cards = vec![];
let at = |b: Value, now| parse(&b, now).unwrap();
assert!(apply(
&mut cards,
at(json!({ "id": "build", "label": "1/7" }), 10),
10
));
assert!(apply(&mut cards, at(json!({ "label": "other" }), 11), 11));
assert!(apply(
&mut cards,
at(json!({ "id": "build", "label": "2/7" }), 12),
12
));
assert_eq!(cards[0].label, "2/7");
assert_eq!(cards[0].at, 10, "an update keeps when it was first put");
assert_eq!(cards[1].id, "c1");
assert!(
!apply(
&mut cards,
at(json!({ "id": "build", "label": "2/7" }), 13),
13
),
"the same again changes nothing"
);
for n in 0..6 {
apply(&mut cards, at(json!({ "label": format!("n{n}") }), 20), 20);
}
assert_eq!(cards.len(), KEEP);
assert!(
!cards.iter().any(|c| c.id == "build"),
"the oldest went first"
);
assert!(apply(&mut cards, Op::Clear("c3".into()), 21));
assert!(!cards.iter().any(|c| c.id == "c3"));
}
#[test]
fn a_card_with_a_lifetime_is_gone_once_it_has_run() {
let mut cards = vec![];
apply(
&mut cards,
parse(
&json!({ "id": "t", "label": "tests passed", "for": 30 }),
100,
)
.unwrap(),
100,
);
assert!(cards[0].live(129) && !cards[0].live(130));
apply(
&mut cards,
parse(&json!({ "id": "t", "label": "still" }), 110).unwrap(),
110,
);
assert_eq!((cards[0].at, cards[0].life), (100, 30));
assert!(
apply(&mut cards, Op::Clear("nothing".into()), 131),
"the expired one is dropped on the next write"
);
assert!(cards.is_empty());
}
}