Skip to main content

cloudiful_redactor/
input.rs

1use 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}