1use std::collections::HashMap;
2use std::future::Future;
3use std::path::{Path, PathBuf};
4use std::pin::Pin;
5use std::sync::Arc;
6use std::time::UNIX_EPOCH;
7
8use tokio::process::Command;
9
10use crate::error::Error;
11use crate::types::{Config, RepoMetadata};
12
13async fn run_git(repo_path: &Path, args: &[&str]) -> Result<String, Error> {
14 let output = Command::new("git")
15 .args(["-C", &repo_path.to_string_lossy()])
16 .args(args)
17 .output()
18 .await
19 .map_err(|e| Error::Git(format!("failed to run git: {e}")))?;
20
21 if !output.status.success() {
22 let stderr = String::from_utf8_lossy(&output.stderr);
23 return Err(Error::Git(stderr.trim().to_string()));
24 }
25
26 Ok(String::from_utf8(output.stdout)
27 .unwrap_or_else(|e| String::from_utf8_lossy(e.as_bytes()).into_owned()))
28}
29
30#[derive(Debug)]
32pub struct RepoInfo {
33 pub root: PathBuf,
35 pub is_git: bool,
37 pub scope: Option<PathBuf>,
40 pub single_file: Option<PathBuf>,
42}
43
44pub async fn verify_repo(path: &Path) -> Result<RepoInfo, Error> {
52 let canonical = std::fs::canonicalize(path)
53 .map_err(|_| Error::Git(format!("{}: path not found", path.display())))?;
54
55 let git_dir = if canonical.is_file() {
57 canonical
58 .parent()
59 .ok_or_else(|| Error::Git("file has no parent directory".to_string()))?
60 .to_path_buf()
61 } else {
62 canonical.clone()
63 };
64
65 let output = Command::new("git")
66 .args(["-C", &git_dir.to_string_lossy()])
67 .args(["rev-parse", "--show-toplevel"])
68 .output()
69 .await
70 .map_err(|e| Error::Git(format!("failed to run git: {e}")))?;
71
72 if output.status.success() {
73 let root = PathBuf::from(String::from_utf8_lossy(&output.stdout).trim().to_string());
74
75 if canonical.is_file() {
76 let rel = canonical
77 .strip_prefix(&root)
78 .map_err(|_| Error::Git("file is outside the git repository".to_string()))?
79 .to_path_buf();
80 return Ok(RepoInfo {
81 root,
82 is_git: true,
83 scope: None,
84 single_file: Some(rel),
85 });
86 }
87
88 let scope = (canonical != root)
89 .then(|| canonical.strip_prefix(&root).ok().map(|p| p.to_path_buf()))
90 .flatten();
91 return Ok(RepoInfo {
92 root,
93 is_git: true,
94 scope,
95 single_file: None,
96 });
97 }
98
99 if canonical.is_file() {
101 let parent = canonical
102 .parent()
103 .ok_or_else(|| Error::Git("file has no parent directory".to_string()))?
104 .to_path_buf();
105 return Ok(RepoInfo {
106 root: parent,
107 is_git: false,
108 scope: None,
109 single_file: Some(PathBuf::from(canonical.file_name().unwrap())),
110 });
111 }
112
113 if canonical.is_dir() {
114 return Ok(RepoInfo {
115 root: canonical,
116 is_git: false,
117 scope: None,
118 single_file: None,
119 });
120 }
121
122 Err(Error::Git(format!(
123 "{}: not a git repository, directory, or file",
124 path.display()
125 )))
126}
127
128pub async fn get_metadata(
129 repo_path: &Path,
130 config: &Config,
131 is_git: bool,
132 scope: Option<&Path>,
133) -> Result<RepoMetadata, Error> {
134 let base = repo_path
135 .file_name()
136 .map(|n| n.to_string_lossy().to_string())
137 .unwrap_or_else(|| "unknown".to_string());
138 let name = match scope {
139 Some(s) => format!("{}/{}", base, s.display()),
140 None => base,
141 };
142
143 if !is_git {
144 return Ok(RepoMetadata {
145 name,
146 branch: String::new(),
147 commit_hash: String::new(),
148 commit_hash_short: String::new(),
149 commit_date: String::new(),
150 commit_message: String::new(),
151 file_count: 0,
152 total_lines: 0,
153 });
154 }
155
156 let rev = match (&config.commit, &config.branch) {
157 (Some(c), _) => c.clone(),
158 (_, Some(b)) => b.clone(),
159 _ => "HEAD".to_string(),
160 };
161
162 let log_args = ["log", "-1", "--format=%H%n%ci%n%s", &rev];
164 let (branch, log_output) = tokio::join!(
165 async {
166 match &config.branch {
167 Some(b) => b.clone(),
168 None => run_git(repo_path, &["rev-parse", "--abbrev-ref", "HEAD"])
169 .await
170 .map(|s| s.trim().to_string())
171 .unwrap_or_else(|_| "detached".to_string()),
172 }
173 },
174 run_git(repo_path, &log_args),
175 );
176 let log_output = log_output?;
177
178 let mut lines = log_output.trim().lines();
179 let commit_hash = lines.next().unwrap_or("").to_string();
180 let commit_hash_short = commit_hash[..7.min(commit_hash.len())].to_string();
181 let commit_date = lines.next().unwrap_or("").to_string();
182 let commit_message = lines.collect::<Vec<_>>().join("\n");
183
184 Ok(RepoMetadata {
185 name,
186 branch,
187 commit_hash,
188 commit_hash_short,
189 commit_date,
190 commit_message,
191 file_count: 0,
192 total_lines: 0,
193 })
194}
195
196pub async fn list_tracked_files(
197 repo_path: &Path,
198 config: &Config,
199 is_git: bool,
200 scope: Option<&Path>,
201) -> Result<Vec<PathBuf>, Error> {
202 if !is_git {
203 return walk_files_async(repo_path.to_path_buf()).await;
204 }
205
206 let scope_str = scope.and_then(|p| p.to_str());
207 let output = match (&config.commit, &config.branch) {
208 (Some(commit), _) => match scope_str {
209 Some(s) => {
210 run_git(
211 repo_path,
212 &["ls-tree", "-r", "--name-only", commit, "--", s],
213 )
214 .await?
215 }
216 None => run_git(repo_path, &["ls-tree", "-r", "--name-only", commit]).await?,
217 },
218 (_, Some(branch)) => match scope_str {
219 Some(s) => {
220 run_git(
221 repo_path,
222 &["ls-tree", "-r", "--name-only", branch, "--", s],
223 )
224 .await?
225 }
226 None => run_git(repo_path, &["ls-tree", "-r", "--name-only", branch]).await?,
227 },
228 _ => match scope_str {
229 Some(s) => run_git(repo_path, &["ls-files", "--", s]).await?,
230 None => run_git(repo_path, &["ls-files"]).await?,
231 },
232 };
233
234 Ok(output
235 .lines()
236 .filter(|l| !l.is_empty())
237 .map(PathBuf::from)
238 .collect())
239}
240
241pub async fn file_last_modified_dates(
244 repo_path: &Path,
245 config: &Config,
246 is_git: bool,
247 scope: Option<&Path>,
248) -> Result<HashMap<PathBuf, String>, Error> {
249 if !is_git {
250 return walk_dates_async(repo_path.to_path_buf()).await;
251 }
252
253 let rev = match (&config.commit, &config.branch) {
254 (Some(c), _) => c.clone(),
255 (_, Some(b)) => b.clone(),
256 _ => "HEAD".to_string(),
257 };
258
259 let scope_str = scope.and_then(|p| p.to_str());
260 let output = match scope_str {
261 Some(s) => {
262 run_git(
263 repo_path,
264 &["log", "--format=COMMIT:%ci", "--name-only", &rev, "--", s],
265 )
266 .await?
267 }
268 None => {
269 run_git(
270 repo_path,
271 &["log", "--format=COMMIT:%ci", "--name-only", &rev],
272 )
273 .await?
274 }
275 };
276
277 let mut map = HashMap::new();
278 let mut current_date = String::new();
279
280 output.lines().for_each(|line| {
281 if let Some(date_str) = line.strip_prefix("COMMIT:") {
282 current_date = date_str.chars().take(10).collect();
283 } else if !line.is_empty() && !current_date.is_empty() {
284 map.entry(PathBuf::from(line))
285 .or_insert_with(|| current_date.clone());
286 }
287 });
288
289 Ok(map)
290}
291
292pub async fn file_last_modified(root: &Path, file: &Path, config: &Config, is_git: bool) -> String {
295 if is_git {
296 let rev = config
297 .commit
298 .as_deref()
299 .or(config.branch.as_deref())
300 .unwrap_or("HEAD");
301 let file_str = file.to_string_lossy();
302 run_git(
303 root,
304 &["log", "-1", "--format=%ci", rev, "--", file_str.as_ref()],
305 )
306 .await
307 .ok()
308 .map(|s| s.trim().chars().take(10).collect())
309 .unwrap_or_default()
310 } else {
311 tokio::fs::metadata(root.join(file))
312 .await
313 .ok()
314 .and_then(|m| m.modified().ok())
315 .map(|t| {
316 let secs = t.duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
317 let (y, m, d) = unix_secs_to_ymd(secs);
318 format!("{y:04}-{m:02}-{d:02}")
319 })
320 .unwrap_or_default()
321 }
322}
323
324pub async fn read_file_content(
325 repo_path: &Path,
326 file_path: &Path,
327 config: &Config,
328) -> Result<String, Error> {
329 let rev = config.commit.as_deref().or(config.branch.as_deref());
330 match rev {
331 Some(rev) => {
332 let spec = format!("{rev}:{}", file_path.display());
333 run_git(repo_path, &["show", &spec]).await
334 }
335 None => tokio::fs::read_to_string(repo_path.join(file_path))
336 .await
337 .map_err(Error::Io),
338 }
339}
340
341fn unix_secs_to_ymd(secs: u64) -> (u32, u32, u32) {
346 let z = (secs / 86400) as i64 + 719_468;
347 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
348 let doe = (z - era * 146_097) as u32;
349 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
350 let y = yoe as i64 + era * 400;
351 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
352 let mp = (5 * doy + 2) / 153;
353 let d = doy - (153 * mp + 2) / 5 + 1;
354 let m = if mp < 10 { mp + 3 } else { mp - 9 };
355 let y = if m <= 2 { y + 1 } else { y };
356 (y as u32, m, d)
357}
358
359fn walk_files_inner(
363 root: Arc<PathBuf>,
364 dir: PathBuf,
365) -> Pin<Box<dyn Future<Output = Result<Vec<PathBuf>, Error>> + Send>> {
366 Box::pin(async move {
367 let mut rd = tokio::fs::read_dir(&dir).await.map_err(Error::Io)?;
368 let mut files: Vec<PathBuf> = Vec::new();
369 let mut set: tokio::task::JoinSet<Result<Vec<PathBuf>, Error>> =
370 tokio::task::JoinSet::new();
371
372 while let Some(entry) = rd.next_entry().await.map_err(Error::Io)? {
373 let ft = entry.file_type().await.map_err(Error::Io)?;
374 if ft.is_dir() {
375 set.spawn(walk_files_inner(Arc::clone(&root), entry.path()));
376 } else if ft.is_file()
377 && let Ok(rel) = entry.path().strip_prefix(root.as_ref())
378 {
379 files.push(rel.to_path_buf());
380 }
381 }
382
383 set.join_all()
384 .await
385 .into_iter()
386 .try_for_each(|res| res.map(|sub| files.extend(sub)))?;
387
388 Ok(files)
389 })
390}
391
392async fn walk_files_async(root: PathBuf) -> Result<Vec<PathBuf>, Error> {
393 walk_files_inner(Arc::new(root.clone()), root).await
394}
395
396async fn walk_dates_async(root: PathBuf) -> Result<HashMap<PathBuf, String>, Error> {
398 let files = walk_files_async(root.clone()).await?;
399 let mut set: tokio::task::JoinSet<Option<(PathBuf, String)>> = tokio::task::JoinSet::new();
400
401 files.into_iter().for_each(|rel| {
402 let abs = root.join(&rel);
403 set.spawn(async move {
404 let date = tokio::fs::metadata(&abs)
405 .await
406 .ok()
407 .and_then(|m| m.modified().ok())
408 .map(|t| {
409 let secs = t.duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
410 let (y, m, d) = unix_secs_to_ymd(secs);
411 format!("{y:04}-{m:02}-{d:02}")
412 })?;
413 Some((rel, date))
414 });
415 });
416
417 Ok(set.join_all().await.into_iter().flatten().collect())
418}