Skip to main content

tldr_cli/commands/bugbot/
baseline.rs

1//! Git baseline extraction for bugbot
2//!
3//! Retrieves the "before" version of changed files from git so the analysis
4//! pipeline can compare baseline vs current and detect regressions.
5
6use std::io::Write;
7use std::path::Path;
8use std::process::Command;
9
10use anyhow::{Context, Result};
11use tempfile::NamedTempFile;
12
13/// Result of checking baseline status for a file.
14#[derive(Debug)]
15pub enum BaselineStatus {
16    /// File exists at the baseline ref; contains the original content.
17    Exists(String),
18    /// File is new -- it did not exist at the baseline ref.
19    NewFile,
20    /// `git show` failed for an unexpected reason (stderr captured).
21    GitShowFailed(String),
22}
23
24/// Get the content of a file at the given git ref.
25///
26/// # Arguments
27/// * `project` - Project root directory (must be inside a git repo).
28/// * `file`    - Path to the file (absolute or relative to `project`).
29/// * `base_ref`- Git ref to read from, e.g. `"HEAD"`, `"main"`.
30///
31/// # Returns
32/// * `BaselineStatus::Exists(content)` when the file existed at `base_ref`.
33/// * `BaselineStatus::NewFile` when `git show` reports the path does not exist.
34/// * `BaselineStatus::GitShowFailed(stderr)` on other git failures.
35pub fn get_baseline_content(project: &Path, file: &Path, base_ref: &str) -> Result<BaselineStatus> {
36    // Compute relative path from project root.
37    // If the file is already relative (or outside the project) we fall through.
38    let relative = file.strip_prefix(project).unwrap_or(file);
39
40    // On all platforms git expects forward-slash separators in `ref:path`.
41    let relative_str = relative
42        .components()
43        .map(|c| c.as_os_str().to_string_lossy().to_string())
44        .collect::<Vec<_>>()
45        .join("/");
46
47    let output = Command::new("git")
48        .args(["show", &format!("{}:{}", base_ref, relative_str)])
49        .current_dir(project)
50        .output()
51        .context("Failed to run git show")?;
52
53    if output.status.success() {
54        let content =
55            String::from_utf8(output.stdout).context("git show output is not valid UTF-8")?;
56        Ok(BaselineStatus::Exists(content))
57    } else {
58        let stderr = String::from_utf8_lossy(&output.stderr);
59        if stderr.contains("does not exist")
60            || stderr.contains("not exist in")
61            || stderr.contains("exists on disk, but not in")
62            || stderr.contains("did not match any")
63        {
64            Ok(BaselineStatus::NewFile)
65        } else {
66            Ok(BaselineStatus::GitShowFailed(stderr.to_string()))
67        }
68    }
69}
70
71/// Write baseline content to a temporary file with the correct extension.
72///
73/// The extension is preserved so that tree-sitter can detect the language
74/// when parsing the temporary file.  The caller must keep the returned
75/// `NamedTempFile` handle alive -- dropping it deletes the file.
76pub fn write_baseline_tmpfile(content: &str, file_path: &Path) -> Result<NamedTempFile> {
77    let extension = file_path
78        .extension()
79        .and_then(|e| e.to_str())
80        .unwrap_or("txt");
81
82    let mut tmpfile = tempfile::Builder::new()
83        .prefix("bugbot_baseline_")
84        .suffix(&format!(".{}", extension))
85        .tempfile()
86        .context("Failed to create temp file for baseline")?;
87
88    tmpfile
89        .write_all(content.as_bytes())
90        .context("Failed to write baseline content to temp file")?;
91    tmpfile.flush()?;
92
93    Ok(tmpfile)
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99    use std::path::PathBuf;
100
101    /// Helper: initialize a git repo with an initial commit in a temp directory.
102    fn init_git_repo() -> tempfile::TempDir {
103        let tmp = tempfile::TempDir::new().expect("create temp dir");
104        let dir = tmp.path();
105
106        Command::new("git")
107            .args(["init"])
108            .current_dir(dir)
109            .output()
110            .expect("git init");
111
112        Command::new("git")
113            .args(["config", "user.email", "test@test.com"])
114            .current_dir(dir)
115            .output()
116            .expect("git config email");
117
118        Command::new("git")
119            .args(["config", "user.name", "Test"])
120            .current_dir(dir)
121            .output()
122            .expect("git config name");
123
124        // Create an initial commit so HEAD exists.
125        std::fs::write(dir.join("README.md"), "# test\n").expect("write readme");
126        Command::new("git")
127            .args(["add", "."])
128            .current_dir(dir)
129            .output()
130            .expect("git add");
131        Command::new("git")
132            .args(["commit", "-m", "init"])
133            .current_dir(dir)
134            .output()
135            .expect("git commit");
136
137        tmp
138    }
139
140    #[test]
141    fn test_get_baseline_existing_file() {
142        let tmp = init_git_repo();
143        let dir = tmp.path();
144
145        // Commit a file with known content.
146        let original = "fn original() {}\n";
147        std::fs::write(dir.join("lib.rs"), original).expect("write lib.rs");
148        Command::new("git")
149            .args(["add", "lib.rs"])
150            .current_dir(dir)
151            .output()
152            .expect("git add");
153        Command::new("git")
154            .args(["commit", "-m", "add lib.rs"])
155            .current_dir(dir)
156            .output()
157            .expect("git commit");
158
159        // Modify the file (uncommitted).
160        std::fs::write(dir.join("lib.rs"), "fn modified() {}\n").expect("overwrite lib.rs");
161
162        // Baseline at HEAD should return the original content.
163        let status =
164            get_baseline_content(dir, &dir.join("lib.rs"), "HEAD").expect("get_baseline_content");
165
166        match status {
167            BaselineStatus::Exists(content) => {
168                assert_eq!(
169                    content, original,
170                    "Baseline should return the committed content"
171                );
172            }
173            other => panic!("Expected BaselineStatus::Exists, got: {:?}", other),
174        }
175    }
176
177    #[test]
178    fn test_get_baseline_new_file() {
179        let tmp = init_git_repo();
180        let dir = tmp.path();
181
182        // Create a file that has never been committed.
183        std::fs::write(dir.join("brand_new.rs"), "fn new() {}\n").expect("write new file");
184
185        let status = get_baseline_content(dir, &dir.join("brand_new.rs"), "HEAD")
186            .expect("get_baseline_content");
187
188        match status {
189            BaselineStatus::NewFile => {} // expected
190            other => panic!("Expected BaselineStatus::NewFile, got: {:?}", other),
191        }
192    }
193
194    #[test]
195    fn test_get_baseline_deleted_file() {
196        let tmp = init_git_repo();
197        let dir = tmp.path();
198
199        // Commit a file.
200        let original = "fn to_delete() {}\n";
201        std::fs::write(dir.join("doomed.rs"), original).expect("write doomed.rs");
202        Command::new("git")
203            .args(["add", "doomed.rs"])
204            .current_dir(dir)
205            .output()
206            .expect("git add");
207        Command::new("git")
208            .args(["commit", "-m", "add doomed.rs"])
209            .current_dir(dir)
210            .output()
211            .expect("git commit");
212
213        // Delete the file from the working tree.
214        std::fs::remove_file(dir.join("doomed.rs")).expect("delete doomed.rs");
215
216        // Baseline at HEAD should still return the committed content.
217        let status = get_baseline_content(dir, &dir.join("doomed.rs"), "HEAD")
218            .expect("get_baseline_content");
219
220        match status {
221            BaselineStatus::Exists(content) => {
222                assert_eq!(
223                    content, original,
224                    "Baseline should return the committed content even after deletion"
225                );
226            }
227            other => panic!("Expected BaselineStatus::Exists, got: {:?}", other),
228        }
229    }
230
231    #[test]
232    fn test_tmpfile_has_correct_extension() {
233        let tmpfile =
234            write_baseline_tmpfile("content", &PathBuf::from("src/lib.rs")).expect("write tmpfile");
235
236        let path = tmpfile.path();
237        let ext = path.extension().and_then(|e| e.to_str());
238        assert_eq!(ext, Some("rs"), "Temp file should have .rs extension");
239    }
240
241    #[test]
242    fn test_tmpfile_content_matches() {
243        let content = "fn hello() { println!(\"world\"); }\n";
244        let tmpfile =
245            write_baseline_tmpfile(content, &PathBuf::from("example.py")).expect("write tmpfile");
246
247        let read_back = std::fs::read_to_string(tmpfile.path()).expect("read tmpfile");
248        assert_eq!(
249            read_back, content,
250            "Content read back from temp file should match what was written"
251        );
252    }
253}