1use std::fmt;
2use std::io::{self, ErrorKind};
3use std::path::Path;
4use std::process::{Command, Stdio};
5
6pub const NO_EDITOR: &str = "set $VISUAL or $EDITOR to open the file";
7
8#[derive(Clone, Debug, PartialEq, Eq)]
9pub enum NoHead {
10 NoGit,
11 NotRepo,
12 Untracked,
13 Failed(String),
14}
15
16impl fmt::Display for NoHead {
17 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
18 match self {
19 NoHead::NoGit => f.write_str("git is not on PATH"),
20 NoHead::NotRepo => f.write_str("the file is not in a git repository"),
21 NoHead::Untracked => f.write_str("git HEAD has no such file"),
22 NoHead::Failed(why) => write!(f, "git HEAD cannot be read: {why}"),
23 }
24 }
25}
26
27pub trait Head {
28 fn show(&self, file: &Path) -> Result<String, NoHead>;
29}
30
31#[derive(Clone, Copy, Debug, Default)]
32pub struct Git;
33
34fn git(dir: &Path) -> Command {
35 let mut command = Command::new("git");
36 command.current_dir(dir).stdin(Stdio::null());
37 command
38}
39
40fn spawned(error: io::Error) -> NoHead {
41 if error.kind() == ErrorKind::NotFound {
42 NoHead::NoGit
43 } else {
44 NoHead::Failed(error.to_string())
45 }
46}
47
48impl Head for Git {
49 fn show(&self, file: &Path) -> Result<String, NoHead> {
50 let dir = file
51 .parent()
52 .filter(|dir| !dir.as_os_str().is_empty())
53 .unwrap_or(Path::new("."));
54 let name = file.file_name().ok_or(NoHead::Untracked)?;
55 if !dir.is_dir() {
56 return Err(NoHead::NotRepo);
57 }
58 let shown = git(dir)
59 .arg("show")
60 .arg(format!("HEAD:./{}", name.to_string_lossy()))
61 .output()
62 .map_err(spawned)?;
63 if shown.status.success() {
64 return String::from_utf8(shown.stdout)
65 .map_err(|_| NoHead::Failed("the file is not UTF-8".into()));
66 }
67 let inside = git(dir)
68 .args(["rev-parse", "--is-inside-work-tree"])
69 .output()
70 .map_err(spawned)?;
71 Err(if inside.status.success() {
72 NoHead::Untracked
73 } else {
74 NoHead::NotRepo
75 })
76 }
77}
78
79pub trait Opener {
80 fn command(&self) -> Option<String>;
81 fn open(&self, command: &str, file: &Path) -> io::Result<()>;
82}
83
84#[derive(Clone, Copy, Debug, Default)]
85pub struct Process;
86
87pub fn pick(visual: Option<String>, editor: Option<String>) -> Option<String> {
88 [visual, editor]
89 .into_iter()
90 .flatten()
91 .find(|value| !value.trim().is_empty())
92}
93
94impl Opener for Process {
95 fn command(&self) -> Option<String> {
96 pick(std::env::var("VISUAL").ok(), std::env::var("EDITOR").ok())
97 }
98
99 fn open(&self, command: &str, file: &Path) -> io::Result<()> {
100 #[cfg(unix)]
101 let mut run = {
102 let mut run = Command::new("sh");
103 run.arg("-c")
104 .arg(format!("{command} \"$1\""))
105 .arg("sh")
106 .arg(file);
107 run
108 };
109 #[cfg(windows)]
110 let mut run = {
111 let mut run = Command::new("cmd");
112 run.arg("/C").arg(command).arg(file);
113 run
114 };
115 let mut child = run
116 .stdin(Stdio::null())
117 .stdout(Stdio::null())
118 .stderr(Stdio::null())
119 .spawn()?;
120 std::thread::spawn(move || {
121 let _ = child.wait();
122 });
123 Ok(())
124 }
125}
126
127#[cfg(test)]
128mod tests {
129 use super::*;
130
131 #[test]
132 fn visual_wins_over_editor_and_blanks_are_skipped() {
133 let some = |text: &str| Some(text.to_string());
134 assert_eq!(pick(some("code -w"), some("vi")), some("code -w"));
135 assert_eq!(pick(None, some("vi")), some("vi"));
136 assert_eq!(pick(some(" "), some("vi")), some("vi"));
137 assert_eq!(pick(some(""), None), None);
138 assert_eq!(pick(None, None), None);
139 }
140
141 #[test]
142 fn a_missing_folder_is_not_a_repository() {
143 let gone = Path::new("tmp/no-such-folder/scene.toml");
144 assert_eq!(Git.show(gone), Err(NoHead::NotRepo));
145 }
146
147 #[test]
148 fn the_reasons_read_plainly() {
149 assert_eq!(NoHead::NoGit.to_string(), "git is not on PATH");
150 assert!(NoHead::NotRepo.to_string().contains("repository"));
151 }
152}