1use serde::{Deserialize, Serialize};
2
3pub const R23_MAX_DISPLAY_TOTAL: usize = 999_999_999;
5pub const R23_CLAMP_THRESHOLD: usize = 1_000_000_000;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
11#[serde(rename_all = "snake_case")]
12pub enum Reason {
13 Cap,
14 Budget,
15 Depth,
16 Walk,
17}
18
19impl Reason {
20 pub const fn precedence(self) -> u8 {
22 match self {
23 Self::Walk => 4,
24 Self::Depth => 3,
25 Self::Budget => 2,
26 Self::Cap => 1,
27 }
28 }
29
30 pub const fn as_str(self) -> &'static str {
31 match self {
32 Self::Walk => "walk",
33 Self::Depth => "depth",
34 Self::Budget => "budget",
35 Self::Cap => "cap",
36 }
37 }
38}
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
42#[serde(rename_all = "snake_case")]
43pub enum Unit {
44 Sites,
45 Paths,
46 Files,
47 Results,
48 Rows,
49 Lines,
50 Items,
51 Directories,
52 Hops,
53}
54
55impl Unit {
56 pub const fn as_str(self) -> &'static str {
57 match self {
58 Self::Sites => "sites",
59 Self::Paths => "paths",
60 Self::Files => "files",
61 Self::Results => "results",
62 Self::Rows => "rows",
63 Self::Lines => "lines",
64 Self::Items => "items",
65 Self::Directories => "directories",
66 Self::Hops => "hops",
67 }
68 }
69}
70
71impl std::fmt::Display for Unit {
72 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
73 write!(f, "{}", self.as_str())
74 }
75}
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
79#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
80pub enum Total {
81 Exact(usize),
82 AtLeast(usize),
83}
84
85impl Total {
86 pub fn value(&self) -> usize {
87 match *self {
88 Total::Exact(v) | Total::AtLeast(v) => v,
89 }
90 }
91
92 pub fn is_exact(&self) -> bool {
93 matches!(self, Total::Exact(_))
94 }
95
96 pub fn is_at_least(&self) -> bool {
97 matches!(self, Total::AtLeast(_))
98 }
99}
100
101#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
103pub struct ListEnvelope {
104 pub shown: usize,
105 pub total: Total,
106 pub unit: Unit,
107 #[serde(skip_serializing_if = "Option::is_none")]
108 pub reason: Option<Reason>,
109 pub causes: Vec<Reason>,
110 pub narrow: Vec<String>,
111}
112
113impl ListEnvelope {
114 pub fn new(
115 shown: usize,
116 total: Total,
117 unit: Unit,
118 mut causes: Vec<Reason>,
119 narrow: &[&str],
120 ) -> Self {
121 causes.sort_by(|a, b| b.precedence().cmp(&a.precedence()));
122 causes.dedup();
123 let reason = causes.first().copied();
124 Self {
125 shown,
126 total,
127 unit,
128 reason,
129 causes,
130 narrow: narrow.iter().map(|s| s.to_string()).collect(),
131 }
132 }
133}
134
135pub fn render_total(total: &Total) -> String {
138 if total.value() >= R23_CLAMP_THRESHOLD {
139 format!("≥{R23_MAX_DISPLAY_TOTAL}")
140 } else {
141 match *total {
142 Total::Exact(v) => format!("{v}"),
143 Total::AtLeast(v) => format!("≥{v}"),
144 }
145 }
146}
147
148pub fn render_trailer(envelope: &ListEnvelope) -> Option<String> {
159 let reason = envelope.reason?;
160 let shown = envelope.shown;
161
162 let total_str = render_total(&envelope.total);
163
164 let unit_str = envelope.unit.as_str();
165 let reason_str = reason.as_str();
166
167 let base = format!("shown {shown} of {total_str} {unit_str} ({reason_str})");
168 if envelope.narrow.is_empty() {
169 Some(base)
170 } else {
171 Some(format!("{base} · narrow: {}", envelope.narrow.join(", ")))
172 }
173}
174
175pub fn measure_trailer_len(envelope: &ListEnvelope) -> usize {
177 render_trailer(envelope).map(|s| s.len()).unwrap_or(0)
178}
179
180pub fn derive_wire_key(list_id: &str, is_text_surface: bool) -> String {
184 if is_text_surface {
185 format!("{}_list_envelope", list_id.replace('.', "_"))
186 } else {
187 let last_segment = list_id.rsplit('.').next().unwrap_or(list_id);
188 format!("{last_segment}_list_envelope")
189 }
190}