Skip to main content

comindly_core/
sources.rs

1//! File contents of the indexed commit, kept in the index so code can be shown without a checkout.
2
3use std::path::Path;
4
5use anyhow::Result;
6
7/// Files larger than this are not stored.
8const MAX_BYTES: u64 = 512 * 1024;
9
10/// One stored file: `hash` is a content hash, so unchanged files are stored once across runs.
11#[derive(Debug, Clone, PartialEq)]
12pub struct SourceFile {
13    pub repo: String,
14    pub path: String,
15    pub hash: String,
16    pub content: String,
17}
18
19/// The text files git tracks at `root`'s HEAD; binaries, links and large files are skipped.
20pub fn collect(repo: &str, root: &Path) -> Result<Vec<SourceFile>> {
21    let Ok(git) = git2::Repository::open(root) else {
22        return Ok(Vec::new());
23    };
24    let tree = git.head()?.peel_to_tree()?;
25    let mut out = Vec::new();
26    tree.walk(git2::TreeWalkMode::PreOrder, |dir, e| {
27        let regular = matches!(e.filemode(), 0o100644 | 0o100755);
28        if let (true, Ok(name)) = (regular, e.name()) {
29            let path = format!("{dir}{name}");
30            if let Some(content) = read_text(&root.join(&path)) {
31                out.push(SourceFile {
32                    repo: repo.to_string(),
33                    hash: hash(&content),
34                    path,
35                    content,
36                });
37            }
38        }
39        git2::TreeWalkResult::Ok
40    })?;
41    Ok(out)
42}
43
44fn read_text(p: &Path) -> Option<String> {
45    let m = std::fs::symlink_metadata(p).ok()?;
46    if !m.is_file() || m.len() > MAX_BYTES {
47        return None;
48    }
49    let s = String::from_utf8(std::fs::read(p).ok()?).ok()?;
50    (!s.contains('\0')).then_some(s)
51}
52
53fn hash(content: &str) -> String {
54    blake3::hash(content.as_bytes()).to_hex()[..32].to_string()
55}
56
57/// The lines around 1-based `line`: `around` before and after, numbered.
58pub fn around(text: &str, line: usize, around: usize) -> Vec<(usize, String)> {
59    let from = line.max(1).saturating_sub(around + 1);
60    text.lines()
61        .enumerate()
62        .skip(from)
63        .take(around * 2 + 1)
64        .map(|(n, l)| (n + 1, l.to_string()))
65        .collect()
66}
67
68#[cfg(test)]
69mod tests {
70    use super::*;
71    use std::process::Command;
72
73    fn git(dir: &Path, args: &[&str]) {
74        let ok = Command::new("git")
75            .args([
76                "-c",
77                "user.name=t",
78                "-c",
79                "user.email=t@t",
80                "-c",
81                "commit.gpgsign=false",
82            ])
83            .args(args)
84            .current_dir(dir)
85            .status()
86            .unwrap()
87            .success();
88        assert!(ok, "git {args:?}");
89    }
90
91    #[test]
92    fn collects_tracked_text_files_only() {
93        let dir = std::env::temp_dir().join(format!("comindly-sources-{}", std::process::id()));
94        let _ = std::fs::remove_dir_all(&dir);
95        std::fs::create_dir_all(dir.join("src")).unwrap();
96        std::fs::write(dir.join("src/a.py"), "def a():\n    return 1\n").unwrap();
97        std::fs::write(dir.join("logo.png"), [0u8, 159, 146, 150]).unwrap();
98        std::fs::write(dir.join("untracked.py"), "x = 1\n").unwrap();
99        git(&dir, &["init", "-q"]);
100        git(&dir, &["add", "src/a.py", "logo.png"]);
101        git(&dir, &["commit", "-qm", "init"]);
102
103        let files = collect("demo", &dir).unwrap();
104        let paths: Vec<&str> = files.iter().map(|f| f.path.as_str()).collect();
105        assert_eq!(paths, ["src/a.py"]);
106        assert_eq!(files[0].hash, hash("def a():\n    return 1\n"));
107        assert!(
108            collect("plain", &std::env::temp_dir().join("no-such-folder"))
109                .unwrap()
110                .is_empty()
111        );
112        std::fs::remove_dir_all(&dir).ok();
113    }
114
115    #[test]
116    fn lines_around_a_line() {
117        let text = "one\ntwo\nthree\nfour\nfive\n";
118        let got = around(text, 3, 1);
119        assert_eq!(
120            got,
121            [(2, "two".into()), (3, "three".into()), (4, "four".into())]
122        );
123        assert_eq!(around(text, 1, 1)[0].0, 1);
124    }
125}