From 1f87faf8c838b88d46c53f9c24fb006a9888e99c Mon Sep 17 00:00:00 2001 From: Vladislav Mamon Date: Sat, 6 Jan 2024 23:26:04 +0300 Subject: [PATCH] refactor: rewrite basics according to new spec --- Cargo.toml | 9 +- README.md | 18 -- src/app.rs | 140 +++++----- src/config.rs | 285 --------------------- src/error.rs | 19 ++ src/fs/mod.rs | 3 + src/fs/traverser.rs | 149 +++++++++++ src/graph.rs | 108 -------- src/lib.rs | 15 +- src/main.rs | 7 +- src/manifest/manifest.rs | 537 +++++++++++++++++++++++++++++++++++++++ src/manifest/mod.rs | 5 + src/manifest/utils.rs | 35 +++ src/parser.rs | 120 --------- src/path/expand.rs | 24 ++ src/path/mod.rs | 5 + src/path/utils.rs | 28 ++ src/processing.rs | 59 ----- src/repository.rs | 457 +++++++++++++++++++++++++++++++-- src/tar.rs | 78 ------ src/unpacker.rs | 89 +++++++ 21 files changed, 1418 insertions(+), 772 deletions(-) delete mode 100644 src/config.rs create mode 100644 src/error.rs create mode 100644 src/fs/mod.rs create mode 100644 src/fs/traverser.rs delete mode 100644 src/graph.rs create mode 100644 src/manifest/manifest.rs create mode 100644 src/manifest/mod.rs create mode 100644 src/manifest/utils.rs delete mode 100644 src/parser.rs create mode 100644 src/path/expand.rs create mode 100644 src/path/mod.rs create mode 100644 src/path/utils.rs delete mode 100644 src/processing.rs delete mode 100644 src/tar.rs create mode 100644 src/unpacker.rs diff --git a/Cargo.toml b/Cargo.toml index 64c3275..4be24b3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,16 +8,21 @@ repository = "https://github.com/norskeld/arx" publish = false [dependencies] -chumsky = { version = "0.8.0" } +anyhow = { version = "1.0.76" } clap = { version = "4.4.11", features = ["cargo", "derive"] } +console = { version = "0.15.7" } +dirs = "5.0.1" 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" } -petgraph = { version = "0.6.4", features = ["stable_graph"] } 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"] } walkdir = { version = "2.4.0" } diff --git a/README.md b/README.md index 63980c4..1feceff 100644 --- a/README.md +++ b/README.md @@ -10,24 +10,6 @@ Simple CLI for scaffolding projects from templates in a touch. WIP. -## Features - -`arx` allows you to make copies of git repositories, much like [degit], but with added sugar on top of its basic functionality to help scaffold projects even faster and easier. - -Some of that sugar includes: - -- Ability to define [replacement tags](#replacements) (aka placeholders) and simple [actions](#actions) to perform on the repository being copied. This is done via `arx.kdl` config file using the [KDL document language][kdl], which is really easy to grasp, write and read, unlike ubiquitous **JSON** and **YAML**. - -- Automatically generated prompts based on the `arx.kdl` config, that will allow you to interactively replace placeholders with actual values and (optionally) run only selected actions. - -## Replacements - -> TODO: Document replacements. - -## Actions - -> TODO: Document actions. - ## Acknowledgements Thanks to [Rich Harris][rich-harris] and his [degit] tool for inspiration. `:^)` diff --git a/src/app.rs b/src/app.rs index 486a1f5..bc18c97 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,99 +1,105 @@ -use std::fmt; +use std::fs; use std::path::PathBuf; +use std::str::FromStr; use clap::Parser; -use crate::config::{self, Action}; +use crate::manifest::Manifest; +use crate::path::PathUtils; use crate::repository::{Repository, RepositoryMeta}; -use crate::tar; -use crate::{parser, processing}; - -/// Newtype for app errors which get propagated across the app. -#[derive(Debug)] -pub struct AppError(pub String); - -impl fmt::Display for AppError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{message}", message = self.0) - } -} +use crate::unpacker::Unpacker; #[derive(Parser, Debug)] #[clap(version, about, long_about = None)] -pub struct App { +pub struct Cli { /// Repository to download. #[clap(name = "target")] - target: String, + pub target: String, /// Directory to download to. #[clap(name = "path")] - path: Option, - - /// Init git repository. - #[clap(short, long, display_order = 0)] - git: bool, + pub path: Option, - /// Remove imp config after download. + /// Delete arx config after download. #[clap(short, long, display_order = 1)] - remove: bool, - - /// Do not run actions defined in the repository. - #[clap(short, long, display_order = 2)] - ignore: bool, + pub delete: bool, - /// Download at specific ref (branch, tag, commit). + /// Download using specified ref (branch, tag, commit). #[clap(short, long, display_order = 3)] - meta: Option, + pub meta: Option, } -pub async fn run() -> Result<(), AppError> { - let options = App::parse(); +pub struct App; - // Parse repository information from the CLI argument. - let repository = parser::shortcut(&options.target)?; - - // Now 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 { meta, ..repository }; +impl App { + pub fn new() -> Self { + Self + } - // Fetch the tarball as bytes (compressed). - let tarball = repository.fetch().await?; + pub async fn run(&mut self) -> anyhow::Result<()> { + // Parse CLI options. + let options = Cli::parse(); - // Get destination path. - let destination = options - .path - .map(PathBuf::from) - .unwrap_or_else(|| PathBuf::from(repository.repo)); + // Parse repository information from the CLI argument. + let repository = Repository::from_str(&options.target)?; - // Decompress and unpack the tarball. - let unpacked = tar::unpack(&tarball, &destination)?; + // 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); - // Read the kdl config. - let arx_config = config::resolve_arx_config(&destination)?; + // TODO: Check if destination already exists before downloading or performing local clone. - // Get replacements and actions. - let replacements = config::get_replacements(&arx_config); - let actions = config::get_actions(&arx_config); + // 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); - if let Some(items) = replacements { - processing::process_replacements(&unpacked, &items).await?; - } + // Fetch the tarball as bytes (compressed). + let tarball = remote.fetch().await?; - if let Some(action) = actions { - match action { - | Action::Suite(suites) => { - let (resolved, unresolved) = config::resolve_requirements(&suites); + // Decompress and unpack the tarball. + let unpacker = Unpacker::new(tarball); + unpacker.unpack_to(&destination)?; - println!("-- Action suites:"); - println!("Resolved: {resolved:?}"); - println!("Unresolved: {unresolved:?}"); + destination }, - | Action::Single(actions) => { - println!("-- Actions:"); - println!("Resolved: {actions:?}"); + | Repository::Local(local) => { + // TODO: Check if source exists and valid. + let source = PathBuf::from(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 }, - } - } + }; + + // Now we need to read the manifest (if it is present). + let mut manifest = Manifest::with_options(&destination); + manifest.load()?; - Ok(()) + Ok(()) + } } diff --git a/src/config.rs b/src/config.rs deleted file mode 100644 index 4802e11..0000000 --- a/src/config.rs +++ /dev/null @@ -1,285 +0,0 @@ -use std::fs; -use std::path::{Path, PathBuf}; - -use kdl::{KdlDocument, KdlEntry, KdlNode}; - -use crate::app::AppError; -use crate::graph::{DependenciesGraph, Node, Step}; - -const ARX_CONFIG_NAME: &str = "arx.kdl"; - -/// Represents a replacement action. -#[derive(Debug)] -pub struct Replacement { - /// Replacement tag (name). - /// - /// ```kdl - /// replacements { - /// TAG "Tag description" - /// ^^^ - /// } - /// ``` - pub tag: String, - /// Replacement tag description. If not defined, will fallback to `tag`. - /// - /// ```kdl - /// replacements { - /// TAG "Tag description" - /// ^^^^^^^^^^^^^^^^^ - /// } - /// ``` - pub description: String, -} - -/// Represents an action that can be either an [ActionSuite] *or* an [ActionSingle]. -/// -/// So actions should be defined either like this: -/// -/// ```kdl -/// actions { -/// suite name="suite-one" { ... } -/// suite name="suite-two" { ... } -/// ... -/// } -/// ``` -/// -/// Or like this: -/// -/// ```kdl -/// actions { -/// copy from="path/to/file/or/dir" to="path/to/target" -/// move from="path/to/file/or/dir" to="path/to/target" -/// ... -/// } -/// ``` -#[derive(Debug)] -pub enum Action { - Suite(Vec), - Single(Vec), -} - -/// A suite of actions that contains a flat list of single actions and may also depend on other -/// suites (hence the **requirements** field). -#[derive(Clone, Debug)] -pub struct ActionSuite { - /// Suite name. - pub name: String, - /// Suite actions to run (synchronously). - pub actions: Vec, - /// Other actions this suite depends on. - pub requirements: Vec, -} - -impl Node for ActionSuite { - type Item = String; - - fn dependencies(&self) -> &[Self::Item] { - &self.requirements[..] - } - - fn matches(&self, dependency: &Self::Item) -> bool { - self.name == *dependency - } -} - -/// A single "atomic" action. -#[derive(Clone, Debug)] -pub enum ActionSingle { - /// Copies a file or directory. Glob-friendly. Overwrites by default. - Copy { - from: Option, - to: Option, - overwrite: bool, - }, - /// Moves a file or directory. Glob-friendly. Overwrites by default. - Move { - from: Option, - to: Option, - overwrite: bool, - }, - /// Deletes a file or directory. Glob-friendly. - Delete { target: Option }, - /// Runs an arbitrary command in the shell. - Run { command: Option }, - /// Fallback action for pattern matching ergonomics and reporting purposes. - Unknown { name: String }, -} - -/// Checks if arx config exists under the given directory. -pub fn has_arx_config(root: &Path) -> bool { - // TODO: Allow to override the config name. - let file = root.join(ARX_CONFIG_NAME); - let file_exists = file.try_exists(); - - file_exists.is_ok() -} - -/// Resolves, reads and parses an arx config into a [KdlDocument]. -pub fn resolve_arx_config(root: &Path) -> Result { - let filename = root.join(ARX_CONFIG_NAME); - - let contents = fs::read_to_string(filename) - .map_err(|_| AppError("Couldn't read the config file.".to_string()))?; - - let document: KdlDocument = contents - .parse() - .map_err(|_| AppError("Couldn't parse the config file.".to_string()))?; - - Ok(document) -} - -/// Resolves requirements (dependencies) for an [ActionSuite]. -pub fn resolve_requirements(suites: &[ActionSuite]) -> (Vec, Vec) { - let graph = DependenciesGraph::from(suites); - - graph.fold((vec![], vec![]), |(mut resolved, mut unresolved), next| { - match next { - | Step::Resolved(suite) => resolved.push(suite.clone()), - | Step::Unresolved(dep) => unresolved.push(dep.clone()), - } - - (resolved, unresolved) - }) -} - -/// Gets actions from a KDL document. -pub fn get_actions(doc: &KdlDocument) -> Option { - doc - .get("actions") - .and_then(KdlNode::children) - .map(|children| { - let nodes = children.nodes(); - - if nodes.iter().all(is_suite) { - let suites = nodes.iter().filter_map(to_action_suite).collect(); - Action::Suite(suites) - } else { - let actions = nodes.iter().filter_map(to_action_single).collect(); - Action::Single(actions) - } - }) -} - -/// Gets replacements from a KDL document. -pub fn get_replacements(doc: &KdlDocument) -> Option> { - doc - .get("replacements") - .and_then(KdlNode::children) - .map(|children| { - children - .nodes() - .iter() - .filter_map(to_replacement) - .collect::>() - }) -} - -// Helpers and mappers. - -fn to_replacement(node: &KdlNode) -> Option { - let tag = node.name().to_string(); - - let description = node - .get(0) - .and_then(entry_to_string) - .unwrap_or_else(|| tag.clone()); - - Some(Replacement { tag, description }) -} - -fn to_action_suite(node: &KdlNode) -> Option { - let name = node.get("name").and_then(entry_to_string); - - let requirements = node.get("requires").and_then(entry_to_string).map(|value| { - value - .split_ascii_whitespace() - .map(str::to_string) - .collect::>() - }); - - let actions = node.children().map(|children| { - children - .nodes() - .iter() - .filter_map(to_action_single) - .collect::>() - }); - - let suite = ( - name, - actions.unwrap_or_default(), - requirements.unwrap_or_default(), - ); - - match suite { - | (Some(name), actions, requirements) => { - Some(ActionSuite { - name, - actions, - requirements, - }) - }, - | _ => None, - } -} - -/// TODO: This probably should be refactored and abstracted away into something separate. -fn to_action_single(node: &KdlNode) -> Option { - let action_kind = node.name().to_string(); - - let action = match action_kind.to_ascii_lowercase().as_str() { - | "copy" => { - ActionSingle::Copy { - from: node.get("from").and_then(entry_to_pathbuf), - to: node.get("to").and_then(entry_to_pathbuf), - overwrite: node - .get("overwrite") - .and_then(entry_to_bool) - .unwrap_or(true), - } - }, - | "move" => { - ActionSingle::Move { - from: node.get("from").and_then(entry_to_pathbuf), - to: node.get("to").and_then(entry_to_pathbuf), - overwrite: node - .get("overwrite") - .and_then(entry_to_bool) - .unwrap_or(true), - } - }, - | "delete" => { - ActionSingle::Delete { - target: node.get(0).and_then(entry_to_pathbuf), - } - }, - | "run" => { - ActionSingle::Run { - command: node.get(0).and_then(entry_to_string), - } - }, - | action => { - ActionSingle::Unknown { - name: action.to_string(), - } - }, - }; - - Some(action) -} - -fn is_suite(node: &KdlNode) -> bool { - node.name().value().to_string().eq("suite") -} - -fn entry_to_string(entry: &KdlEntry) -> Option { - entry.value().as_string().map(str::to_string) -} - -fn entry_to_bool(entry: &KdlEntry) -> Option { - entry.value().as_bool() -} - -fn entry_to_pathbuf(entry: &KdlEntry) -> Option { - entry.value().as_string().map(PathBuf::from) -} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..6626041 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,19 @@ +use thiserror::Error; + +#[derive(Clone, Debug, Error, PartialEq, Eq)] +pub enum ArxError { + #[error("Host must be one of: github/gh, gitlab/gl, or bitbucket/bb.")] + ShortcutInvalidHost, + #[error("Host can't be zero-length.")] + ShortcutEmptyHost, + #[error("Expected colon after the host.")] + ShortcutHostDelimiterRequired, + #[error("Invalid user name. Allowed symbols: [a-zA-Z0-9_-].")] + ShortcutInvalidUser, + #[error("Invalid repository name. Allowed symbols: [a-zA-Z0-9_-.].")] + ShortcutInvalidRepository, + #[error("Both user and repository name must be specified.")] + ShortcutUserRepositoryNameRequired, + #[error("Meta can't be zero-length.")] + ShortcutEmptyMeta, +} diff --git a/src/fs/mod.rs b/src/fs/mod.rs new file mode 100644 index 0000000..9a298bd --- /dev/null +++ b/src/fs/mod.rs @@ -0,0 +1,3 @@ +pub use traverser::*; + +mod traverser; diff --git a/src/fs/traverser.rs b/src/fs/traverser.rs new file mode 100644 index 0000000..fd3f6f4 --- /dev/null +++ b/src/fs/traverser.rs @@ -0,0 +1,149 @@ +use std::path::PathBuf; + +use glob_match::glob_match_with_captures; +use thiserror::Error; +use walkdir::{DirEntry, IntoIter as WalkDirIter, WalkDir}; + +#[derive(Debug, Error)] +pub enum TraverseError { + #[error("Could not read entry while traversing directory.")] + InvalidEntry(walkdir::Error), +} + +#[derive(Debug)] +pub struct Match { + /// Full path. + pub path: PathBuf, + /// Captured path relative to the traverser's root. + pub captured: PathBuf, + /// Original entry. + pub entry: DirEntry, +} + +#[derive(Debug)] +pub struct TraverseOptions { + /// Directory to traverse. + root: PathBuf, + /// Pattern to match the path against. If `None`, all paths will match. + pattern: Option, + /// Whether to ignore directories (not threir contents) when traversing. Defaults to `false`. + ignore_dirs: bool, + /// Whether to traverse contents of directories first (depth-first). Defaults to `false`. + contents_first: bool, +} + +#[derive(Debug)] +pub struct Traverser { + /// Traverser options. + options: TraverseOptions, +} + +impl Traverser { + /// Creates a new (consuming) builder. + pub fn new(root: PathBuf) -> Self { + Self { + options: TraverseOptions { + root, + pattern: None, + ignore_dirs: false, + contents_first: false, + }, + } + } + + /// Set the pattern to match the path against. + pub fn pattern(mut self, pattern: &str) -> Self { + self.options.pattern = Some(pattern.to_string()); + self + } + + /// Set whether to ignore directories (not their contents) when traversing or not. + pub fn ignore_dirs(mut self, ignore_dirs: bool) -> Self { + self.options.ignore_dirs = ignore_dirs; + self + } + + /// Set whether to traverse contents of directories first or not. + pub fn contents_first(mut self, contents_first: bool) -> Self { + self.options.contents_first = contents_first; + self + } + + /// Creates an iterator without consuming the traverser builder. + pub fn iter(&self) -> TraverserIterator { + let it = WalkDir::new(&self.options.root) + .contents_first(self.options.contents_first) + .into_iter(); + + let root_pattern = self + .options + .pattern + .as_ref() + .map(|pat| self.options.root.join(pat).display().to_string()); + + TraverserIterator { + it, + root_pattern, + options: &self.options, + } + } +} + +/// Traverser iterator. +pub struct TraverserIterator<'t> { + /// Inner iterator (using [walkdir::IntoIter]) that is used to do actual traversing. + it: WalkDirIter, + /// Pattern prepended with the root path to avoid conversions on every iteration. + root_pattern: Option, + /// Traverser options. + options: &'t TraverseOptions, +} + +impl<'t> Iterator for TraverserIterator<'t> { + type Item = Result; + + fn next(&mut self) -> Option { + let mut item = self.it.next()?; + + 'skip: loop { + match item { + | Ok(entry) => { + let path = entry.path(); + + // This ignores only _entry_, while still stepping into the directory. + if self.options.ignore_dirs && entry.file_type().is_dir() { + item = self.it.next()?; + + continue 'skip; + } + + if let Some(pattern) = &self.root_pattern { + let candidate = path.display().to_string(); + + if let Some(captures) = glob_match_with_captures(&pattern, &candidate) { + let range = captures.get(0).cloned().unwrap_or_default(); + let captured = PathBuf::from(&candidate[range.start..]); + + return Some(Ok(Match { + path: path.to_path_buf(), + captured, + entry, + })); + } else { + item = self.it.next()?; + + continue 'skip; + } + } else { + return Some(Ok(Match { + path: path.to_path_buf(), + captured: path.to_path_buf(), + entry, + })); + } + }, + | Err(err) => return Some(Err(TraverseError::InvalidEntry(err))), + } + } + } +} diff --git a/src/graph.rs b/src/graph.rs deleted file mode 100644 index 919df30..0000000 --- a/src/graph.rs +++ /dev/null @@ -1,108 +0,0 @@ -use petgraph::stable_graph::StableDiGraph; -use petgraph::Direction; - -pub trait Node { - type Item; - - fn dependencies(&self) -> &[Self::Item]; - fn matches(&self, dep: &Self::Item) -> bool; -} - -#[derive(Debug)] -pub enum Step<'a, N: Node> { - Resolved(&'a N), - Unresolved(&'a N::Item), -} - -impl<'a, N: Node> Step<'a, N> { - pub fn is_resolved(&self) -> bool { - match self { - | Step::Resolved(_) => true, - | Step::Unresolved(_) => false, - } - } - - pub fn as_resolved(&self) -> Option<&N> { - match self { - | Step::Resolved(node) => Some(node), - | Step::Unresolved(_) => None, - } - } - - pub fn as_unresolved(&self) -> Option<&N::Item> { - match self { - | Step::Resolved(_) => None, - | Step::Unresolved(requirement) => Some(requirement), - } - } -} - -#[derive(Debug)] -pub struct DependenciesGraph<'a, N: Node> { - graph: StableDiGraph, &'a N::Item>, -} - -impl<'a, N> From<&'a [N]> for DependenciesGraph<'a, N> -where - N: Node, -{ - fn from(nodes: &'a [N]) -> Self { - let mut graph = StableDiGraph::, &'a N::Item>::new(); - - // Insert the input nodes into the graph, and record their positions. We'll be adding the edges - // next, and filling in any unresolved steps we find along the way. - let nodes: Vec<(_, _)> = nodes - .iter() - .map(|node| (node, graph.add_node(Step::Resolved(node)))) - .collect(); - - for (node, index) in nodes.iter() { - for dependency in node.dependencies() { - // Check to see if we can resolve this dependency internally. - if let Some((_, dependent)) = nodes.iter().find(|(dep, _)| dep.matches(dependency)) { - // If we can, just add an edge between the two nodes. - graph.add_edge(*index, *dependent, dependency); - } else { - // If not, create a new Unresolved node, and create an edge to that. - let unresolved = graph.add_node(Step::Unresolved(dependency)); - graph.add_edge(*index, unresolved, dependency); - } - } - } - - Self { graph } - } -} - -impl<'a, N> DependenciesGraph<'a, N> -where - N: Node, -{ - pub fn is_resolvable(&self) -> bool { - self.graph.node_weights().all(Step::is_resolved) - } - - pub fn unresolved(&self) -> impl Iterator { - self.graph.node_weights().filter_map(Step::as_unresolved) - } -} - -impl<'a, N> Iterator for DependenciesGraph<'a, N> -where - N: Node, -{ - type Item = Step<'a, N>; - - fn next(&mut self) -> Option { - // Returns the first node, which does not have any Outgoing edges, which means it is terminal. - for index in self.graph.node_indices().rev() { - let neighbors = self.graph.neighbors_directed(index, Direction::Outgoing); - - if neighbors.count() == 0 { - return self.graph.remove_node(index); - } - } - - None - } -} diff --git a/src/lib.rs b/src/lib.rs index 3e8d1ce..4152f0d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,10 +1,7 @@ -#![allow(dead_code)] - pub mod app; -pub mod config; - -mod graph; -mod parser; -mod processing; -mod repository; -mod tar; +pub mod error; +pub mod fs; +pub mod manifest; +pub mod path; +pub mod repository; +pub mod unpacker; diff --git a/src/main.rs b/src/main.rs index 6681cc3..92a7078 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,7 @@ -use arx::app::{self, AppError}; +use arx::app::App; #[tokio::main] -async fn main() -> Result<(), AppError> { - app::run().await +async fn main() -> anyhow::Result<()> { + let mut app = App::new(); + app.run().await } diff --git a/src/manifest/manifest.rs b/src/manifest/manifest.rs new file mode 100644 index 0000000..6efd207 --- /dev/null +++ b/src/manifest/manifest.rs @@ -0,0 +1,537 @@ +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; + +use kdl::{KdlDocument, KdlNode}; +use thiserror::Error; + +use crate::manifest::KdlUtils; + +const MANIFEST_NAME: &str = "arx.kdl"; + +#[derive(Debug, Error)] +pub enum ManifestError { + #[error("Couldn't read the manifest.")] + ReadFail(#[from] io::Error), + #[error("Couldn't parse the manifest.")] + ParseFail(#[from] kdl::KdlError), + #[error("You can use either suites of actions or a flat list of single actions, not both.")] + MixedActions, + #[error("Unknown node '{0}'.")] + UnknownNode(String), + #[error("Unknown prompt '{0}'.")] + UnknownPrompt(String), + #[error("Expected a suite name.")] + ExpectedSuiteName, + #[error("Expected attribute '{0}' to be present and not empty.")] + ExpectedAttribute(String), + #[error("Expected argument.")] + ExpectedArgument, + #[error("Expected argument for node '{0}'.")] + ExpectedArgumentFor(String), + #[error("Input prompt must have defined name and hint.")] + ExpectedInputNodes, + #[error("Editor prompt must have defined name and hint.")] + ExpectedEditorNodes, + #[error("Select prompt must have defined name, hint and variants.")] + ExpectedSelectNodes, + #[error("Confirm prompt must have defined name and hint.")] + ExpectedConfirmNodes, +} + +/// Manifest options. These may be overriden from the CLI. +#[derive(Debug)] +pub struct ManifestOptions { + /// Whether to delete the manifest after we (successfully) done running. + pub delete: bool, +} + +impl Default for ManifestOptions { + fn default() -> Self { + Self { delete: true } + } +} + +/// Represents a manifest actions set that can be either an [ActionSuite] *or* an [ActionSingle]. +/// +/// Actions should be defined either like this: +/// +/// ```kdl +/// actions { +/// suite "suite-one" { ... } +/// suite "suite-two" { ... } +/// ... +/// } +/// ``` +/// +/// Or like this: +/// +/// ```kdl +/// actions { +/// cp from="..." to="..." +/// mv from="..." to="..." +/// ... +/// } +/// ``` +#[derive(Debug)] +pub enum Actions { + Suite(Vec), + Single(Vec), + Empty, +} + +/// A suite of actions that contains a flat list of [ActionSingle]. +#[derive(Debug)] +pub struct ActionSuite { + /// Suite name. + pub name: String, + /// Suite actions to run (synchronously). + pub actions: Vec, +} + +/// A single "atomic" action. +#[derive(Debug)] +pub enum ActionSingle { + /// Copies a file or directory. Glob-friendly. Overwrites by default. + Copy { + /// Source(s) to copy. + from: PathBuf, + /// Where to copy to. + to: PathBuf, + /// Whether to overwrite or not. + overwrite: bool, + }, + /// Moves a file or directory. Glob-friendly. Overwrites by default. + Move { + /// Source(s) to move. + from: PathBuf, + /// Where to move to. + to: PathBuf, + /// Whether to overwrite or not. + overwrite: bool, + }, + /// Deletes a file or directory. Glob-friendly. + Delete { + /// Target to delete. + target: PathBuf, + }, + /// Simply outputs a message. + Echo { + /// Message to output. + message: String, + /// Whether to trim multiline message or not. + trim: bool, + }, + /// Runs an arbitrary command in the shell. + Run { + /// Command name. Optional, defaults either to the command itself or to the first line of + /// the multiline command. + name: Option, + /// Comannd to run in the shell. + command: String, + /// An optional list of replacements to be injected into the command. Consider the following + /// example: + /// + /// We use inject to disambiguate whether `{R_PM}` is part of a command or is a replacement + /// that should be replaced with something, we pass `inject` node that explicitly tells arx + /// what to inject into the string. + /// + /// ```kdl + /// run "{R_PM} install {R_PM_ARGS}" { + /// inject "R_PM" "R_PM_ARGS" + /// } + /// ``` + /// + /// All replacements are processed _before_ running a command. + inject: Option>, + }, + /// Executes a prompt asking a declaratively defined "question". + Prompt(Prompt), + /// Execute given replacements using values provided by prompts. Optionally, only apply + /// replacements to files matching the provided glob. + Replace { + /// Replacements to apply. + replacements: Vec, + /// Optional glob to limit files to apply replacements to. + glob: Option, + }, + /// Fallback action for pattern matching ergonomics and reporting purposes. + Unknown { name: String }, +} + +#[derive(Debug)] +pub enum Prompt { + Input { + /// Name of the variable that will store the answer. + name: String, + /// Short description. + hint: String, + /// Default value if input is empty. + default: Option, + }, + Select { + /// Name of the variable that will store the answer. + name: String, + /// Short description. + hint: String, + /// List of options. + options: Vec, + /// Default value. If none or invalid option is provided, the first one is selected. + default: Option, + }, + Confirm { + /// Name of the variable that will store the answer. + name: String, + /// Short description of the prompt. + hint: String, + /// Default value. + default: Option, + }, + Editor { + /// Name of the variable that will store the answer. + name: String, + /// Short description. + hint: String, + /// Default value if input is empty. + default: Option, + }, +} + +/// Arx manifest (config). +#[derive(Debug)] +pub struct Manifest { + /// Manifest directory. + root: PathBuf, + /// Manifest options. + options: ManifestOptions, + /// Actions. + actions: Actions, +} + +impl Manifest { + /// Creates a new manifest from the given path and options. + pub fn with_options(path: &Path) -> Self { + Self { + root: path.to_path_buf(), + options: ManifestOptions::default(), + actions: Actions::Empty, + } + } + + /// Tries to load and parse the manifest. + pub fn load(&mut self) -> Result<(), ManifestError> { + if self.exists() { + let doc = self.parse()?; + let options = self.get_options(&doc)?; + let actions = self.get_actions(&doc)?; + + println!("Options: {options:#?}"); + println!("Actions: {actions:#?}"); + + self.options = options; + self.actions = actions; + } + + Ok(()) + } + + /// Checks if the manifest exists under `self.root`. + fn exists(&self) -> bool { + // TODO: Allow to override the config name. + let file = self.root.join(MANIFEST_NAME); + let file_exists = file.try_exists(); + + file_exists.is_ok() + } + + /// Reads and parses the manifest into a [KdlDocument]. + fn parse(&self) -> Result { + let filename = self.root.join(MANIFEST_NAME); + + let contents = fs::read_to_string(filename).map_err(ManifestError::ReadFail)?; + let document = contents.parse().map_err(ManifestError::ParseFail)?; + + Ok(document) + } + + fn get_options(&self, doc: &KdlDocument) -> Result { + let options = doc + .get("options") + .and_then(KdlNode::children) + .map(|children| { + let nodes = children.nodes(); + let mut defaults = ManifestOptions::default(); + + for node in nodes.into_iter() { + let name = node.name().to_string().to_ascii_lowercase(); + + match name.as_str() { + | "delete" => { + defaults.delete = node + .get_bool(0) + .ok_or(ManifestError::ExpectedArgumentFor("delete".into()))?; + }, + | _ => { + continue; + }, + } + } + + Ok(defaults) + }); + + match options { + | Some(Ok(options)) => Ok(options), + | Some(Err(err)) => Err(err), + | None => Ok(ManifestOptions::default()), + } + } + + fn get_actions(&self, doc: &KdlDocument) -> Result { + #[inline] + fn is_suite(node: &KdlNode) -> bool { + node.name().value().to_string() == "suite" + } + + #[inline] + fn is_not_suite(node: &KdlNode) -> bool { + !is_suite(node) + } + + let actions = doc + .get("actions") + .and_then(KdlNode::children) + .map(|children| { + let nodes = children.nodes(); + + // Check if all nodes are suites. + if nodes.iter().all(is_suite) { + let mut suites = Vec::new(); + + for node in nodes.iter() { + let suite = self.get_action_suite(node)?; + suites.push(suite); + } + + Ok(Actions::Suite(suites)) + } + // Check if all nodes are single actions. + else if nodes.iter().all(is_not_suite) { + let mut actions = Vec::new(); + + for node in nodes.iter() { + let action = self.get_action_single(node)?; + actions.push(action); + } + + Ok(Actions::Single(actions)) + } + // Otherwise we have invalid actions block. + else { + Err(ManifestError::MixedActions) + } + }); + + match actions { + | Some(Ok(action)) => Ok(action), + | Some(Err(err)) => Err(err), + | None => Ok(Actions::Empty), + } + } + + fn get_action_suite(&self, node: &KdlNode) -> Result { + let mut actions = Vec::new(); + + // Fail if we stumbled upon a nameless suite. + let name = node.get_string(0).ok_or(ManifestError::ExpectedSuiteName)?; + + if let Some(children) = node.children() { + for children in children.nodes().into_iter() { + let action = self.get_action_single(children)?; + actions.push(action); + } + } + + Ok(ActionSuite { name, actions }) + } + + fn get_action_single(&self, node: &KdlNode) -> Result { + let kind = node.name().to_string().to_ascii_lowercase(); + + let action = match kind.as_str() { + // Actions for manipulating files and directories. + | "cp" => { + let from = node + .get_pathbuf("from") + .ok_or(ManifestError::ExpectedAttribute("from".into()))?; + + let to = node + .get_pathbuf("to") + .ok_or(ManifestError::ExpectedAttribute("to".into()))?; + + let overwrite = node.get_bool("overwrite").unwrap_or(true); + + ActionSingle::Copy { + from, + to, + overwrite, + } + }, + | "mv" => { + let from = node + .get_pathbuf("from") + .ok_or(ManifestError::ExpectedAttribute("from".into()))?; + + let to = node + .get_pathbuf("to") + .ok_or(ManifestError::ExpectedAttribute("to".into()))?; + + let overwrite = node.get_bool("overwrite").unwrap_or(true); + + ActionSingle::Move { + from, + to, + overwrite, + } + }, + | "rm" => { + ActionSingle::Delete { + target: node.get_pathbuf(0).ok_or(ManifestError::ExpectedArgument)?, + } + }, + // Running commands and echoing output. + | "echo" => { + let message = node + .get_string(0) + .ok_or(ManifestError::ExpectedAttribute("message".into()))?; + + let trim = node.get_bool("trim").unwrap_or(false); + + ActionSingle::Echo { message, trim } + }, + | "run" => { + let name = node.get_string("name"); + let command = node.get_string(0).ok_or(ManifestError::ExpectedArgument)?; + + let inject = node.children().map(|children| { + children + .get_args("inject") + .into_iter() + .filter_map(|arg| arg.as_string().map(str::to_string)) + .collect() + }); + + ActionSingle::Run { + name, + command, + inject, + } + }, + // Prompts and replacements. + | "prompt" => { + let prompt = self.get_prompt(node)?; + + ActionSingle::Prompt(prompt) + }, + | "replace" => { + let replacements = node + .children() + .map(|children| { + children + .nodes() + .into_iter() + .map(|node| node.name().value().to_string()) + .collect::>() + }) + .unwrap_or_default(); + + let glob = node.get_string("in").map(PathBuf::from); + + ActionSingle::Replace { replacements, glob } + }, + // Fallback. + | action => { + return Err(ManifestError::UnknownNode(action.into())); + }, + }; + + Ok(action) + } + + fn get_prompt(&self, node: &KdlNode) -> Result { + // Prompt kind, defaults to "input". + let kind = node + .get_string(0) + .unwrap_or("input".into()) + .to_ascii_lowercase(); + + #[inline] + fn name(nodes: &KdlDocument) -> Result { + nodes + .get("name") + .and_then(|node| node.get_string(0)) + .ok_or(ManifestError::ExpectedArgumentFor("name".into())) + } + + #[inline] + fn hint(nodes: &KdlDocument) -> Result { + nodes + .get("hint") + .and_then(|node| node.get_string(0)) + .ok_or(ManifestError::ExpectedArgumentFor("hint".into())) + } + + #[inline] + fn variants(nodes: &KdlDocument) -> Vec { + nodes + .get_args("variants") + .into_iter() + .filter_map(|arg| arg.as_string().map(str::to_string)) + .collect() + } + + // Depending on the type construct a prompt. + match kind.as_str() { + | "input" => { + let nodes = node.children().ok_or(ManifestError::ExpectedInputNodes)?; + + Ok(Prompt::Input { + name: name(nodes)?, + hint: hint(nodes)?, + default: nodes.get("default").and_then(|node| node.get_string(0)), + }) + }, + | "editor" => { + let nodes = node.children().ok_or(ManifestError::ExpectedEditorNodes)?; + + Ok(Prompt::Editor { + name: name(nodes)?, + hint: hint(nodes)?, + default: nodes.get("default").and_then(|node| node.get_string(0)), + }) + }, + | "select" => { + let nodes = node.children().ok_or(ManifestError::ExpectedSelectNodes)?; + + Ok(Prompt::Select { + name: name(nodes)?, + hint: hint(nodes)?, + options: variants(nodes), + default: nodes.get("default").and_then(|node| node.get_string(0)), + }) + }, + | "confirm" => { + let nodes = node.children().ok_or(ManifestError::ExpectedConfirmNodes)?; + + Ok(Prompt::Confirm { + name: name(nodes)?, + hint: hint(nodes)?, + default: nodes.get("default").and_then(|node| node.get_bool(0)), + }) + }, + | kind => { + return Err(ManifestError::UnknownPrompt(kind.into())); + }, + } + } +} diff --git a/src/manifest/mod.rs b/src/manifest/mod.rs new file mode 100644 index 0000000..f810eb5 --- /dev/null +++ b/src/manifest/mod.rs @@ -0,0 +1,5 @@ +pub use manifest::*; +pub use utils::*; + +mod manifest; +mod utils; diff --git a/src/manifest/utils.rs b/src/manifest/utils.rs new file mode 100644 index 0000000..14d2ca3 --- /dev/null +++ b/src/manifest/utils.rs @@ -0,0 +1,35 @@ +use std::path::PathBuf; + +use kdl::{KdlNode, NodeKey}; + +pub trait KdlUtils { + /// Fetches an entry by key and tries to map to a [PathBuf]. + fn get_pathbuf(&self, key: K) -> Option; + + /// Fetches an entry by key and tries to map to a [String]. + fn get_string(&self, key: K) -> Option; + + /// Fetches an entry by key and tries to map it to a [bool]. + fn get_bool(&self, key: K) -> Option; +} + +impl KdlUtils for KdlNode +where + K: Into, +{ + fn get_pathbuf(&self, key: K) -> Option { + self + .get(key) + .and_then(|entry| entry.value().as_string().map(PathBuf::from)) + } + + fn get_string(&self, key: K) -> Option { + self + .get(key) + .and_then(|entry| entry.value().as_string().map(str::to_string)) + } + + fn get_bool(&self, key: K) -> Option { + self.get(key).and_then(|entry| entry.value().as_bool()) + } +} diff --git a/src/parser.rs b/src/parser.rs deleted file mode 100644 index acdb399..0000000 --- a/src/parser.rs +++ /dev/null @@ -1,120 +0,0 @@ -use chumsky::error::Cheap; -use chumsky::prelude::*; - -use crate::app::AppError; -use crate::repository::{Host, Repository, RepositoryHost, RepositoryMeta}; - -type ParseResult = (Option, (String, String), Option); - -/// Parses source argument of the following form: -/// -/// `({host}:){user}/{repo}(#{branch|commit|tag})`. -pub(crate) fn shortcut(input: &str) -> Result { - let host = host().or_not(); - let meta = meta().or_not().then_ignore(end()); - let repo = repository().then(meta); - - let shortcut = host.then(repo).map(|(a, (b, c))| (a, b, c)).parse(input); - - match shortcut { - | Ok(data) => produce_result(data), - | Err(error) => Err(produce_error(error)), - } -} - -/// Parses the repository host. Must be one of: -/// - `github` or `gh` -/// - `gitlab` or `gl` -/// - `bitbucket` or `bb` -fn host() -> impl Parser> { - let host = filter::<_, _, Cheap>(char::is_ascii_alphabetic) - .repeated() - .at_least(1) - .collect::() - .map(|variant| { - match variant.as_str() { - | "github" | "gh" => Host::Known(RepositoryHost::GitHub), - | "gitlab" | "gl" => Host::Known(RepositoryHost::GitLab), - | "bitbucket" | "bb" => Host::Known(RepositoryHost::BitBucket), - | _ => Host::Unknown, - } - }) - .labelled("Host can't be zero-length."); - - host.then_ignore(just(':')) -} - -/// Parses the user name and repository name. -fn repository() -> impl Parser> { - fn is_valid_user(ch: &char) -> bool { - ch.is_ascii_alphanumeric() || ch == &'_' || ch == &'-' - } - - fn is_valid_repo(ch: &char) -> bool { - is_valid_user(ch) || ch == &'.' - } - - let user = filter::<_, _, Cheap>(is_valid_user) - .repeated() - .at_least(1) - .labelled("Must be a valid user name. Allowed symbols: [a-zA-Z0-9_-]") - .collect::(); - - let repo = filter::<_, _, Cheap>(is_valid_repo) - .repeated() - .at_least(1) - .labelled("Must be a valid repository name. Allowed symbols: [a-zA-Z0-9_-.]") - .collect::(); - - user - .then_ignore( - just('/').labelled("There must be a slash between the user name and the repository name."), - ) - .then(repo) -} - -/// Parses the shortcut meta (branch, commit hash, or tag), which may be specified after `#`. -/// -/// TODO: Add some loose validation. -fn meta() -> impl Parser> { - let meta = filter::<_, _, Cheap>(char::is_ascii) - .repeated() - .at_least(1) - .labelled("Meta can't be zero-length.") - .collect::() - .map(RepositoryMeta); - - just('#').ignore_then(meta) -} - -fn produce_result(data: ParseResult) -> Result { - match data { - | (host, (user, repo), meta) => { - let meta = meta.unwrap_or_default(); - let host = host.unwrap_or_default(); - - if let Host::Known(host) = host { - Ok(Repository { - host, - user, - repo, - meta, - }) - } else { - Err(AppError( - "Host must be one of: github/gh, gitlab/gl, or bitbucket/bb.".to_string(), - )) - } - }, - } -} - -fn produce_error(errors: Vec>) -> AppError { - let reduced = errors - .iter() - .filter_map(|error| error.label()) - .map(str::to_string) - .collect::>(); - - AppError(reduced.join("\n")) -} diff --git a/src/path/expand.rs b/src/path/expand.rs new file mode 100644 index 0000000..db1fe97 --- /dev/null +++ b/src/path/expand.rs @@ -0,0 +1,24 @@ +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.into())), + | 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 new file mode 100644 index 0000000..bd41509 --- /dev/null +++ b/src/path/mod.rs @@ -0,0 +1,5 @@ +pub use expand::*; +pub use utils::*; + +mod expand; +mod utils; diff --git a/src/path/utils.rs b/src/path/utils.rs new file mode 100644 index 0000000..59ea72d --- /dev/null +++ b/src/path/utils.rs @@ -0,0 +1,28 @@ +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 { + fn to_root>(&self, root: P) -> PathBuf { + if self == Path::new(".") { + root.as_ref().to_path_buf() + } else { + 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/processing.rs b/src/processing.rs deleted file mode 100644 index 9b59ada..0000000 --- a/src/processing.rs +++ /dev/null @@ -1,59 +0,0 @@ -use std::path::PathBuf; - -use tokio::fs::{File, OpenOptions}; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; - -use crate::app::AppError; -use crate::config::Replacement; - -/// Given a list of unpacked files and pairs of replacement, value, perform substitutions in files. -/// -/// TODO: Come up with something better, because this is so lame... -pub async fn process_replacements( - unpacked: &[PathBuf], - replacements: &[Replacement], -) -> Result<(), AppError> { - for unpacked_entry in unpacked.iter() { - let mut buffer = String::new(); - - let mut file = File::open(unpacked_entry) - .await - .map_err(|err| AppError(err.to_string()))?; - - let metadata = file - .metadata() - .await - .map_err(|err| AppError(err.to_string()))?; - - if metadata.is_file() { - file - .read_to_string(&mut buffer) - .await - .map_err(|err| AppError(err.to_string()))?; - - for Replacement { tag, .. } in replacements.iter() { - // In `format!` macro `{` should be doubled to be properly escaped. - let replacement_tag = format!("{{{{ {tag} }}}}"); - - // TODO: This will contain value from a prompt mapped to a specific replacement tag. - let replacement_value = "@"; - - buffer = buffer.replace(&replacement_tag, replacement_value); - } - - let mut result = OpenOptions::new() - .write(true) - .truncate(true) - .open(unpacked_entry) - .await - .map_err(|err| AppError(err.to_string()))?; - - result - .write_all(buffer.as_bytes()) - .await - .map_err(|err| AppError(err.to_string()))?; - } - } - - Ok(()) -} diff --git a/src/repository.rs b/src/repository.rs index 884ffa9..36cb785 100644 --- a/src/repository.rs +++ b/src/repository.rs @@ -1,8 +1,66 @@ -use crate::app::AppError; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; +use std::str::FromStr; + +use git2::build::CheckoutBuilder; +use git2::Repository as GitRepository; +use thiserror::Error; + +use crate::fs::Traverser; +use crate::path::PathUtils; + +#[derive(Debug, Error, PartialEq)] +pub enum ParseError { + #[error("Host must be one of: github/gh, gitlab/gl, or bitbucket/bb.")] + InvalidHost, + #[error("Invalid user name. Only ASCII alphanumeric characters, _ and - allowed.")] + InvalidUserName, + #[error("Invalid repository name. Only ASCII alphanumeric characters, _, - and . allowed.")] + InvalidRepositoryName, + #[error("Missing repository name.")] + MissingRepositoryName, + #[error("Multiple / in the input.")] + MultipleSlash, +} + +#[derive(Debug, Error, PartialEq)] +pub enum FetchError { + #[error("Request failed.")] + RequestFailed, + #[error("Repository download ({0}) failed with code {1}.")] + RequestFailedWithCode(String, u16), + #[error("Couldn't get the response body as bytes.")] + RequestBodyFailed, +} + +#[derive(Debug, Error)] +pub enum CopyError { + #[error("Failed to create directory.")] + CreateDirFailed(io::Error), + #[error("Failed to copy file.")] + CopyFailed(io::Error), +} + +#[derive(Debug, Error)] +pub enum CheckoutError { + #[error("Failed to open the git repository.")] + OpenFailed(git2::Error), + #[error("Failed to parse revision string '{0}'.")] + RevparseFailed(String), + #[error("Failed to checkout revision (tree).")] + TreeCheckoutFailed, + #[error("Reference name is not a valid UTF-8 string.")] + InvalidRefName, + #[error("Failed to set HEAD to '{0}'.")] + SetHeadFailed(String), + #[error("Failed to detach HEAD to '{0}'.")] + DetachHeadFailed(String), +} /// Supported hosts. [GitHub][RepositoryHost::GitHub] is the default one. -#[derive(Debug, Default)] -pub(crate) enum RepositoryHost { +#[derive(Debug, Default, PartialEq)] +pub enum RepositoryHost { #[default] GitHub, GitLab, @@ -11,7 +69,7 @@ pub(crate) enum RepositoryHost { /// Container for a repository host. #[derive(Debug)] -pub(crate) enum Host { +pub enum Host { Known(RepositoryHost), Unknown, } @@ -22,11 +80,11 @@ impl Default for Host { } } -/// Repository meta, i.e. *ref*. +/// Repository meta or *ref*, i.e. branch, tag or commit. /// /// This newtype exists solely for providing the default value. -#[derive(Debug)] -pub(crate) struct RepositoryMeta(pub String); +#[derive(Clone, Debug, PartialEq)] +pub struct RepositoryMeta(pub String); impl Default for RepositoryMeta { fn default() -> Self { @@ -36,18 +94,27 @@ impl Default for RepositoryMeta { } } -#[derive(Debug)] -pub(crate) struct Repository { +/// Represents a remote repository. Repositories of this kind need to be downloaded first. +#[derive(Debug, PartialEq)] +pub struct RemoteRepository { pub host: RepositoryHost, pub user: String, pub repo: String, pub meta: RepositoryMeta, } -impl Repository { +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() + } + /// Resolves a URL depending on the host and other repository fields. - pub(crate) fn get_tar_url(&self) -> String { - let Repository { + pub fn get_tar_url(&self) -> String { + let RemoteRepository { host, user, repo, @@ -70,24 +137,368 @@ impl Repository { } /// Fetches the tarball using the resolved URL, and reads it into bytes (`Vec`). - pub(crate) async fn fetch(&self) -> Result, AppError> { + pub async fn fetch(&self) -> Result, FetchError> { let url = self.get_tar_url(); - let response = reqwest::get(url).await.map_err(|err| { - err - .status() - .map_or(AppError("Request failed.".to_string()), |status| { - AppError(format!( - "Request failed with the code: {code}.", - code = status.as_u16() - )) - }) + let response = reqwest::get(&url).await.map_err(|err| { + err.status().map_or(FetchError::RequestFailed, |status| { + FetchError::RequestFailedWithCode(url.clone(), status.as_u16()) + }) })?; + let status = response.status(); + + if !status.is_success() { + return Err(FetchError::RequestFailedWithCode(url, status.as_u16())); + } + response .bytes() .await .map(|bytes| bytes.to_vec()) - .map_err(|_| AppError("Couldn't get the response body as bytes.".to_string())) + .map_err(|_| FetchError::RequestBodyFailed) + } +} + +/// 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)] +pub struct LocalRepository { + pub source: PathBuf, + pub meta: RepositoryMeta, +} + +impl LocalRepository { + /// Returns a list of valid prefixes that can be used to identify local repositories. + pub fn prefixes() -> [&'static str; 2] { + ["file", "local"] + } + + /// 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) + .pattern("**/*") + .ignore_dirs(true) + .contents_first(true); + + for matched in traverser.iter().flatten() { + let target = destination.join(&matched.captured); + + if let Some(parent) = target.parent() { + fs::create_dir_all(parent).map_err(CopyError::CreateDirFailed)?; + fs::copy(matched.path, &target).map_err(CopyError::CopyFailed)?; + } + } + + Ok(()) + } + + /// Checks out the repository located at the `destination`. + pub fn checkout(&self, destination: &Path) -> Result<(), CheckoutError> { + let RepositoryMeta(meta) = &self.meta; + + // First, try to create Repository. + let repository = GitRepository::open(destination).map_err(CheckoutError::OpenFailed)?; + + // Note: in case of local repositories, instead of HEAD we want to check origin/HEAD first, + // which should be the default branch if the repository has been cloned from a remote. + // Otherwise we fallback to HEAD, which will point to whatever the repository points at the time + // of cloning (can be absolutely arbitrary reference/state). + let meta = if meta == "HEAD" { + repository + .revparse_ext("origin/HEAD") + .ok() + .and_then(|(_, reference)| reference) + .and_then(|reference| reference.name().map(str::to_string)) + .unwrap_or("HEAD".to_string()) + } else { + "HEAD".to_string() + }; + + // Try to find (parse revision) the desired reference: branch, tag or commit. They are encoded + // in two objects: + // + // - `object` contains (among other things) the commit hash. + // - `reference` points to the branch or tag. + let (object, reference) = repository + .revparse_ext(&meta) + .map_err(|_| CheckoutError::RevparseFailed(meta))?; + + // Build checkout options. + let mut checkout = CheckoutBuilder::new(); + + checkout + .skip_unmerged(true) + .remove_untracked(true) + .remove_ignored(true) + .force(); + + // Updates files in the index and working tree. + repository + .checkout_tree(&object, Some(&mut checkout)) + .map_err(|_| CheckoutError::TreeCheckoutFailed)?; + + match reference { + // Here `gref`` is an actual reference like branch or tag. + | Some(gref) => { + let ref_name = gref.name().ok_or_else(|| CheckoutError::InvalidRefName)?; + + repository + .set_head(ref_name) + .map_err(|_| CheckoutError::SetHeadFailed(ref_name.to_string()))?; + }, + // This is a commit, detach HEAD. + | None => { + let hash = object.id(); + + repository + .set_head_detached(hash) + .map_err(|_| CheckoutError::DetachHeadFailed(hash.to_string()))?; + }, + } + + Ok(()) + } +} + +/// 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::*; + + #[test] + fn parse_remote_default() { + assert_eq!( + Repository::from_str("foo/bar"), + Ok(Repository::Remote(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"), + Err(ParseError::MissingRepositoryName) + ); + } + + #[test] + fn parse_remote_invalid_host() { + assert_eq!( + Repository::from_str("srht:foo/bar"), + Err(ParseError::InvalidHost) + ); + } + + #[test] + fn parse_remote_meta() { + let cases = [ + ("foo/bar", RepositoryMeta::default()), + ("foo/bar#foo", RepositoryMeta("foo".to_string())), + ("foo/bar#4a5a56fd", RepositoryMeta("4a5a56fd".to_string())), + ( + "foo/bar#feat/some-feature-name", + RepositoryMeta("feat/some-feature-name".to_string()), + ), + ]; + + for (input, meta) in cases { + assert_eq!( + Repository::from_str(input), + Ok(Repository::Remote(RemoteRepository { + host: RepositoryHost::GitHub, + user: "foo".to_string(), + repo: "bar".to_string(), + meta + })) + ); + } + } + + #[test] + fn parse_remote_hosts() { + let cases = [ + ("github:foo/bar", RepositoryHost::GitHub), + ("gh:foo/bar", RepositoryHost::GitHub), + ("gitlab:foo/bar", RepositoryHost::GitLab), + ("gl:foo/bar", RepositoryHost::GitLab), + ("bitbucket:foo/bar", RepositoryHost::BitBucket), + ("bb:foo/bar", RepositoryHost::BitBucket), + ]; + + for (input, host) in cases { + assert_eq!( + Repository::from_str(input), + Ok(Repository::Remote(RemoteRepository { + host, + user: "foo".to_string(), + repo: "bar".to_string(), + meta: RepositoryMeta::default() + })) + ); + } + } + + #[test] + fn test_remote_empty_meta() { + assert_eq!( + Repository::from_str("foo/bar#"), + Ok(Repository::Remote(RemoteRepository { + host: RepositoryHost::GitHub, + user: "foo".to_string(), + repo: "bar".to_string(), + meta: RepositoryMeta::default() + })) + ); + } + + #[test] + fn parse_remote_ambiguous_username() { + let cases = [ + ("github/foo", "github", "foo"), + ("gh/foo", "gh", "foo"), + ("gitlab/foo", "gitlab", "foo"), + ("gl/foo", "gl", "foo"), + ("bitbucket/foo", "bitbucket", "foo"), + ("bb/foo", "bb", "foo"), + ]; + + for (input, user, repo) in cases { + assert_eq!( + Repository::from_str(input), + Ok(Repository::Remote(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() + })) + ); } } diff --git a/src/tar.rs b/src/tar.rs deleted file mode 100644 index 005b96a..0000000 --- a/src/tar.rs +++ /dev/null @@ -1,78 +0,0 @@ -use std::fs; -use std::path::{Path, PathBuf}; - -use flate2::bufread::GzDecoder; -use tar::Archive; - -use crate::app::AppError; - -#[cfg(target_os = "windows")] -const USE_XATTRS: bool = false; - -#[cfg(not(target_os = "windows"))] -const USE_XATTRS: bool = true; - -#[cfg(target_os = "windows")] -const USE_PERMISSIONS: bool = false; - -#[cfg(not(target_os = "windows"))] -const USE_PERMISSIONS: bool = true; - -/// Unpacks a given tar archive. -pub(crate) fn unpack(bytes: &[u8], dest_path: &Path) -> Result, AppError> { - let mut archive = Archive::new(GzDecoder::new(bytes)); - let mut written_paths = Vec::new(); - - // Get iterator over the entries. - let raw_entries = archive - .entries() - .map_err(|_| AppError("Couldn't get entries from the tarball.".to_string()))?; - - // Create output structure (if necessary). - create_output_structure(dest_path)?; - - for mut entry in raw_entries.flatten() { - let entry_path = entry - .path() - .map_err(|_| AppError("Couldn't get the entry's path.".to_string()))?; - - let fixed_path = fix_entry_path(&entry_path, dest_path); - - entry.set_preserve_permissions(USE_PERMISSIONS); - entry.set_unpack_xattrs(USE_XATTRS); - - entry - .unpack(&fixed_path) - .map_err(|_| AppError("Couldn't unpack the entry.".to_string()))?; - - written_paths.push(fixed_path); - } - - // Deduplicate, because it **will** contain duplicates. - written_paths.dedup(); - - Ok(written_paths) -} - -/// Recursively creates the output structure if there's more than 1 component in the destination -/// path AND if the destination path does not exist. -#[inline(always)] -fn create_output_structure(dest_path: &Path) -> Result<(), AppError> { - // FIXME: The use of `exists` method here is a bit worrisome, since it can open possibilities for - // TOCTOU attacks, so should probably replace with `try_exists`. - if dest_path.iter().count().gt(&1) && !dest_path.exists() { - fs::create_dir_all(dest_path) - .map_err(|_| AppError("Couldn't create the output structure.".to_string()))?; - } - - Ok(()) -} - -/// Produces a "fixed" path for an entry. -#[inline(always)] -fn fix_entry_path(entry_path: &Path, dest_path: &Path) -> PathBuf { - dest_path - .components() - .chain(entry_path.components().skip(1)) - .fold(PathBuf::new(), |acc, next| acc.join(next)) -} diff --git a/src/unpacker.rs b/src/unpacker.rs new file mode 100644 index 0000000..d1ee3da --- /dev/null +++ b/src/unpacker.rs @@ -0,0 +1,89 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +use flate2::bufread::GzDecoder; +use tar::Archive; +use thiserror::Error; + +#[cfg(target_os = "windows")] +const USE_XATTRS: bool = false; + +#[cfg(not(target_os = "windows"))] +const USE_XATTRS: bool = true; + +#[cfg(target_os = "windows")] +const USE_PERMISSIONS: bool = false; + +#[cfg(not(target_os = "windows"))] +const USE_PERMISSIONS: bool = true; + +#[derive(Debug, Error, PartialEq)] +pub enum UnpackError { + #[error("Couldn't get entries from the tarball.")] + UnableGetEntries, + #[error("Couldn't get the entry's path.")] + UnableGetEntryPath, + #[error("Couldn't create the output structure.")] + UnableCreateStructure, + #[error("Couldn't unpack the entry.")] + UnableUnpackEntry, +} + +pub struct Unpacker { + bytes: Vec, +} + +impl Unpacker { + pub fn new(bytes: Vec) -> Self { + Self { bytes } + } + + /// Unpacks the tar archive to the given [Path]. + pub fn unpack_to(&self, path: &Path) -> Result, UnpackError> { + let mut archive = Archive::new(GzDecoder::new(&self.bytes[..])); + let mut written_paths = Vec::new(); + + // Get iterator over the entries. + let raw_entries = archive + .entries() + .map_err(|_| UnpackError::UnableGetEntries)?; + + // Create output structure (if necessary). + fs::create_dir_all(&path).map_err(|_| UnpackError::UnableCreateStructure)?; + + for mut entry in raw_entries.flatten() { + let entry_path = entry.path().map_err(|_| UnpackError::UnableGetEntryPath)?; + + let fixed_path = fix_entry_path(&entry_path, path); + + entry.set_preserve_permissions(USE_PERMISSIONS); + entry.set_unpack_xattrs(USE_XATTRS); + + entry + .unpack(&fixed_path) + .map_err(|_| UnpackError::UnableUnpackEntry)?; + + written_paths.push(fixed_path); + } + + // Deduplicate, because it **will** contain duplicates. + written_paths.dedup(); + + Ok(written_paths) + } +} + +impl From> for Unpacker { + fn from(bytes: Vec) -> Self { + Unpacker::new(bytes) + } +} + +/// Produces a "fixed" path for an entry. +#[inline(always)] +fn fix_entry_path(entry_path: &Path, dest_path: &Path) -> PathBuf { + dest_path + .components() + .chain(entry_path.components().skip(1)) + .fold(PathBuf::new(), |acc, next| acc.join(next)) +}