1use std::path::Path;
4
5use anyhow::Result;
6
7const MAX_BYTES: u64 = 512 * 1024;
9
10#[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
19pub 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
57pub 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}