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