Skip to main content

drep/analysis/
acknowledgements.rs

1//! Persistent, source-sensitive acknowledgement of rejected LLM findings.
2
3use std::collections::BTreeSet;
4use std::io::Write;
5use std::path::Path;
6
7use anyhow::{Context, Result, anyhow};
8use serde::{Deserialize, Serialize};
9
10use crate::analysis::findings::Finding;
11use crate::diff::hunks::Hunk;
12
13pub const DEFAULT_PATH: &str = ".drep/acknowledgements.toml";
14const CONTEXT_RADIUS: u32 = 3;
15
16#[derive(Debug, Default, Deserialize, Serialize)]
17#[serde(deny_unknown_fields)]
18pub struct Store {
19    #[serde(default)]
20    fingerprints: BTreeSet<String>,
21}
22
23impl Store {
24    pub fn load(root: &Path) -> Result<Self> {
25        let path = root.join(DEFAULT_PATH);
26        let raw = match std::fs::read_to_string(&path) {
27            Ok(raw) => raw,
28            Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Self::default()),
29            Err(err) => {
30                return Err(err).with_context(|| format!("could not read {}", path.display()));
31            }
32        };
33        toml::from_str(&raw).with_context(|| format!("could not parse {}", path.display()))
34    }
35
36    pub fn contains(&self, fingerprint: &str) -> bool {
37        self.fingerprints.contains(fingerprint)
38    }
39
40    pub fn insert(&mut self, fingerprint: String) -> bool {
41        self.fingerprints.insert(fingerprint)
42    }
43
44    pub fn save(&self, root: &Path) -> Result<()> {
45        let path = root.join(DEFAULT_PATH);
46        let parent = path
47            .parent()
48            .ok_or_else(|| anyhow!("{} has no parent", path.display()))?;
49        std::fs::create_dir_all(parent)
50            .with_context(|| format!("could not create {}", parent.display()))?;
51        let rendered = toml::to_string_pretty(self).context("could not render acknowledgements")?;
52        let mut temporary = tempfile::NamedTempFile::new_in(parent).with_context(|| {
53            format!("could not create a temporary file in {}", parent.display())
54        })?;
55        temporary
56            .write_all(rendered.as_bytes())
57            .and_then(|()| temporary.flush())
58            .and_then(|()| temporary.as_file().sync_all())
59            .with_context(|| format!("could not write {}", path.display()))?;
60        temporary
61            .persist(&path)
62            .map_err(|err| err.error)
63            .with_context(|| format!("could not publish {}", path.display()))?;
64        Ok(())
65    }
66}
67
68pub fn validate_fingerprint(value: &str) -> Result<()> {
69    if value.len() == 64
70        && value
71            .bytes()
72            .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
73    {
74        Ok(())
75    } else {
76        Err(anyhow!(
77            "invalid finding fingerprint `{value}`; expected 64 lowercase hexadecimal characters"
78        ))
79    }
80}
81
82/// Attach source-sensitive fingerprints and remove ones already adjudicated.
83pub fn apply(findings: &mut Vec<Finding>, by_file: &[Vec<Hunk>], store: &Store) {
84    let hunks_by_file: std::collections::BTreeMap<&Path, &[Hunk]> = by_file
85        .iter()
86        .filter_map(|hunks| {
87            hunks
88                .first()
89                .map(|first| (first.file_path.as_path(), hunks.as_slice()))
90        })
91        .collect();
92    for finding in findings.iter_mut() {
93        finding.fingerprint = hunks_by_file
94            .get(Path::new(&finding.file_path))
95            .and_then(|hunks| fingerprint(finding, hunks));
96    }
97    findings.retain(|finding| {
98        finding
99            .fingerprint
100            .as_deref()
101            .is_none_or(|fingerprint| !store.contains(fingerprint))
102    });
103}
104
105fn fingerprint(finding: &Finding, hunks: &[Hunk]) -> Option<String> {
106    let start = finding.line.saturating_sub(CONTEXT_RADIUS);
107    let end = finding.line.saturating_add(CONTEXT_RADIUS);
108    let mut context = Vec::new();
109    let mut contains_target = false;
110    for (number, content) in hunks.iter().flat_map(Hunk::numbered_new_lines) {
111        if number == finding.line {
112            contains_target = true;
113        }
114        if (start..=end).contains(&number) {
115            context.push((number == finding.line, content));
116        }
117    }
118    if !contains_target {
119        return None;
120    }
121    let mut hasher = blake3::Hasher::new();
122    hasher.update(b"drep-acknowledgement-v1\0");
123    hasher.update(finding.file_path.as_bytes());
124    hasher.update(b"\0");
125    hasher.update(finding.kind.as_bytes());
126    for (is_target, line) in context {
127        hasher.update(b"\0");
128        hasher.update(if is_target { b"target\0" } else { b"context\0" });
129        hasher.update(line.as_bytes());
130    }
131    Some(hasher.finalize().to_hex().to_string())
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137    use crate::analysis::findings::Severity;
138    use std::path::PathBuf;
139
140    fn finding(line: u32) -> Finding {
141        Finding {
142            kind: "bug".to_owned(),
143            severity: Severity::Error,
144            file_path: "src/lib.rs".to_owned(),
145            line,
146            column: None,
147            message: "message".to_owned(),
148            suggestion: None,
149            asserts_compile_failure: false,
150            fingerprint: None,
151        }
152    }
153
154    #[test]
155    fn fingerprint_survives_line_movement_but_expires_on_source_change() {
156        let original = Hunk::whole_file(
157            PathBuf::from("src/lib.rs"),
158            "zero\na\nb\nc\ntarget\nd\ne\nf\ntail\n",
159        );
160        let shifted = Hunk::whole_file(
161            PathBuf::from("src/lib.rs"),
162            "extra\nzero\na\nb\nc\ntarget\nd\ne\nf\ntail\n",
163        );
164        let changed = Hunk::whole_file(
165            PathBuf::from("src/lib.rs"),
166            "zero\na\nb\nc\ntarget changed\nd\ne\nf\ntail\n",
167        );
168        let first = fingerprint(&finding(5), &[original]).expect("first fingerprint");
169        assert_eq!(first, fingerprint(&finding(6), &[shifted]).unwrap());
170        assert_ne!(first, fingerprint(&finding(5), &[changed]).unwrap());
171    }
172
173    #[test]
174    fn store_round_trips_atomically() {
175        let dir = tempfile::tempdir().expect("tempdir");
176        let mut store = Store::default();
177        let key = "a".repeat(64);
178        assert!(store.insert(key.clone()));
179        store.save(dir.path()).expect("save");
180        assert!(Store::load(dir.path()).expect("load").contains(&key));
181    }
182
183    #[test]
184    fn applying_a_recorded_fingerprint_suppresses_the_same_source_context() {
185        let hunks = vec![vec![Hunk::whole_file(
186            PathBuf::from("src/lib.rs"),
187            "one\ntarget\nthree\n",
188        )]];
189        let mut first = vec![finding(2)];
190        apply(&mut first, &hunks, &Store::default());
191        let key = first[0].fingerprint.clone().expect("fingerprint");
192
193        let mut store = Store::default();
194        store.insert(key);
195        let mut repeated = vec![finding(2)];
196        apply(&mut repeated, &hunks, &store);
197
198        assert!(repeated.is_empty());
199    }
200
201    #[test]
202    fn two_findings_in_the_same_context_have_distinct_fingerprints() {
203        let hunk = Hunk::whole_file(PathBuf::from("src/lib.rs"), "one\ntwo\nthree\n");
204        assert_ne!(
205            fingerprint(&finding(1), std::slice::from_ref(&hunk)),
206            fingerprint(&finding(2), std::slice::from_ref(&hunk))
207        );
208    }
209}