Skip to main content

aft/
list_envelope.rs

1use serde::{Deserialize, Serialize};
2
3/// Maximum total count before render-time display clamping (R23).
4pub const R23_MAX_DISPLAY_TOTAL: usize = 999_999_999;
5/// Threshold at or above which total count is clamped to `≥999999999` (R23).
6pub const R23_CLAMP_THRESHOLD: usize = 1_000_000_000;
7
8/// Reason why a list-shaped surface was cut short.
9/// Precedence ordering: Walk > Depth > Budget > Cap.
10#[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    /// Numerical precedence rank: higher value means higher precedence.
21    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/// Permitted unit word for a list-shaped surface.
41#[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/// Total count of items, either exact or a proved lower bound.
78#[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/// Common truncation envelope returned beside list-shaped replies.
102#[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
135/// Format a total count in the envelope's form: bare integer for Exact,
136/// prefixed with `≥` for AtLeast, and clamped to `≥999999999` if >= 1e9 (R23).
137pub 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
148/// Render the trailer text for a list envelope.
149///
150/// Trailer grammar (pinned):
151/// `shown <n> of <total> <unit> (<reason>) · narrow: <a>, <b>`
152/// - Decimal integers, no separators
153/// - `Exact` bare, `AtLeast` prefixed `≥`
154/// - No trailing punctuation
155/// - Narrow clause omitted only for surfaces with `narrow: []`
156/// - Emitted iff `reason.is_some()`
157/// - Clamped to `≥999999999` at render time if `total.value >= 1e9`
158pub 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
175/// Measure the rendered trailer byte length without exposing trailer text to producers (R13).
176pub fn measure_trailer_len(envelope: &ListEnvelope) -> usize {
177    render_trailer(envelope).map(|s| s.len()).unwrap_or(0)
178}
179
180/// Derive the wire key for serializing a list envelope per R14/R21.
181/// - For array lists: `<last-id-segment>_list_envelope` beside the array in its owning object.
182/// - For text surfaces: `<full-id-with-_-replacing-.>_list_envelope` at the reply root.
183pub 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}