Skip to main content

relay_event_schema/processor/
chunks.rs

1//! Utilities for dealing with annotated strings.
2//!
3//! This module contains the `split` and `join` function to destructure and recombine strings by
4//! redaction remarks. This allows to quickly inspect modified sections of a string.
5//!
6//! ### Example
7//!
8//! ```
9//! use relay_event_schema::processor;
10//! use relay_protocol::{Meta, Remark, RemarkType};
11//!
12//! let remarks = vec![Remark::with_range(
13//!     RemarkType::Substituted,
14//!     "myrule",
15//!     (7, 17),
16//! )];
17//!
18//! let chunks = processor::split_chunks("Hello, [redacted]!", &remarks);
19//! let (joined, join_remarks) = processor::join_chunks(chunks);
20//!
21//! assert_eq!(joined, "Hello, [redacted]!");
22//! assert_eq!(join_remarks, remarks);
23//! ```
24
25use std::borrow::Cow;
26use std::fmt;
27
28use relay_protocol::{Meta, Remark, RemarkType};
29use serde::{Deserialize, Serialize};
30
31/// A type for dealing with chunks of annotated text.
32#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
33#[serde(tag = "type", rename_all = "lowercase")]
34pub enum Chunk<'a> {
35    /// Unmodified text chunk.
36    Text {
37        /// The text value of the chunk
38        text: Cow<'a, str>,
39    },
40    /// Redacted text chunk with a note.
41    Redaction {
42        /// The redacted text value
43        text: Cow<'a, str>,
44        /// The rule that crated this redaction
45        rule_id: Cow<'a, str>,
46        /// Type type of remark for this redaction
47        #[serde(rename = "remark")]
48        ty: RemarkType,
49    },
50}
51
52impl Chunk<'_> {
53    /// The text of this chunk.
54    pub fn as_str(&self) -> &str {
55        match self {
56            Chunk::Text { text } => text,
57            Chunk::Redaction { text, .. } => text,
58        }
59    }
60
61    /// Effective length of the text in this chunk.
62    pub fn len(&self) -> usize {
63        self.as_str().len()
64    }
65
66    /// The number of UTF-8 encoded Unicode codepoints in this chunk.
67    pub fn count(&self) -> usize {
68        bytecount::num_chars(self.as_str().as_bytes())
69    }
70
71    /// Determines whether this chunk is empty.
72    pub fn is_empty(&self) -> bool {
73        self.len() == 0
74    }
75}
76
77impl fmt::Display for Chunk<'_> {
78    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
79        write!(f, "{}", self.as_str())
80    }
81}
82
83/// Chunks the given text based on remarks.
84pub fn split_chunks<'a, I>(text: &'a str, remarks: I) -> Vec<Chunk<'a>>
85where
86    I: IntoIterator<Item = &'a Remark>,
87{
88    let mut rv = vec![];
89    let mut pos = 0;
90
91    for remark in remarks {
92        let Some((from, to)) = remark.range().cloned() else {
93            continue;
94        };
95
96        if from > pos {
97            if let Some(piece) = text.get(pos..from) {
98                rv.push(Chunk::Text {
99                    text: Cow::Borrowed(piece),
100                });
101            } else {
102                break;
103            }
104            pos = from;
105        } else if from < pos {
106            // A lower `from` would duplicate parts of the string and is therefore illegal.
107            break;
108        }
109        if let Some(piece) = text.get(from..to) {
110            rv.push(Chunk::Redaction {
111                text: Cow::Borrowed(piece),
112                rule_id: remark.rule_id().into(),
113                ty: remark.ty(),
114            });
115        } else {
116            break;
117        }
118        pos = to;
119    }
120
121    if pos < text.len()
122        && let Some(piece) = text.get(pos..)
123    {
124        rv.push(Chunk::Text {
125            text: Cow::Borrowed(piece),
126        });
127    }
128
129    rv
130}
131
132/// Concatenates chunks into a string and emits remarks for redacted sections.
133pub fn join_chunks<'a, I>(chunks: I) -> (String, Vec<Remark>)
134where
135    I: IntoIterator<Item = Chunk<'a>>,
136{
137    let mut rv = String::new();
138    let mut remarks = vec![];
139    let mut pos = 0;
140
141    for chunk in chunks {
142        let new_pos = pos + chunk.len();
143        rv.push_str(chunk.as_str());
144
145        match chunk {
146            Chunk::Redaction { rule_id, ty, .. } => {
147                remarks.push(Remark::with_range(ty, rule_id.clone(), (pos, new_pos)))
148            }
149            Chunk::Text { .. } => {
150                // Plain text segments do not need remarks
151            }
152        }
153
154        pos = new_pos;
155    }
156
157    (rv, remarks)
158}
159
160/// Splits the string into chunks, maps each chunk and then joins chunks again, emitting
161/// remarks along the process.
162pub fn process_chunked_value<F>(value: &mut String, meta: &mut Meta, f: F)
163where
164    F: FnOnce(Vec<Chunk>) -> Vec<Chunk>,
165{
166    let chunks = split_chunks(value, meta.iter_remarks());
167    let (new_value, remarks) = join_chunks(f(chunks));
168
169    if new_value != *value {
170        meta.clear_remarks();
171        for remark in remarks.into_iter() {
172            meta.add_remark(remark);
173        }
174        meta.set_original_length(Some(bytecount::num_chars(value.as_bytes())));
175        *value = new_value;
176    }
177}
178
179#[cfg(test)]
180mod tests {
181    use similar_asserts::assert_eq;
182
183    use super::*;
184
185    #[test]
186    fn test_chunk_split() {
187        let remarks = vec![Remark::with_range(
188            RemarkType::Masked,
189            "@email:strip",
190            (33, 47),
191        )];
192
193        let text = "Hello Peter, my email address is ****@*****.com. See you";
194
195        let chunks = vec![
196            Chunk::Text {
197                text: "Hello Peter, my email address is ".into(),
198            },
199            Chunk::Redaction {
200                ty: RemarkType::Masked,
201                text: "****@*****.com".into(),
202                rule_id: "@email:strip".into(),
203            },
204            Chunk::Text {
205                text: ". See you".into(),
206            },
207        ];
208
209        assert_eq!(split_chunks(text, &remarks), chunks);
210        assert_eq!(join_chunks(chunks), (text.into(), remarks));
211    }
212}