1mod log_macros;
2
3use clap::{CommandFactory, Parser, Subcommand};
4use core::fmt::Arguments;
5use duct::cmd;
6use easy_error::{self, bail, ResultExt};
7use lazy_static::lazy_static;
8use regex::{Regex, RegexBuilder};
9use serde::Deserialize;
10use std::collections::HashMap;
11use std::error::Error;
12use std::fs;
13use std::{env, path::PathBuf};
14
15lazy_static! {
16 static ref RE_ORIGIN: Regex =
17 RegexBuilder::new("^(?P<name>[a-zA-Z0-9\\-]+)\\s+(?P<repo>.*)\\s+\\(fetch\\)$")
18 .multi_line(true)
19 .build()
20 .unwrap();
21 static ref RE_SSH: Regex =
22 RegexBuilder::new("^git@(?P<domain>[a-z0-9\\-\\.]+):(?P<user>[a-zA-Z0-9\\-_]+)/(?P<project>[a-zA-Z0-9\\-_]+)\\.git$")
23 .build()
24 .unwrap();
25 static ref RE_HTTPS: Regex =
26 RegexBuilder::new("^https://([a-zA-Z0-9\\-_]+@)?(?P<domain>[a-z0-9\\-\\.]+)/(?P<user>[a-zA-Z0-9\\-_]+)/(?P<project>[a-zA-Z0-9\\-_]+)\\.git$")
27 .build()
28 .unwrap();
29 static ref RE_FILE: Regex = RegexBuilder::new("^file://.*$").build().unwrap();
30}
31
32const DEFAULT_CUSTOMIZER_NAME: &str = "customize.ts";
33
34#[derive(Debug, Parser)]
36#[clap(version, about, long_about = None)]
37struct Cli {
38 #[clap(subcommand)]
39 command: Commands,
40}
41
42#[derive(Debug, Subcommand)]
43enum Commands {
44 Browse {
46 #[clap(long)]
48 origin: Option<String>,
49 },
50 QuickStart {
52 #[clap(subcommand)]
54 quick_start: QuickStartCommands,
55 },
56}
57
58#[derive(Debug, Subcommand)]
59enum QuickStartCommands {
60 List {
62 #[clap(short, long)]
63 list: bool,
64 },
65 Create {
67 url_or_name: String,
69 directory: String,
71 #[clap(short, long)]
73 customizer: Option<String>,
74 },
75}
76
77#[derive(Debug, Deserialize)]
78struct ReposFile {
79 #[serde(flatten)]
80 repos: HashMap<String, RepoEntry>,
81}
82
83#[derive(Debug, Deserialize)]
84struct RepoEntry {
85 description: Option<String>,
86 origin: String,
87 customizer: Option<String>,
88}
89
90pub trait GitExtraLog {
91 fn output(self: &Self, args: Arguments);
92 fn warning(self: &Self, args: Arguments);
93 fn error(self: &Self, args: Arguments);
94}
95
96pub struct GitExtraTool<'a> {
97 log: &'a dyn GitExtraLog,
98}
99
100impl<'a> GitExtraTool<'a> {
101 pub fn new(log: &'a dyn GitExtraLog) -> GitExtraTool<'a> {
102 GitExtraTool { log }
103 }
104
105 pub fn run(
106 self: &mut Self,
107 args: impl IntoIterator<Item = std::ffi::OsString>,
108 ) -> Result<(), Box<dyn Error>> {
109 let matches = match Cli::command().try_get_matches_from(args) {
110 Ok(m) => m,
111 Err(err) => {
112 output!(self.log, "{}", err.to_string());
113 return Ok(());
114 }
115 };
116 use clap::FromArgMatches;
117 let cli = Cli::from_arg_matches(&matches)?;
118
119 match &cli.command {
120 Commands::Browse { origin } => {
121 self.browse_to_remote(&origin)?;
122 }
123 Commands::QuickStart { quick_start } => match quick_start {
124 QuickStartCommands::List { list: _ } => {
125 self.quick_start_list()?;
126 }
127 QuickStartCommands::Create {
128 url_or_name,
129 directory,
130 customizer,
131 } => {
132 self.quick_start_create(url_or_name, directory, customizer)?;
133 }
134 },
135 }
136
137 Ok(())
138 }
139
140 fn browse_to_remote(self: &Self, origin: &Option<String>) -> Result<(), Box<dyn Error>> {
141 let origin_name = match origin {
142 Some(s) => s.to_owned(),
143 None => "origin".to_string(),
144 };
145 let output = cmd!("git", "remote", "-vv").read()?;
146
147 for cap_origin in RE_ORIGIN.captures_iter(&output) {
148 if &cap_origin["name"] != origin_name {
149 continue;
150 }
151
152 match RE_SSH
153 .captures(&cap_origin["repo"])
154 .or(RE_HTTPS.captures(&cap_origin["repo"]))
155 {
156 Some(cap_repo) => {
157 let url = format!(
158 "https://{}/{}/{}",
159 &cap_repo["domain"], &cap_repo["user"], &cap_repo["project"]
160 );
161 output!(self.log, "Opening URL '{}'", url);
162 opener::open_browser(url)?;
163 return Ok(());
164 }
165 None => continue,
166 }
167 }
168
169 Ok(())
170 }
171
172 fn read_repos_file(self: &Self) -> Result<ReposFile, Box<dyn Error>> {
173 let mut repos_file = PathBuf::from(env::var("HOME")?);
174
175 repos_file.push(".config/git_extra/repos.toml");
176
177 match fs::read_to_string(repos_file.as_path()) {
178 Ok(s) => Ok(toml::from_str(&s)?),
179 Err(_) => {
180 warning!(self.log, "'{}' not found", repos_file.to_string_lossy());
181 Ok(ReposFile {
182 repos: HashMap::new(),
183 })
184 }
185 }
186 }
187
188 fn quick_start_list(self: &Self) -> Result<(), Box<dyn Error>> {
189 let file = self.read_repos_file()?;
190
191 if !file.repos.is_empty() {
192 use colored::Colorize;
193
194 let width = file.repos.keys().map(|s| s.len()).max().unwrap() + 3;
195 let empty_string = "".to_string();
196
197 for (name, entry) in file.repos.iter() {
198 output!(
199 self.log,
200 "{:width$} {}\n{:width$} {}",
201 name,
202 &entry.origin,
203 "",
204 entry
205 .description
206 .as_ref()
207 .unwrap_or(&empty_string)
208 .bright_white(),
209 );
210 }
211 }
212
213 Ok(())
214 }
215
216 fn quick_start_create(
217 self: &Self,
218 opt_url_or_name: &String,
219 opt_dir: &String,
220 opt_customizer: &Option<String>,
221 ) -> Result<(), Box<dyn Error>> {
222 let file = self.read_repos_file()?;
223 let url: String;
224
225 let mut customizer_file_name = String::new();
227
228 if RE_SSH.is_match(opt_url_or_name) || RE_HTTPS.is_match(opt_url_or_name) {
229 url = opt_url_or_name.to_owned();
230 } else if RE_FILE.is_match(opt_url_or_name) {
231 url = opt_url_or_name.clone().split_off("file://".len());
232 } else if let Some(entry) = file.repos.get(opt_url_or_name) {
233 url = entry.origin.to_owned();
234
235 customizer_file_name = opt_customizer.as_ref().map_or(
237 entry
238 .customizer
239 .as_ref()
240 .map_or(DEFAULT_CUSTOMIZER_NAME.to_string(), |e| e.to_owned()),
241 |e| e.to_owned(),
242 );
243 } else {
244 bail!(
245 "Repository name '{}' must start with https://, git@ or file://",
246 opt_url_or_name
247 );
248 }
249
250 if customizer_file_name.is_empty() {
251 customizer_file_name = opt_customizer
252 .as_ref()
253 .map_or(DEFAULT_CUSTOMIZER_NAME.to_string(), |e| e.to_owned());
254 }
255
256 let new_dir_path = PathBuf::from(opt_dir);
257 let customizer_file_path = new_dir_path.join(&customizer_file_name);
258
259 cmd!("git", "clone", url.as_str(), new_dir_path.as_path())
260 .run()
261 .context(format!("Unable to run `git clone` for '{}'", url.as_str()))?;
262
263 if let Ok(_) = fs::File::open(&customizer_file_path) {
264 output!(self.log, "Running the customization script");
265
266 cmd!(&customizer_file_path, new_dir_path.file_name().unwrap())
267 .dir(new_dir_path.as_path())
268 .run()
269 .context(format!(
270 "There was a problem running customizer file '{}'",
271 customizer_file_path.to_string_lossy()
272 ))?;
273 } else {
274 warning!(
275 self.log,
276 "Customization file '{}' not found",
277 customizer_file_path.to_string_lossy()
278 )
279 }
280
281 Ok(())
282 }
283}
284
285#[cfg(test)]
286mod tests {
287 use super::*;
288
289 #[test]
290 fn basic_test() {
291 struct TestLogger;
292
293 impl TestLogger {
294 fn new() -> TestLogger {
295 TestLogger {}
296 }
297 }
298
299 impl GitExtraLog for TestLogger {
300 fn output(self: &Self, _args: Arguments) {}
301 fn warning(self: &Self, _args: Arguments) {}
302 fn error(self: &Self, _args: Arguments) {}
303 }
304
305 let logger = TestLogger::new();
306 let mut tool = GitExtraTool::new(&logger);
307 let args: Vec<std::ffi::OsString> = vec!["".into(), "--help".into()];
308
309 tool.run(args).unwrap();
310 }
311}