git_extra/
lib.rs

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/// The git_extra CLI
35#[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 to origin repository web page
45    Browse {
46        /// Override name of the origin repository
47        #[clap(long)]
48        origin: Option<String>,
49    },
50    /// Commands to quickly start projects
51    QuickStart {
52        /// Quick start sub-commands
53        #[clap(subcommand)]
54        quick_start: QuickStartCommands,
55    },
56}
57
58#[derive(Debug, Subcommand)]
59enum QuickStartCommands {
60    /// List all available named repositories in your `$HOME/.config/git_extra/repos.toml`
61    List {
62        #[clap(short, long)]
63        list: bool,
64    },
65    /// Create a new project by cloning a repo and running a customization script
66    Create {
67        /// A name or URL of a Git repository to clone
68        url_or_name: String,
69        /// Name of the directory to clone the repo into
70        directory: String,
71        /// The name of the customization file relative to the new project directory
72        #[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        // Customizer is command line or default
226        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 is command line, file entry or default
236            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}