1use std::path::{Path, PathBuf};
10use std::process::Command;
11use std::sync::mpsc;
12use std::time::Duration;
13
14const PROBE_TIMEOUT: Duration = Duration::from_secs(3);
17const AUTHOR_SCAN_LIMIT: &str = "2000";
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum Vcs {
23 Git,
24 Plastic,
26 Mercurial,
27 Svn,
28 Fossil,
29 Jujutsu,
30 None,
32}
33
34impl Vcs {
35 pub fn name(self) -> &'static str {
37 match self {
38 Vcs::Git => "git",
39 Vcs::Plastic => "plastic",
40 Vcs::Mercurial => "mercurial",
41 Vcs::Svn => "svn",
42 Vcs::Fossil => "fossil",
43 Vcs::Jujutsu => "jujutsu",
44 Vcs::None => "none",
45 }
46 }
47}
48
49pub fn detect(start: &Path) -> Vcs {
53 let abs = std::fs::canonicalize(start).unwrap_or_else(|_| start.to_path_buf());
54 let mut cur: Option<PathBuf> = Some(abs);
55 while let Some(dir) = cur {
56 for (marker, vcs) in [
58 (".git", Vcs::Git),
59 (".plastic", Vcs::Plastic),
60 (".hg", Vcs::Mercurial),
61 (".svn", Vcs::Svn),
62 (".jj", Vcs::Jujutsu),
63 ] {
64 if dir.join(marker).exists() {
65 return vcs;
66 }
67 }
68 if dir.join(".fslckout").exists() || dir.join("_FOSSIL_").exists() {
70 return Vcs::Fossil;
71 }
72 cur = dir.parent().map(Path::to_path_buf);
73 }
74 Vcs::None
75}
76
77pub fn identity(start: &Path) -> Option<String> {
80 match detect(start) {
81 Vcs::Git => git_identity(start),
82 Vcs::Plastic => plastic_identity(start),
83 Vcs::Mercurial => hg_identity(start),
84 Vcs::Fossil => fossil_identity(start),
85 Vcs::Jujutsu => jj_identity(start),
86 Vcs::Svn | Vcs::None => None,
88 }
89}
90
91pub fn authors(start: &Path) -> Vec<(String, String)> {
94 match detect(start) {
95 Vcs::Git => git_authors(start),
96 Vcs::Mercurial => hg_authors(start),
97 _ => Vec::new(),
98 }
99}
100
101pub fn system_user() -> Option<String> {
104 std::env::var("USERNAME")
105 .or_else(|_| std::env::var("USER"))
106 .ok()
107 .map(|s| s.trim().to_string())
108 .filter(|s| !s.is_empty())
109}
110
111pub(crate) fn plain(root: &Path) -> PathBuf {
117 let s = root.to_string_lossy();
118 if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
119 return PathBuf::from(format!(r"\\{rest}"));
120 }
121 match s.strip_prefix(r"\\?\") {
122 Some(rest) => PathBuf::from(rest),
123 None => root.to_path_buf(),
124 }
125}
126
127fn run_timed(mut cmd: Command) -> Option<String> {
131 let (tx, rx) = mpsc::channel();
132 std::thread::spawn(move || {
133 let _ = tx.send(cmd.output());
134 });
135 match rx.recv_timeout(PROBE_TIMEOUT) {
136 Ok(Ok(out)) => finish(out),
137 _ => None,
138 }
139}
140
141fn output_in(dir: &Path, program: &str, args: &[&str]) -> Option<String> {
143 let mut cmd = Command::new(program);
144 cmd.arg("-C").arg(plain(dir)).args(args);
145 run_timed(cmd)
146}
147
148fn output_cwd(dir: &Path, program: &str, args: &[&str]) -> Option<String> {
151 let mut cmd = Command::new(program);
152 cmd.current_dir(plain(dir)).args(args);
153 run_timed(cmd)
154}
155
156fn finish(out: std::process::Output) -> Option<String> {
157 if !out.status.success() {
158 return None;
159 }
160 let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
161 if s.is_empty() {
162 None
163 } else {
164 Some(s)
165 }
166}
167
168fn combine(name: Option<String>, email: Option<String>) -> Option<String> {
169 match (name, email) {
170 (Some(n), Some(e)) => Some(format!("{n} <{e}>")),
171 (Some(n), None) => Some(n),
172 (None, Some(e)) => Some(e),
173 (None, None) => None,
174 }
175}
176
177fn git_identity(root: &Path) -> Option<String> {
178 let name = output_in(root, "git", &["config", "user.name"]);
179 let email = output_in(root, "git", &["config", "user.email"]);
180 combine(name, email)
181}
182
183fn git_authors(root: &Path) -> Vec<(String, String)> {
184 let Some(out) = output_in(
185 root,
186 "git",
187 &[
188 "--no-pager",
189 "log",
190 "-n",
191 AUTHOR_SCAN_LIMIT,
192 "--format=%an\t%ae",
193 ],
194 ) else {
195 return Vec::new();
196 };
197 let mut seen = std::collections::HashSet::new();
198 let mut authors = Vec::new();
199 for line in out.lines() {
200 if let Some((name, email)) = line.split_once('\t') {
201 if seen.insert(email.to_string()) {
202 authors.push((name.to_string(), email.to_string()));
203 }
204 }
205 }
206 authors
207}
208
209fn plastic_identity(root: &Path) -> Option<String> {
210 output_cwd(root, "cm", &["whoami"])
212}
213
214fn hg_identity(root: &Path) -> Option<String> {
215 output_in(root, "hg", &["config", "ui.username"])
217}
218
219fn hg_authors(root: &Path) -> Vec<(String, String)> {
220 let Some(out) = output_in(
221 root,
222 "hg",
223 &["log", "-l", AUTHOR_SCAN_LIMIT, "--template", "{author}\n"],
224 ) else {
225 return Vec::new();
226 };
227 let mut seen = std::collections::HashSet::new();
228 let mut authors = Vec::new();
229 for line in out.lines() {
230 let line = line.trim();
231 if line.is_empty() {
232 continue;
233 }
234 let (name, email) = match (line.find('<'), line.find('>')) {
236 (Some(a), Some(b)) if b > a => {
237 (line[..a].trim().to_string(), line[a + 1..b].to_string())
238 }
239 _ => (line.to_string(), line.to_string()),
240 };
241 if seen.insert(email.clone()) {
242 authors.push((name, email));
243 }
244 }
245 authors
246}
247
248fn fossil_identity(root: &Path) -> Option<String> {
249 output_cwd(root, "fossil", &["user", "default"])
250}
251
252fn jj_identity(root: &Path) -> Option<String> {
253 let name = output_cwd(root, "jj", &["config", "get", "user.name"]);
254 let email = output_cwd(root, "jj", &["config", "get", "user.email"]);
255 combine(name, email)
256}
257
258#[cfg(test)]
259mod tests {
260 use super::*;
261
262 #[test]
263 fn detects_git_and_resolves_identity() {
264 let dir = tempfile::tempdir().unwrap();
265 let root = dir.path();
266 let git = |args: &[&str]| {
267 Command::new("git")
268 .arg("-C")
269 .arg(root)
270 .args(args)
271 .output()
272 .unwrap();
273 };
274 git(&["init", "-q"]);
275 git(&["config", "user.name", "Ada Lovelace"]);
276 git(&["config", "user.email", "ada@example.com"]);
277
278 assert_eq!(detect(root), Vcs::Git);
279 assert_eq!(
280 identity(root).as_deref(),
281 Some("Ada Lovelace <ada@example.com>")
282 );
283 }
284
285 #[test]
286 fn no_vcs_yields_none() {
287 let dir = tempfile::tempdir().unwrap();
288 assert_eq!(detect(dir.path()), Vcs::None);
289 assert_eq!(identity(dir.path()), None);
290 }
291
292 #[test]
293 fn vcs_names_are_stable() {
294 assert_eq!(Vcs::Plastic.name(), "plastic");
295 assert_eq!(Vcs::Git.name(), "git");
296 assert_eq!(Vcs::None.name(), "none");
297 }
298}