From 2bedfce479f5a386b8dce4fb20536cd5cbd788b6 Mon Sep 17 00:00:00 2001 From: Vladislav Mamon Date: Fri, 1 Mar 2024 20:00:28 +0300 Subject: [PATCH] refactor: redesign cli and make it more shell-friendly I decided to switch to command-based design and get rid of `file:` prefix for local repositories. This allows: - Get rid of programmatic shell expansion and simply leverage shell capabilities. - Make use of proper tab completions since now there's no prefix that'd been breaking such functionality, which was rather annoying. Also refactored code a bit and slightly simplified/reorganized it. --- Cargo.toml | 2 - src/app.rs | 172 ++++++++++++++++++------------- src/main.rs | 3 +- src/path/expand.rs | 24 ----- src/path/mod.rs | 2 - src/path/utils.rs | 12 --- src/repository.rs | 250 ++++++++++++++++++--------------------------- 7 files changed, 203 insertions(+), 262 deletions(-) delete mode 100644 src/path/expand.rs diff --git a/Cargo.toml b/Cargo.toml index 4be24b3..300122d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,11 +16,9 @@ flate2 = { version = "1.0.28" } git2 = { version = "0.18.1", features = ["vendored-libgit2"] } glob-match = { version = "0.2.1" } indoc = "2.0.4" -itertools = { version = "0.12.0" } kdl = { version = "4.6.0" } reqwest = { version = "0.11.22", features = ["json"] } run_script = { version = "0.10.1" } -shellexpand = { version = "3.1.0", features = ["full"] } tar = { version = "0.4.40" } thiserror = { version = "1.0.51" } tokio = { version = "1.35.0", features = ["macros", "fs", "rt-multi-thread"] } diff --git a/src/app.rs b/src/app.rs index 94c3d90..a90b140 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,101 +1,133 @@ use std::fs; use std::path::PathBuf; -use std::str::FromStr; -use clap::Parser; +use clap::{Parser, Subcommand}; use crate::manifest::Manifest; -use crate::path::PathUtils; -use crate::repository::{Repository, RepositoryMeta}; +use crate::repository::{LocalRepository, RemoteRepository}; use crate::unpacker::Unpacker; #[derive(Parser, Debug)] -#[clap(version, about, long_about = None)] +#[command(version, about, long_about = None)] pub struct Cli { - /// Repository to download. - #[clap(name = "target")] - pub target: String, + #[command(subcommand)] + pub command: BaseCommands, - /// Directory to download to. - #[clap(name = "path")] - pub path: Option, - - /// Delete arx config after download. - #[clap(short, long, display_order = 1)] + /// Delete arx config after scaffolding. + #[arg(short, long)] pub delete: bool, +} - /// Download using specified ref (branch, tag, commit). - #[clap(short, long, display_order = 3)] - pub meta: Option, +#[derive(Debug, Subcommand)] +pub enum BaseCommands { + /// Scaffold from a remote repository. + Remote { + /// Template repository to use for scaffolding. + src: String, + + /// Directory to scaffold to. + path: Option, + + /// Scaffold from a specified ref (branch, tag, or commit). + #[arg(name = "REF", short = 'r', long = "ref")] + meta: Option, + }, + /// Scaffold from a local repository. + Local { + /// Template repository to use for scaffolding. + src: String, + + /// Directory to scaffold to. + path: Option, + + /// Scaffold from a specified ref (branch, tag, or commit). + #[arg(name = "REF", short = 'r', long = "ref")] + meta: Option, + }, } -pub struct App; +impl BaseCommands { + pub fn path(&self) -> Option { + match self { + | BaseCommands::Remote { path, .. } | BaseCommands::Local { path, .. } => { + path.as_ref().map(PathBuf::from) + }, + } + } +} + +#[derive(Debug)] +pub struct App { + cli: Cli, +} impl App { pub fn new() -> Self { - Self + Self { cli: Cli::parse() } } - pub async fn run(&mut self) -> anyhow::Result<()> { - // Parse CLI options. - let options = Cli::parse(); + pub async fn run(self) -> anyhow::Result<()> { + // TODO: For `Remote` and `Local` variants check if destination already exists before + // downloading or performing local clone. + if let Some(path) = &self.cli.command.path() { + todo!("Check if destination {path:?} already exists"); + } + + match self.cli.command { + | BaseCommands::Remote { src, path, meta } => Self::remote(src, path, meta).await, + | BaseCommands::Local { src, path, meta } => Self::local(src, path, meta).await, + } + } - // Parse repository information from the CLI argument. - let repository = Repository::from_str(&options.target)?; + /// Preparation flow for remote repositories. + async fn remote(src: String, path: Option, meta: Option) -> anyhow::Result<()> { + // Parse repository. + let remote = RemoteRepository::new(src, meta)?; - // Check if any specific meta (ref) was passed, if so, then use it; otherwise use parsed meta. - let meta = options.meta.map_or(repository.meta(), RepositoryMeta); - let repository = repository.with_meta(meta); + let name = path.unwrap_or(remote.repo.clone()); + let destination = PathBuf::from(name); - // TODO: Check if destination already exists before downloading or performing local clone. + // Fetch the tarball as bytes (compressed). + let tarball = remote.fetch().await?; - // Depending on the repository type, either download and unpack or make a local clone. - let destination = match repository { - | Repository::Remote(remote) => { - let name = options.path.unwrap_or(remote.repo.clone()); - let destination = PathBuf::from(name); + // Decompress and unpack the tarball. + let unpacker = Unpacker::new(tarball); + unpacker.unpack_to(&destination)?; - // Fetch the tarball as bytes (compressed). - let tarball = remote.fetch().await?; + // Now we need to read the manifest (if it is present). + let mut manifest = Manifest::with_options(&destination); + manifest.load()?; - // Decompress and unpack the tarball. - let unpacker = Unpacker::new(tarball); - unpacker.unpack_to(&destination)?; + Ok(()) + } - destination - }, - | Repository::Local(local) => { - // TODO: Check if source exists and valid. - let source = local.source.clone().expand(); - - let destination = if let Some(destination) = options.path { - PathBuf::from(destination).expand() - } else { - source - .file_name() - .map(|name| name.into()) - .unwrap_or_default() - }; - - // Copy the directory. - local.copy(&destination)?; - local.checkout(&destination)?; - - // Delete inner .git. - let inner_git = destination.join(".git"); - - if inner_git.exists() { - println!("Removing {}", inner_git.display()); - fs::remove_dir_all(inner_git)?; - } - - // TODO: Check if source is a plain directory or git repo. If the latter, then we should - // also do a checkout. - - destination - }, + /// Preparation flow for local repositories. + async fn local(src: String, path: Option, meta: Option) -> anyhow::Result<()> { + // Create repository. + let local = LocalRepository::new(src, meta); + + let destination = if let Some(destination) = path { + PathBuf::from(destination) + } else { + local + .source + .file_name() + .map(|name| name.into()) + .unwrap_or_default() }; + // Copy the directory. + local.copy(&destination)?; + local.checkout(&destination)?; + + // Delete inner .git. + let inner_git = destination.join(".git"); + + if inner_git.exists() { + println!("Removing {}", inner_git.display()); + fs::remove_dir_all(inner_git)?; + } + // Now we need to read the manifest (if it is present). let mut manifest = Manifest::with_options(&destination); manifest.load()?; diff --git a/src/main.rs b/src/main.rs index 92a7078..94dc2b3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,5 @@ use arx::app::App; #[tokio::main] async fn main() -> anyhow::Result<()> { - let mut app = App::new(); - app.run().await + App::new().run().await } diff --git a/src/path/expand.rs b/src/path/expand.rs deleted file mode 100644 index f558ca0..0000000 --- a/src/path/expand.rs +++ /dev/null @@ -1,24 +0,0 @@ -use std::env::{self, VarError}; - -fn home() -> Option { - Some( - dirs::home_dir() - .map(|path| path.display().to_string()) - .unwrap_or_else(|| "~".to_string()), - ) -} - -fn context(name: &str) -> Result, VarError> { - match env::var(name) { - | Ok(value) => Ok(Some(value)), - | Err(VarError::NotPresent) => Ok(Some("".into())), - | Err(err) => Err(err), - } -} - -/// Expands tilde and environment variables in given `path`. -pub fn expand(path: &str) -> String { - shellexpand::full_with_context(path, home, context) - .map(|expanded| expanded.to_string()) - .unwrap_or_else(|_| path.to_string()) -} diff --git a/src/path/mod.rs b/src/path/mod.rs index bd41509..1cbe237 100644 --- a/src/path/mod.rs +++ b/src/path/mod.rs @@ -1,5 +1,3 @@ -pub use expand::*; pub use utils::*; -mod expand; mod utils; diff --git a/src/path/utils.rs b/src/path/utils.rs index 59ea72d..af3cb4b 100644 --- a/src/path/utils.rs +++ b/src/path/utils.rs @@ -1,13 +1,8 @@ use std::path::{Path, PathBuf}; -use crate::path::expand; - pub trait PathUtils { /// Given `root`, returns `root` if `self` is `.`, otherwise returns `self`. fn to_root>(&self, root: P) -> PathBuf; - - /// Expands tilde and environment variables in given `path`. - fn expand(&self) -> PathBuf; } impl PathUtils for Path { @@ -18,11 +13,4 @@ impl PathUtils for Path { self.to_path_buf() } } - - fn expand(&self) -> PathBuf { - let path = self.display().to_string(); - let expanded = expand(&path); - - PathBuf::from(expanded) - } } diff --git a/src/repository.rs b/src/repository.rs index fb15001..fe2af3c 100644 --- a/src/repository.rs +++ b/src/repository.rs @@ -8,7 +8,6 @@ use git2::Repository as GitRepository; use thiserror::Error; use crate::fs::Traverser; -use crate::path::PathUtils; #[derive(Debug, Error, PartialEq)] pub enum ParseError { @@ -104,12 +103,12 @@ pub struct RemoteRepository { } impl RemoteRepository { - /// Returns a list of valid host prefixes. - pub fn prefixes() -> Vec { - vec!["github", "gh", "gitlab", "gl", "bitbucket", "bb"] - .into_iter() - .map(str::to_string) - .collect() + /// Creates new `RemoteRepository`. + pub fn new(target: String, meta: Option) -> Result { + let repo = Self::from_str(&target)?; + let meta = meta.map_or(repo.meta, RepositoryMeta); + + Ok(Self { meta, ..repo }) } /// Resolves a URL depending on the host and other repository fields. @@ -160,6 +159,76 @@ impl RemoteRepository { } } +impl FromStr for RemoteRepository { + type Err = ParseError; + + /// Parses a `&str` into a `RemoteRepository`. + fn from_str(input: &str) -> Result { + #[inline(always)] + fn is_valid_user(ch: char) -> bool { + ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' + } + + #[inline(always)] + fn is_valid_repo(ch: char) -> bool { + is_valid_user(ch) || ch == '.' + } + + // TODO: Handle an edge case with multuple slashes in the repository name. + + let input = input.trim(); + + // Parse host if present or use default otherwise. + let (host, input) = if let Some((host, rest)) = input.split_once(':') { + match host.to_ascii_lowercase().as_str() { + | "github" | "gh" => (RepositoryHost::GitHub, rest), + | "gitlab" | "gl" => (RepositoryHost::GitLab, rest), + | "bitbucket" | "bb" => (RepositoryHost::BitBucket, rest), + | _ => return Err(ParseError::InvalidHost), + } + } else { + (RepositoryHost::default(), input) + }; + + // Parse user name. + let (user, input) = if let Some((user, rest)) = input.split_once('/') { + if user.chars().all(is_valid_user) { + (user.to_string(), rest) + } else { + return Err(ParseError::InvalidUserName); + } + } else { + return Err(ParseError::MissingRepositoryName); + }; + + // Parse repository name. + let (repo, input) = if let Some((repo, rest)) = input.split_once('#') { + if repo.chars().all(is_valid_repo) { + (repo.to_string(), Some(rest)) + } else { + return Err(ParseError::InvalidRepositoryName); + } + } else { + (input.to_string(), None) + }; + + // Produce meta if anything left from the input. Empty meta is accepted but ignored, default + // value is used then. + let meta = input + .filter(|input| !input.is_empty()) + .map_or(RepositoryMeta::default(), |input| { + RepositoryMeta(input.to_string()) + }); + + Ok(RemoteRepository { + host, + user, + repo, + meta, + }) + } +} + /// Represents a local repository. Repositories of this kind don't need to be downloaded, we can /// simply clone them locally and switch to desired meta (ref). #[derive(Debug, PartialEq)] @@ -169,16 +238,17 @@ pub struct LocalRepository { } impl LocalRepository { - /// Returns a list of valid prefixes that can be used to identify local repositories. - pub fn prefixes() -> [&'static str; 2] { - ["file", "local"] + /// Creates new `LocalRepository`. + pub fn new(source: String, meta: Option) -> Self { + Self { + source: PathBuf::from(source), + meta: meta.map_or(RepositoryMeta::default(), RepositoryMeta), + } } /// Copies the repository into the `destination` directory. pub fn copy(&self, destination: &Path) -> Result<(), CopyError> { - let root = self.source.expand(); - - let traverser = Traverser::new(root) + let traverser = Traverser::new(self.source.to_owned()) .pattern("**/*") .ignore_dirs(true) .contents_first(true); @@ -263,115 +333,6 @@ impl LocalRepository { } } -/// Wrapper around `RemoteRepository` and `LocalRepository`. -#[derive(Debug, PartialEq)] -pub enum Repository { - Remote(RemoteRepository), - Local(LocalRepository), -} - -impl Repository { - /// Returns a new `Repository` with the given `meta`. - pub fn with_meta(self, meta: RepositoryMeta) -> Self { - match self { - | Self::Remote(remote) => Self::Remote(RemoteRepository { meta, ..remote }), - | Self::Local(local) => Self::Local(LocalRepository { meta, ..local }), - } - } - - /// Returns a copy of the `Repository`'s `meta`. - pub fn meta(&self) -> RepositoryMeta { - match self { - | Self::Remote(remote) => remote.meta.clone(), - | Self::Local(local) => local.meta.clone(), - } - } -} - -impl FromStr for Repository { - type Err = ParseError; - - /// Parses a `&str` into a `Repository`. - fn from_str(input: &str) -> Result { - #[inline(always)] - fn is_valid_user(ch: char) -> bool { - ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' - } - - #[inline(always)] - fn is_valid_repo(ch: char) -> bool { - is_valid_user(ch) || ch == '.' - } - - // Try to find and remove a local repository prefix. If we get Some(..), we are facing a local - // repository, otherwise a remote one. - let unprefix = LocalRepository::prefixes() - .into_iter() - .map(|prefix| format!("{prefix}:")) - .find_map(|prefix| input.strip_prefix(&prefix)); - - if let Some(input) = unprefix { - Ok(Repository::Local(LocalRepository { - source: PathBuf::from(input), - meta: RepositoryMeta::default(), - })) - } else { - // TODO: Handle an edge case with multuple slashes in the repository name. - - let input = input.trim(); - - // Parse host if present or use default otherwise. - let (host, input) = if let Some((host, rest)) = input.split_once(':') { - match host.to_ascii_lowercase().as_str() { - | "github" | "gh" => (RepositoryHost::GitHub, rest), - | "gitlab" | "gl" => (RepositoryHost::GitLab, rest), - | "bitbucket" | "bb" => (RepositoryHost::BitBucket, rest), - | _ => return Err(ParseError::InvalidHost), - } - } else { - (RepositoryHost::default(), input) - }; - - // Parse user name. - let (user, input) = if let Some((user, rest)) = input.split_once('/') { - if user.chars().all(is_valid_user) { - (user.to_string(), rest) - } else { - return Err(ParseError::InvalidUserName); - } - } else { - return Err(ParseError::MissingRepositoryName); - }; - - // Parse repository name. - let (repo, input) = if let Some((repo, rest)) = input.split_once('#') { - if repo.chars().all(is_valid_repo) { - (repo.to_string(), Some(rest)) - } else { - return Err(ParseError::InvalidRepositoryName); - } - } else { - (input.to_string(), None) - }; - - // Produce meta if anything left from the input. Empty meta is accepted but ignored, default - // value is used then. - let meta = input - .filter(|input| !input.is_empty()) - .map_or(RepositoryMeta::default(), |input| { - RepositoryMeta(input.to_string()) - }); - - Ok(Repository::Remote(RemoteRepository { - host, - user, - repo, - meta, - })) - } - } -} - #[cfg(test)] mod tests { use super::*; @@ -379,20 +340,20 @@ mod tests { #[test] fn parse_remote_default() { assert_eq!( - Repository::from_str("foo/bar"), - Ok(Repository::Remote(RemoteRepository { + RemoteRepository::from_str("foo/bar"), + Ok(RemoteRepository { host: RepositoryHost::GitHub, user: "foo".to_string(), repo: "bar".to_string(), meta: RepositoryMeta::default() - })) + }) ); } #[test] fn parse_remote_invalid_userrepo() { assert_eq!( - Repository::from_str("foo-bar"), + RemoteRepository::from_str("foo-bar"), Err(ParseError::MissingRepositoryName) ); } @@ -400,7 +361,7 @@ mod tests { #[test] fn parse_remote_invalid_host() { assert_eq!( - Repository::from_str("srht:foo/bar"), + RemoteRepository::from_str("srht:foo/bar"), Err(ParseError::InvalidHost) ); } @@ -419,13 +380,13 @@ mod tests { for (input, meta) in cases { assert_eq!( - Repository::from_str(input), - Ok(Repository::Remote(RemoteRepository { + RemoteRepository::from_str(input), + Ok(RemoteRepository { host: RepositoryHost::GitHub, user: "foo".to_string(), repo: "bar".to_string(), meta - })) + }) ); } } @@ -443,13 +404,13 @@ mod tests { for (input, host) in cases { assert_eq!( - Repository::from_str(input), - Ok(Repository::Remote(RemoteRepository { + RemoteRepository::from_str(input), + Ok(RemoteRepository { host, user: "foo".to_string(), repo: "bar".to_string(), meta: RepositoryMeta::default() - })) + }) ); } } @@ -457,13 +418,13 @@ mod tests { #[test] fn test_remote_empty_meta() { assert_eq!( - Repository::from_str("foo/bar#"), - Ok(Repository::Remote(RemoteRepository { + RemoteRepository::from_str("foo/bar#"), + Ok(RemoteRepository { host: RepositoryHost::GitHub, user: "foo".to_string(), repo: "bar".to_string(), meta: RepositoryMeta::default() - })) + }) ); } @@ -480,25 +441,14 @@ mod tests { for (input, user, repo) in cases { assert_eq!( - Repository::from_str(input), - Ok(Repository::Remote(RemoteRepository { + RemoteRepository::from_str(input), + Ok(RemoteRepository { host: RepositoryHost::default(), user: user.to_string(), repo: repo.to_string(), meta: RepositoryMeta::default() - })) + }) ); } } - - #[test] - fn parse_local() { - assert_eq!( - Repository::from_str("file:~/dev/templates"), - Ok(Repository::Local(LocalRepository { - source: PathBuf::from("~/dev/templates"), - meta: RepositoryMeta::default() - })) - ); - } }