cloudiful_redactor/
input.rs1use serde::{Deserialize, Serialize};
2use std::ops::Range;
3
4#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
5#[serde(rename_all = "snake_case")]
6pub enum InputKind {
7 #[default]
8 Text,
9 GitDiff,
10}
11
12#[derive(Debug, Clone)]
13pub(crate) struct RedactableRange {
14 pub range: Range<usize>,
15 pub file_path: Option<String>,
16}
17
18pub(crate) fn redactable_ranges(
19 text: &str,
20 input_kind: InputKind,
21 source_path: Option<&str>,
22) -> Vec<RedactableRange> {
23 match input_kind {
24 InputKind::Text => {
25 let file_path = source_path.map(|s| s.to_string());
26 vec![RedactableRange {
27 range: 0..text.len(),
28 file_path,
29 }]
30 }
31 InputKind::GitDiff => git_diff_redactable_ranges(text),
32 }
33}
34
35fn git_diff_redactable_ranges(text: &str) -> Vec<RedactableRange> {
36 let mut ranges = Vec::new();
37 let mut offset = 0;
38 let mut in_hunk = false;
39 let mut in_binary_patch = false;
40 let mut current_file_path: Option<String> = None;
41
42 for line in text.split_inclusive('\n') {
43 if line.starts_with("diff --git ") {
44 in_hunk = false;
45 in_binary_patch = false;
46 current_file_path = parse_diff_git_path(line);
47 offset += line.len();
48 continue;
49 }
50
51 if line.starts_with("+++ ") || line.starts_with("--- ") {
52 current_file_path = parse_diff_path_line(line)
53 .or(current_file_path.take())
54 .or(current_file_path.clone());
55 if line.starts_with("+++ ") {
56 current_file_path = parse_diff_path_line(line).or(current_file_path);
57 }
58 offset += line.len();
59 continue;
60 }
61
62 if line.starts_with("GIT binary patch") || line.starts_with("Binary files ") {
63 in_hunk = false;
64 in_binary_patch = true;
65 offset += line.len();
66 continue;
67 }
68
69 if in_binary_patch {
70 offset += line.len();
71 continue;
72 }
73
74 if line.starts_with("@@") {
75 in_hunk = true;
76 offset += line.len();
77 continue;
78 }
79
80 if !in_hunk {
81 offset += line.len();
82 continue;
83 }
84
85 if line.starts_with("\\ No newline at end of file") {
86 offset += line.len();
87 continue;
88 }
89
90 if matches!(
91 line.as_bytes().first(),
92 Some(b'+') | Some(b'-') | Some(b' ')
93 ) && line.len() > 1
94 {
95 ranges.push(RedactableRange {
96 range: (offset + 1)..(offset + line.len()),
97 file_path: current_file_path.clone(),
98 });
99 }
100
101 offset += line.len();
102 }
103
104 ranges
105}
106
107fn parse_diff_git_path(line: &str) -> Option<String> {
108 let rest = line.strip_prefix("diff --git ")?;
109 let path_part = rest.split_whitespace().next()?;
110 let path = path_part
111 .strip_prefix("a/")
112 .unwrap_or(path_part)
113 .to_string();
114 Some(path)
115}
116
117fn parse_diff_path_line(line: &str) -> Option<String> {
118 let rest = if line.starts_with("+++ ") {
119 line.strip_prefix("+++ ")?
120 } else if line.starts_with("--- ") {
121 line.strip_prefix("--- ")?
122 } else {
123 return None;
124 };
125 let path = rest
126 .strip_prefix("b/")
127 .or_else(|| rest.strip_prefix("a/"))
128 .unwrap_or(rest);
129 let path = path.trim();
130 if path.is_empty() || path == "/dev/null" {
131 return None;
132 }
133 Some(path.to_string())
134}
135
136#[cfg(test)]
137mod tests {
138 use super::{InputKind, redactable_ranges};
139
140 #[test]
141 fn git_diff_ranges_only_cover_hunk_lines() {
142 let diff = concat!(
143 "diff --git a/config.yml b/config.yml\n",
144 "index 1111111..2222222 100644\n",
145 "--- a/config.yml\n",
146 "+++ b/config.yml\n",
147 "@@ -1,2 +1,3 @@\n",
148 "-old_secret=abc123\n",
149 "+new_secret=def456\n",
150 " unchanged=value\n",
151 );
152
153 let ranges = redactable_ranges(diff, InputKind::GitDiff, None);
154 let covered = ranges
155 .into_iter()
156 .map(|r| &diff[r.range])
157 .collect::<Vec<_>>();
158
159 assert_eq!(
160 covered,
161 vec![
162 "old_secret=abc123\n",
163 "new_secret=def456\n",
164 "unchanged=value\n"
165 ]
166 );
167 }
168
169 #[test]
170 fn text_mode_covers_entire_input() {
171 let text = "plain text";
172 let ranges = redactable_ranges(text, InputKind::Text, None);
173 assert_eq!(ranges.len(), 1);
174 assert_eq!(&text[ranges[0].range.clone()], text);
175 }
176
177 #[test]
178 fn text_mode_with_source_path() {
179 let text = "content";
180 let ranges = redactable_ranges(text, InputKind::Text, Some("secrets/env"));
181 assert_eq!(ranges.len(), 1);
182 assert_eq!(ranges[0].file_path.as_deref(), Some("secrets/env"));
183 }
184
185 #[test]
186 fn git_diff_extracts_file_path_from_headers() {
187 let diff = concat!(
188 "diff --git a/app/config.yml b/app/config.yml\n",
189 "index 1111111..2222222 100644\n",
190 "--- a/app/config.yml\n",
191 "+++ b/app/config.yml\n",
192 "@@ -1,1 +1,1 @@\n",
193 "-old_secret=abc123\n",
194 );
195
196 let ranges = redactable_ranges(diff, InputKind::GitDiff, None);
197 assert_eq!(ranges.len(), 1);
198 assert_eq!(ranges[0].file_path.as_deref(), Some("app/config.yml"));
199 }
200}