Skip to main content

cloudillo_types/
reactions.rs

1// SPDX-FileCopyrightText: Szilárd Hajba
2// SPDX-License-Identifier: LGPL-3.0-or-later
3
4//! Canonical reaction-count codec shared between the meta adapter
5//! (which writes to the `actions_data.reactions` column) and the
6//! STAT native hook (which normalizes inbound STAT `content.r`).
7//!
8//! Wire format: "<total>,<code><count>,<code><count>,..."
9//!   - `total` is the uncapped sum; the per-type list is capped to
10//!     the top 5 entries, sorted DESC by count then ASC by code.
11//!   - Empty list with `total == 0` encodes as an empty string.
12
13/// Map a reaction sub_type (e.g. "LIKE") to its single-char wire key.
14pub fn reaction_type_key(sub_type: &str) -> Option<char> {
15	match sub_type {
16		"LIKE" => Some('L'),
17		"LOVE" => Some('V'),
18		"LAUGH" => Some('H'),
19		"WOW" => Some('W'),
20		"SAD" => Some('S'),
21		"ANGRY" => Some('A'),
22		_ => None,
23	}
24}
25
26/// Encodes reactions into the canonical wire format. Sorts and truncates
27/// `entries` in place so callers can't accidentally pass unsorted data.
28/// Returns "" when `total == 0`.
29pub fn encode_reaction_counts(mut entries: Vec<(char, u32)>, total: u32) -> String {
30	if total == 0 {
31		return String::new();
32	}
33	entries.retain(|(_, c)| *c > 0);
34	entries.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
35	entries.truncate(5);
36	if entries.is_empty() {
37		return total.to_string();
38	}
39	let mut out = total.to_string();
40	for (k, c) in &entries {
41		out.push(',');
42		out.push(*k);
43		out.push_str(&c.to_string());
44	}
45	out
46}
47
48/// Decodes the canonical wire format into `(entries, total)`.
49/// Lenient: skips malformed tokens silently.
50pub fn decode_reaction_counts(s: &str) -> (Vec<(char, u32)>, u32) {
51	if s.is_empty() {
52		return (Vec::new(), 0);
53	}
54	let mut parts = s.split(',');
55	let total: u32 = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
56	let mut entries = Vec::new();
57	for part in parts {
58		let mut chars = part.chars();
59		let Some(key) = chars.next() else { continue };
60		let n_str: String = chars.collect();
61		let Ok(n) = n_str.parse::<u32>() else { continue };
62		if n == 0 {
63			continue;
64		}
65		entries.push((key, n));
66	}
67	(entries, total)
68}
69
70#[cfg(test)]
71mod tests {
72	use super::*;
73
74	#[test]
75	fn encode_empty() {
76		assert_eq!(encode_reaction_counts(Vec::new(), 0), "");
77	}
78
79	#[test]
80	fn encode_single() {
81		assert_eq!(encode_reaction_counts(vec![('L', 1)], 1), "1,L1");
82	}
83
84	#[test]
85	fn encode_with_overflow() {
86		// Already sorted DESC by count, ASC by code on tie.
87		assert_eq!(
88			encode_reaction_counts(vec![('L', 40), ('V', 30), ('H', 20), ('W', 7), ('S', 5)], 103,),
89			"103,L40,V30,H20,W7,S5"
90		);
91	}
92
93	#[test]
94	fn encode_total_only() {
95		// Bare-integer total when there are no per-type entries.
96		assert_eq!(encode_reaction_counts(Vec::new(), 7), "7");
97	}
98
99	#[test]
100	fn encode_normalises_unsorted_input() {
101		// Caller passed unsorted entries; encoder sorts them.
102		let s = encode_reaction_counts(vec![('A', 1), ('L', 5), ('V', 3)], 9);
103		assert_eq!(s, "9,L5,V3,A1");
104	}
105
106	#[test]
107	fn encode_caps_to_top_five() {
108		// 6 entries; only top 5 by count appear (last in code-asc order).
109		let s = encode_reaction_counts(
110			vec![('A', 6), ('B', 5), ('C', 4), ('D', 3), ('E', 2), ('F', 1)],
111			21,
112		);
113		assert_eq!(s, "21,A6,B5,C4,D3,E2");
114	}
115
116	#[test]
117	fn decode_empty() {
118		assert_eq!(decode_reaction_counts(""), (Vec::new(), 0));
119	}
120
121	#[test]
122	fn decode_total_only() {
123		assert_eq!(decode_reaction_counts("7"), (Vec::new(), 7));
124	}
125
126	#[test]
127	fn decode_with_entries() {
128		assert_eq!(
129			decode_reaction_counts("103,L40,V30,H20,W7,S5"),
130			(vec![('L', 40), ('V', 30), ('H', 20), ('W', 7), ('S', 5)], 103)
131		);
132	}
133
134	#[test]
135	fn roundtrip_encode_decode_encode() {
136		let original = "103,L40,V30,H20,W7,S5";
137		let (entries, total) = decode_reaction_counts(original);
138		assert_eq!(encode_reaction_counts(entries, total), original);
139	}
140
141	#[test]
142	fn decode_skips_malformed() {
143		// "xy" parses key='x', tail="y" — not a number, skipped.
144		let (entries, total) = decode_reaction_counts("5,L3,xy,V2");
145		assert_eq!(total, 5);
146		assert_eq!(entries, vec![('L', 3), ('V', 2)]);
147	}
148}
149
150// vim: ts=4