Skip to main content

aft/hashline/apply/
registers.rs

1//! Bounded session clipboard registers for hashline CUT/PUT.
2//!
3//! Named registers persist for the life of a session binding. The anonymous
4//! register is request-local. Captures that would exceed any declared bound
5//! reject the whole patch in Phase 1; named registers are never silently
6//! evicted to make room.
7
8use std::collections::BTreeMap;
9
10use crate::hashline::syntax::{
11    HashlineRejection, HashlineRejectionCode, RegisterRef, RejectionStage,
12};
13
14/// Maximum number of simultaneously retained named registers in one session.
15pub const MAX_NAMED_REGISTERS: usize = 64;
16/// Maximum payload bytes retained in one register (joined line contents).
17pub const MAX_REGISTER_BYTES: usize = 8 * 1024 * 1024;
18/// Maximum payload bytes retained across every register in one session.
19pub const MAX_REGISTER_TOTAL_BYTES: usize = 32 * 1024 * 1024;
20
21/// One register's captured logical lines (content without terminators).
22pub type RegisterLines = Vec<String>;
23
24/// Session-owned register store. Mutations are applied only through
25/// [`RegisterStore::commit`]; request-local staging never touches this state
26/// until every planned file has an `applied*` classification.
27#[derive(Clone, Debug, Default, Eq, PartialEq)]
28pub struct RegisterStore {
29    named: BTreeMap<String, RegisterLines>,
30    anonymous: Option<RegisterLines>,
31}
32
33impl RegisterStore {
34    pub fn new() -> Self {
35        Self::default()
36    }
37
38    pub fn named_count(&self) -> usize {
39        self.named.len()
40    }
41
42    pub fn get(&self, register: &RegisterRef) -> Option<&[String]> {
43        match register {
44            RegisterRef::Anonymous => self.anonymous.as_deref(),
45            RegisterRef::Named(name) => self.named.get(name).map(Vec::as_slice),
46        }
47    }
48
49    pub fn total_bytes(&self) -> usize {
50        let named: usize = self
51            .named
52            .values()
53            .map(|lines| register_payload_bytes(lines))
54            .sum();
55        let anonymous = self
56            .anonymous
57            .as_ref()
58            .map(|lines| register_payload_bytes(lines))
59            .unwrap_or(0);
60        named + anonymous
61    }
62
63    /// Begin a request-local fork that can stage captures without publishing
64    /// them to the session until commit.
65    pub fn stage(&self) -> StagedRegisters {
66        StagedRegisters {
67            base: self.clone(),
68            working: self.clone(),
69            writes: Vec::new(),
70        }
71    }
72
73    /// Publish a successful request's staged captures. Callers must only invoke
74    /// this when every planned file classified as `applied*`.
75    pub fn commit(&mut self, staged: StagedRegisters) {
76        *self = staged.working;
77    }
78
79    /// Drop a request-local fork without changing session state. Used when any
80    /// planned file stops, fails, or is not attempted.
81    pub fn discard(_staged: StagedRegisters) {}
82
83    pub fn clear(&mut self) {
84        self.named.clear();
85        self.anonymous = None;
86    }
87}
88
89/// Request-local register view used while planning and applying one patch.
90#[derive(Clone, Debug, Eq, PartialEq)]
91pub struct StagedRegisters {
92    base: RegisterStore,
93    working: RegisterStore,
94    writes: Vec<RegisterWrite>,
95}
96
97/// One capture recorded during planning so commit/discard stay explicit.
98#[derive(Clone, Debug, Eq, PartialEq)]
99pub struct RegisterWrite {
100    pub register: RegisterRef,
101    pub lines: RegisterLines,
102}
103
104impl StagedRegisters {
105    pub fn writes(&self) -> &[RegisterWrite] {
106        &self.writes
107    }
108
109    pub fn working(&self) -> &RegisterStore {
110        &self.working
111    }
112
113    pub fn base(&self) -> &RegisterStore {
114        &self.base
115    }
116
117    pub fn get(&self, register: &RegisterRef) -> Option<&[String]> {
118        self.working.get(register)
119    }
120
121    /// Capture lines into a register after bounds checks against the working set.
122    pub fn capture(
123        &mut self,
124        register: RegisterRef,
125        lines: RegisterLines,
126    ) -> Result<(), HashlineRejection> {
127        check_register_bounds(&self.working, &register, &lines)?;
128        match &register {
129            RegisterRef::Anonymous => {
130                self.working.anonymous = Some(lines.clone());
131            }
132            RegisterRef::Named(name) => {
133                if !self.working.named.contains_key(name)
134                    && self.working.named.len() >= MAX_NAMED_REGISTERS
135                {
136                    return Err(register_overflow(
137                        "named register count would exceed MAX_NAMED_REGISTERS",
138                    ));
139                }
140                self.working.named.insert(name.clone(), lines.clone());
141            }
142        }
143        self.writes.push(RegisterWrite { register, lines });
144        Ok(())
145    }
146
147    /// Read a register for a PUT source. Missing named registers read as empty
148    /// for gap inserts; span replacements refuse empty named/anonymous sources
149    /// so a missing capture cannot silently delete the addressed range.
150    pub fn read_for_put(
151        &self,
152        register: &RegisterRef,
153        target_is_span: bool,
154    ) -> Result<RegisterLines, HashlineRejection> {
155        match self.get(register) {
156            Some(lines) if !lines.is_empty() || !target_is_span => Ok(lines.to_vec()),
157            Some(_) => Err(HashlineRejection::new(
158                HashlineRejectionCode::ParseError,
159                RejectionStage::Register,
160                "register paste over a span requires a non-empty capture",
161            )),
162            None if matches!(register, RegisterRef::Named(_)) && !target_is_span => Ok(Vec::new()),
163            None => Err(HashlineRejection::new(
164                HashlineRejectionCode::ParseError,
165                RejectionStage::Register,
166                "register is empty or unknown for this PUT",
167            )),
168        }
169    }
170}
171
172fn register_payload_bytes(lines: &[String]) -> usize {
173    if lines.is_empty() {
174        return 0;
175    }
176    lines.iter().map(|line| line.len()).sum::<usize>() + lines.len().saturating_sub(1)
177}
178
179fn check_register_bounds(
180    store: &RegisterStore,
181    register: &RegisterRef,
182    lines: &[String],
183) -> Result<(), HashlineRejection> {
184    let incoming = register_payload_bytes(lines);
185    if incoming > MAX_REGISTER_BYTES {
186        return Err(register_overflow(
187            "a single register capture exceeds MAX_REGISTER_BYTES",
188        ));
189    }
190
191    let previous = store.get(register).map(register_payload_bytes).unwrap_or(0);
192    let total = store.total_bytes() - previous + incoming;
193    if total > MAX_REGISTER_TOTAL_BYTES {
194        return Err(register_overflow(
195            "register captures would exceed MAX_REGISTER_TOTAL_BYTES",
196        ));
197    }
198
199    if let RegisterRef::Named(name) = register {
200        if !store.named.contains_key(name) && store.named.len() >= MAX_NAMED_REGISTERS {
201            return Err(register_overflow(
202                "named register count would exceed MAX_NAMED_REGISTERS",
203            ));
204        }
205    }
206    Ok(())
207}
208
209fn register_overflow(message: impl Into<String>) -> HashlineRejection {
210    HashlineRejection::new(
211        HashlineRejectionCode::RegisterOverflow,
212        RejectionStage::Register,
213        message,
214    )
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220
221    #[test]
222    fn named_register_count_bound_rejects_without_eviction() {
223        let store = RegisterStore::new();
224        let mut staged = store.stage();
225        for index in 0..MAX_NAMED_REGISTERS {
226            staged
227                .capture(RegisterRef::Named(format!("r{index}")), vec!["x".into()])
228                .expect("within bound");
229        }
230        let err = staged
231            .capture(RegisterRef::Named("overflow".into()), vec!["y".into()])
232            .expect_err("one past the bound");
233        assert_eq!(err.code, HashlineRejectionCode::RegisterOverflow);
234        assert_eq!(err.stage, RejectionStage::Register);
235        RegisterStore::discard(staged);
236        assert_eq!(store.named_count(), 0);
237    }
238
239    #[test]
240    fn commit_publishes_only_after_explicit_success() {
241        let mut store = RegisterStore::new();
242        let mut staged = store.stage();
243        staged
244            .capture(RegisterRef::Named("clip".into()), vec!["body".into()])
245            .unwrap();
246        assert!(store.get(&RegisterRef::Named("clip".into())).is_none());
247        store.commit(staged);
248        assert_eq!(
249            store.get(&RegisterRef::Named("clip".into())),
250            Some(["body".to_string()].as_slice())
251        );
252    }
253
254    #[test]
255    fn discard_leaves_session_registers_untouched() {
256        let mut store = RegisterStore::new();
257        let mut baseline = store.stage();
258        baseline
259            .capture(RegisterRef::Named("keep".into()), vec!["stable".into()])
260            .unwrap();
261        store.commit(baseline);
262
263        let mut staged = store.stage();
264        staged
265            .capture(RegisterRef::Named("temp".into()), vec!["scratch".into()])
266            .unwrap();
267        RegisterStore::discard(staged);
268        assert_eq!(
269            store.get(&RegisterRef::Named("keep".into())),
270            Some(["stable".to_string()].as_slice())
271        );
272        assert!(store.get(&RegisterRef::Named("temp".into())).is_none());
273    }
274}