From 5f76573c6312e029046dbd2aa3424bc36de67081 Mon Sep 17 00:00:00 2001 From: Vladislav Mamon Date: Fri, 12 Aug 2022 17:15:30 +0500 Subject: [PATCH 01/77] feat(config): add arx config resolver --- src/config.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/config.rs b/src/config.rs index b101eed..1228bbe 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,5 +1,9 @@ +use std::fs; +use std::path::Path; + use kdl::{KdlDocument, KdlEntry, KdlNode}; +use crate::app::AppError; use crate::graph::{DependenciesGraph, Node, Step}; /// Represents a replacement action. @@ -96,6 +100,20 @@ pub enum ActionSingle { Unknown, } +/// Resolves, reads and parses an arx config into a [KdlDocument]. +pub fn resolve_arx_config(root: &Path) -> Result { + let filename = root.join("arx.kdl"); + + 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); From 098b598cae752b582888f15eed7ddb40c27dbea4 Mon Sep 17 00:00:00 2001 From: Vladislav Mamon Date: Fri, 12 Aug 2022 17:18:49 +0500 Subject: [PATCH 02/77] feat(app): flesh out getting replacements and actions --- src/app.rs | 36 +++++++++++++++++++++++++++++++++++- src/tar.rs | 3 +-- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/src/app.rs b/src/app.rs index a61940d..3bcb2b3 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,7 +1,9 @@ use std::fmt; +use std::path::PathBuf; use clap::Parser; +use crate::config::{self, Action}; use crate::parser; use crate::repository::{Repository, RepositoryMeta}; use crate::tar; @@ -57,7 +59,39 @@ pub async fn run() -> Result<(), AppError> { // Fetch the tarball as bytes (compressed). let tarball = repository.fetch().await?; - tar::unpack(&tarball, &options.path.unwrap_or(repository.repo))?; + // Get destination path. + let destination = options + .path + .map(PathBuf::from) + .unwrap_or(PathBuf::from(repository.repo)); + + // Decompress and unpack the tarball. + tar::unpack(&tarball, &destination)?; + + // Read the kdl config. + let arx_config = config::resolve_arx_config(&destination)?; + + // Get replacements and actions. + let replacements = config::get_replacements(&arx_config); + let actions = config::get_actions(&arx_config); + + replacements.map(|items| { + items.iter().for_each(|item| { + let tag = &item.tag; + let description = &item.description; + + println!("{tag} = {description}"); + }) + }); + + actions.map(|action| { + if let Action::Suite(suites) = action { + let (resolved, unresolved) = config::resolve_requirements(&suites); + + println!("Resolved: {resolved:#?}"); + println!("Unresolved: {unresolved:#?}"); + } + }); Ok(()) } diff --git a/src/tar.rs b/src/tar.rs index 42f4c6f..1680e3f 100644 --- a/src/tar.rs +++ b/src/tar.rs @@ -19,10 +19,9 @@ const USE_PERMISSIONS: bool = false; const USE_PERMISSIONS: bool = true; /// Unpacks a given tar archive. -pub(crate) fn unpack(bytes: &[u8], dest: &String) -> Result, AppError> { +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(); - let dest_path = PathBuf::from(dest); // Get iterator over the entries. let raw_entries = archive From bb4f57b6a010a2f01e1b8abf1b7a05411f406d17 Mon Sep 17 00:00:00 2001 From: Vladislav Mamon Date: Fri, 12 Aug 2022 17:29:45 +0500 Subject: [PATCH 03/77] refactor(app): handle suites and single actions --- src/app.rs | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/src/app.rs b/src/app.rs index 3bcb2b3..46c6820 100644 --- a/src/app.rs +++ b/src/app.rs @@ -85,11 +85,18 @@ pub async fn run() -> Result<(), AppError> { }); actions.map(|action| { - if let Action::Suite(suites) = action { - let (resolved, unresolved) = config::resolve_requirements(&suites); - - println!("Resolved: {resolved:#?}"); - println!("Unresolved: {unresolved:#?}"); + match action { + | Action::Suite(suites) => { + let (resolved, unresolved) = config::resolve_requirements(&suites); + + println!("-- Action suites:"); + println!("Resolved: {resolved:#?}"); + println!("Unresolved: {unresolved:#?}"); + }, + | Action::Single(actions) => { + println!("-- Actions:"); + println!("Resolved: {actions:#?}"); + }, } }); From 1e18fcea9e5646af270b0d0e2150af47ee3f78cb Mon Sep 17 00:00:00 2001 From: Vladislav Mamon Date: Fri, 12 Aug 2022 17:32:13 +0500 Subject: [PATCH 04/77] refactor(app): apply clippy suggestions --- src/app.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/app.rs b/src/app.rs index 46c6820..0ac4857 100644 --- a/src/app.rs +++ b/src/app.rs @@ -63,7 +63,7 @@ pub async fn run() -> Result<(), AppError> { let destination = options .path .map(PathBuf::from) - .unwrap_or(PathBuf::from(repository.repo)); + .unwrap_or_else(|| PathBuf::from(repository.repo)); // Decompress and unpack the tarball. tar::unpack(&tarball, &destination)?; @@ -75,16 +75,16 @@ pub async fn run() -> Result<(), AppError> { let replacements = config::get_replacements(&arx_config); let actions = config::get_actions(&arx_config); - replacements.map(|items| { + if let Some(items) = replacements { items.iter().for_each(|item| { let tag = &item.tag; let description = &item.description; println!("{tag} = {description}"); }) - }); + } - actions.map(|action| { + if let Some(action) = actions { match action { | Action::Suite(suites) => { let (resolved, unresolved) = config::resolve_requirements(&suites); @@ -98,7 +98,7 @@ pub async fn run() -> Result<(), AppError> { println!("Resolved: {actions:#?}"); }, } - }); + } Ok(()) } From a348c4ac50df13d9f90d5f4f3f478e5e705f579d Mon Sep 17 00:00:00 2001 From: Vladislav Mamon Date: Fri, 12 Aug 2022 17:32:56 +0500 Subject: [PATCH 05/77] refactor(tar): apply clippy suggestions --- src/tar.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tar.rs b/src/tar.rs index 1680e3f..1d263ff 100644 --- a/src/tar.rs +++ b/src/tar.rs @@ -29,14 +29,14 @@ pub(crate) fn unpack(bytes: &[u8], dest_path: &Path) -> Result, App .map_err(|_| AppError("Couldn't get entries from the tarball.".to_string()))?; // Create output structure (if necessary). - create_output_structure(&dest_path)?; + 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); + let fixed_path = fix_entry_path(&entry_path, dest_path); entry.set_preserve_permissions(USE_PERMISSIONS); entry.set_unpack_xattrs(USE_XATTRS); From 5b9a02bd84e56f145e14bc24f1a2eb8a8ccc0ad5 Mon Sep 17 00:00:00 2001 From: Vladislav Mamon Date: Sat, 13 Aug 2022 20:26:47 +0500 Subject: [PATCH 06/77] feat: replacement processing basic functionality --- Cargo.toml | 2 +- src/app.rs | 17 +++++--------- src/lib.rs | 1 + src/processing.rs | 59 +++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 67 insertions(+), 12 deletions(-) create mode 100644 src/processing.rs diff --git a/Cargo.toml b/Cargo.toml index 0b75687..635fed7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,7 @@ kdl = "4.2" petgraph = { version = "0.6.2", features = ["stable_graph"] } reqwest = { version = "0.11.11", features = ["json"] } tar = { version = "0.4.38" } -tokio = { version = "1.20.1", features = ["macros", "rt-multi-thread"] } +tokio = { version = "1.20.1", features = ["macros", "fs", "rt-multi-thread"] } [profile.release] lto = "thin" diff --git a/src/app.rs b/src/app.rs index 0ac4857..486a1f5 100644 --- a/src/app.rs +++ b/src/app.rs @@ -4,9 +4,9 @@ use std::path::PathBuf; use clap::Parser; use crate::config::{self, Action}; -use crate::parser; use crate::repository::{Repository, RepositoryMeta}; use crate::tar; +use crate::{parser, processing}; /// Newtype for app errors which get propagated across the app. #[derive(Debug)] @@ -66,7 +66,7 @@ pub async fn run() -> Result<(), AppError> { .unwrap_or_else(|| PathBuf::from(repository.repo)); // Decompress and unpack the tarball. - tar::unpack(&tarball, &destination)?; + let unpacked = tar::unpack(&tarball, &destination)?; // Read the kdl config. let arx_config = config::resolve_arx_config(&destination)?; @@ -76,12 +76,7 @@ pub async fn run() -> Result<(), AppError> { let actions = config::get_actions(&arx_config); if let Some(items) = replacements { - items.iter().for_each(|item| { - let tag = &item.tag; - let description = &item.description; - - println!("{tag} = {description}"); - }) + processing::process_replacements(&unpacked, &items).await?; } if let Some(action) = actions { @@ -90,12 +85,12 @@ pub async fn run() -> Result<(), AppError> { let (resolved, unresolved) = config::resolve_requirements(&suites); println!("-- Action suites:"); - println!("Resolved: {resolved:#?}"); - println!("Unresolved: {unresolved:#?}"); + println!("Resolved: {resolved:?}"); + println!("Unresolved: {unresolved:?}"); }, | Action::Single(actions) => { println!("-- Actions:"); - println!("Resolved: {actions:#?}"); + println!("Resolved: {actions:?}"); }, } } diff --git a/src/lib.rs b/src/lib.rs index 0818419..3e8d1ce 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,5 +5,6 @@ pub mod config; mod graph; mod parser; +mod processing; mod repository; mod tar; diff --git a/src/processing.rs b/src/processing.rs new file mode 100644 index 0000000..9b59ada --- /dev/null +++ b/src/processing.rs @@ -0,0 +1,59 @@ +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(()) +} From 3072f76af05f61fecdba4dbca1275d4244efe5e2 Mon Sep 17 00:00:00 2001 From: Vladislav Mamon Date: Sat, 13 Aug 2022 20:27:16 +0500 Subject: [PATCH 07/77] style(main): remove commented out code --- src/main.rs | 36 ------------------------------------ 1 file changed, 36 deletions(-) diff --git a/src/main.rs b/src/main.rs index a9a02f7..6681cc3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,39 +4,3 @@ use arx::app::{self, AppError}; async fn main() -> Result<(), AppError> { app::run().await } - -// use std::env; -// use std::fs; - -// use kdl::KdlDocument; -// use imp::config::{self, Action}; - -// fn main() -> std::io::Result<()> { -// let filename = env::current_dir()?.join("arx.kdl"); - -// let contents = fs::read_to_string(filename)?; -// let doc: KdlDocument = contents.parse().expect("Failed to parse config file."); - -// let replacements = config::get_replacements(&doc); -// let actions = config::get_actions(&doc); - -// replacements.map(|items| { -// items.iter().for_each(|item| { -// let tag = &item.tag; -// let description = &item.description; - -// println!("{tag} = {description}"); -// }) -// }); - -// actions.map(|action| { -// if let Action::Suite(suites) = action { -// let (resolved, unresolved) = config::resolve_requirements(&suites); - -// println!("Resolved: {resolved:#?}"); -// println!("Unresolved: {unresolved:#?}"); -// } -// }); - -// Ok(()) -// } From b3417ed5b409b34a9b410452fd1198c2b5e16853 Mon Sep 17 00:00:00 2001 From: Vladislav Mamon Date: Sat, 13 Aug 2022 20:27:27 +0500 Subject: [PATCH 08/77] chore: update README --- README.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index c6cda21..be875d1 100644 --- a/README.md +++ b/README.md @@ -6,13 +6,18 @@ Simple CLI for scaffolding projects from templates in a touch. +## Status + +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 (**JSON** and **YAML**). +- 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 **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 From e3b23a76e215249da061598abe152ea926ca81da Mon Sep 17 00:00:00 2001 From: Vladislav Mamon Date: Wed, 13 Dec 2023 23:47:53 +0300 Subject: [PATCH 09/77] chore: update README and LICENSE --- LICENSE | 2 +- README.md | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/LICENSE b/LICENSE index c9afe90..5fb9289 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2021-2022 Vladislav Mamon +Copyright (c) 2023 Vladislav Mamon Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index be875d1..63980c4 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ WIP. 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 **JSON** and **YAML**. +- 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. @@ -37,6 +37,7 @@ Thanks to [Rich Harris][rich-harris] and his [degit] tool for inspiration. `:^)` [MIT](./LICENSE) + [degit]: https://github.com/Rich-Harris/degit [kdl]: https://github.com/kdl-org/kdl [rich-harris]: https://github.com/Rich-Harris From 52f4848cdcf2d9ac28769d098fac03b5fb3449f7 Mon Sep 17 00:00:00 2001 From: Vladislav Mamon Date: Wed, 13 Dec 2023 23:48:05 +0300 Subject: [PATCH 10/77] chore(deps): bump deps --- Cargo.toml | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 635fed7..64c3275 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,13 +9,17 @@ publish = false [dependencies] chumsky = { version = "0.8.0" } -clap = { version = "3.2.16", features = ["cargo", "derive"] } -flate2 = { version = "1.0.24" } -kdl = "4.2" -petgraph = { version = "0.6.2", features = ["stable_graph"] } -reqwest = { version = "0.11.11", features = ["json"] } -tar = { version = "0.4.38" } -tokio = { version = "1.20.1", features = ["macros", "fs", "rt-multi-thread"] } +clap = { version = "4.4.11", features = ["cargo", "derive"] } +flate2 = { version = "1.0.28" } +glob-match = { version = "0.2.1" } +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" } +tar = { version = "0.4.40" } +tokio = { version = "1.35.0", features = ["macros", "fs", "rt-multi-thread"] } +walkdir = { version = "2.4.0" } [profile.release] lto = "thin" From 912bde34e23a636e43b339b51b6fbb31a557fdb1 Mon Sep 17 00:00:00 2001 From: Vladislav Mamon Date: Wed, 13 Dec 2023 23:51:24 +0300 Subject: [PATCH 11/77] refactor(config): refactor config parsing and construction, clean up a bit --- src/config.rs | 90 +++++++++++++++++++++++++++++++-------------------- 1 file changed, 55 insertions(+), 35 deletions(-) diff --git a/src/config.rs b/src/config.rs index 1228bbe..4802e11 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,11 +1,13 @@ use std::fs; -use std::path::Path; +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 { @@ -60,8 +62,11 @@ pub enum Action { /// 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, } @@ -82,27 +87,36 @@ impl Node for ActionSuite { pub enum ActionSingle { /// Copies a file or directory. Glob-friendly. Overwrites by default. Copy { - from: Option, - to: Option, + from: Option, + to: Option, overwrite: bool, }, /// Moves a file or directory. Glob-friendly. Overwrites by default. Move { - from: Option, - to: Option, + from: Option, + to: Option, overwrite: bool, }, /// Deletes a file or directory. Glob-friendly. - Delete { target: Option }, + Delete { target: Option }, /// Runs an arbitrary command in the shell. Run { command: Option }, - /// Fallback action for pattern matching ergonomics. - Unknown, + /// 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.kdl"); + 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()))?; @@ -132,15 +146,15 @@ pub fn resolve_requirements(suites: &[ActionSuite]) -> (Vec, Vec Option { doc .get("actions") - .and_then(|node| node.children()) + .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::>(); + let suites = nodes.iter().filter_map(to_action_suite).collect(); Action::Suite(suites) } else { - let actions = nodes.iter().filter_map(to_action).collect::>(); + let actions = nodes.iter().filter_map(to_action_single).collect(); Action::Single(actions) } }) @@ -150,7 +164,7 @@ pub fn get_actions(doc: &KdlDocument) -> Option { pub fn get_replacements(doc: &KdlDocument) -> Option> { doc .get("replacements") - .and_then(|node| node.children()) + .and_then(KdlNode::children) .map(|children| { children .nodes() @@ -164,6 +178,7 @@ pub fn get_replacements(doc: &KdlDocument) -> Option> { fn to_replacement(node: &KdlNode) -> Option { let tag = node.name().to_string(); + let description = node .get(0) .and_then(entry_to_string) @@ -172,18 +187,9 @@ fn to_replacement(node: &KdlNode) -> Option { Some(Replacement { tag, description }) } -fn to_action(node: &KdlNode) -> Option { - let action = to_action_single(node); - - if let ActionSingle::Unknown = action { - None - } else { - Some(action) - } -} - 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() @@ -195,7 +201,7 @@ fn to_action_suite(node: &KdlNode) -> Option { children .nodes() .iter() - .map(to_action_single) + .filter_map(to_action_single) .collect::>() }); @@ -218,33 +224,33 @@ fn to_action_suite(node: &KdlNode) -> Option { } /// TODO: This probably should be refactored and abstracted away into something separate. -fn to_action_single(node: &KdlNode) -> ActionSingle { +fn to_action_single(node: &KdlNode) -> Option { let action_kind = node.name().to_string(); - match action_kind.to_ascii_lowercase().as_str() { + let action = match action_kind.to_ascii_lowercase().as_str() { | "copy" => { ActionSingle::Copy { - from: node.get("from").and_then(entry_to_string), - to: node.get("to").and_then(entry_to_string), + from: node.get("from").and_then(entry_to_pathbuf), + to: node.get("to").and_then(entry_to_pathbuf), overwrite: node .get("overwrite") - .and_then(|value| value.value().as_bool()) + .and_then(entry_to_bool) .unwrap_or(true), } }, | "move" => { ActionSingle::Move { - from: node.get("from").and_then(entry_to_string), - to: node.get("to").and_then(entry_to_string), + from: node.get("from").and_then(entry_to_pathbuf), + to: node.get("to").and_then(entry_to_pathbuf), overwrite: node .get("overwrite") - .and_then(|value| value.value().as_bool()) + .and_then(entry_to_bool) .unwrap_or(true), } }, | "delete" => { ActionSingle::Delete { - target: node.get(0).and_then(entry_to_string), + target: node.get(0).and_then(entry_to_pathbuf), } }, | "run" => { @@ -252,8 +258,14 @@ fn to_action_single(node: &KdlNode) -> ActionSingle { command: node.get(0).and_then(entry_to_string), } }, - | _ => ActionSingle::Unknown, - } + | action => { + ActionSingle::Unknown { + name: action.to_string(), + } + }, + }; + + Some(action) } fn is_suite(node: &KdlNode) -> bool { @@ -263,3 +275,11 @@ fn is_suite(node: &KdlNode) -> bool { 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) +} From 0315bd44cb6786862a5f3e94415f30559c001394 Mon Sep 17 00:00:00 2001 From: Vladislav Mamon Date: Wed, 13 Dec 2023 23:52:18 +0300 Subject: [PATCH 12/77] refactor(parser): slightly change host parser --- src/parser.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/parser.rs b/src/parser.rs index 55eefc9..acdb399 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -27,7 +27,7 @@ pub(crate) fn shortcut(input: &str) -> Result { /// - `gitlab` or `gl` /// - `bitbucket` or `bb` fn host() -> impl Parser> { - let host = filter::<_, _, Cheap>(|ch: &char| ch.is_ascii_alphabetic()) + let host = filter::<_, _, Cheap>(char::is_ascii_alphabetic) .repeated() .at_least(1) .collect::() From 45b7dc9c93b9024582a2f284e7dd3c11b326cadf Mon Sep 17 00:00:00 2001 From: Vladislav Mamon Date: Thu, 14 Dec 2023 00:05:18 +0300 Subject: [PATCH 13/77] refactor(repository): derive `Default` impl --- src/repository.rs | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/repository.rs b/src/repository.rs index b463f2d..884ffa9 100644 --- a/src/repository.rs +++ b/src/repository.rs @@ -1,19 +1,14 @@ use crate::app::AppError; /// Supported hosts. [GitHub][RepositoryHost::GitHub] is the default one. -#[derive(Debug)] +#[derive(Debug, Default)] pub(crate) enum RepositoryHost { + #[default] GitHub, GitLab, BitBucket, } -impl Default for RepositoryHost { - fn default() -> Self { - RepositoryHost::GitHub - } -} - /// Container for a repository host. #[derive(Debug)] pub(crate) enum Host { From 6071bea321d491c5f7be47980b032c34aa847659 Mon Sep 17 00:00:00 2001 From: Vladislav Mamon Date: Thu, 14 Dec 2023 00:05:52 +0300 Subject: [PATCH 14/77] refactor(tar): remove unnecessary reference --- src/tar.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tar.rs b/src/tar.rs index 1d263ff..005b96a 100644 --- a/src/tar.rs +++ b/src/tar.rs @@ -61,7 +61,7 @@ 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) + fs::create_dir_all(dest_path) .map_err(|_| AppError("Couldn't create the output structure.".to_string()))?; } From 5fbb805d6fc78a89d2f0267d5e1f18f31fa5bede Mon Sep 17 00:00:00 2001 From: Vladislav Mamon Date: Mon, 18 Dec 2023 15:16:16 +0300 Subject: [PATCH 15/77] chore(spec): redefine arx spec --- arx.kdl | 31 ------------ spec.kdl | 141 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 141 insertions(+), 31 deletions(-) delete mode 100644 arx.kdl create mode 100644 spec.kdl diff --git a/arx.kdl b/arx.kdl deleted file mode 100644 index 0a1ad82..0000000 --- a/arx.kdl +++ /dev/null @@ -1,31 +0,0 @@ -// Static replacements -replacements { - R_NAME "Repository name" - R_DESC "Repository description" - R_AUTHOR "Repository author" -} - -// Actions to perform on files -actions { - suite name="init" { - copy from="path/to/file/or/dir" to="path/to/target" - copy from="glob/pattern" to="path/to/target/dir" - - // Can also be used to rename files or directories - move from="path/to/file/or/dir" to="path/to/target" - move from="glob/pattern" to="path/to/target/dir" - - delete "path/to/file/or/dir" - delete "glob/pattern" - } - - suite name="lint" requires="init" { - run "cargo fmt" - } - - suite name="git" requires="init lint" { - run "git init" - run "git add ." - run "git commit -m 'chore: init'" - } -} diff --git a/spec.kdl b/spec.kdl new file mode 100644 index 0000000..ddbebcc --- /dev/null +++ b/spec.kdl @@ -0,0 +1,141 @@ +// Options defined here can be overridden from CLI. +options { + // Remove arx.kdl after it's done. Defaults to `true`. + remove false +} + +// Actions to run after the repository was successfully downloaded and unpacked. All actions or +// suites of actions run sequentially, there is no concurrency or out-of-order execution for +// predictable outcome. +// +// You can define either suites of actions — named groups of actions — or a flat list of actions, +// but not both. +// +// Notes: +// +// - Unpacking into an existing destination is forbidden. +// - Invalid or unknown actions, nodes or replacements will be skipped. Warnings will be issued. +// - Action failure terminates the main process. +// - No cleanup on failures whatsoever. +actions { + suite "hello" { + // This action simply echoes the argument into stdout. Raw strings are trimmed and aligned to + // the leftmost non-whitespace character. + echo r#" + Sup! Let's set everything up. We will: + + - Print this message. + - Ask some questions via prompts. + - Initialize git repository. + - Run some commands that will use input from prompts. + - Commit everything. + "# + } + + suite "git" { + // This action runs a given shell command and prints its output to stdout. + run "git init" + } + + // In this suite we run a series of prompts asking different questions. + // + // Answers will be stored globally and available from any _subsequent_ action ar suite of actions. + suite "prompts" { + // Simple text prompt. + prompt "R_NAME" { + type "input" + hint "Repository name" + default "norskeld/serpent" + } + + // This will run user's $EDITOR. + prompt "R_DESC" { + type "editor" + hint "Repository description" + } + + // If you don't specify prompt `type`, it will default to "input". + prompt "R_AUTHOR" { + hint "Repository author" + default "Vladislav M." + } + + // Simple select prompt. + prompt "R_PM" { + type "select" + hint "Package manager of choice" + variants "npm" "pnpm" "yarn" "bun" + default "npm" + } + + // If you don't specify default value using `default` node it will be an empty string. + prompt "R_PM_ARGS" { + hint "Additional arguments for package manager" + } + } + + // Here we demonstrate using replacements. + suite "replacements" { + // Replace all occurences of given replacements in files that match the glob pattern. + replace in=".template/**" { + "R_NAME" + "R_DESC" + "R_AUTHOR" + } + + // Replace all occurences of given replacements in _all_ files. This is equivalent to "." as the + // glob pattern. + replace { + "R_PM" + } + + // Trying to run a non-existent replacement will do nothing (a warning will be issued though). + replace { + "NONEXISTENTREPLACEMENT" + } + } + + // In this suite we demonstrate actions for operating on files. All these actions support glob + // patterns, except the `to` field, that should be a relative path. + // + // Note: + // + // - Paths don't expand, i.e. ~ won't expand to the home directory and env vars won't work either. + // - Paths don't escape the target directory, so delete "../../../**/*" won't work. + suite "files" { + cp from=".template/*.toml" to="." + rm ".template/*.toml" + mv from=".template/**/*" to="." + rm ".template" + } + + // Here we demonstrate how to inject prompts' values. + suite "install" { + // 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. + // + // All replacements are processed _before_ running a command. + run "{R_PM} install {R_PM_ARGS}" { + inject "R_PM" "R_PM_ARGS" + } + } + + // Here we demonstrate multiline commands using `run`. + suite "commit" { + // Similarly to the `echo` action you can use raw strings to define multiline commands. Plus, + // you don't have to escape anything. + // + // The action below will be executed as if it were two separate `run` actions: + // + // run "git add ." + // run "git commit -m 'chore: init repository'" + // + // You can name `run` actions for clarity, otherwise it will use either the command itself or + // the first line of a multiline command as the hint. + run name="stage and commit" r#" + git add . + git commit -m 'chore: init repository' + "# + } +} From 870d281af961933d012809eea089447bbc2777a3 Mon Sep 17 00:00:00 2001 From: Vladislav Mamon Date: Sat, 6 Jan 2024 23:25:24 +0300 Subject: [PATCH 16/77] chore: update spec.kdl --- spec.kdl | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/spec.kdl b/spec.kdl index ddbebcc..4ce799b 100644 --- a/spec.kdl +++ b/spec.kdl @@ -42,34 +42,36 @@ actions { // Answers will be stored globally and available from any _subsequent_ action ar suite of actions. suite "prompts" { // Simple text prompt. - prompt "R_NAME" { - type "input" + prompt "input" { + name "R_NAME" hint "Repository name" default "norskeld/serpent" } // This will run user's $EDITOR. - prompt "R_DESC" { - type "editor" + prompt "editor" { + name "R_DESC" hint "Repository description" } // If you don't specify prompt `type`, it will default to "input". - prompt "R_AUTHOR" { + prompt { + name "R_AUTHOR" hint "Repository author" default "Vladislav M." } // Simple select prompt. - prompt "R_PM" { - type "select" + prompt "select" { + name "R_PM" hint "Package manager of choice" variants "npm" "pnpm" "yarn" "bun" default "npm" } // If you don't specify default value using `default` node it will be an empty string. - prompt "R_PM_ARGS" { + prompt { + name "R_PM_ARGS" hint "Additional arguments for package manager" } } From 1f87faf8c838b88d46c53f9c24fb006a9888e99c Mon Sep 17 00:00:00 2001 From: Vladislav Mamon Date: Sat, 6 Jan 2024 23:26:04 +0300 Subject: [PATCH 17/77] 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)) +} From 17a3b4a06bb57bc0b86a234c96df18e2439c65b0 Mon Sep 17 00:00:00 2001 From: Vladislav Mamon Date: Thu, 29 Feb 2024 22:36:32 +0300 Subject: [PATCH 18/77] refactor: apply clippy suggestions --- src/app.rs | 8 +++++++- src/fs/traverser.rs | 4 ++-- src/lib.rs | 2 ++ src/manifest/manifest.rs | 12 +++++------- src/path/expand.rs | 2 +- src/repository.rs | 8 ++++---- src/unpacker.rs | 2 +- 7 files changed, 22 insertions(+), 16 deletions(-) diff --git a/src/app.rs b/src/app.rs index bc18c97..94c3d90 100644 --- a/src/app.rs +++ b/src/app.rs @@ -66,7 +66,7 @@ impl App { }, | Repository::Local(local) => { // TODO: Check if source exists and valid. - let source = PathBuf::from(local.source.clone()).expand(); + let source = local.source.clone().expand(); let destination = if let Some(destination) = options.path { PathBuf::from(destination).expand() @@ -103,3 +103,9 @@ impl App { Ok(()) } } + +impl Default for App { + fn default() -> Self { + Self::new() + } +} diff --git a/src/fs/traverser.rs b/src/fs/traverser.rs index fd3f6f4..6f91984 100644 --- a/src/fs/traverser.rs +++ b/src/fs/traverser.rs @@ -120,8 +120,8 @@ impl<'t> Iterator for TraverserIterator<'t> { 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(); + if let Some(captures) = glob_match_with_captures(pattern, &candidate) { + let range = captures.first().cloned().unwrap_or_default(); let captured = PathBuf::from(&candidate[range.start..]); return Some(Ok(Match { diff --git a/src/lib.rs b/src/lib.rs index 4152f0d..f5b9d9f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,5 @@ +#![allow(clippy::module_inception)] + pub mod app; pub mod error; pub mod fs; diff --git a/src/manifest/manifest.rs b/src/manifest/manifest.rs index 6efd207..0676a51 100644 --- a/src/manifest/manifest.rs +++ b/src/manifest/manifest.rs @@ -262,7 +262,7 @@ impl Manifest { let nodes = children.nodes(); let mut defaults = ManifestOptions::default(); - for node in nodes.into_iter() { + for node in nodes { let name = node.name().to_string().to_ascii_lowercase(); match name.as_str() { @@ -290,7 +290,7 @@ impl Manifest { fn get_actions(&self, doc: &KdlDocument) -> Result { #[inline] fn is_suite(node: &KdlNode) -> bool { - node.name().value().to_string() == "suite" + node.name().value() == "suite" } #[inline] @@ -346,7 +346,7 @@ impl Manifest { let name = node.get_string(0).ok_or(ManifestError::ExpectedSuiteName)?; if let Some(children) = node.children() { - for children in children.nodes().into_iter() { + for children in children.nodes() { let action = self.get_action_single(children)?; actions.push(action); } @@ -439,7 +439,7 @@ impl Manifest { .map(|children| { children .nodes() - .into_iter() + .iter() .map(|node| node.name().value().to_string()) .collect::>() }) @@ -529,9 +529,7 @@ impl Manifest { default: nodes.get("default").and_then(|node| node.get_bool(0)), }) }, - | kind => { - return Err(ManifestError::UnknownPrompt(kind.into())); - }, + | kind => Err(ManifestError::UnknownPrompt(kind.into())), } } } diff --git a/src/path/expand.rs b/src/path/expand.rs index db1fe97..f558ca0 100644 --- a/src/path/expand.rs +++ b/src/path/expand.rs @@ -10,7 +10,7 @@ fn home() -> Option { fn context(name: &str) -> Result, VarError> { match env::var(name) { - | Ok(value) => Ok(Some(value.into())), + | Ok(value) => Ok(Some(value)), | Err(VarError::NotPresent) => Ok(Some("".into())), | Err(err) => Err(err), } diff --git a/src/repository.rs b/src/repository.rs index 36cb785..fb15001 100644 --- a/src/repository.rs +++ b/src/repository.rs @@ -243,7 +243,7 @@ impl LocalRepository { match reference { // Here `gref`` is an actual reference like branch or tag. | Some(gref) => { - let ref_name = gref.name().ok_or_else(|| CheckoutError::InvalidRefName)?; + let ref_name = gref.name().ok_or(CheckoutError::InvalidRefName)?; repository .set_head(ref_name) @@ -321,7 +321,7 @@ impl FromStr for Repository { let input = input.trim(); // Parse host if present or use default otherwise. - let (host, input) = if let Some((host, rest)) = input.split_once(":") { + 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), @@ -333,7 +333,7 @@ impl FromStr for Repository { }; // Parse user name. - let (user, input) = if let Some((user, rest)) = input.split_once("/") { + let (user, input) = if let Some((user, rest)) = input.split_once('/') { if user.chars().all(is_valid_user) { (user.to_string(), rest) } else { @@ -344,7 +344,7 @@ impl FromStr for Repository { }; // Parse repository name. - let (repo, input) = if let Some((repo, rest)) = input.split_once("#") { + let (repo, input) = if let Some((repo, rest)) = input.split_once('#') { if repo.chars().all(is_valid_repo) { (repo.to_string(), Some(rest)) } else { diff --git a/src/unpacker.rs b/src/unpacker.rs index d1ee3da..d5fba11 100644 --- a/src/unpacker.rs +++ b/src/unpacker.rs @@ -49,7 +49,7 @@ impl Unpacker { .map_err(|_| UnpackError::UnableGetEntries)?; // Create output structure (if necessary). - fs::create_dir_all(&path).map_err(|_| UnpackError::UnableCreateStructure)?; + 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)?; From de0c7b3a63942ba14d92d5d22d3003a01ca2834b Mon Sep 17 00:00:00 2001 From: Vladislav Mamon Date: Thu, 29 Feb 2024 22:36:50 +0300 Subject: [PATCH 19/77] chore: add `clippy.toml` with MSRV specified --- clippy.toml | 1 + 1 file changed, 1 insertion(+) create mode 100644 clippy.toml diff --git a/clippy.toml b/clippy.toml new file mode 100644 index 0000000..3b9db9d --- /dev/null +++ b/clippy.toml @@ -0,0 +1 @@ +msrv = "1.74.0" From c09115bae448a8927e3681a75690a2c779caa7ae Mon Sep 17 00:00:00 2001 From: Vladislav Mamon Date: Thu, 29 Feb 2024 22:45:20 +0300 Subject: [PATCH 20/77] refactor: remove unnecessary `else` blocks --- src/fs/traverser.rs | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/fs/traverser.rs b/src/fs/traverser.rs index 6f91984..f3a92fa 100644 --- a/src/fs/traverser.rs +++ b/src/fs/traverser.rs @@ -129,18 +129,18 @@ impl<'t> Iterator for TraverserIterator<'t> { 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, - })); + + item = self.it.next()?; + + continue 'skip; } + + return Some(Ok(Match { + path: path.to_path_buf(), + captured: path.to_path_buf(), + entry, + })); }, | Err(err) => return Some(Err(TraverseError::InvalidEntry(err))), } From 2bedfce479f5a386b8dce4fb20536cd5cbd788b6 Mon Sep 17 00:00:00 2001 From: Vladislav Mamon Date: Fri, 1 Mar 2024 20:00:28 +0300 Subject: [PATCH 21/77] 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() - })) - ); - } } From 6f2d168f3dcf4fc75f70be3a21536c2ff8bb0770 Mon Sep 17 00:00:00 2001 From: Vladislav Mamon Date: Sun, 3 Mar 2024 01:09:47 +0300 Subject: [PATCH 22/77] chore: update spec.kdl --- spec.kdl | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/spec.kdl b/spec.kdl index 4ce799b..015612d 100644 --- a/spec.kdl +++ b/spec.kdl @@ -1,7 +1,7 @@ // Options defined here can be overridden from CLI. options { - // Remove arx.kdl after it's done. Defaults to `true`. - remove false + // Delete arx.kdl after it's done. Defaults to `true`. + delete false } // Actions to run after the repository was successfully downloaded and unpacked. All actions or @@ -16,12 +16,12 @@ options { // - Unpacking into an existing destination is forbidden. // - Invalid or unknown actions, nodes or replacements will be skipped. Warnings will be issued. // - Action failure terminates the main process. -// - No cleanup on failures whatsoever. +// - No cleanup on failures by default. actions { suite "hello" { // This action simply echoes the argument into stdout. Raw strings are trimmed and aligned to // the leftmost non-whitespace character. - echo r#" + echo trim=true r#" Sup! Let's set everything up. We will: - Print this message. From cc57f379e7022c3125587aefb5d2cd00b3f8e513 Mon Sep 17 00:00:00 2001 From: Vladislav Mamon Date: Sun, 3 Mar 2024 02:32:12 +0300 Subject: [PATCH 23/77] feat(executor): flesh out basic executor, implement `echo` actions --- Cargo.toml | 1 + rustfmt.toml | 1 + src/actions/actions.rs | 69 +++++++++++++ src/actions/executor.rs | 78 ++++++++++++++ src/actions/mod.rs | 4 + src/app.rs | 32 ++++-- src/fs/traverser.rs | 6 +- src/lib.rs | 1 + src/manifest/actions.rs | 100 ++++++++++++++++++ src/manifest/manifest.rs | 216 ++++++++++++--------------------------- src/manifest/mod.rs | 3 + src/manifest/prompts.rs | 41 ++++++++ src/manifest/utils.rs | 15 +-- src/repository.rs | 14 +-- 14 files changed, 393 insertions(+), 188 deletions(-) create mode 100644 src/actions/actions.rs create mode 100644 src/actions/executor.rs create mode 100644 src/actions/mod.rs create mode 100644 src/manifest/actions.rs create mode 100644 src/manifest/prompts.rs diff --git a/Cargo.toml b/Cargo.toml index 300122d..4c6de4f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,7 @@ run_script = { version = "0.10.1" } tar = { version = "0.4.40" } thiserror = { version = "1.0.51" } tokio = { version = "1.35.0", features = ["macros", "fs", "rt-multi-thread"] } +unindent = "0.2.3" walkdir = { version = "2.4.0" } [profile.release] diff --git a/rustfmt.toml b/rustfmt.toml index 5b21b97..c1c6559 100644 --- a/rustfmt.toml +++ b/rustfmt.toml @@ -22,6 +22,7 @@ match_block_trailing_comma = true # General width constraints. max_width = 100 +struct_lit_width = 40 # Match tweaks. match_arm_leading_pipes = "Always" # Because I'm a weirdo. diff --git a/src/actions/actions.rs b/src/actions/actions.rs new file mode 100644 index 0000000..0cb65c0 --- /dev/null +++ b/src/actions/actions.rs @@ -0,0 +1,69 @@ +use unindent::Unindent; + +use crate::actions::Replacements; +use crate::manifest::actions::*; + +impl Copy { + pub async fn execute(&self) -> anyhow::Result<()> { + Ok(println!("cp action")) + } +} + +impl Move { + pub async fn execute(&self) -> anyhow::Result<()> { + Ok(println!("mv action")) + } +} + +impl Delete { + pub async fn execute(&self) -> anyhow::Result<()> { + Ok(println!("rm action")) + } +} + +impl Echo { + pub async fn execute(&self, replacements: &Replacements) -> anyhow::Result<()> { + let message = if self.trim { + self.message.trim() + } else { + &self.message + }; + + let mut message = message.unindent(); + + if let Some(injects) = &self.injects { + for inject in injects { + if let Some(value) = replacements.get(inject) { + message = message.replace(&format!("{{{inject}}}"), value); + } + } + } + + Ok(println!("{message}")) + } +} + +impl Run { + pub async fn execute(&self, _replacements: &Replacements) -> anyhow::Result<()> { + Ok(println!("run action")) + } +} + +impl Prompt { + // TODO: This will require mutable reference to `Executor` or `prompts`. + pub async fn execute(&self, _replacements: &mut Replacements) -> anyhow::Result<()> { + Ok(println!("prompt action")) + } +} + +impl Replace { + pub async fn execute(&self, _replacements: &Replacements) -> anyhow::Result<()> { + Ok(println!("replace action")) + } +} + +impl Unknown { + pub async fn execute(&self) -> anyhow::Result<()> { + Ok(println!("unknown action {}", self.name)) + } +} diff --git a/src/actions/executor.rs b/src/actions/executor.rs new file mode 100644 index 0000000..e0d2401 --- /dev/null +++ b/src/actions/executor.rs @@ -0,0 +1,78 @@ +use std::collections::HashMap; + +use console::style; + +use crate::manifest::{ActionSingle, ActionSuite, Actions, Manifest}; + +/// Alias for a map of string replacements. +pub type Replacements = HashMap; + +pub struct Executor { + manifest: Manifest, +} + +impl Executor { + pub fn new(manifest: Manifest) -> Self { + Self { manifest } + } + + pub async fn execute(&self) -> anyhow::Result<()> { + let executor = match &self.manifest.actions { + | Actions::Suite(suites) => self.execute_suite(suites).await, + | Actions::Flat(actions) => self.execute_flat(actions).await, + | Actions::Empty => return Ok(println!("No actions found.")), + }; + + Ok(executor?) + } + + async fn execute_suite(&self, suites: &[ActionSuite]) -> anyhow::Result<()> { + let mut replacements = HashMap::::new(); + + for ActionSuite { name, actions, .. } in suites.iter() { + println!( + "{symbol} {title}: {name}\n", + symbol = style("◆").blue().bold(), + title = style("Running suite").blue(), + name = style(name).green() + ); + + for action in actions.iter() { + self.execute_single(action, &mut replacements).await?; + println!(); + } + } + + Ok(()) + } + + async fn execute_flat(&self, actions: &[ActionSingle]) -> anyhow::Result<()> { + let mut injects = HashMap::::new(); + + for action in actions.iter() { + self.execute_single(action, &mut injects).await?; + println!(); + } + + Ok(()) + } + + async fn execute_single( + &self, + action: &ActionSingle, + replacements: &mut Replacements, + ) -> anyhow::Result<()> { + let executor = match action { + | ActionSingle::Copy(action) => action.execute().await, + | ActionSingle::Move(action) => action.execute().await, + | ActionSingle::Delete(action) => action.execute().await, + | ActionSingle::Echo(action) => action.execute(&replacements).await, + | ActionSingle::Run(action) => action.execute(&replacements).await, + | ActionSingle::Prompt(action) => action.execute(replacements).await, + | ActionSingle::Replace(action) => action.execute(&replacements).await, + | ActionSingle::Unknown(action) => action.execute().await, + }; + + Ok(executor?) + } +} diff --git a/src/actions/mod.rs b/src/actions/mod.rs new file mode 100644 index 0000000..092f90c --- /dev/null +++ b/src/actions/mod.rs @@ -0,0 +1,4 @@ +pub use executor::*; + +mod actions; +mod executor; diff --git a/src/app.rs b/src/app.rs index a90b140..8f3a964 100644 --- a/src/app.rs +++ b/src/app.rs @@ -3,6 +3,7 @@ use std::path::PathBuf; use clap::{Parser, Subcommand}; +use crate::actions::Executor; use crate::manifest::Manifest; use crate::repository::{LocalRepository, RemoteRepository}; use crate::unpacker::Unpacker; @@ -73,14 +74,25 @@ impl App { 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, - } + // Load the manifest. + let manifest = 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?, + }; + + // Create executor and kick off execution. + let executor = Executor::new(manifest); + executor.execute().await?; + + Ok(()) } /// Preparation flow for remote repositories. - async fn remote(src: String, path: Option, meta: Option) -> anyhow::Result<()> { + async fn remote( + src: String, + path: Option, + meta: Option, + ) -> anyhow::Result { // Parse repository. let remote = RemoteRepository::new(src, meta)?; @@ -98,11 +110,15 @@ impl App { let mut manifest = Manifest::with_options(&destination); manifest.load()?; - Ok(()) + Ok(manifest) } /// Preparation flow for local repositories. - async fn local(src: String, path: Option, meta: Option) -> anyhow::Result<()> { + async fn local( + src: String, + path: Option, + meta: Option, + ) -> anyhow::Result { // Create repository. let local = LocalRepository::new(src, meta); @@ -132,7 +148,7 @@ impl App { let mut manifest = Manifest::with_options(&destination); manifest.load()?; - Ok(()) + Ok(manifest) } } diff --git a/src/fs/traverser.rs b/src/fs/traverser.rs index f3a92fa..4af0178 100644 --- a/src/fs/traverser.rs +++ b/src/fs/traverser.rs @@ -81,11 +81,7 @@ impl Traverser { .as_ref() .map(|pat| self.options.root.join(pat).display().to_string()); - TraverserIterator { - it, - root_pattern, - options: &self.options, - } + TraverserIterator { it, root_pattern, options: &self.options } } } diff --git a/src/lib.rs b/src/lib.rs index f5b9d9f..022377b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,6 @@ #![allow(clippy::module_inception)] +pub mod actions; pub mod app; pub mod error; pub mod fs; diff --git a/src/manifest/actions.rs b/src/manifest/actions.rs new file mode 100644 index 0000000..b66c399 --- /dev/null +++ b/src/manifest/actions.rs @@ -0,0 +1,100 @@ +use std::collections::HashSet; + +use crate::manifest::prompts::*; + +/// Copies a file or directory. Glob-friendly. Overwrites by default. +#[derive(Debug)] +pub struct Copy { + /// Source(s) to copy. + pub from: String, + /// Where to copy to. + pub to: String, + /// Whether to overwrite or not. + pub overwrite: bool, +} + +/// Moves a file or directory. Glob-friendly. Overwrites by default. +#[derive(Debug)] +pub struct Move { + /// Source(s) to move. + pub from: String, + /// Where to move to. + pub to: String, + /// Whether to overwrite or not. + pub overwrite: bool, +} + +/// Deletes a file or directory. Glob-friendly. +#[derive(Debug)] +pub struct Delete { + /// Target to delete. + pub target: String, +} + +/// Echoes a message to stdout. +#[derive(Debug)] +pub struct Echo { + /// Message to output. + pub message: String, + /// An optional list of placeholders to be injected into the command. + /// + /// ```kdl + /// echo "Hello {R_PM}" { + /// inject "R_PM" + /// } + /// ``` + /// + /// All placeholders are processed _before_ running a command. + pub injects: Option>, + /// Whether to trim multiline message or not. + pub trim: bool, +} + +/// Runs an arbitrary command in the shell. +#[derive(Debug)] +pub struct Run { + /// Command name. Optional, defaults either to the command itself or to the first line of + /// the multiline command. + pub name: Option, + /// Comannd to run in the shell. + pub command: String, + /// An optional list of placeholders 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 placeholder + /// that should be replaced with something. + /// + /// ```kdl + /// run "{R_PM} install {R_PM_ARGS}" { + /// inject "R_PM" "R_PM_ARGS" + /// } + /// ``` + /// + /// All placeholders are processed _before_ running a command. + pub injects: Option>, +} + +/// Prompt actions. +#[derive(Debug)] +pub enum Prompt { + Input(Input), + Select(Select), + Confirm(Confirm), + Editor(Editor), +} + +/// Execute given replacements using values provided by prompts. Optionally, only apply +/// replacements to files matching the provided glob. +#[derive(Debug)] +pub struct Replace { + /// Replacements to apply. + pub replacements: HashSet, + /// Optional glob to limit files to apply replacements to. + pub glob: Option, +} + +/// Fallback action for pattern matching ergonomics and reporting purposes. +#[derive(Debug)] +pub struct Unknown { + pub name: String, +} diff --git a/src/manifest/manifest.rs b/src/manifest/manifest.rs index 0676a51..030ebf5 100644 --- a/src/manifest/manifest.rs +++ b/src/manifest/manifest.rs @@ -5,6 +5,8 @@ use std::path::{Path, PathBuf}; use kdl::{KdlDocument, KdlNode}; use thiserror::Error; +use crate::manifest::actions::*; +use crate::manifest::prompts::*; use crate::manifest::KdlUtils; const MANIFEST_NAME: &str = "arx.kdl"; @@ -52,9 +54,9 @@ impl Default for ManifestOptions { } } -/// Represents a manifest actions set that can be either an [ActionSuite] *or* an [ActionSingle]. +/// Represents a manifest actions set that can be a vec of [ActionSuite] *or* [ActionSingle]. /// -/// Actions should be defined either like this: +/// So, actions should be defined either like this: /// /// ```kdl /// actions { @@ -76,7 +78,7 @@ impl Default for ManifestOptions { #[derive(Debug)] pub enum Actions { Suite(Vec), - Single(Vec), + Flat(Vec), Empty, } @@ -85,7 +87,7 @@ pub enum Actions { pub struct ActionSuite { /// Suite name. pub name: String, - /// Suite actions to run (synchronously). + /// Suite actions to run. pub actions: Vec, } @@ -93,119 +95,33 @@ pub struct ActionSuite { #[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, - }, + Copy(Copy), /// 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, - }, + Move(Move), /// 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, - }, + Delete(Delete), + /// Echoes a message to stdout. + Echo(Echo), /// 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>, - }, + Run(Run), /// 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, - }, + Replace(Replace), /// 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, - }, + Unknown(Unknown), } /// Arx manifest (config). #[derive(Debug)] pub struct Manifest { /// Manifest directory. - root: PathBuf, + pub root: PathBuf, /// Manifest options. - options: ManifestOptions, + pub options: ManifestOptions, /// Actions. - actions: Actions, + pub actions: Actions, } impl Manifest { @@ -254,6 +170,7 @@ impl Manifest { Ok(document) } + /// Tries to parse options from the manifest. fn get_options(&self, doc: &KdlDocument) -> Result { let options = doc .get("options") @@ -287,6 +204,7 @@ impl Manifest { } } + /// Tries to parse actions from the manifest. fn get_actions(&self, doc: &KdlDocument) -> Result { #[inline] fn is_suite(node: &KdlNode) -> bool { @@ -294,7 +212,7 @@ impl Manifest { } #[inline] - fn is_not_suite(node: &KdlNode) -> bool { + fn is_flat(node: &KdlNode) -> bool { !is_suite(node) } @@ -316,7 +234,7 @@ impl Manifest { Ok(Actions::Suite(suites)) } // Check if all nodes are single actions. - else if nodes.iter().all(is_not_suite) { + else if nodes.iter().all(is_flat) { let mut actions = Vec::new(); for node in nodes.iter() { @@ -324,7 +242,7 @@ impl Manifest { actions.push(action); } - Ok(Actions::Single(actions)) + Ok(Actions::Flat(actions)) } // Otherwise we have invalid actions block. else { @@ -362,42 +280,34 @@ impl Manifest { // Actions for manipulating files and directories. | "cp" => { let from = node - .get_pathbuf("from") + .get_string("from") .ok_or(ManifestError::ExpectedAttribute("from".into()))?; let to = node - .get_pathbuf("to") + .get_string("to") .ok_or(ManifestError::ExpectedAttribute("to".into()))?; let overwrite = node.get_bool("overwrite").unwrap_or(true); - ActionSingle::Copy { - from, - to, - overwrite, - } + ActionSingle::Copy(Copy { from, to, overwrite }) }, | "mv" => { let from = node - .get_pathbuf("from") + .get_string("from") .ok_or(ManifestError::ExpectedAttribute("from".into()))?; let to = node - .get_pathbuf("to") + .get_string("to") .ok_or(ManifestError::ExpectedAttribute("to".into()))?; let overwrite = node.get_bool("overwrite").unwrap_or(true); - ActionSingle::Move { - from, - to, - overwrite, - } + ActionSingle::Move(Move { from, to, overwrite }) }, | "rm" => { - ActionSingle::Delete { - target: node.get_pathbuf(0).ok_or(ManifestError::ExpectedArgument)?, - } + let target = node.get_string(0).ok_or(ManifestError::ExpectedArgument)?; + + ActionSingle::Delete(Delete { target }) }, // Running commands and echoing output. | "echo" => { @@ -405,15 +315,23 @@ impl Manifest { .get_string(0) .ok_or(ManifestError::ExpectedAttribute("message".into()))?; - let trim = node.get_bool("trim").unwrap_or(false); + let injects = node.children().map(|children| { + children + .get_args("inject") + .into_iter() + .filter_map(|arg| arg.as_string().map(str::to_string)) + .collect() + }); + + let trim = node.get_bool("trim").unwrap_or(true); - ActionSingle::Echo { message, trim } + ActionSingle::Echo(Echo { message, injects, trim }) }, | "run" => { let name = node.get_string("name"); let command = node.get_string(0).ok_or(ManifestError::ExpectedArgument)?; - let inject = node.children().map(|children| { + let injects = node.children().map(|children| { children .get_args("inject") .into_iter() @@ -421,11 +339,7 @@ impl Manifest { .collect() }); - ActionSingle::Run { - name, - command, - inject, - } + ActionSingle::Run(Run { name, command, injects }) }, // Prompts and replacements. | "prompt" => { @@ -441,18 +355,16 @@ impl Manifest { .nodes() .iter() .map(|node| node.name().value().to_string()) - .collect::>() + .collect() }) .unwrap_or_default(); - let glob = node.get_string("in").map(PathBuf::from); + let glob = node.get_string("in"); - ActionSingle::Replace { replacements, glob } + ActionSingle::Replace(Replace { replacements, glob }) }, // Fallback. - | action => { - return Err(ManifestError::UnknownNode(action.into())); - }, + | action => ActionSingle::Unknown(Unknown { name: action.into() }), }; Ok(action) @@ -482,9 +394,9 @@ impl Manifest { } #[inline] - fn variants(nodes: &KdlDocument) -> Vec { + fn options(nodes: &KdlDocument) -> Vec { nodes - .get_args("variants") + .get_args("options") .into_iter() .filter_map(|arg| arg.as_string().map(str::to_string)) .collect() @@ -494,40 +406,44 @@ impl Manifest { match kind.as_str() { | "input" => { let nodes = node.children().ok_or(ManifestError::ExpectedInputNodes)?; + let default = nodes.get("default").and_then(|node| node.get_string(0)); - Ok(Prompt::Input { + Ok(Prompt::Input(Input { name: name(nodes)?, hint: hint(nodes)?, - default: nodes.get("default").and_then(|node| node.get_string(0)), - }) + default, + })) }, | "editor" => { let nodes = node.children().ok_or(ManifestError::ExpectedEditorNodes)?; + let default = nodes.get("default").and_then(|node| node.get_string(0)); - Ok(Prompt::Editor { + Ok(Prompt::Editor(Editor { name: name(nodes)?, hint: hint(nodes)?, - default: nodes.get("default").and_then(|node| node.get_string(0)), - }) + default, + })) }, | "select" => { let nodes = node.children().ok_or(ManifestError::ExpectedSelectNodes)?; + let default = nodes.get("default").and_then(|node| node.get_string(0)); - Ok(Prompt::Select { + Ok(Prompt::Select(Select { name: name(nodes)?, hint: hint(nodes)?, - options: variants(nodes), - default: nodes.get("default").and_then(|node| node.get_string(0)), - }) + options: options(nodes), + default, + })) }, | "confirm" => { let nodes = node.children().ok_or(ManifestError::ExpectedConfirmNodes)?; + let default = nodes.get("default").and_then(|node| node.get_bool(0)); - Ok(Prompt::Confirm { + Ok(Prompt::Confirm(Confirm { name: name(nodes)?, hint: hint(nodes)?, - default: nodes.get("default").and_then(|node| node.get_bool(0)), - }) + default, + })) }, | kind => Err(ManifestError::UnknownPrompt(kind.into())), } diff --git a/src/manifest/mod.rs b/src/manifest/mod.rs index f810eb5..fc0f6d5 100644 --- a/src/manifest/mod.rs +++ b/src/manifest/mod.rs @@ -1,5 +1,8 @@ pub use manifest::*; pub use utils::*; +pub mod actions; +pub mod prompts; + mod manifest; mod utils; diff --git a/src/manifest/prompts.rs b/src/manifest/prompts.rs new file mode 100644 index 0000000..a3cc0d5 --- /dev/null +++ b/src/manifest/prompts.rs @@ -0,0 +1,41 @@ +#[derive(Debug)] +pub struct Input { + /// Name of the variable that will store the answer. + pub name: String, + /// Short description. + pub hint: String, + /// Default value if input is empty. + pub default: Option, +} + +#[derive(Debug)] +pub struct Select { + /// Name of the variable that will store the answer. + pub name: String, + /// Short description. + pub hint: String, + /// List of options. + pub options: Vec, + /// Default value. If none or invalid option is provided, the first one is selected. + pub default: Option, +} + +#[derive(Debug)] +pub struct Confirm { + /// Name of the variable that will store the answer. + pub name: String, + /// Short description of the prompt. + pub hint: String, + /// Default value. + pub default: Option, +} + +#[derive(Debug)] +pub struct Editor { + /// Name of the variable that will store the answer. + pub name: String, + /// Short description. + pub hint: String, + /// Default value if input is empty. + pub default: Option, +} diff --git a/src/manifest/utils.rs b/src/manifest/utils.rs index 14d2ca3..0b16d8c 100644 --- a/src/manifest/utils.rs +++ b/src/manifest/utils.rs @@ -1,15 +1,10 @@ -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]. + /// Gets 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]. + /// Gets an entry by key and tries to map it to a [bool]. fn get_bool(&self, key: K) -> Option; } @@ -17,12 +12,6 @@ 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) diff --git a/src/repository.rs b/src/repository.rs index fe2af3c..aca6f2f 100644 --- a/src/repository.rs +++ b/src/repository.rs @@ -113,12 +113,7 @@ impl RemoteRepository { /// Resolves a URL depending on the host and other repository fields. pub fn get_tar_url(&self) -> String { - let RemoteRepository { - host, - user, - repo, - meta, - } = self; + let RemoteRepository { host, user, repo, meta } = self; let RepositoryMeta(meta) = meta; @@ -220,12 +215,7 @@ impl FromStr for RemoteRepository { RepositoryMeta(input.to_string()) }); - Ok(RemoteRepository { - host, - user, - repo, - meta, - }) + Ok(RemoteRepository { host, user, repo, meta }) } } From e0361556b002c1855c2067bf61b25412a5806688 Mon Sep 17 00:00:00 2001 From: Vladislav Mamon Date: Sun, 3 Mar 2024 02:34:04 +0300 Subject: [PATCH 24/77] refactor: remove useless q-mark operators --- src/actions/executor.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/actions/executor.rs b/src/actions/executor.rs index e0d2401..f58643a 100644 --- a/src/actions/executor.rs +++ b/src/actions/executor.rs @@ -23,7 +23,7 @@ impl Executor { | Actions::Empty => return Ok(println!("No actions found.")), }; - Ok(executor?) + executor } async fn execute_suite(&self, suites: &[ActionSuite]) -> anyhow::Result<()> { @@ -73,6 +73,6 @@ impl Executor { | ActionSingle::Unknown(action) => action.execute().await, }; - Ok(executor?) + executor } } From 1ddfbed04cb1e3817c7e96b0316925a55898d6b8 Mon Sep 17 00:00:00 2001 From: Vladislav Mamon Date: Sun, 3 Mar 2024 02:36:06 +0300 Subject: [PATCH 25/77] refactor: remove useless let bindings and references --- src/actions/executor.rs | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/src/actions/executor.rs b/src/actions/executor.rs index f58643a..f10bdc6 100644 --- a/src/actions/executor.rs +++ b/src/actions/executor.rs @@ -17,13 +17,11 @@ impl Executor { } pub async fn execute(&self) -> anyhow::Result<()> { - let executor = match &self.manifest.actions { + match &self.manifest.actions { | Actions::Suite(suites) => self.execute_suite(suites).await, | Actions::Flat(actions) => self.execute_flat(actions).await, | Actions::Empty => return Ok(println!("No actions found.")), - }; - - executor + } } async fn execute_suite(&self, suites: &[ActionSuite]) -> anyhow::Result<()> { @@ -62,17 +60,15 @@ impl Executor { action: &ActionSingle, replacements: &mut Replacements, ) -> anyhow::Result<()> { - let executor = match action { + match action { | ActionSingle::Copy(action) => action.execute().await, | ActionSingle::Move(action) => action.execute().await, | ActionSingle::Delete(action) => action.execute().await, - | ActionSingle::Echo(action) => action.execute(&replacements).await, - | ActionSingle::Run(action) => action.execute(&replacements).await, + | ActionSingle::Echo(action) => action.execute(replacements).await, + | ActionSingle::Run(action) => action.execute(replacements).await, | ActionSingle::Prompt(action) => action.execute(replacements).await, - | ActionSingle::Replace(action) => action.execute(&replacements).await, + | ActionSingle::Replace(action) => action.execute(replacements).await, | ActionSingle::Unknown(action) => action.execute().await, - }; - - executor + } } } From 90571620c32d4d9b49cb771cfc09e997b4e52454 Mon Sep 17 00:00:00 2001 From: Vladislav Mamon Date: Sun, 3 Mar 2024 02:36:48 +0300 Subject: [PATCH 26/77] chore: enable autocompletion inside format strings --- .vscode/settings.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.vscode/settings.json b/.vscode/settings.json index 65fd68a..514eb56 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,6 +2,9 @@ "editor.rulers": [100], "editor.formatOnSave": true, "editor.inlayHints.enabled": "offUnlessPressed", + "editor.quickSuggestions": { + "strings": "on" + }, "rust-analyzer.rustfmt.extraArgs": ["+nightly"], "rust-analyzer.diagnostics.disabled": ["macro-error"], "rust-analyzer.lens.enable": true From f8abc7654a27a1f3f8b765229f526dd690178776 Mon Sep 17 00:00:00 2001 From: Vladislav Mamon Date: Sun, 3 Mar 2024 02:38:05 +0300 Subject: [PATCH 27/77] refactor: apply clippy suggestions --- src/actions/executor.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/actions/executor.rs b/src/actions/executor.rs index f10bdc6..23b99d8 100644 --- a/src/actions/executor.rs +++ b/src/actions/executor.rs @@ -20,7 +20,7 @@ impl Executor { match &self.manifest.actions { | Actions::Suite(suites) => self.execute_suite(suites).await, | Actions::Flat(actions) => self.execute_flat(actions).await, - | Actions::Empty => return Ok(println!("No actions found.")), + | Actions::Empty => Ok(println!("No actions found.")), } } From 4b26565e92e79cc67f95122f161ebcdd89450490 Mon Sep 17 00:00:00 2001 From: Vladislav Mamon Date: Sun, 3 Mar 2024 02:45:37 +0300 Subject: [PATCH 28/77] chore: remove `indoc` dependency, checkout `Cargo.lock` --- .gitignore | 4 - Cargo.lock | 1814 ++++++++++++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 1 - 3 files changed, 1814 insertions(+), 5 deletions(-) create mode 100755 Cargo.lock diff --git a/.gitignore b/.gitignore index db8af27..7fd7373 100644 --- a/.gitignore +++ b/.gitignore @@ -11,9 +11,5 @@ # Generated by Cargo, will have compiled files and executables. /target/ -# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries. -# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html -Cargo.lock - # These are backup files generated by rustfmt. **/*.rs.bk diff --git a/Cargo.lock b/Cargo.lock new file mode 100755 index 0000000..b9d2733 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1814 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "addr2line" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a30b2e23b9e17a9f90641c7ab1549cd9b44f296d3ccbf309d2863cfe398a0cb" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" + +[[package]] +name = "anstream" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d664a92ecae85fd0a7392615844904654d1d5f5514837f471ddef4a057aba1b6" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7079075b41f533b8c61d2a4d073c4676e1f8b249ff94a393b0595db304e0dd87" + +[[package]] +name = "anstyle-parse" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c75ac65da39e5fe5ab759307499ddad880d724eed2f6ce5b5e8a26f4f387928c" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e28923312444cdd728e4738b3f9c9cac739500909bb3d3c94b43551b16517648" +dependencies = [ + "windows-sys 0.52.0", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cd54b81ec8d6180e24654d0b371ad22fc3dd083b6ff8ba325b72e00c87660a7" +dependencies = [ + "anstyle", + "windows-sys 0.52.0", +] + +[[package]] +name = "anyhow" +version = "1.0.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59d2a3357dde987206219e78ecfbbb6e8dad06cbb65292758d3270e6254f7355" + +[[package]] +name = "arx" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "console", + "dirs", + "flate2", + "git2", + "glob-match", + "kdl", + "reqwest", + "run_script", + "tar", + "thiserror", + "tokio", + "unindent", + "walkdir", +] + +[[package]] +name = "autocfg" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" + +[[package]] +name = "backtrace" +version = "0.3.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2089b7e3f35b9dd2d0ed921ead4f6d318c27680d4a5bd167b3ee120edb105837" +dependencies = [ + "addr2line", + "cc", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", +] + +[[package]] +name = "base64" +version = "0.21.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35636a1494ede3b646cc98f74f8e62c773a38a659ebc777a2cf26b9b74171df9" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "327762f6e5a765692301e5bb513e0d9fef63be86bbc14528052b1cd3e6f03e07" + +[[package]] +name = "bumpalo" +version = "3.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37ccbd214614c6783386c1af30caf03192f17891059cecc394b4fb119e363de3" + +[[package]] +name = "bytes" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8a7b6a70fde80372154c65702f00a0f56f3e1c36abbc6c440484be248856db" + +[[package]] +name = "cc" +version = "1.0.73" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fff2a6927b3bb87f9595d67196a70493f627687a71d87a0d692242c33f58c11" +dependencies = [ + "jobserver", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "clap" +version = "4.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfaff671f6b22ca62406885ece523383b9b64022e341e53e009a62ebc47a45f2" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a216b506622bb1d316cd51328dce24e07bdff4a6128a47c7e7fad11878d5adbb" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf9804afaaf59a91e75b022a30fb7229a7901f60c755489cc61c9b423b836442" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.41", +] + +[[package]] +name = "clap_lex" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "702fc72eb24e5a1e48ce58027a675bc24edd52096d5397d4aea7c6dd9eca0bd1" + +[[package]] +name = "colorchoice" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7" + +[[package]] +name = "console" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c926e00cc70edefdc64d3a5ff31cc65bb97a3460097762bd23afb4d8145fccf8" +dependencies = [ + "encode_unicode", + "lazy_static", + "libc", + "unicode-width", + "windows-sys 0.45.0", +] + +[[package]] +name = "core-foundation" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "194a7a9e6de53fa55116934067c844d9d749312f75c6f6d0980e8c252f8c2146" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5827cebf4670468b8772dd191856768aedcb1b0278a04f989f7766351917b9dc" + +[[package]] +name = "crc32fast" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b540bd8bc810d3885c6ea91e2018302f68baba2129ab3e88f32389ee9370880d" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + +[[package]] +name = "dunce" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "453440c271cf5577fd2a40e4942540cb7d0d2f85e27c8d07dd0023c925a67541" + +[[package]] +name = "encode_unicode" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a357d28ed41a50f9c765dbfe56cbc04a64e53e5fc58ba79fbc34c10ef3df831f" + +[[package]] +name = "encoding_rs" +version = "0.8.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9852635589dc9f9ea1b6fe9f05b50ef208c85c834a562f0c6abb1c475736ec2b" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" + +[[package]] +name = "errno" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a258e46cdc063eb8519c00b9fc845fc47bcfca4130e2f08e88665ceda8474245" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "fastrand" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7a407cfaa3385c4ae6b23e84623d48c2798d06e3e6a1878f7f59f17b3f86499" +dependencies = [ + "instant", +] + +[[package]] +name = "filetime" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e94a7bbaa59354bc20dd75b67f23e2797b4490e9d6928203fb105c79e448c86c" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.2.16", + "windows-sys 0.36.1", +] + +[[package]] +name = "flate2" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46303f565772937ffe1d394a4fac6f411c6013172fadde9dcdb1e147a086940e" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fc25a87fa4fd2094bffb06925852034d90a17f0d1e05197d4956d3555752191" +dependencies = [ + "matches", + "percent-encoding", +] + +[[package]] +name = "fsio" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dad0ce30be0cc441b325c5d705c8b613a0ca0d92b6a8953d41bd236dc09a36d0" +dependencies = [ + "dunce", + "rand", +] + +[[package]] +name = "futures-channel" +version = "0.3.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3083ce4b914124575708913bca19bfe887522d6e2e6d0952943f5eac4a74010" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c09fd04b7e4073ac7156a9539b57a484a8ea920f79c7c675d05d289ab6110d3" + +[[package]] +name = "futures-sink" +version = "0.3.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21163e139fa306126e6eedaf49ecdb4588f939600f0b1e770f4205ee4b7fa868" + +[[package]] +name = "futures-task" +version = "0.3.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c66a976bf5909d801bbef33416c41372779507e7a6b3a5e25e4749c58f776a" + +[[package]] +name = "futures-util" +version = "0.3.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b7abd5d659d9b90c8cba917f6ec750a74e2dc23902ef9cd4cc8c8b22e6036a" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "pin-utils", +] + +[[package]] +name = "getrandom" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4eb1a864a501629691edf6c15a593b7a51eebaa1e8468e9ddc623de7c9b58ec6" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "gimli" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253" + +[[package]] +name = "git2" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbf97ba92db08df386e10c8ede66a2a0369bd277090afd8710e19e38de9ec0cd" +dependencies = [ + "bitflags 2.4.1", + "libc", + "libgit2-sys", + "log", + "openssl-probe", + "openssl-sys", + "url", +] + +[[package]] +name = "glob-match" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9985c9503b412198aa4197559e9a318524ebc4519c229bfa05a535828c950b9d" + +[[package]] +name = "h2" +version = "0.3.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d6250322ef6e60f93f9a2162799302cd6f68f79f6e5d85c8c16f14d1d958178" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f1a1d9242c78d09ce40a5e87e7554ee637af1351968159f4952f028f75604" + +[[package]] +name = "heck" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2540771e65fc8cb83cd6e8a237f70c319bd5c29f78ed1084ba5d50eeac86f7f9" + +[[package]] +name = "hermit-abi" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" +dependencies = [ + "libc", +] + +[[package]] +name = "http" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75f43d41e26995c17e71ee126451dd3941010b0514a81a9d11f3b341debc2399" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5f38f16d184e36f2408a55281cd658ecbd3ca05cce6d6510a176eca393e26d1" +dependencies = [ + "bytes", + "http", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d897f394bad6a705d5f4104762e116a75639e470d80901eed05a860a95cb1904" + +[[package]] +name = "httpdate" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4a1e36c821dbe04574f602848a19f742f4fb3c98d40449f11bcad18d6b17421" + +[[package]] +name = "hyper" +version = "0.14.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffb1cfd654a8219eaef89881fdb3bb3b1cdc5fa75ded05d6933b2b382e395468" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.4.10", + "tokio", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "hyper-tls" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" +dependencies = [ + "bytes", + "hyper", + "native-tls", + "tokio", + "tokio-native-tls", +] + +[[package]] +name = "idna" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418a0a6fab821475f634efe3ccc45c013f742efe03d853e8d3355d5cb850ecf8" +dependencies = [ + "matches", + "unicode-bidi", + "unicode-normalization", +] + +[[package]] +name = "indexmap" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d530e1a18b1cb4c484e6e34556a0d948706958449fca0cab753d649f2bce3d1f" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "instant" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5bbe824c507c5da5956355e86a746d82e0e1464f65d862cc5e71da70e94b2c" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "ipnet" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879d54834c8c76457ef4293a689b2a8c59b076067ad77b15efafbb05f92a592b" + +[[package]] +name = "itoa" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c8af84674fe1f223a982c933a0ee1086ac4d4052aa0fb8060c12c6ad838e754" + +[[package]] +name = "jobserver" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c37f63953c4c63420ed5fd3d6d398c719489b9f872b9fa683262f8edd363c7d" +dependencies = [ + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "258451ab10b34f8af53416d1fdab72c22e805f0c92a1136d59470ec0b11138b2" +dependencies = [ + "wasm-bindgen", +] + +[[package]] +name = "kdl" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "062c875482ccb676fd40c804a40e3824d4464c18c364547456d1c8e8e951ae47" +dependencies = [ + "miette", + "nom", + "thiserror", +] + +[[package]] +name = "lazy_static" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" + +[[package]] +name = "libc" +version = "0.2.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "302d7ab3130588088d277783b1e2d2e10c9e9e4a16dd9050e6ec93fb3e7048f4" + +[[package]] +name = "libgit2-sys" +version = "0.16.1+1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2a2bb3680b094add03bb3732ec520ece34da31a8cd2d633d1389d0f0fb60d0c" +dependencies = [ + "cc", + "libc", + "libssh2-sys", + "libz-sys", + "openssl-sys", + "pkg-config", +] + +[[package]] +name = "libredox" +version = "0.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85c833ca1e66078851dba29046874e38f08b2c883700aa29a03ddd3b23814ee8" +dependencies = [ + "bitflags 2.4.1", + "libc", + "redox_syscall 0.4.1", +] + +[[package]] +name = "libssh2-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dc8a030b787e2119a731f1951d6a773e2280c660f8ec4b0f5e1505a386e71ee" +dependencies = [ + "cc", + "libc", + "libz-sys", + "openssl-sys", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "libz-sys" +version = "1.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d97137b25e321a73eef1418d1d5d2eda4d77e12813f8e6dead84bc52c5870a7b" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4cd1a83af159aa67994778be9070f0ae1bd732942279cabb14f86f986a21456" + +[[package]] +name = "log" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abb12e687cfb44aa40f41fc3978ef76448f9b6038cad6aef4259d3c095a2382e" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "matches" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e378b66a060d48947b590737b30a1be76706c8dd7b8ba0f2fe3989c68a853f" + +[[package]] +name = "memchr" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" + +[[package]] +name = "miette" +version = "5.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59bb584eaeeab6bd0226ccf3509a69d7936d148cf3d036ad350abe35e8c6856e" +dependencies = [ + "miette-derive", + "once_cell", + "thiserror", + "unicode-width", +] + +[[package]] +name = "miette-derive" +version = "5.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49e7bc1560b95a3c4a25d03de42fe76ca718ab92d1a22a55b9b4cf67b3ae635c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.41", +] + +[[package]] +name = "mime" +version = "0.3.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a60c7ce501c71e03a9c9c0d35b861413ae925bd979cc7a4e30d060069aaac8d" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7810e0be55b428ada41041c41f32c9f1a42817901b4ccf45fa3d4b6561e74c7" +dependencies = [ + "adler", +] + +[[package]] +name = "mio" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f3d0b296e374a4e6f3c7b0a1f5a51d748a0d34c85e7dc48fc3fa9a87657fe09" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.48.0", +] + +[[package]] +name = "native-tls" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd7e2f3618557f980e0b17e8856252eee3c97fa12c54dff0ca290fb6266ca4a9" +dependencies = [ + "lazy_static", + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "nom" +version = "7.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8903e5a29a317527874d0402f867152a3d21c908bb0b933e416c65e301d4c36" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num_cpus" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19e64526ebdee182341572e50e9ad03965aa510cd94427a4549448f285e957a1" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "object" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cf5f9dd3933bd50a9e1f149ec995f39ae2c496d31fd772c1fd45ebc27e902b0" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18a6dbe30758c9f83eb00cbea4ac95966305f5a7772f3f42ebfc7fc7eddbd8e1" + +[[package]] +name = "openssl" +version = "0.10.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "618febf65336490dfcf20b73f885f5651a0c89c64c2d4a8c3662585a70bf5bd0" +dependencies = [ + "bitflags 1.3.2", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b501e44f11665960c7e7fcf062c7d96a14ade4aa98116c004b2e37b5be7d736c" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.99", +] + +[[package]] +name = "openssl-probe" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" + +[[package]] +name = "openssl-sys" +version = "0.9.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5f9bd0c2710541a3cda73d6f9ac4f1b240de4ae261065d309dbe73d9dceb42f" +dependencies = [ + "autocfg", + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "percent-encoding" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fd5641d01c8f18a23da7b6fe29298ff4b55afcccdf78973b24cf3175fee32e" + +[[package]] +name = "pin-project-lite" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8afb450f006bf6385ca15ef45d71d2288452bc3683ce2e2cacc0d18e4be60b58" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkg-config" +version = "0.3.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1df8c4ec4b0627e53bdf214615ad287367e482558cf84b109250b37464dc03ae" + +[[package]] +name = "ppv-lite86" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb9f9e6e233e5c4a35559a617bf40a4ec447db2e84c20b55a6f83167b7e57872" + +[[package]] +name = "proc-macro2" +version = "1.0.70" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39278fbbf5fb4f646ce651690877f89d1c5811a3d4acb27700c1cb3cdb78fd3b" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5267fca4496028628a95160fc423a33e8b2e6af8a5302579e322e4b520293cae" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d34f1408f55294453790c48b2f1ebbb1c5b4b7563eb1f418bcfcfdbb06ebb4e7" +dependencies = [ + "getrandom", +] + +[[package]] +name = "redox_syscall" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_users" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18479200779601e498ada4e8c1e1f50e3ee19deb0259c25825a98b5603b2cb4" +dependencies = [ + "getrandom", + "libredox", + "thiserror", +] + +[[package]] +name = "remove_dir_all" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acd125665422973a33ac9d3dd2df85edad0f4ae9b00dafb1a05e43a9f5ef8e7" +dependencies = [ + "winapi", +] + +[[package]] +name = "reqwest" +version = "0.11.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046cd98826c46c2ac8ddecae268eb5c2e58628688a5fc7a2643704a73faba95b" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "hyper", + "hyper-tls", + "ipnet", + "js-sys", + "log", + "mime", + "native-tls", + "once_cell", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "serde_urlencoded", + "system-configuration", + "tokio", + "tokio-native-tls", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "winreg", +] + +[[package]] +name = "run_script" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829f98fdc58d78989dd9af83be28bc15c94a7d77f9ecdb54abbbc0b1829ba9c7" +dependencies = [ + "fsio", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76" + +[[package]] +name = "rustix" +version = "0.38.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72e572a5e8ca657d7366229cdde4bd14c4eb5499a9573d4d366fe1b599daa316" +dependencies = [ + "bitflags 2.4.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.52.0", +] + +[[package]] +name = "ryu" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4501abdff3ae82a1c1b477a17252eb69cee9e66eb915c1abaa4f44d873df9f09" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88d6731146462ea25d9244b2ed5fd1d716d25c52e4d54aa4fb0f3c4e9854dbe2" +dependencies = [ + "lazy_static", + "windows-sys 0.36.1", +] + +[[package]] +name = "security-framework" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dc14f172faf8a0194a3aded622712b0de276821addc574fa54fc0a1167e10dc" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0160a13a177a45bfb43ce71c01580998474f556ad854dcbca936dd2841a5c556" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "serde" +version = "1.0.143" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e8e5d5b70924f74ff5c6d64d9a5acd91422117c60f48c4e07855238a254553" + +[[package]] +name = "serde_json" +version = "1.0.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38dd04e3c8279e75b31ef29dbdceebfe5ad89f4d0937213c53f7d49d01b3d5a7" +dependencies = [ + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "slab" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4614a76b2a8be0058caa9dbbaf66d988527d86d003c11a94fbd335d7661edcef" +dependencies = [ + "autocfg", +] + +[[package]] +name = "socket2" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7916fc008ca5542385b89a3d3ce689953c143e9304a9bf8beec1de48994c0d" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "socket2" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5fac59a5cb5dd637972e5fca70daf0523c9067fcdc4842f053dae04a18f8e9" +dependencies = [ + "libc", + "windows-sys 0.48.0", +] + +[[package]] +name = "strsim" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" + +[[package]] +name = "syn" +version = "1.0.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58dbef6ec655055e20b86b15a8cc6d439cca19b667537ac6a1369572d151ab13" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c8b28c477cc3bf0e7966561e3460130e1255f7a1cf71931075f1c5e7a7e269" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "system-configuration" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tar" +version = "0.4.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b16afcea1f22891c49a00c751c7b63b2233284064f11a200fc624137c51e2ddb" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "tempfile" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cdb1ef4eaeeaddc8fbd371e5017057064af0911902ef36b39801f67cc6d79e4" +dependencies = [ + "cfg-if", + "fastrand", + "libc", + "redox_syscall 0.2.16", + "remove_dir_all", + "winapi", +] + +[[package]] +name = "thiserror" +version = "1.0.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f11c217e1416d6f036b870f14e0413d480dbf28edbee1f877abaf0206af43bb7" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01742297787513b79cf8e29d1056ede1313e2420b7b3b15d0a768b4921f549df" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.41", +] + +[[package]] +name = "tinyvec" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87cc5ceb3875bb20c2890005a4e226a4651264a5c75edb2421b52861a0a0cb50" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cda74da7e1a664f795bb1f8a87ec406fb89a02522cf6e50620d016add6dbbf5c" + +[[package]] +name = "tokio" +version = "1.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d45b238a16291a4e1584e61820b8ae57d696cc5015c459c229ccc6990cc1c" +dependencies = [ + "backtrace", + "bytes", + "libc", + "mio", + "num_cpus", + "pin-project-lite", + "socket2 0.5.5", + "tokio-macros", + "windows-sys 0.48.0", +] + +[[package]] +name = "tokio-macros" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b8a1e28f2deaa14e508979454cb3a223b10b938b45af148bc0986de36f1923b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.41", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7d995660bd2b7f8c1568414c1126076c13fbb725c40112dc0120b78eb9b717b" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc463cd8deddc3770d20f9852143d50bf6094e640b485cb2e189a2099085ff45" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", + "tracing", +] + +[[package]] +name = "tower-service" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6bc1c9ce2b5135ac7f93c72918fc37feb872bdc6a5533a8b85eb4b86bfdae52" + +[[package]] +name = "tracing" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fce9567bd60a67d08a16488756721ba392f24f29006402881e43b19aac64307" +dependencies = [ + "cfg-if", + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aeea4303076558a00714b823f9ad67d58a3bbda1df83d8827d21193156e22f7" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59547bce71d9c38b83d9c0e92b6066c4253371f15005def0c30d9657f50c7642" + +[[package]] +name = "unicode-bidi" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "099b7128301d285f79ddd55b9a83d5e6b9e97c92e0ea0daebee7263e932de992" + +[[package]] +name = "unicode-ident" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4f5b37a154999a8f3f98cc23a628d850e154479cd94decf3414696e12e31aaf" + +[[package]] +name = "unicode-normalization" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "854cbdc4f7bc6ae19c820d44abdc3277ac3e1b2b93db20a636825d9322fb60e6" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-width" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed742d4ea2bd1176e236172c8429aaf54486e7ac098db29ffe6529e0ce50973" + +[[package]] +name = "unindent" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7de7d73e1754487cb58364ee906a499937a0dfabd86bcb980fa99ec8c8fa2ce" + +[[package]] +name = "url" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a507c383b2d33b5fc35d1861e77e6b383d158b2da5e14fe51b83dfedf6fd578c" +dependencies = [ + "form_urlencoded", + "idna", + "matches", + "percent-encoding", +] + +[[package]] +name = "utf8parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "walkdir" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71d857dc86794ca4c280d616f7da00d2dbfd8cd788846559a6813e6aa4b54ee" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ce8a968cb1cd110d136ff8b819a556d6fb6d919363c61534f6860c7eb172ba0" +dependencies = [ + "log", + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "wasm-bindgen" +version = "0.2.82" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7652e3f6c4706c8d9cd54832c4a4ccb9b5336e2c3bd154d5cccfbf1c1f5f7d" +dependencies = [ + "cfg-if", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.82" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "662cd44805586bd52971b9586b1df85cdbbd9112e4ef4d8f41559c334dc6ac3f" +dependencies = [ + "bumpalo", + "log", + "once_cell", + "proc-macro2", + "quote", + "syn 1.0.99", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa76fb221a1f8acddf5b54ace85912606980ad661ac7a503b4570ffd3a624dad" +dependencies = [ + "cfg-if", + "js-sys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.82" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b260f13d3012071dfb1512849c033b1925038373aea48ced3012c09df952c602" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.82" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be8e654bdd9b79216c2929ab90721aa82faf65c48cdf08bdc4e7f51357b80da" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.99", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.82" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6598dd0bd3c7d51095ff6531a5b23e02acdc81804e30d8f07afb77b7215a140a" + +[[package]] +name = "web-sys" +version = "0.3.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed055ab27f941423197eb86b2035720b1a3ce40504df082cac2ecc6ed73335a1" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178" +dependencies = [ + "winapi", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-sys" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea04155a16a59f9eab786fe12a4a450e75cdb175f9e0d80da1e17db09f55b8d2" +dependencies = [ + "windows_aarch64_msvc 0.36.1", + "windows_i686_gnu 0.36.1", + "windows_i686_msvc 0.36.1", + "windows_x86_64_gnu 0.36.1", + "windows_x86_64_msvc 0.36.1", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.0", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a18201040b24831fbb9e4eb208f8892e1f50a37feb53cc7ff887feb8f50e7cd" +dependencies = [ + "windows_aarch64_gnullvm 0.52.0", + "windows_aarch64_msvc 0.52.0", + "windows_i686_gnu 0.52.0", + "windows_i686_msvc 0.52.0", + "windows_x86_64_gnu 0.52.0", + "windows_x86_64_gnullvm 0.52.0", + "windows_x86_64_msvc 0.52.0", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7764e35d4db8a7921e09562a0304bf2f93e0a51bfccee0bd0bb0b666b015ea" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb8c3fd39ade2d67e9874ac4f3db21f0d710bee00fe7cab16949ec184eeaa47" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbaa0368d4f1d2aaefc55b6fcfee13f41544ddf36801e793edbbfd7d7df075ef" + +[[package]] +name = "windows_i686_gnu" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180e6ccf01daf4c426b846dfc66db1fc518f074baa793aa7d9b9aaeffad6a3b6" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28637cb1fa3560a16915793afb20081aba2c92ee8af57b4d5f28e4b3e7df313" + +[[package]] +name = "windows_i686_msvc" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e7917148b2812d1eeafaeb22a97e4813dfa60a3f8f78ebe204bcc88f12f024" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffe5e8e31046ce6230cc7215707b816e339ff4d4d67c65dffa206fd0f7aa7b9a" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dcd171b8776c41b97521e5da127a2d86ad280114807d0b2ab1e462bc764d9e1" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d6fa32db2bc4a2f5abeacf2b69f7992cd09dca97498da74a151a3132c26befd" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a657e1e9d3f514745a572a6846d3c7aa7dbe1658c056ed9c3344c4109a6949e" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c811ca4a8c853ef420abd8592ba53ddbbac90410fab6903b3e79972a631f7680" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dff9641d1cd4be8d1a070daf9e3773c5f67e78b4d9d42263020c057706765c04" + +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + +[[package]] +name = "xattr" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7dae5072fe1f8db8f8d29059189ac175196e410e40ba42d5d4684ae2f750995" +dependencies = [ + "libc", + "linux-raw-sys", + "rustix", +] diff --git a/Cargo.toml b/Cargo.toml index 4c6de4f..3325f27 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,6 @@ 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" kdl = { version = "4.6.0" } reqwest = { version = "0.11.22", features = ["json"] } run_script = { version = "0.10.1" } From 0270d1ab7eea6989b82828d7ea03f7d1016b084b Mon Sep 17 00:00:00 2001 From: Vladislav Mamon Date: Sun, 3 Mar 2024 02:48:22 +0300 Subject: [PATCH 29/77] refactor: remove unnecessary `iter()` calls --- src/actions/executor.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/actions/executor.rs b/src/actions/executor.rs index 23b99d8..d4fc23a 100644 --- a/src/actions/executor.rs +++ b/src/actions/executor.rs @@ -27,7 +27,7 @@ impl Executor { async fn execute_suite(&self, suites: &[ActionSuite]) -> anyhow::Result<()> { let mut replacements = HashMap::::new(); - for ActionSuite { name, actions, .. } in suites.iter() { + for ActionSuite { name, actions, .. } in suites { println!( "{symbol} {title}: {name}\n", symbol = style("◆").blue().bold(), @@ -35,7 +35,7 @@ impl Executor { name = style(name).green() ); - for action in actions.iter() { + for action in actions { self.execute_single(action, &mut replacements).await?; println!(); } @@ -45,10 +45,10 @@ impl Executor { } async fn execute_flat(&self, actions: &[ActionSingle]) -> anyhow::Result<()> { - let mut injects = HashMap::::new(); + let mut replacements = HashMap::::new(); - for action in actions.iter() { - self.execute_single(action, &mut injects).await?; + for action in actions { + self.execute_single(action, &mut replacements).await?; println!(); } From d9b8a53b4f11a4a1b46c4da01fbf33b029800e75 Mon Sep 17 00:00:00 2001 From: Vladislav Mamon Date: Mon, 4 Mar 2024 02:21:06 +0300 Subject: [PATCH 30/77] chore: add `confirm` prompt example --- spec.kdl | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/spec.kdl b/spec.kdl index 015612d..754e80c 100644 --- a/spec.kdl +++ b/spec.kdl @@ -74,6 +74,13 @@ actions { name "R_PM_ARGS" hint "Additional arguments for package manager" } + + // Simple confirm prompt. + prompt "confirm" { + name "R_COMMIT" + hint "Whether to stage and commit changes after scaffolding" + default false + } } // Here we demonstrate using replacements. From 1b00aaebffb1d92f1896bb2db115455f95da7de4 Mon Sep 17 00:00:00 2001 From: Vladislav Mamon Date: Mon, 4 Mar 2024 03:19:42 +0300 Subject: [PATCH 31/77] feat(executor): implement prompts and answers store --- Cargo.lock | 276 +++++++++++++++++++++++++++------------ Cargo.toml | 3 +- src/actions/actions.rs | 20 +-- src/actions/executor.rs | 104 ++++++++++----- src/actions/mod.rs | 1 + src/actions/prompts.rs | 134 +++++++++++++++++++ src/app.rs | 2 +- src/manifest/manifest.rs | 5 - src/manifest/prompts.rs | 2 - 9 files changed, 414 insertions(+), 133 deletions(-) create mode 100644 src/actions/prompts.rs diff --git a/Cargo.lock b/Cargo.lock index b9d2733..4d5dec2 100755 --- a/Cargo.lock +++ b/Cargo.lock @@ -77,11 +77,12 @@ version = "0.1.0" dependencies = [ "anyhow", "clap", - "console", + "crossterm 0.27.0", "dirs", "flate2", "git2", "glob-match", + "inquire", "kdl", "reqwest", "run_script", @@ -137,6 +138,12 @@ version = "3.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37ccbd214614c6783386c1af30caf03192f17891059cecc394b4fb119e363de3" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.2.1" @@ -204,19 +211,6 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7" -[[package]] -name = "console" -version = "0.15.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c926e00cc70edefdc64d3a5ff31cc65bb97a3460097762bd23afb4d8145fccf8" -dependencies = [ - "encode_unicode", - "lazy_static", - "libc", - "unicode-width", - "windows-sys 0.45.0", -] - [[package]] name = "core-foundation" version = "0.9.3" @@ -242,6 +236,47 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossterm" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e64e6c0fbe2c17357405f7c758c1ef960fce08bdfb2c03d88d2a18d7e09c4b67" +dependencies = [ + "bitflags 1.3.2", + "crossterm_winapi", + "libc", + "mio", + "parking_lot", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f476fe445d41c9e991fd07515a6f463074b782242ccf4a5b7b1d1012e70824df" +dependencies = [ + "bitflags 2.4.1", + "crossterm_winapi", + "libc", + "mio", + "parking_lot", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "dirs" version = "5.0.1" @@ -270,10 +305,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "453440c271cf5577fd2a40e4942540cb7d0d2f85e27c8d07dd0023c925a67541" [[package]] -name = "encode_unicode" -version = "0.3.6" +name = "dyn-clone" +version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a357d28ed41a50f9c765dbfe56cbc04a64e53e5fc58ba79fbc34c10ef3df831f" +checksum = "0d6ef0072f8a535281e4876be788938b528e9a1d43900b82c2569af7da799125" [[package]] name = "encoding_rs" @@ -411,6 +446,24 @@ dependencies = [ "pin-utils", ] +[[package]] +name = "fuzzy-matcher" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94" +dependencies = [ + "thread_local", +] + +[[package]] +name = "fxhash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +dependencies = [ + "byteorder", +] + [[package]] name = "getrandom" version = "0.2.7" @@ -581,6 +634,24 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "inquire" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd05e4e63529f3c9c5f5c668c398217f72756ffe48c85266b49692c55accd1f7" +dependencies = [ + "bitflags 2.4.1", + "crossterm 0.25.0", + "dyn-clone", + "fuzzy-matcher", + "fxhash", + "newline-converter", + "once_cell", + "tempfile", + "unicode-segmentation", + "unicode-width", +] + [[package]] name = "instant" version = "0.1.12" @@ -700,6 +771,16 @@ version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4cd1a83af159aa67994778be9070f0ae1bd732942279cabb14f86f986a21456" +[[package]] +name = "lock_api" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c168f8615b12bc01f9c17e2eb0cc07dcae1940121185446edc3744920e8ef45" +dependencies = [ + "autocfg", + "scopeguard", +] + [[package]] name = "log" version = "0.4.17" @@ -772,6 +853,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f3d0b296e374a4e6f3c7b0a1f5a51d748a0d34c85e7dc48fc3fa9a87657fe09" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.48.0", ] @@ -794,6 +876,15 @@ dependencies = [ "tempfile", ] +[[package]] +name = "newline-converter" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b6b097ecb1cbfed438542d16e84fd7ad9b0c76c8a65b7f9039212a3d14dc7f" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "nom" version = "7.1.1" @@ -825,9 +916,9 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.13.0" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18a6dbe30758c9f83eb00cbea4ac95966305f5a7772f3f42ebfc7fc7eddbd8e1" +checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" [[package]] name = "openssl" @@ -880,6 +971,29 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "parking_lot" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c42a9226546d68acdd9c0a280d17ce19bfe27a46bf68784e4066115788d008e" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.4.1", + "smallvec", + "windows-targets 0.48.5", +] + [[package]] name = "percent-encoding" version = "2.1.0" @@ -1087,6 +1201,12 @@ dependencies = [ "windows-sys 0.36.1", ] +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "security-framework" version = "2.6.1" @@ -1139,6 +1259,36 @@ dependencies = [ "serde", ] +[[package]] +name = "signal-hook" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8621587d4798caf8eb44879d42e56b9a93ea5dcd315a6487c357130095b62801" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29ad2e15f37ec9a6cc544097b78a1ec90001e9f71b81338ca39f430adaca99af" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8229b473baa5980ac72ef434c4415e70c4b5e71b423043adb4ba059f89c99a1" +dependencies = [ + "libc", +] + [[package]] name = "slab" version = "0.4.7" @@ -1148,6 +1298,12 @@ dependencies = [ "autocfg", ] +[[package]] +name = "smallvec" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6ecd384b10a64542d77071bd64bd7b231f4ed5940fba55e98c3de13824cf3d7" + [[package]] name = "socket2" version = "0.4.10" @@ -1262,6 +1418,16 @@ dependencies = [ "syn 2.0.41", ] +[[package]] +name = "thread_local" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c" +dependencies = [ + "cfg-if", + "once_cell", +] + [[package]] name = "tinyvec" version = "1.6.0" @@ -1382,6 +1548,12 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "unicode-segmentation" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4c87d22b6e3f4a18d4d40ef354e97c90fcb14dd91d7dc0aa9d8a1172ebf7202" + [[package]] name = "unicode-width" version = "0.1.9" @@ -1564,15 +1736,6 @@ dependencies = [ "windows_x86_64_msvc 0.36.1", ] -[[package]] -name = "windows-sys" -version = "0.45.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" -dependencies = [ - "windows-targets 0.42.2", -] - [[package]] name = "windows-sys" version = "0.48.0" @@ -1591,21 +1754,6 @@ dependencies = [ "windows-targets 0.52.0", ] -[[package]] -name = "windows-targets" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" -dependencies = [ - "windows_aarch64_gnullvm 0.42.2", - "windows_aarch64_msvc 0.42.2", - "windows_i686_gnu 0.42.2", - "windows_i686_msvc 0.42.2", - "windows_x86_64_gnu 0.42.2", - "windows_x86_64_gnullvm 0.42.2", - "windows_x86_64_msvc 0.42.2", -] - [[package]] name = "windows-targets" version = "0.48.5" @@ -1636,12 +1784,6 @@ dependencies = [ "windows_x86_64_msvc 0.52.0", ] -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" - [[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" @@ -1660,12 +1802,6 @@ version = "0.36.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9bb8c3fd39ade2d67e9874ac4f3db21f0d710bee00fe7cab16949ec184eeaa47" -[[package]] -name = "windows_aarch64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" - [[package]] name = "windows_aarch64_msvc" version = "0.48.5" @@ -1684,12 +1820,6 @@ version = "0.36.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180e6ccf01daf4c426b846dfc66db1fc518f074baa793aa7d9b9aaeffad6a3b6" -[[package]] -name = "windows_i686_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" - [[package]] name = "windows_i686_gnu" version = "0.48.5" @@ -1708,12 +1838,6 @@ version = "0.36.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2e7917148b2812d1eeafaeb22a97e4813dfa60a3f8f78ebe204bcc88f12f024" -[[package]] -name = "windows_i686_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" - [[package]] name = "windows_i686_msvc" version = "0.48.5" @@ -1732,12 +1856,6 @@ version = "0.36.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4dcd171b8776c41b97521e5da127a2d86ad280114807d0b2ab1e462bc764d9e1" -[[package]] -name = "windows_x86_64_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" - [[package]] name = "windows_x86_64_gnu" version = "0.48.5" @@ -1750,12 +1868,6 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d6fa32db2bc4a2f5abeacf2b69f7992cd09dca97498da74a151a3132c26befd" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" - [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" @@ -1774,12 +1886,6 @@ version = "0.36.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c811ca4a8c853ef420abd8592ba53ddbbac90410fab6903b3e79972a631f7680" -[[package]] -name = "windows_x86_64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" - [[package]] name = "windows_x86_64_msvc" version = "0.48.5" diff --git a/Cargo.toml b/Cargo.toml index 3325f27..65e771b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,11 +10,12 @@ publish = false [dependencies] anyhow = { version = "1.0.76" } clap = { version = "4.4.11", features = ["cargo", "derive"] } -console = { version = "0.15.7" } +crossterm = "0.27.0" dirs = "5.0.1" flate2 = { version = "1.0.28" } git2 = { version = "0.18.1", features = ["vendored-libgit2"] } glob-match = { version = "0.2.1" } +inquire = { version = "0.7.0", features = ["editor"] } kdl = { version = "4.6.0" } reqwest = { version = "0.11.22", features = ["json"] } run_script = { version = "0.10.1" } diff --git a/src/actions/actions.rs b/src/actions/actions.rs index 0cb65c0..ce34508 100644 --- a/src/actions/actions.rs +++ b/src/actions/actions.rs @@ -1,6 +1,6 @@ use unindent::Unindent; -use crate::actions::Replacements; +use crate::actions::{State, Value}; use crate::manifest::actions::*; impl Copy { @@ -22,7 +22,7 @@ impl Delete { } impl Echo { - pub async fn execute(&self, replacements: &Replacements) -> anyhow::Result<()> { + pub async fn execute(&self, state: &State) -> anyhow::Result<()> { let message = if self.trim { self.message.trim() } else { @@ -33,7 +33,7 @@ impl Echo { if let Some(injects) = &self.injects { for inject in injects { - if let Some(value) = replacements.get(inject) { + if let Some(Value::String(value)) = state.values.get(inject) { message = message.replace(&format!("{{{inject}}}"), value); } } @@ -44,20 +44,24 @@ impl Echo { } impl Run { - pub async fn execute(&self, _replacements: &Replacements) -> anyhow::Result<()> { + pub async fn execute(&self, _state: &State) -> anyhow::Result<()> { Ok(println!("run action")) } } impl Prompt { - // TODO: This will require mutable reference to `Executor` or `prompts`. - pub async fn execute(&self, _replacements: &mut Replacements) -> anyhow::Result<()> { - Ok(println!("prompt action")) + pub async fn execute(&self, state: &mut State) -> anyhow::Result<()> { + match self { + | Self::Confirm(prompt) => prompt.execute(state).await, + | Self::Input(prompt) => prompt.execute(state).await, + | Self::Select(prompt) => prompt.execute(state).await, + | Self::Editor(prompt) => prompt.execute(state).await, + } } } impl Replace { - pub async fn execute(&self, _replacements: &Replacements) -> anyhow::Result<()> { + pub async fn execute(&self, _state: &State) -> anyhow::Result<()> { Ok(println!("replace action")) } } diff --git a/src/actions/executor.rs b/src/actions/executor.rs index d4fc23a..4390385 100644 --- a/src/actions/executor.rs +++ b/src/actions/executor.rs @@ -1,73 +1,115 @@ use std::collections::HashMap; -use console::style; +use crossterm::style::Stylize; use crate::manifest::{ActionSingle, ActionSuite, Actions, Manifest}; -/// Alias for a map of string replacements. -pub type Replacements = HashMap; +/// Replacement value. +#[derive(Debug)] +pub enum Value { + /// A string value. + String(String), + /// A boolean value. + Bool(bool), +} + +#[derive(Debug)] +pub struct State { + /// A map of replacements and associated values. + pub values: HashMap, +} + +impl State { + /// Create a new state. + pub fn new() -> Self { + Self { values: HashMap::new() } + } + + /// Get a value from the state. + pub fn get(&self, name: &str) -> Option<&Value> { + self.values.get(name) + } + + /// Set a value in the state. + pub fn set + AsRef>(&mut self, name: N, replacement: Value) { + self.values.insert(name.into(), replacement); + } +} +impl Default for State { + fn default() -> Self { + Self::new() + } +} + +/// An executor. +#[derive(Debug)] pub struct Executor { + /// The manifest to use for execution. manifest: Manifest, } impl Executor { + /// Create a new executor. pub fn new(manifest: Manifest) -> Self { Self { manifest } } + /// Execute the actions. pub async fn execute(&self) -> anyhow::Result<()> { - match &self.manifest.actions { - | Actions::Suite(suites) => self.execute_suite(suites).await, - | Actions::Flat(actions) => self.execute_flat(actions).await, - | Actions::Empty => Ok(println!("No actions found.")), - } + let replacements = match &self.manifest.actions { + | Actions::Suite(suites) => self.suite(suites).await, + | Actions::Flat(actions) => self.flat(actions).await, + | Actions::Empty => return Ok(println!("No actions found.")), + }; + + println!("{replacements:#?}"); + + Ok(()) } - async fn execute_suite(&self, suites: &[ActionSuite]) -> anyhow::Result<()> { - let mut replacements = HashMap::::new(); + /// Execute a suite of actions. + async fn suite(&self, suites: &[ActionSuite]) -> anyhow::Result { + let mut state = State::new(); for ActionSuite { name, actions, .. } in suites { - println!( - "{symbol} {title}: {name}\n", - symbol = style("◆").blue().bold(), - title = style("Running suite").blue(), - name = style(name).green() - ); + let symbol = "⦿".blue().bold(); + let title = "Running suite".blue(); + let name = name.clone().green(); + + println!("{symbol} {title}: {name}\n"); for action in actions { - self.execute_single(action, &mut replacements).await?; + self.single(action, &mut state).await?; println!(); } } - Ok(()) + Ok(state) } - async fn execute_flat(&self, actions: &[ActionSingle]) -> anyhow::Result<()> { - let mut replacements = HashMap::::new(); + /// Execute a flat list of actions. + async fn flat(&self, actions: &[ActionSingle]) -> anyhow::Result { + let mut state = State::new(); for action in actions { - self.execute_single(action, &mut replacements).await?; + self.single(action, &mut state).await?; println!(); } - Ok(()) + Ok(state) } - async fn execute_single( - &self, - action: &ActionSingle, - replacements: &mut Replacements, - ) -> anyhow::Result<()> { + /// Execute a single action. + async fn single(&self, action: &ActionSingle, state: &mut State) -> anyhow::Result<()> { match action { | ActionSingle::Copy(action) => action.execute().await, | ActionSingle::Move(action) => action.execute().await, | ActionSingle::Delete(action) => action.execute().await, - | ActionSingle::Echo(action) => action.execute(replacements).await, - | ActionSingle::Run(action) => action.execute(replacements).await, - | ActionSingle::Prompt(action) => action.execute(replacements).await, - | ActionSingle::Replace(action) => action.execute(replacements).await, + | ActionSingle::Echo(action) => action.execute(state).await, + | ActionSingle::Run(action) => action.execute(state).await, + | ActionSingle::Prompt(action) => action.execute(state).await, + | ActionSingle::Replace(action) => action.execute(state).await, | ActionSingle::Unknown(action) => action.execute().await, } } diff --git a/src/actions/mod.rs b/src/actions/mod.rs index 092f90c..25e87d3 100644 --- a/src/actions/mod.rs +++ b/src/actions/mod.rs @@ -2,3 +2,4 @@ pub use executor::*; mod actions; mod executor; +mod prompts; diff --git a/src/actions/prompts.rs b/src/actions/prompts.rs new file mode 100644 index 0000000..3197fb5 --- /dev/null +++ b/src/actions/prompts.rs @@ -0,0 +1,134 @@ +use std::fmt::Display; + +use inquire::formatter::StringFormatter; +use inquire::ui::{Color, RenderConfig, StyleSheet, Styled}; +use inquire::{Confirm, Editor, Select, Text}; + +use crate::actions::{State, Value}; +use crate::manifest::prompts; + +/// Helper struct holding static methods for convenience. +struct Inquirer; + +impl Inquirer { + /// Returns configured theme. + pub fn theme<'r>() -> RenderConfig<'r> { + let default = RenderConfig::default(); + let stylesheet = StyleSheet::default(); + + let prompt_prefix = Styled::new("?").with_fg(Color::LightYellow); + let answered_prefix = Styled::new("✓").with_fg(Color::LightGreen); + + default + .with_prompt_prefix(prompt_prefix) + .with_answered_prompt_prefix(answered_prefix) + .with_answer(stylesheet.with_fg(Color::White)) + .with_default_value(stylesheet.with_fg(Color::DarkGrey)) + } + + /// Returns a formatter that shows `` if the input is empty. + pub fn empty_formatter<'s>() -> StringFormatter<'s> { + &|input| { + if input.is_empty() { + "".to_string() + } else { + input.to_string() + } + } + } + + /// Helper method that generates `(name, hint, help)`. + pub fn messages(name: S, hint: S) -> (String, String, String) + where + S: Into + AsRef + Display, + { + let name = name.into(); + let hint = format!("{}:", &hint); + let help = format!("The answer will be mapped to: {}", &name); + + (name, hint, help) + } +} + +impl prompts::Confirm { + /// Execute the prompt and populate the state. + pub async fn execute(&self, state: &mut State) -> anyhow::Result<()> { + let (name, hint, help) = Inquirer::messages(&self.name, &self.hint); + + let mut prompt = Confirm::new(&hint) + .with_help_message(&help) + .with_render_config(Inquirer::theme()); + + if let Some(default) = self.default { + prompt = prompt.with_default(default); + } + + if let Ok(value) = prompt.prompt() { + state.set(name, Value::Bool(value)); + } + + Ok(()) + } +} + +impl prompts::Input { + /// Execute the prompt and populate the state. + pub async fn execute(&self, state: &mut State) -> anyhow::Result<()> { + let (name, hint, help) = Inquirer::messages(&self.name, &self.hint); + + let mut prompt = Text::new(&hint) + .with_help_message(&help) + .with_formatter(Inquirer::empty_formatter()) + .with_render_config(Inquirer::theme()); + + if let Some(default) = &self.default { + prompt = prompt.with_default(default); + } + + if let Ok(value) = prompt.prompt() { + state.set(name, Value::String(value)); + } + + Ok(()) + } +} + +impl prompts::Select { + /// Execute the prompt and populate the state. + pub async fn execute(&self, state: &mut State) -> anyhow::Result<()> { + let (name, hint, help) = Inquirer::messages(&self.name, &self.hint); + + let options = self.options.iter().map(String::to_string).collect(); + + let prompt = Select::new(&hint, options) + .with_help_message(&help) + .with_render_config(Inquirer::theme()); + + if let Ok(value) = prompt.prompt() { + state.set(name, Value::String(value)); + } + + Ok(()) + } +} + +impl prompts::Editor { + /// Execute the prompt and populate the state. + pub async fn execute(&self, state: &mut State) -> anyhow::Result<()> { + let (name, hint, help) = Inquirer::messages(&self.name, &self.hint); + + let mut prompt = Editor::new(&hint) + .with_help_message(&help) + .with_render_config(Inquirer::theme()); + + if let Some(default) = &self.default { + prompt = prompt.with_predefined_text(default); + } + + if let Ok(value) = prompt.prompt() { + state.set(name, Value::String(value)); + } + + Ok(()) + } +} diff --git a/src/app.rs b/src/app.rs index 8f3a964..3dfb9f8 100644 --- a/src/app.rs +++ b/src/app.rs @@ -140,7 +140,7 @@ impl App { let inner_git = destination.join(".git"); if inner_git.exists() { - println!("Removing {}", inner_git.display()); + println!("Removing {}\n", inner_git.display()); fs::remove_dir_all(inner_git)?; } diff --git a/src/manifest/manifest.rs b/src/manifest/manifest.rs index 030ebf5..84d37c8 100644 --- a/src/manifest/manifest.rs +++ b/src/manifest/manifest.rs @@ -141,9 +141,6 @@ impl Manifest { let options = self.get_options(&doc)?; let actions = self.get_actions(&doc)?; - println!("Options: {options:#?}"); - println!("Actions: {actions:#?}"); - self.options = options; self.actions = actions; } @@ -426,13 +423,11 @@ impl Manifest { }, | "select" => { let nodes = node.children().ok_or(ManifestError::ExpectedSelectNodes)?; - let default = nodes.get("default").and_then(|node| node.get_string(0)); Ok(Prompt::Select(Select { name: name(nodes)?, hint: hint(nodes)?, options: options(nodes), - default, })) }, | "confirm" => { diff --git a/src/manifest/prompts.rs b/src/manifest/prompts.rs index a3cc0d5..a430b5d 100644 --- a/src/manifest/prompts.rs +++ b/src/manifest/prompts.rs @@ -16,8 +16,6 @@ pub struct Select { pub hint: String, /// List of options. pub options: Vec, - /// Default value. If none or invalid option is provided, the first one is selected. - pub default: Option, } #[derive(Debug)] From 3b398aa244a6c082662e0d5ac6dd6ea326b0c13f Mon Sep 17 00:00:00 2001 From: Vladislav Mamon Date: Mon, 4 Mar 2024 03:27:17 +0300 Subject: [PATCH 32/77] refactor: remove unused mod --- src/error.rs | 19 ------------------- src/lib.rs | 1 - 2 files changed, 20 deletions(-) delete mode 100644 src/error.rs diff --git a/src/error.rs b/src/error.rs deleted file mode 100644 index 6626041..0000000 --- a/src/error.rs +++ /dev/null @@ -1,19 +0,0 @@ -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/lib.rs b/src/lib.rs index 022377b..0d1c7d9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,7 +2,6 @@ pub mod actions; pub mod app; -pub mod error; pub mod fs; pub mod manifest; pub mod path; From 7a331c4ddae21c9c33933b670df0f8791f504414 Mon Sep 17 00:00:00 2001 From: Vladislav Mamon Date: Mon, 4 Mar 2024 22:53:45 +0300 Subject: [PATCH 33/77] fix(actions/prompts): mark inputs as required if no default value provided --- src/actions/prompts.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/actions/prompts.rs b/src/actions/prompts.rs index 3197fb5..7946b61 100644 --- a/src/actions/prompts.rs +++ b/src/actions/prompts.rs @@ -1,13 +1,14 @@ use std::fmt::Display; use inquire::formatter::StringFormatter; +use inquire::required; use inquire::ui::{Color, RenderConfig, StyleSheet, Styled}; use inquire::{Confirm, Editor, Select, Text}; use crate::actions::{State, Value}; use crate::manifest::prompts; -/// Helper struct holding static methods for convenience. +/// Helper struct holding useful static methods. struct Inquirer; impl Inquirer { @@ -83,6 +84,8 @@ impl prompts::Input { if let Some(default) = &self.default { prompt = prompt.with_default(default); + } else { + prompt = prompt.with_validator(required!("This field is required.")); } if let Ok(value) = prompt.prompt() { From ddbde8d251f08f412cdbee598c885e82ee918e55 Mon Sep 17 00:00:00 2001 From: Vladislav Mamon Date: Mon, 4 Mar 2024 23:51:28 +0300 Subject: [PATCH 34/77] feat(actions/prompts): handle interrupts/cancelation Right now it simply exits the process, maybe I'll come up with something better like proper error propagation and reporting. --- src/actions/prompts.rs | 40 ++++++++++++++++++++++++++++++---------- 1 file changed, 30 insertions(+), 10 deletions(-) diff --git a/src/actions/prompts.rs b/src/actions/prompts.rs index 7946b61..04b03e2 100644 --- a/src/actions/prompts.rs +++ b/src/actions/prompts.rs @@ -1,9 +1,11 @@ use std::fmt::Display; +use std::process; +use crossterm::style::Stylize; use inquire::formatter::StringFormatter; use inquire::required; use inquire::ui::{Color, RenderConfig, StyleSheet, Styled}; -use inquire::{Confirm, Editor, Select, Text}; +use inquire::{Confirm, Editor, InquireError, Select, Text}; use crate::actions::{State, Value}; use crate::manifest::prompts; @@ -31,7 +33,7 @@ impl Inquirer { pub fn empty_formatter<'s>() -> StringFormatter<'s> { &|input| { if input.is_empty() { - "".to_string() + "".dark_grey().to_string() } else { input.to_string() } @@ -49,6 +51,20 @@ impl Inquirer { (name, hint, help) } + + /// Handle interruption/cancelation events. + pub fn handle_interruption(err: InquireError) { + match err { + | InquireError::OperationCanceled => { + process::exit(0); + }, + | InquireError::OperationInterrupted => { + println!("{}", "".red()); + process::exit(0); + }, + | _ => {}, + } + } } impl prompts::Confirm { @@ -64,8 +80,9 @@ impl prompts::Confirm { prompt = prompt.with_default(default); } - if let Ok(value) = prompt.prompt() { - state.set(name, Value::Bool(value)); + match prompt.prompt() { + | Ok(value) => state.set(name, Value::Bool(value)), + | Err(err) => Inquirer::handle_interruption(err), } Ok(()) @@ -88,8 +105,9 @@ impl prompts::Input { prompt = prompt.with_validator(required!("This field is required.")); } - if let Ok(value) = prompt.prompt() { - state.set(name, Value::String(value)); + match prompt.prompt() { + | Ok(value) => state.set(name, Value::String(value)), + | Err(err) => Inquirer::handle_interruption(err), } Ok(()) @@ -107,8 +125,9 @@ impl prompts::Select { .with_help_message(&help) .with_render_config(Inquirer::theme()); - if let Ok(value) = prompt.prompt() { - state.set(name, Value::String(value)); + match prompt.prompt() { + | Ok(value) => state.set(name, Value::String(value)), + | Err(err) => Inquirer::handle_interruption(err), } Ok(()) @@ -128,8 +147,9 @@ impl prompts::Editor { prompt = prompt.with_predefined_text(default); } - if let Ok(value) = prompt.prompt() { - state.set(name, Value::String(value)); + match prompt.prompt() { + | Ok(value) => state.set(name, Value::String(value)), + | Err(err) => Inquirer::handle_interruption(err), } Ok(()) From 0a2ce0f6a32e8932be3ba39924266afc141a83d0 Mon Sep 17 00:00:00 2001 From: Vladislav Mamon Date: Tue, 5 Mar 2024 00:37:13 +0300 Subject: [PATCH 35/77] feat(actions): implement `run` action It's a bit too simplistic, but works. --- src/actions/actions.rs | 29 +++++++++++++++++++++++++++-- src/actions/executor.rs | 2 +- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/src/actions/actions.rs b/src/actions/actions.rs index ce34508..cc5a6d8 100644 --- a/src/actions/actions.rs +++ b/src/actions/actions.rs @@ -1,3 +1,6 @@ +use std::path::{Path, PathBuf}; + +use run_script::ScriptOptions; use unindent::Unindent; use crate::actions::{State, Value}; @@ -44,8 +47,30 @@ impl Echo { } impl Run { - pub async fn execute(&self, _state: &State) -> anyhow::Result<()> { - Ok(println!("run action")) + pub async fn execute

(&self, root: P, state: &State) -> anyhow::Result<()> + where + P: Into + AsRef, + { + let mut command = self.command.clone(); + + if let Some(injects) = &self.injects { + for inject in injects { + if let Some(Value::String(value)) = state.values.get(inject) { + // In format strings we escape `{` and `}` by doubling them. + command = command.replace(&format!("{{{inject}}}"), value); + } + } + } + + let options = ScriptOptions { + working_directory: Some(root.into()), + ..ScriptOptions::new() + }; + + // NOTE: This will exit the main process in case of error. + let (output, _) = run_script::run_script_or_exit!(command, options); + + Ok(println!("{}", output.trim())) } } diff --git a/src/actions/executor.rs b/src/actions/executor.rs index 4390385..0fab769 100644 --- a/src/actions/executor.rs +++ b/src/actions/executor.rs @@ -107,7 +107,7 @@ impl Executor { | ActionSingle::Move(action) => action.execute().await, | ActionSingle::Delete(action) => action.execute().await, | ActionSingle::Echo(action) => action.execute(state).await, - | ActionSingle::Run(action) => action.execute(state).await, + | ActionSingle::Run(action) => action.execute(&self.manifest.root, state).await, | ActionSingle::Prompt(action) => action.execute(state).await, | ActionSingle::Replace(action) => action.execute(state).await, | ActionSingle::Unknown(action) => action.execute().await, From c16cb22bf623837ab52e639388676480c93b43f8 Mon Sep 17 00:00:00 2001 From: Vladislav Mamon Date: Tue, 5 Mar 2024 02:22:21 +0300 Subject: [PATCH 36/77] feat(actions): add spinner for `run` action, improve styling a bit --- Cargo.lock | 45 ++++++++++++++++++++++++++++++++++ Cargo.toml | 1 + src/actions/actions.rs | 39 ++++++++++++++++++++++++++++-- src/actions/executor.rs | 4 ++-- src/lib.rs | 1 + src/spinner.rs | 53 +++++++++++++++++++++++++++++++++++++++++ 6 files changed, 139 insertions(+), 4 deletions(-) create mode 100644 src/spinner.rs diff --git a/Cargo.lock b/Cargo.lock index 4d5dec2..b32df35 100755 --- a/Cargo.lock +++ b/Cargo.lock @@ -82,6 +82,7 @@ dependencies = [ "flate2", "git2", "glob-match", + "indicatif", "inquire", "kdl", "reqwest", @@ -211,6 +212,19 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7" +[[package]] +name = "console" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e1f83fc076bd6dd27517eacdf25fef6c4dfe5f1d7448bafaaf3a26f13b5e4eb" +dependencies = [ + "encode_unicode", + "lazy_static", + "libc", + "unicode-width", + "windows-sys 0.52.0", +] + [[package]] name = "core-foundation" version = "0.9.3" @@ -310,6 +324,12 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d6ef0072f8a535281e4876be788938b528e9a1d43900b82c2569af7da799125" +[[package]] +name = "encode_unicode" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a357d28ed41a50f9c765dbfe56cbc04a64e53e5fc58ba79fbc34c10ef3df831f" + [[package]] name = "encoding_rs" version = "0.8.31" @@ -634,6 +654,19 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "indicatif" +version = "0.17.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "763a5a8f45087d6bcea4222e7b72c291a054edf80e4ef6efd2a4979878c7bea3" +dependencies = [ + "console", + "instant", + "number_prefix", + "portable-atomic", + "unicode-width", +] + [[package]] name = "inquire" version = "0.7.0" @@ -905,6 +938,12 @@ dependencies = [ "libc", ] +[[package]] +name = "number_prefix" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" + [[package]] name = "object" version = "0.32.1" @@ -1018,6 +1057,12 @@ version = "0.3.25" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1df8c4ec4b0627e53bdf214615ad287367e482558cf84b109250b37464dc03ae" +[[package]] +name = "portable-atomic" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7170ef9988bc169ba16dd36a7fa041e5c4cbeb6a35b76d4c03daded371eae7c0" + [[package]] name = "ppv-lite86" version = "0.2.16" diff --git a/Cargo.toml b/Cargo.toml index 65e771b..12842ca 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +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" } +indicatif = "0.17.8" inquire = { version = "0.7.0", features = ["editor"] } kdl = { version = "4.6.0" } reqwest = { version = "0.11.22", features = ["json"] } diff --git a/src/actions/actions.rs b/src/actions/actions.rs index cc5a6d8..83879c7 100644 --- a/src/actions/actions.rs +++ b/src/actions/actions.rs @@ -1,10 +1,13 @@ use std::path::{Path, PathBuf}; +use std::process; +use crossterm::style::Stylize; use run_script::ScriptOptions; use unindent::Unindent; use crate::actions::{State, Value}; use crate::manifest::actions::*; +use crate::spinner::Spinner; impl Copy { pub async fn execute(&self) -> anyhow::Result<()> { @@ -52,6 +55,7 @@ impl Run { P: Into + AsRef, { let mut command = self.command.clone(); + let spinner = Spinner::new(); if let Some(injects) = &self.injects { for inject in injects { @@ -62,13 +66,44 @@ impl Run { } } + let name = self + .name + .clone() + .or_else(|| { + let lines = command.trim().lines().count(); + + if lines > 1 { + Some(command.trim().lines().next().unwrap().to_string() + "...") + } else { + Some(command.clone()) + } + }) + .unwrap(); + let options = ScriptOptions { working_directory: Some(root.into()), ..ScriptOptions::new() }; - // NOTE: This will exit the main process in case of error. - let (output, _) = run_script::run_script_or_exit!(command, options); + spinner.set_message(format!("{}", name.clone().grey())); + + // Actually run the script. + let (code, output, err) = run_script::run_script!(command, options)?; + let has_failed = code > 0; + + // Re-format depending on the exit code. + let name = if has_failed { name.red() } else { name.green() }; + + // Stopping before printing output/errors, otherwise the spinner message won't be cleared. + spinner.stop_with_message(format!("{name}\n",)); + + if has_failed { + if !err.is_empty() { + eprintln!("{err}"); + } + + process::exit(1); + } Ok(println!("{}", output.trim())) } diff --git a/src/actions/executor.rs b/src/actions/executor.rs index 0fab769..78e1ae6 100644 --- a/src/actions/executor.rs +++ b/src/actions/executor.rs @@ -73,8 +73,8 @@ impl Executor { let mut state = State::new(); for ActionSuite { name, actions, .. } in suites { - let symbol = "⦿".blue().bold(); - let title = "Running suite".blue(); + let symbol = "+".blue().bold(); + let title = "Suite".blue(); let name = name.clone().green(); println!("{symbol} {title}: {name}\n"); diff --git a/src/lib.rs b/src/lib.rs index 0d1c7d9..fd3dc81 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,4 +6,5 @@ pub mod fs; pub mod manifest; pub mod path; pub mod repository; +pub mod spinner; pub mod unpacker; diff --git a/src/spinner.rs b/src/spinner.rs new file mode 100644 index 0000000..83408af --- /dev/null +++ b/src/spinner.rs @@ -0,0 +1,53 @@ +use std::time::Duration; + +use indicatif::{ProgressBar, ProgressStyle}; + +/// Small wrapper around the `indicatif` spinner. +pub struct Spinner { + spinner: ProgressBar, +} + +impl Spinner { + /// Creates a new spinner. + pub fn new() -> Self { + let style = ProgressStyle::default_spinner().tick_chars("⠋⠙⠚⠒⠂⠂⠒⠲⠴⠦⠖⠒⠐⠐⠒⠓⠋·"); + let spinner = ProgressBar::new_spinner(); + + spinner.set_style(style); + spinner.enable_steady_tick(Duration::from_millis(80)); + + Self { spinner } + } + + /// Sets the message of the spinner. + pub fn set_message(&self, message: S) + where + S: Into + AsRef, + { + self.spinner.set_message(message.into()); + } + + /// Stops the spinner. + pub fn stop(&self) { + self.spinner.finish(); + } + + /// Stops the spinner with the message. + pub fn stop_with_message(&self, message: S) + where + S: Into + AsRef, + { + self.spinner.finish_with_message(message.into()); + } + + /// Stops the spinner and clears the message. + pub fn stop_with_clear(&self) { + self.spinner.finish_and_clear(); + } +} + +impl Default for Spinner { + fn default() -> Self { + Self::new() + } +} From 83afe5ee184e2d278054347fa2e866ce4d420c5a Mon Sep 17 00:00:00 2001 From: Vladislav Mamon Date: Tue, 5 Mar 2024 03:28:50 +0300 Subject: [PATCH 37/77] feat(actions): implement `replace` action --- src/actions/actions.rs | 59 ++++++++++++++++++++++++++++++++++++++--- src/actions/executor.rs | 6 +++-- src/repository.rs | 2 +- 3 files changed, 61 insertions(+), 6 deletions(-) diff --git a/src/actions/actions.rs b/src/actions/actions.rs index 83879c7..435b93e 100644 --- a/src/actions/actions.rs +++ b/src/actions/actions.rs @@ -1,11 +1,16 @@ use std::path::{Path, PathBuf}; use std::process; +use std::thread; +use std::time::{Duration, Instant}; use crossterm::style::Stylize; use run_script::ScriptOptions; +use tokio::fs::{File, OpenOptions}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; use unindent::Unindent; use crate::actions::{State, Value}; +use crate::fs::Traverser; use crate::manifest::actions::*; use crate::spinner::Spinner; @@ -60,7 +65,6 @@ impl Run { if let Some(injects) = &self.injects { for inject in injects { if let Some(Value::String(value)) = state.values.get(inject) { - // In format strings we escape `{` and `}` by doubling them. command = command.replace(&format!("{{{inject}}}"), value); } } @@ -121,8 +125,57 @@ impl Prompt { } impl Replace { - pub async fn execute(&self, _state: &State) -> anyhow::Result<()> { - Ok(println!("replace action")) + pub async fn execute

(&self, root: P, state: &State) -> anyhow::Result<()> + where + P: Into + AsRef, + { + let spinner = Spinner::new(); + let start = Instant::now(); + + // If no glob pattern specified, traverse all files. + let pattern = self.glob.clone().unwrap_or("**/*".to_string()); + + let traverser = Traverser::new(root.into()) + .ignore_dirs(true) + .contents_first(true) + .pattern(&pattern); + + if !self.replacements.is_empty() { + spinner.set_message("Performing replacements"); + + for matched in traverser.iter().flatten() { + let mut buffer = String::new(); + let mut file = File::open(&matched.path).await?; + + file.read_to_string(&mut buffer).await?; + + for replacement in &self.replacements { + if let Some(Value::String(value)) = state.values.get(replacement) { + buffer = buffer.replace(&format!("{{{replacement}}}"), value); + } + } + + let mut result = OpenOptions::new() + .write(true) + .truncate(true) + .open(&matched.path) + .await?; + + result.write_all(buffer.as_bytes()).await?; + } + + // Add artificial delay if replacements were performed too fast. + let elapsed = start.elapsed(); + + // This way we spent at least 1 second before stopping the spinner. + if elapsed < Duration::from_millis(750) { + thread::sleep(Duration::from_millis(1_000) - elapsed); + } + + spinner.stop_with_message("Successfully performed replacements\n"); + } + + Ok(()) } } diff --git a/src/actions/executor.rs b/src/actions/executor.rs index 78e1ae6..84601bc 100644 --- a/src/actions/executor.rs +++ b/src/actions/executor.rs @@ -102,14 +102,16 @@ impl Executor { /// Execute a single action. async fn single(&self, action: &ActionSingle, state: &mut State) -> anyhow::Result<()> { + let root = &self.manifest.root; + match action { | ActionSingle::Copy(action) => action.execute().await, | ActionSingle::Move(action) => action.execute().await, | ActionSingle::Delete(action) => action.execute().await, | ActionSingle::Echo(action) => action.execute(state).await, - | ActionSingle::Run(action) => action.execute(&self.manifest.root, state).await, + | ActionSingle::Run(action) => action.execute(root, state).await, | ActionSingle::Prompt(action) => action.execute(state).await, - | ActionSingle::Replace(action) => action.execute(state).await, + | ActionSingle::Replace(action) => action.execute(root, state).await, | ActionSingle::Unknown(action) => action.execute().await, } } diff --git a/src/repository.rs b/src/repository.rs index aca6f2f..31e90a9 100644 --- a/src/repository.rs +++ b/src/repository.rs @@ -169,7 +169,7 @@ impl FromStr for RemoteRepository { is_valid_user(ch) || ch == '.' } - // TODO: Handle an edge case with multuple slashes in the repository name. + // TODO: Handle an edge case with multiple slashes in the repository name. let input = input.trim(); From 0ebe0dc27801ea3cbb934d0179936c703301b37f Mon Sep 17 00:00:00 2001 From: Vladislav Mamon Date: Tue, 5 Mar 2024 04:31:45 +0300 Subject: [PATCH 38/77] feat(actions): implement `cp` action --- src/actions/actions.rs | 37 ++++++++- src/actions/executor.rs | 6 +- src/fs/traverser.rs | 4 +- src/path/clean.rs | 168 ++++++++++++++++++++++++++++++++++++++++ src/path/mod.rs | 2 + 5 files changed, 209 insertions(+), 8 deletions(-) create mode 100644 src/path/clean.rs diff --git a/src/actions/actions.rs b/src/actions/actions.rs index 435b93e..2ec1c04 100644 --- a/src/actions/actions.rs +++ b/src/actions/actions.rs @@ -1,3 +1,4 @@ +use std::fs; use std::path::{Path, PathBuf}; use std::process; use std::thread; @@ -12,11 +13,43 @@ use unindent::Unindent; use crate::actions::{State, Value}; use crate::fs::Traverser; use crate::manifest::actions::*; +use crate::path::PathClean; use crate::spinner::Spinner; impl Copy { - pub async fn execute(&self) -> anyhow::Result<()> { - Ok(println!("cp action")) + pub async fn execute

(&self, root: P) -> anyhow::Result<()> + where + P: Into + AsRef, + { + let root: PathBuf = root.into(); + let destination = &root.join(&self.to); + + let traverser = Traverser::new(&root) + .ignore_dirs(true) + .contents_first(true) + .pattern(&self.from); + + println!( + "⋅ Copying: {}", + format!("{} ╌╌ {}", &self.from, &self.to).dim() + ); + + for matched in traverser.iter().flatten() { + let target = destination.join(&matched.captured).clean(); + + if !self.overwrite && target.is_file() { + continue; + } + + if let Some(parent) = target.parent() { + fs::create_dir_all(parent)?; + fs::copy(&matched.path, &target)?; + } + + println!("└─ {} ╌╌ {}", &matched.path.display(), &target.display()); + } + + Ok(()) } } diff --git a/src/actions/executor.rs b/src/actions/executor.rs index 84601bc..1d8b802 100644 --- a/src/actions/executor.rs +++ b/src/actions/executor.rs @@ -57,14 +57,12 @@ impl Executor { /// Execute the actions. pub async fn execute(&self) -> anyhow::Result<()> { - let replacements = match &self.manifest.actions { + let _ = match &self.manifest.actions { | Actions::Suite(suites) => self.suite(suites).await, | Actions::Flat(actions) => self.flat(actions).await, | Actions::Empty => return Ok(println!("No actions found.")), }; - println!("{replacements:#?}"); - Ok(()) } @@ -105,7 +103,7 @@ impl Executor { let root = &self.manifest.root; match action { - | ActionSingle::Copy(action) => action.execute().await, + | ActionSingle::Copy(action) => action.execute(root).await, | ActionSingle::Move(action) => action.execute().await, | ActionSingle::Delete(action) => action.execute().await, | ActionSingle::Echo(action) => action.execute(state).await, diff --git a/src/fs/traverser.rs b/src/fs/traverser.rs index 4af0178..3b1dc88 100644 --- a/src/fs/traverser.rs +++ b/src/fs/traverser.rs @@ -40,10 +40,10 @@ pub struct Traverser { impl Traverser { /// Creates a new (consuming) builder. - pub fn new(root: PathBuf) -> Self { + pub fn new>(root: P) -> Self { Self { options: TraverseOptions { - root, + root: root.into(), pattern: None, ignore_dirs: false, contents_first: false, diff --git a/src/path/clean.rs b/src/path/clean.rs new file mode 100644 index 0000000..b156d66 --- /dev/null +++ b/src/path/clean.rs @@ -0,0 +1,168 @@ +use std::path::{Component, Path, PathBuf}; + +/// Implements the [clean] method. +pub trait PathClean { + fn clean(&self) -> PathBuf; +} + +/// [PathClean] implemented for [Path]. +impl PathClean for Path { + fn clean(&self) -> PathBuf { + clean(self) + } +} + +/// Cleans up a [Path]. +/// +/// It performs the following, lexically: +/// +/// - Reduces multiple slashes to a single slash. +/// - Eliminates `.` path name elements (the current directory). +/// - Eliminates `..` path name elements (the parent directory) and the non-`.` non-`..`, element +/// that precedes them. +/// - Eliminates `..` elements that begin a rooted path, that is, replace `/..` by `/` at the +/// beginning of a path. +/// - Leaves intact `..` elements that begin a non-rooted path. +/// +/// If the result is an empty string, returns the string `"."`, representing the current directory. +pub fn clean

(path: P) -> PathBuf +where + P: AsRef, +{ + let mut out = Vec::new(); + + for component in path.as_ref().components() { + match component { + | Component::CurDir => (), + | Component::ParentDir => { + match out.last() { + | Some(Component::RootDir) => (), + | Some(Component::Normal(_)) => { + out.pop(); + }, + | None + | Some(Component::CurDir) + | Some(Component::ParentDir) + | Some(Component::Prefix(_)) => out.push(component), + } + }, + | comp => out.push(comp), + } + } + + if !out.is_empty() { + out.iter().collect() + } else { + PathBuf::from(".") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // Helpers. + + fn test_cases(cases: Vec<(&str, &str)>) { + for (actual, expected) in cases { + assert_eq!(clean(actual), PathBuf::from(expected)); + } + } + + // Tests. + + #[test] + fn test_trait() { + assert_eq!( + PathBuf::from("/test/../path/").clean(), + PathBuf::from("/path") + ); + + assert_eq!(Path::new("/test/../path/").clean(), PathBuf::from("/path")); + } + + #[test] + fn test_empty_path_is_current_dir() { + assert_eq!(clean(""), PathBuf::from(".")); + } + + #[test] + fn test_clean_paths_dont_change() { + let cases = vec![(".", "."), ("..", ".."), ("/", "/")]; + + test_cases(cases); + } + + #[test] + fn test_replace_multiple_slashes() { + let cases = vec![ + ("/", "/"), + ("//", "/"), + ("///", "/"), + (".//", "."), + ("//..", "/"), + ("..//", ".."), + ("/..//", "/"), + ("/.//./", "/"), + ("././/./", "."), + ("path//to///thing", "path/to/thing"), + ("/path//to///thing", "/path/to/thing"), + ]; + + test_cases(cases); + } + + #[test] + fn test_eliminate_current_dir() { + let cases = vec![ + ("./", "."), + ("/./", "/"), + ("./test", "test"), + ("./test/./path", "test/path"), + ("/test/./path/", "/test/path"), + ("test/path/.", "test/path"), + ]; + + test_cases(cases); + } + + #[test] + fn test_eliminate_parent_dir() { + let cases = vec![ + ("/..", "/"), + ("/../test", "/test"), + ("test/..", "."), + ("test/path/..", "test"), + ("test/../path", "path"), + ("/test/../path", "/path"), + ("test/path/../../", "."), + ("test/path/../../..", ".."), + ("/test/path/../../..", "/"), + ("/test/path/../../../..", "/"), + ("test/path/../../../..", "../.."), + ("test/path/../../another/path", "another/path"), + ("test/path/../../another/path/..", "another"), + ("../test", "../test"), + ("../test/", "../test"), + ("../test/path", "../test/path"), + ("../test/..", ".."), + ]; + + test_cases(cases); + } + + #[test] + #[cfg(windows)] + fn test_windows_paths() { + let cases = vec![ + ("\\..", "\\"), + ("\\..\\test", "\\test"), + ("test\\..", "."), + ("test\\path\\..\\..\\..", ".."), + ("test\\path/..\\../another\\path", "another\\path"), // Mixed + ("/dir\\../otherDir/test.json", "/otherDir/test.json"), // User example + ]; + + test_cases(cases); + } +} diff --git a/src/path/mod.rs b/src/path/mod.rs index 1cbe237..e5ec490 100644 --- a/src/path/mod.rs +++ b/src/path/mod.rs @@ -1,3 +1,5 @@ +pub use clean::*; pub use utils::*; +mod clean; mod utils; From c390057cd148e0b05426bdbd23476b883ee7eb15 Mon Sep 17 00:00:00 2001 From: Vladislav Mamon Date: Tue, 5 Mar 2024 05:18:31 +0300 Subject: [PATCH 39/77] feat(actions): implement `mv` action --- src/actions/actions.rs | 40 ++++++++++++++++++++++++++++++++++++++-- src/actions/executor.rs | 2 +- src/fs/traverser.rs | 17 +++++++++++++++++ 3 files changed, 56 insertions(+), 3 deletions(-) diff --git a/src/actions/actions.rs b/src/actions/actions.rs index 2ec1c04..ba7960f 100644 --- a/src/actions/actions.rs +++ b/src/actions/actions.rs @@ -54,8 +54,44 @@ impl Copy { } impl Move { - pub async fn execute(&self) -> anyhow::Result<()> { - Ok(println!("mv action")) + pub async fn execute

(&self, root: P) -> anyhow::Result<()> + where + P: Into + AsRef, + { + let root: PathBuf = root.into(); + let destination = &root.join(&self.to); + + let traverser = Traverser::new(&root) + .ignore_dirs(false) + .contents_first(false) + .pattern(&self.from); + + println!( + "⋅ Moving: {}", + format!("{} ╌╌ {}", &self.from, &self.to).dim() + ); + + for matched in traverser.iter().flatten() { + let target = if matched.is_full() { + destination + .join(matched.captured.file_name().unwrap()) + .clean() + } else { + destination.join(&matched.captured).clean() + }; + + // FIXME: Use something else than `.exists()`. + if !self.overwrite && target.exists() { + continue; + } + + // Move or rename. + fs::rename(&matched.path, &target)?; + + println!("└─ {} ╌╌ {}", &matched.path.display(), &target.display()); + } + + Ok(()) } } diff --git a/src/actions/executor.rs b/src/actions/executor.rs index 1d8b802..aaf2d57 100644 --- a/src/actions/executor.rs +++ b/src/actions/executor.rs @@ -104,7 +104,7 @@ impl Executor { match action { | ActionSingle::Copy(action) => action.execute(root).await, - | ActionSingle::Move(action) => action.execute().await, + | ActionSingle::Move(action) => action.execute(root).await, | ActionSingle::Delete(action) => action.execute().await, | ActionSingle::Echo(action) => action.execute(state).await, | ActionSingle::Run(action) => action.execute(root, state).await, diff --git a/src/fs/traverser.rs b/src/fs/traverser.rs index 3b1dc88..e3335fe 100644 --- a/src/fs/traverser.rs +++ b/src/fs/traverser.rs @@ -20,6 +20,23 @@ pub struct Match { pub entry: DirEntry, } +impl Match { + /// Checks if the match is a directory. + pub fn is_dir(&self) -> bool { + self.entry.file_type().is_dir() + } + + /// Checks if the match is a file. + pub fn is_file(&self) -> bool { + self.entry.file_type().is_file() + } + + /// Checks if the match is a full match. + pub fn is_full(&self) -> bool { + self.captured == self.path + } +} + #[derive(Debug)] pub struct TraverseOptions { /// Directory to traverse. From fe72f4a5aec540b94099a324533d11dcffd93db4 Mon Sep 17 00:00:00 2001 From: Vladislav Mamon Date: Tue, 5 Mar 2024 14:42:25 +0300 Subject: [PATCH 40/77] feat(actions): implement `rm` action, refactor other fs actions The goal of fs actions like `cp`, `mv` and `rm` is to mimic corresponding shell commands, which was not the case before this commit. --- src/actions/actions.rs | 72 +++++++++++++++++++++++++++++------------ src/actions/executor.rs | 2 +- src/fs/traverser.rs | 5 --- 3 files changed, 52 insertions(+), 27 deletions(-) diff --git a/src/actions/actions.rs b/src/actions/actions.rs index ba7960f..f2595f7 100644 --- a/src/actions/actions.rs +++ b/src/actions/actions.rs @@ -1,4 +1,3 @@ -use std::fs; use std::path::{Path, PathBuf}; use std::process; use std::thread; @@ -6,7 +5,7 @@ use std::time::{Duration, Instant}; use crossterm::style::Stylize; use run_script::ScriptOptions; -use tokio::fs::{File, OpenOptions}; +use tokio::fs::{self, File, OpenOptions}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use unindent::Unindent; @@ -21,10 +20,9 @@ impl Copy { where P: Into + AsRef, { - let root: PathBuf = root.into(); - let destination = &root.join(&self.to); + let destination = root.as_ref().join(&self.to); - let traverser = Traverser::new(&root) + let traverser = Traverser::new(root.as_ref()) .ignore_dirs(true) .contents_first(true) .pattern(&self.from); @@ -35,15 +33,20 @@ impl Copy { ); for matched in traverser.iter().flatten() { - let target = destination.join(&matched.captured).clean(); + let name = matched + .path + .file_name() + .expect("Path should end with valid file name"); + + let target = destination.join(name).clean(); if !self.overwrite && target.is_file() { continue; } if let Some(parent) = target.parent() { - fs::create_dir_all(parent)?; - fs::copy(&matched.path, &target)?; + fs::create_dir_all(parent).await?; + fs::copy(&matched.path, &target).await?; } println!("└─ {} ╌╌ {}", &matched.path.display(), &target.display()); @@ -63,7 +66,7 @@ impl Move { let traverser = Traverser::new(&root) .ignore_dirs(false) - .contents_first(false) + .contents_first(true) .pattern(&self.from); println!( @@ -72,21 +75,22 @@ impl Move { ); for matched in traverser.iter().flatten() { - let target = if matched.is_full() { - destination - .join(matched.captured.file_name().unwrap()) - .clean() - } else { - destination.join(&matched.captured).clean() - }; + let name = matched + .path + .file_name() + .expect("Path should end with valid file name"); + + let target = destination.join(name).clean(); - // FIXME: Use something else than `.exists()`. + // FIXME: Use something else other than `.exists()`. if !self.overwrite && target.exists() { continue; } - // Move or rename. - fs::rename(&matched.path, &target)?; + if let Some(parent) = target.parent() { + fs::create_dir_all(parent).await?; + fs::rename(&matched.path, &target).await?; + } println!("└─ {} ╌╌ {}", &matched.path.display(), &target.display()); } @@ -96,8 +100,34 @@ impl Move { } impl Delete { - pub async fn execute(&self) -> anyhow::Result<()> { - Ok(println!("rm action")) + pub async fn execute

(&self, root: P) -> anyhow::Result<()> + where + P: Into + AsRef, + { + let root: PathBuf = root.into(); + + let traverser = Traverser::new(root) + .ignore_dirs(false) + .contents_first(false) + .pattern(&self.target); + + println!("⋅ Deleting: {}", &self.target.clone().dim()); + + for matched in traverser.iter().flatten() { + let target = &matched.path.clean(); + + if matched.is_file() { + fs::remove_file(target).await?; + } else if matched.is_dir() { + fs::remove_dir_all(target).await?; + } else { + continue; + } + + println!("└─ {}", &target.display()); + } + + Ok(()) } } diff --git a/src/actions/executor.rs b/src/actions/executor.rs index aaf2d57..c8688a3 100644 --- a/src/actions/executor.rs +++ b/src/actions/executor.rs @@ -105,7 +105,7 @@ impl Executor { match action { | ActionSingle::Copy(action) => action.execute(root).await, | ActionSingle::Move(action) => action.execute(root).await, - | ActionSingle::Delete(action) => action.execute().await, + | ActionSingle::Delete(action) => action.execute(root).await, | ActionSingle::Echo(action) => action.execute(state).await, | ActionSingle::Run(action) => action.execute(root, state).await, | ActionSingle::Prompt(action) => action.execute(state).await, diff --git a/src/fs/traverser.rs b/src/fs/traverser.rs index e3335fe..1249252 100644 --- a/src/fs/traverser.rs +++ b/src/fs/traverser.rs @@ -30,11 +30,6 @@ impl Match { pub fn is_file(&self) -> bool { self.entry.file_type().is_file() } - - /// Checks if the match is a full match. - pub fn is_full(&self) -> bool { - self.captured == self.path - } } #[derive(Debug)] From 4455e63439435e7db257e860914180c7726b737d Mon Sep 17 00:00:00 2001 From: Vladislav Mamon Date: Tue, 5 Mar 2024 15:07:24 +0300 Subject: [PATCH 41/77] refactor(actions): use `State::get` facade method to access values --- src/actions/actions.rs | 6 +++--- src/actions/executor.rs | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/actions/actions.rs b/src/actions/actions.rs index f2595f7..9af8e1e 100644 --- a/src/actions/actions.rs +++ b/src/actions/actions.rs @@ -143,7 +143,7 @@ impl Echo { if let Some(injects) = &self.injects { for inject in injects { - if let Some(Value::String(value)) = state.values.get(inject) { + if let Some(Value::String(value)) = state.get(inject) { message = message.replace(&format!("{{{inject}}}"), value); } } @@ -163,7 +163,7 @@ impl Run { if let Some(injects) = &self.injects { for inject in injects { - if let Some(Value::String(value)) = state.values.get(inject) { + if let Some(Value::String(value)) = state.get(inject) { command = command.replace(&format!("{{{inject}}}"), value); } } @@ -249,7 +249,7 @@ impl Replace { file.read_to_string(&mut buffer).await?; for replacement in &self.replacements { - if let Some(Value::String(value)) = state.values.get(replacement) { + if let Some(Value::String(value)) = state.get(replacement) { buffer = buffer.replace(&format!("{{{replacement}}}"), value); } } diff --git a/src/actions/executor.rs b/src/actions/executor.rs index c8688a3..0ffe559 100644 --- a/src/actions/executor.rs +++ b/src/actions/executor.rs @@ -16,7 +16,7 @@ pub enum Value { #[derive(Debug)] pub struct State { /// A map of replacements and associated values. - pub values: HashMap, + values: HashMap, } impl State { From e8507d1d33edc3223392a4a2c3d608f40efda8a1 Mon Sep 17 00:00:00 2001 From: Vladislav Mamon Date: Tue, 5 Mar 2024 15:08:03 +0300 Subject: [PATCH 42/77] refactor(repository): remove unused enum, handle multiple slash error --- src/repository.rs | 19 ++++--------------- 1 file changed, 4 insertions(+), 15 deletions(-) diff --git a/src/repository.rs b/src/repository.rs index 31e90a9..fd2ec66 100644 --- a/src/repository.rs +++ b/src/repository.rs @@ -66,19 +66,6 @@ pub enum RepositoryHost { BitBucket, } -/// Container for a repository host. -#[derive(Debug)] -pub enum Host { - Known(RepositoryHost), - Unknown, -} - -impl Default for Host { - fn default() -> Self { - Host::Known(RepositoryHost::default()) - } -} - /// Repository meta or *ref*, i.e. branch, tag or commit. /// /// This newtype exists solely for providing the default value. @@ -169,8 +156,6 @@ impl FromStr for RemoteRepository { is_valid_user(ch) || ch == '.' } - // TODO: Handle an edge case with multiple slashes in the repository name. - let input = input.trim(); // Parse host if present or use default otherwise. @@ -198,6 +183,10 @@ impl FromStr for RemoteRepository { // Parse repository name. let (repo, input) = if let Some((repo, rest)) = input.split_once('#') { + if repo.contains("//") { + return Err(ParseError::MultipleSlash); + } + if repo.chars().all(is_valid_repo) { (repo.to_string(), Some(rest)) } else { From 3d0b1d8c296444f616503b562f25e64c1fd42830 Mon Sep 17 00:00:00 2001 From: Vladislav Mamon Date: Tue, 5 Mar 2024 15:08:24 +0300 Subject: [PATCH 43/77] refactor(config): get rid of unused let bindings --- src/manifest/manifest.rs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/manifest/manifest.rs b/src/manifest/manifest.rs index 84d37c8..abed365 100644 --- a/src/manifest/manifest.rs +++ b/src/manifest/manifest.rs @@ -138,11 +138,8 @@ impl 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)?; - - self.options = options; - self.actions = actions; + self.options = self.get_options(&doc)?; + self.actions = self.get_actions(&doc)?; } Ok(()) From ecb0101fb58c7917318d5776f1faad3272acf034 Mon Sep 17 00:00:00 2001 From: Vladislav Mamon Date: Tue, 5 Mar 2024 15:09:33 +0300 Subject: [PATCH 44/77] refactor(spinner): allow unused methods --- src/spinner.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/spinner.rs b/src/spinner.rs index 83408af..9c85807 100644 --- a/src/spinner.rs +++ b/src/spinner.rs @@ -28,6 +28,7 @@ impl Spinner { } /// Stops the spinner. + #[allow(dead_code)] pub fn stop(&self) { self.spinner.finish(); } @@ -41,6 +42,7 @@ impl Spinner { } /// Stops the spinner and clears the message. + #[allow(dead_code)] pub fn stop_with_clear(&self) { self.spinner.finish_and_clear(); } From 0b62f9523f6ce10b6f29ec1106e5433eaf8c9577 Mon Sep 17 00:00:00 2001 From: Vladislav Mamon Date: Tue, 5 Mar 2024 15:12:07 +0300 Subject: [PATCH 45/77] refactor: move `Traverser` to `crate::path`, remove unused path utils, change mods visibility --- src/actions/actions.rs | 2 +- src/fs/mod.rs | 3 --- src/lib.rs | 13 ++++++------- src/path/mod.rs | 4 ++-- src/{fs => path}/traverser.rs | 0 src/path/utils.rs | 16 ---------------- src/repository.rs | 2 +- 7 files changed, 10 insertions(+), 30 deletions(-) delete mode 100644 src/fs/mod.rs rename src/{fs => path}/traverser.rs (100%) delete mode 100644 src/path/utils.rs diff --git a/src/actions/actions.rs b/src/actions/actions.rs index 9af8e1e..add9f9f 100644 --- a/src/actions/actions.rs +++ b/src/actions/actions.rs @@ -10,9 +10,9 @@ use tokio::io::{AsyncReadExt, AsyncWriteExt}; use unindent::Unindent; use crate::actions::{State, Value}; -use crate::fs::Traverser; use crate::manifest::actions::*; use crate::path::PathClean; +use crate::path::Traverser; use crate::spinner::Spinner; impl Copy { diff --git a/src/fs/mod.rs b/src/fs/mod.rs deleted file mode 100644 index 9a298bd..0000000 --- a/src/fs/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -pub use traverser::*; - -mod traverser; diff --git a/src/lib.rs b/src/lib.rs index fd3dc81..93d3e18 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,10 +1,9 @@ #![allow(clippy::module_inception)] -pub mod actions; +pub(crate) mod actions; pub mod app; -pub mod fs; -pub mod manifest; -pub mod path; -pub mod repository; -pub mod spinner; -pub mod unpacker; +pub(crate) mod manifest; +pub(crate) mod path; +pub(crate) mod repository; +pub(crate) mod spinner; +pub(crate) mod unpacker; diff --git a/src/path/mod.rs b/src/path/mod.rs index e5ec490..36c4d52 100644 --- a/src/path/mod.rs +++ b/src/path/mod.rs @@ -1,5 +1,5 @@ pub use clean::*; -pub use utils::*; +pub use traverser::*; mod clean; -mod utils; +mod traverser; diff --git a/src/fs/traverser.rs b/src/path/traverser.rs similarity index 100% rename from src/fs/traverser.rs rename to src/path/traverser.rs diff --git a/src/path/utils.rs b/src/path/utils.rs deleted file mode 100644 index af3cb4b..0000000 --- a/src/path/utils.rs +++ /dev/null @@ -1,16 +0,0 @@ -use std::path::{Path, PathBuf}; - -pub trait PathUtils { - /// Given `root`, returns `root` if `self` is `.`, otherwise returns `self`. - fn to_root>(&self, root: P) -> 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() - } - } -} diff --git a/src/repository.rs b/src/repository.rs index fd2ec66..eb586ce 100644 --- a/src/repository.rs +++ b/src/repository.rs @@ -7,7 +7,7 @@ use git2::build::CheckoutBuilder; use git2::Repository as GitRepository; use thiserror::Error; -use crate::fs::Traverser; +use crate::path::Traverser; #[derive(Debug, Error, PartialEq)] pub enum ParseError { From 93c12427efa2cea92158680fae436a39033e1c0f Mon Sep 17 00:00:00 2001 From: Vladislav Mamon Date: Tue, 5 Mar 2024 15:13:04 +0300 Subject: [PATCH 46/77] refactor(config): remove unused error variant --- src/manifest/manifest.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/manifest/manifest.rs b/src/manifest/manifest.rs index abed365..4d7cbbb 100644 --- a/src/manifest/manifest.rs +++ b/src/manifest/manifest.rs @@ -19,8 +19,6 @@ pub enum ManifestError { 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.")] From 2f0a98df6b6b86876d96665f4e721c2fec9370d1 Mon Sep 17 00:00:00 2001 From: Vladislav Mamon Date: Tue, 5 Mar 2024 15:15:25 +0300 Subject: [PATCH 47/77] refactor(clippy): allow variant names with the same prefix --- src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib.rs b/src/lib.rs index 93d3e18..43cd610 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,4 @@ -#![allow(clippy::module_inception)] +#![allow(clippy::module_inception, clippy::enum_variant_names)] pub(crate) mod actions; pub mod app; From 7148544d7315c53ad6521a5d55daca45dfd67236 Mon Sep 17 00:00:00 2001 From: Vladislav Mamon Date: Tue, 5 Mar 2024 16:03:53 +0300 Subject: [PATCH 48/77] feat(app): bail if the destination path already exists --- src/app.rs | 26 ++++++++++---------------- 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/src/app.rs b/src/app.rs index 3dfb9f8..ba3eea4 100644 --- a/src/app.rs +++ b/src/app.rs @@ -47,16 +47,6 @@ pub enum BaseCommands { }, } -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, @@ -68,12 +58,6 @@ impl App { } 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"); - } - // Load the manifest. let manifest = match self.cli.command { | BaseCommands::Remote { src, path, meta } => Self::remote(src, path, meta).await?, @@ -99,6 +83,11 @@ impl App { let name = path.unwrap_or(remote.repo.clone()); let destination = PathBuf::from(name); + // Check if destination already exists before downloading. + if let Ok(true) = &destination.try_exists() { + anyhow::bail!("{} already exists", destination.display()); + } + // Fetch the tarball as bytes (compressed). let tarball = remote.fetch().await?; @@ -132,6 +121,11 @@ impl App { .unwrap_or_default() }; + // Check if destination already exists before performing local clone. + if let Ok(true) = &destination.try_exists() { + anyhow::bail!("{} already exists", destination.display()); + } + // Copy the directory. local.copy(&destination)?; local.checkout(&destination)?; From da3680ad1687df69b21c94a5d2c6b8a847fd847d Mon Sep 17 00:00:00 2001 From: Vladislav Mamon Date: Tue, 5 Mar 2024 16:26:03 +0300 Subject: [PATCH 49/77] refactor: clean up and improve comments a bit --- src/app.rs | 4 ++-- src/manifest/actions.rs | 10 +++++----- src/manifest/manifest.rs | 5 ++--- 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/src/app.rs b/src/app.rs index ba3eea4..bd87000 100644 --- a/src/app.rs +++ b/src/app.rs @@ -96,7 +96,7 @@ impl App { unpacker.unpack_to(&destination)?; // Now we need to read the manifest (if it is present). - let mut manifest = Manifest::with_options(&destination); + let mut manifest = Manifest::new(&destination); manifest.load()?; Ok(manifest) @@ -139,7 +139,7 @@ impl App { } // Now we need to read the manifest (if it is present). - let mut manifest = Manifest::with_options(&destination); + let mut manifest = Manifest::new(&destination); manifest.load()?; Ok(manifest) diff --git a/src/manifest/actions.rs b/src/manifest/actions.rs index b66c399..d56480b 100644 --- a/src/manifest/actions.rs +++ b/src/manifest/actions.rs @@ -9,7 +9,7 @@ pub struct Copy { pub from: String, /// Where to copy to. pub to: String, - /// Whether to overwrite or not. + /// Whether to overwrite or not. Defaults to `true`. pub overwrite: bool, } @@ -20,7 +20,7 @@ pub struct Move { pub from: String, /// Where to move to. pub to: String, - /// Whether to overwrite or not. + /// Whether to overwrite or not. Defaults to `true`. pub overwrite: bool, } @@ -46,7 +46,7 @@ pub struct Echo { /// /// All placeholders are processed _before_ running a command. pub injects: Option>, - /// Whether to trim multiline message or not. + /// Whether to trim multiline message or not. Defaults to `true`. pub trim: bool, } @@ -56,12 +56,12 @@ pub struct Run { /// Command name. Optional, defaults either to the command itself or to the first line of /// the multiline command. pub name: Option, - /// Comannd to run in the shell. + /// Command to run in the shell. pub command: String, /// An optional list of placeholders 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 placeholder + /// We use `inject` to disambiguate whether `{R_PM}` is part of a command or is a placeholder /// that should be replaced with something. /// /// ```kdl diff --git a/src/manifest/manifest.rs b/src/manifest/manifest.rs index 4d7cbbb..20fb92d 100644 --- a/src/manifest/manifest.rs +++ b/src/manifest/manifest.rs @@ -124,9 +124,9 @@ pub struct Manifest { impl Manifest { /// Creates a new manifest from the given path and options. - pub fn with_options(path: &Path) -> Self { + pub fn new(root: &Path) -> Self { Self { - root: path.to_path_buf(), + root: root.to_path_buf(), options: ManifestOptions::default(), actions: Actions::Empty, } @@ -145,7 +145,6 @@ impl Manifest { /// 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(); From 1e8f02b918d4b52cd82a57c2602abbdcf3153bb2 Mon Sep 17 00:00:00 2001 From: Vladislav Mamon Date: Tue, 5 Mar 2024 16:33:57 +0300 Subject: [PATCH 50/77] refactor(app): use `try_exists` instead of `exists` --- src/app.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app.rs b/src/app.rs index bd87000..cd92918 100644 --- a/src/app.rs +++ b/src/app.rs @@ -117,7 +117,7 @@ impl App { local .source .file_name() - .map(|name| name.into()) + .map(PathBuf::from) .unwrap_or_default() }; @@ -133,7 +133,7 @@ impl App { // Delete inner .git. let inner_git = destination.join(".git"); - if inner_git.exists() { + if let Ok(true) = inner_git.try_exists() { println!("Removing {}\n", inner_git.display()); fs::remove_dir_all(inner_git)?; } From e82a7a2865ac5b6b5331ae9e01c7b4fff78d7b29 Mon Sep 17 00:00:00 2001 From: Vladislav Mamon Date: Tue, 5 Mar 2024 23:32:27 +0300 Subject: [PATCH 51/77] refactor(actions): slightly simplify trait bounds for action executors, use `try_exists` --- src/actions/actions.rs | 26 ++++++++++++-------------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/src/actions/actions.rs b/src/actions/actions.rs index add9f9f..b9ed697 100644 --- a/src/actions/actions.rs +++ b/src/actions/actions.rs @@ -18,7 +18,7 @@ use crate::spinner::Spinner; impl Copy { pub async fn execute

(&self, root: P) -> anyhow::Result<()> where - P: Into + AsRef, + P: AsRef, { let destination = root.as_ref().join(&self.to); @@ -59,12 +59,11 @@ impl Copy { impl Move { pub async fn execute

(&self, root: P) -> anyhow::Result<()> where - P: Into + AsRef, + P: AsRef, { - let root: PathBuf = root.into(); - let destination = &root.join(&self.to); + let destination = root.as_ref().join(&self.to); - let traverser = Traverser::new(&root) + let traverser = Traverser::new(root.as_ref()) .ignore_dirs(false) .contents_first(true) .pattern(&self.from); @@ -82,9 +81,10 @@ impl Move { let target = destination.join(name).clean(); - // FIXME: Use something else other than `.exists()`. - if !self.overwrite && target.exists() { - continue; + if !self.overwrite { + if let Ok(true) = target.try_exists() { + continue; + } } if let Some(parent) = target.parent() { @@ -102,11 +102,9 @@ impl Move { impl Delete { pub async fn execute

(&self, root: P) -> anyhow::Result<()> where - P: Into + AsRef, + P: AsRef, { - let root: PathBuf = root.into(); - - let traverser = Traverser::new(root) + let traverser = Traverser::new(root.as_ref()) .ignore_dirs(false) .contents_first(false) .pattern(&self.target); @@ -226,7 +224,7 @@ impl Prompt { impl Replace { pub async fn execute

(&self, root: P, state: &State) -> anyhow::Result<()> where - P: Into + AsRef, + P: AsRef, { let spinner = Spinner::new(); let start = Instant::now(); @@ -234,7 +232,7 @@ impl Replace { // If no glob pattern specified, traverse all files. let pattern = self.glob.clone().unwrap_or("**/*".to_string()); - let traverser = Traverser::new(root.into()) + let traverser = Traverser::new(root.as_ref()) .ignore_dirs(true) .contents_first(true) .pattern(&pattern); From 51dfb9482f9f7ebf15512a6851179bd24f25628d Mon Sep 17 00:00:00 2001 From: Vladislav Mamon Date: Wed, 6 Mar 2024 00:00:53 +0300 Subject: [PATCH 52/77] feat(executor): delete config file after scaffolding is done --- src/actions/executor.rs | 22 ++++++++++++++-------- src/app.rs | 38 +++++++++++++++++++++++++++----------- src/manifest/manifest.rs | 35 ++++++++++++++++++++++++++++------- 3 files changed, 69 insertions(+), 26 deletions(-) diff --git a/src/actions/executor.rs b/src/actions/executor.rs index 0ffe559..17cb816 100644 --- a/src/actions/executor.rs +++ b/src/actions/executor.rs @@ -1,6 +1,7 @@ use std::collections::HashMap; use crossterm::style::Stylize; +use tokio::fs; use crate::manifest::{ActionSingle, ActionSuite, Actions, Manifest}; @@ -57,17 +58,22 @@ impl Executor { /// Execute the actions. pub async fn execute(&self) -> anyhow::Result<()> { - let _ = match &self.manifest.actions { - | Actions::Suite(suites) => self.suite(suites).await, - | Actions::Flat(actions) => self.flat(actions).await, - | Actions::Empty => return Ok(println!("No actions found.")), + match &self.manifest.actions { + | Actions::Suite(suites) => self.suite(suites).await?, + | Actions::Flat(actions) => self.flat(actions).await?, + | Actions::Empty => println!("No actions found."), }; + // Delete the config file if needed. + if self.manifest.options.delete { + fs::remove_file(&self.manifest.config).await?; + } + Ok(()) } /// Execute a suite of actions. - async fn suite(&self, suites: &[ActionSuite]) -> anyhow::Result { + async fn suite(&self, suites: &[ActionSuite]) -> anyhow::Result<()> { let mut state = State::new(); for ActionSuite { name, actions, .. } in suites { @@ -83,11 +89,11 @@ impl Executor { } } - Ok(state) + Ok(()) } /// Execute a flat list of actions. - async fn flat(&self, actions: &[ActionSingle]) -> anyhow::Result { + async fn flat(&self, actions: &[ActionSingle]) -> anyhow::Result<()> { let mut state = State::new(); for action in actions { @@ -95,7 +101,7 @@ impl Executor { println!(); } - Ok(state) + Ok(()) } /// Execute a single action. diff --git a/src/app.rs b/src/app.rs index cd92918..d4d64d4 100644 --- a/src/app.rs +++ b/src/app.rs @@ -4,7 +4,7 @@ use std::path::PathBuf; use clap::{Parser, Subcommand}; use crate::actions::Executor; -use crate::manifest::Manifest; +use crate::manifest::{Manifest, ManifestOptionsOverrides}; use crate::repository::{LocalRepository, RemoteRepository}; use crate::unpacker::Unpacker; @@ -12,18 +12,14 @@ use crate::unpacker::Unpacker; #[command(version, about, long_about = None)] pub struct Cli { #[command(subcommand)] - pub command: BaseCommands, - - /// Delete arx config after scaffolding. - #[arg(short, long)] - pub delete: bool, + pub command: BaseCommand, } #[derive(Debug, Subcommand)] -pub enum BaseCommands { +pub enum BaseCommand { /// Scaffold from a remote repository. Remote { - /// Template repository to use for scaffolding. + /// Repository to use for scaffolding. src: String, /// Directory to scaffold to. @@ -32,10 +28,14 @@ pub enum BaseCommands { /// Scaffold from a specified ref (branch, tag, or commit). #[arg(name = "REF", short = 'r', long = "ref")] meta: Option, + + /// Delete arx config after scaffolding. + #[arg(short, long)] + delete: Option, }, /// Scaffold from a local repository. Local { - /// Template repository to use for scaffolding. + /// Repository to use for scaffolding. src: String, /// Directory to scaffold to. @@ -44,6 +44,10 @@ pub enum BaseCommands { /// Scaffold from a specified ref (branch, tag, or commit). #[arg(name = "REF", short = 'r', long = "ref")] meta: Option, + + /// Delete arx config after scaffolding. + #[arg(short, long)] + delete: Option, }, } @@ -60,8 +64,14 @@ impl App { pub async fn run(self) -> anyhow::Result<()> { // Load the manifest. let manifest = 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?, + | BaseCommand::Remote { src, path, meta, delete } => { + let options = ManifestOptionsOverrides { delete }; + Self::remote(src, path, meta, options).await? + }, + | BaseCommand::Local { src, path, meta, delete } => { + let options = ManifestOptionsOverrides { delete }; + Self::local(src, path, meta, options).await? + }, }; // Create executor and kick off execution. @@ -76,6 +86,7 @@ impl App { src: String, path: Option, meta: Option, + overrides: ManifestOptionsOverrides, ) -> anyhow::Result { // Parse repository. let remote = RemoteRepository::new(src, meta)?; @@ -97,7 +108,9 @@ impl App { // Now we need to read the manifest (if it is present). let mut manifest = Manifest::new(&destination); + manifest.load()?; + manifest.override_with(overrides); Ok(manifest) } @@ -107,6 +120,7 @@ impl App { src: String, path: Option, meta: Option, + overrides: ManifestOptionsOverrides, ) -> anyhow::Result { // Create repository. let local = LocalRepository::new(src, meta); @@ -140,7 +154,9 @@ impl App { // Now we need to read the manifest (if it is present). let mut manifest = Manifest::new(&destination); + manifest.load()?; + manifest.override_with(overrides); Ok(manifest) } diff --git a/src/manifest/manifest.rs b/src/manifest/manifest.rs index 20fb92d..0d758f6 100644 --- a/src/manifest/manifest.rs +++ b/src/manifest/manifest.rs @@ -14,9 +14,9 @@ const MANIFEST_NAME: &str = "arx.kdl"; #[derive(Debug, Error)] pub enum ManifestError { #[error("Couldn't read the manifest.")] - ReadFail(#[from] io::Error), + ReadFail(io::Error), #[error("Couldn't parse the manifest.")] - ParseFail(#[from] kdl::KdlError), + ParseFail(kdl::KdlError), #[error("You can use either suites of actions or a flat list of single actions, not both.")] MixedActions, #[error("Unknown prompt '{0}'.")] @@ -52,6 +52,18 @@ impl Default for ManifestOptions { } } +/// Manifest options that may override parsed options. +pub struct ManifestOptionsOverrides { + /// Whether to delete the manifest after we (successfully) done running. + pub delete: Option, +} + +impl Default for ManifestOptionsOverrides { + fn default() -> Self { + Self { delete: None } + } +} + /// Represents a manifest actions set that can be a vec of [ActionSuite] *or* [ActionSingle]. /// /// So, actions should be defined either like this: @@ -116,6 +128,8 @@ pub enum ActionSingle { pub struct Manifest { /// Manifest directory. pub root: PathBuf, + /// Manifest file path. + pub config: PathBuf, /// Manifest options. pub options: ManifestOptions, /// Actions. @@ -125,10 +139,20 @@ pub struct Manifest { impl Manifest { /// Creates a new manifest from the given path and options. pub fn new(root: &Path) -> Self { + let root = root.to_path_buf(); + Self { - root: root.to_path_buf(), + config: root.join(MANIFEST_NAME), options: ManifestOptions::default(), actions: Actions::Empty, + root, + } + } + + /// Tries to apply the given overrides to the manifest options. + pub fn override_with(&mut self, overrides: ManifestOptionsOverrides) { + if let Some(delete) = overrides.delete { + self.options.delete = delete; } } @@ -145,10 +169,7 @@ impl Manifest { /// Checks if the manifest exists under `self.root`. fn exists(&self) -> bool { - let file = self.root.join(MANIFEST_NAME); - let file_exists = file.try_exists(); - - file_exists.is_ok() + self.config.try_exists().unwrap_or(false) } /// Reads and parses the manifest into a [KdlDocument]. From e2c3ecb80e393e01115bfd24942f6d080c786f1d Mon Sep 17 00:00:00 2001 From: Vladislav Mamon Date: Wed, 6 Mar 2024 00:02:39 +0300 Subject: [PATCH 53/77] refactor(config): derive `Default` instead of manual impl --- src/manifest/manifest.rs | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/manifest/manifest.rs b/src/manifest/manifest.rs index 0d758f6..a6f8c84 100644 --- a/src/manifest/manifest.rs +++ b/src/manifest/manifest.rs @@ -53,17 +53,12 @@ impl Default for ManifestOptions { } /// Manifest options that may override parsed options. +#[derive(Debug, Default)] pub struct ManifestOptionsOverrides { /// Whether to delete the manifest after we (successfully) done running. pub delete: Option, } -impl Default for ManifestOptionsOverrides { - fn default() -> Self { - Self { delete: None } - } -} - /// Represents a manifest actions set that can be a vec of [ActionSuite] *or* [ActionSingle]. /// /// So, actions should be defined either like this: From e64c50eff81a47194f4f02af0cca56a388fef341 Mon Sep 17 00:00:00 2001 From: Vladislav Mamon Date: Wed, 6 Mar 2024 14:40:40 +0300 Subject: [PATCH 54/77] refactor(actions): replace `expect()` with anyhow's `Result` --- src/actions/actions.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/actions/actions.rs b/src/actions/actions.rs index b9ed697..3c56a8a 100644 --- a/src/actions/actions.rs +++ b/src/actions/actions.rs @@ -36,7 +36,7 @@ impl Copy { let name = matched .path .file_name() - .expect("Path should end with valid file name"); + .ok_or(anyhow::anyhow!("Path should end with valid file name"))?; let target = destination.join(name).clean(); @@ -77,7 +77,7 @@ impl Move { let name = matched .path .file_name() - .expect("Path should end with valid file name"); + .ok_or(anyhow::anyhow!("Path should end with valid file name"))?; let target = destination.join(name).clean(); From 0487ef6b257ebb1ccac86c51ae1dd267723b56c6 Mon Sep 17 00:00:00 2001 From: Vladislav Mamon Date: Thu, 7 Mar 2024 00:57:31 +0300 Subject: [PATCH 55/77] chore: update project description --- Cargo.toml | 2 +- LICENSE | 2 +- README.md | 7 ++----- 3 files changed, 4 insertions(+), 7 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 12842ca..fd40f8d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ name = "arx" version = "0.1.0" edition = "2021" authors = ["Vladislav Mamon "] -description = "Simple CLI for scaffolding projects from templates in a touch." +description = "Simple and user-friendly command-line tool for declarative scaffolding." repository = "https://github.com/norskeld/arx" publish = false diff --git a/LICENSE b/LICENSE index 5fb9289..ba1f9a0 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2023 Vladislav Mamon +Copyright (c) 2024 Vladislav Mamon Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 1feceff..359e8cb 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,7 @@ [![Checks](https://img.shields.io/github/workflow/status/norskeld/arx/checks?style=flat-square&colorA=22272d&colorB=22272d&label=checks)](https://github.com/norskeld/arx/actions/workflows/checks.yml) -> `A`ugmented `R`epository E`x`tractor - -Simple CLI for scaffolding projects from templates in a touch. +Simple and user-friendly command-line tool for declarative scaffolding. ## Status @@ -12,7 +10,7 @@ WIP. ## Acknowledgements -Thanks to [Rich Harris][rich-harris] and his [degit] tool for inspiration. `:^)` +Thanks to [Rich Harris][rich-harris] and his [degit] for inspiration. `:^)` ## License @@ -21,5 +19,4 @@ Thanks to [Rich Harris][rich-harris] and his [degit] tool for inspiration. `:^)` [degit]: https://github.com/Rich-Harris/degit -[kdl]: https://github.com/kdl-org/kdl [rich-harris]: https://github.com/Rich-Harris From 146decfda351ffe8168b9a3ea968fff07fe9a8ed Mon Sep 17 00:00:00 2001 From: Vladislav Mamon Date: Thu, 7 Mar 2024 01:00:59 +0300 Subject: [PATCH 56/77] refactor(spec): improve `spec` --- spec.kdl | 82 ++++++++++++++++++++++++-------------------------------- 1 file changed, 35 insertions(+), 47 deletions(-) diff --git a/spec.kdl b/spec.kdl index 754e80c..70e867d 100644 --- a/spec.kdl +++ b/spec.kdl @@ -1,12 +1,12 @@ // Options defined here can be overridden from CLI. options { - // Delete arx.kdl after it's done. Defaults to `true`. + // Delete arx config file after we're done. Defaults to `true`. delete false } // Actions to run after the repository was successfully downloaded and unpacked. All actions or // suites of actions run sequentially, there is no concurrency or out-of-order execution for -// predictable outcome. +// predictable outcomes. // // You can define either suites of actions — named groups of actions — or a flat list of actions, // but not both. @@ -19,83 +19,71 @@ options { // - No cleanup on failures by default. actions { suite "hello" { - // This action simply echoes the argument into stdout. Raw strings are trimmed and aligned to - // the leftmost non-whitespace character. - echo trim=true r#" + // This action simply echoes the argument to stdout. Raw strings are trimmed by default and + // aligned to the leftmost non-whitespace character. Trimming can be disabled with `trim=false`. + echo r#" Sup! Let's set everything up. We will: - Print this message. - Ask some questions via prompts. - - Initialize git repository. + - Initialize git repository (not for real). - Run some commands that will use input from prompts. - - Commit everything. + - Commit everything (again, not for real). "# } - suite "git" { - // This action runs a given shell command and prints its output to stdout. - run "git init" - } - // In this suite we run a series of prompts asking different questions. // - // Answers will be stored globally and available from any _subsequent_ action ar suite of actions. + // Answers will be stored globally and available from any _subsequent_ action or suite of actions. suite "prompts" { - // Simple text prompt. - prompt "input" { - name "R_NAME" + // Text prompt. + input "repo_name" { hint "Repository name" default "norskeld/serpent" } - // This will run user's $EDITOR. - prompt "editor" { - name "R_DESC" + // Editor prompt. This runs the default $EDITOR. + editor "repo_desc" { hint "Repository description" + default "Scaffolded with arx" } - // If you don't specify prompt `type`, it will default to "input". - prompt { - name "R_AUTHOR" - hint "Repository author" - default "Vladislav M." - } - - // Simple select prompt. - prompt "select" { - name "R_PM" + // Select prompt. + select "repo_pm" { hint "Package manager of choice" - variants "npm" "pnpm" "yarn" "bun" + options "npm" "pnpm" "yarn" "bun" default "npm" } - // If you don't specify default value using `default` node it will be an empty string. - prompt { - name "R_PM_ARGS" + // If no default value provided, prompt will become required. + input "repo_pm_args" { hint "Additional arguments for package manager" } // Simple confirm prompt. - prompt "confirm" { - name "R_COMMIT" + confirm "should_commit" { hint "Whether to stage and commit changes after scaffolding" default false } } + suite "git" { + // This action runs a given shell command and prints its output to stdout. + run "echo git init" + } + // Here we demonstrate using replacements. suite "replacements" { // Replace all occurences of given replacements in files that match the glob pattern. replace in=".template/**" { - "R_NAME" - "R_DESC" - "R_AUTHOR" + "repo_name" + "repo_desc" } - // Replace all occurences of given replacements in _all_ files. This is equivalent to "." as the - // glob pattern. + // Replace all occurences of given replacements in _all_ files. This is equivalent to "**/*" as + // the glob pattern. replace { - "R_PM" + "repo_pm" } // Trying to run a non-existent replacement will do nothing (a warning will be issued though). @@ -120,13 +108,13 @@ actions { // Here we demonstrate how to inject prompts' values. suite "install" { - // To disambiguate whether {R_PM} is part of a command or is a replacement that should be + // To disambiguate whether {repo_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. + // into the command. // // All replacements are processed _before_ running a command. - run "{R_PM} install {R_PM_ARGS}" { - inject "R_PM" "R_PM_ARGS" + run "{repo_pm} install {repo_pm_args}" { + inject "repo_pm" "repo_pm_args" } } @@ -143,8 +131,8 @@ actions { // You can name `run` actions for clarity, otherwise it will use either the command itself or // the first line of a multiline command as the hint. run name="stage and commit" r#" - git add . - git commit -m 'chore: init repository' + echo git add . + echo git commit -m 'chore: init repository' "# } } From 311a1fd756cb9b7d9e874730f24c023b85bcb39c Mon Sep 17 00:00:00 2001 From: Vladislav Mamon Date: Thu, 7 Mar 2024 18:10:00 +0300 Subject: [PATCH 57/77] refactor(actions): stylize unknown action message --- src/actions/actions.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/actions/actions.rs b/src/actions/actions.rs index 3c56a8a..4416205 100644 --- a/src/actions/actions.rs +++ b/src/actions/actions.rs @@ -278,6 +278,9 @@ impl Replace { impl Unknown { pub async fn execute(&self) -> anyhow::Result<()> { - Ok(println!("unknown action {}", self.name)) + let name = self.name.as_str().underline_yellow(); + let message = format!("! Unknown action: {name}").yellow(); + + Ok(println!("{message}")) } } From fa90c6d324bee5d3ad01c59d69908f3b6d348a03 Mon Sep 17 00:00:00 2001 From: Vladislav Mamon Date: Thu, 7 Mar 2024 18:11:27 +0300 Subject: [PATCH 58/77] refactor(config): refactor prompts parsing according to spec --- src/manifest/manifest.rs | 153 ++++++++++++++++++--------------------- src/manifest/prompts.rs | 2 + 2 files changed, 73 insertions(+), 82 deletions(-) diff --git a/src/manifest/manifest.rs b/src/manifest/manifest.rs index a6f8c84..00d92c9 100644 --- a/src/manifest/manifest.rs +++ b/src/manifest/manifest.rs @@ -19,14 +19,14 @@ pub enum ManifestError { ParseFail(kdl::KdlError), #[error("You can use either suites of actions or a flat list of single actions, not both.")] MixedActions, - #[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 name argument.")] + ExpectedNameArgument, #[error("Expected argument for node '{0}'.")] ExpectedArgumentFor(String), #[error("Input prompt must have defined name and hint.")] @@ -283,6 +283,40 @@ impl Manifest { fn get_action_single(&self, node: &KdlNode) -> Result { let kind = node.name().to_string().to_ascii_lowercase(); + #[inline] + fn name(node: &KdlNode) -> Result { + node + .get_string(0) + .ok_or(ManifestError::ExpectedNameArgument) + } + + #[inline] + fn hint(nodes: &KdlDocument) -> Result { + nodes + .get("hint") + .and_then(|node| node.get_string(0)) + .ok_or(ManifestError::ExpectedArgumentFor("hint".into())) + } + + #[inline] + fn options(nodes: &KdlDocument) -> Vec { + nodes + .get_args("options") + .into_iter() + .filter_map(|arg| arg.as_string().map(str::to_string)) + .collect() + } + + #[inline] + fn default_string(nodes: &KdlDocument) -> Option { + nodes.get("default").and_then(|node| node.get_string(0)) + } + + #[inline] + fn default_bool(nodes: &KdlDocument) -> Option { + nodes.get("default").and_then(|node| node.get_bool(0)) + } + let action = match kind.as_str() { // Actions for manipulating files and directories. | "cp" => { @@ -316,7 +350,7 @@ impl Manifest { ActionSingle::Delete(Delete { target }) }, - // Running commands and echoing output. + // Actions for running commands and echoing output. | "echo" => { let message = node .get_string(0) @@ -348,109 +382,64 @@ impl Manifest { ActionSingle::Run(Run { name, command, injects }) }, - // Prompts and replacements. - | "prompt" => { - let prompt = self.get_prompt(node)?; - - ActionSingle::Prompt(prompt) - }, - | "replace" => { - let replacements = node - .children() - .map(|children| { - children - .nodes() - .iter() - .map(|node| node.name().value().to_string()) - .collect() - }) - .unwrap_or_default(); - - let glob = node.get_string("in"); - - ActionSingle::Replace(Replace { replacements, glob }) - }, - // Fallback. - | action => ActionSingle::Unknown(Unknown { name: 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 options(nodes: &KdlDocument) -> Vec { - nodes - .get_args("options") - .into_iter() - .filter_map(|arg| arg.as_string().map(str::to_string)) - .collect() - } - - // Depending on the type construct a prompt. - match kind.as_str() { + // Actions for prompts and replacements. | "input" => { let nodes = node.children().ok_or(ManifestError::ExpectedInputNodes)?; - let default = nodes.get("default").and_then(|node| node.get_string(0)); - Ok(Prompt::Input(Input { - name: name(nodes)?, + ActionSingle::Prompt(Prompt::Input(Input { + name: name(node)?, hint: hint(nodes)?, - default, + default: default_string(nodes), })) }, | "editor" => { let nodes = node.children().ok_or(ManifestError::ExpectedEditorNodes)?; - let default = nodes.get("default").and_then(|node| node.get_string(0)); - Ok(Prompt::Editor(Editor { - name: name(nodes)?, + ActionSingle::Prompt(Prompt::Editor(Editor { + name: name(node)?, hint: hint(nodes)?, - default, + default: default_string(nodes), })) }, | "select" => { let nodes = node.children().ok_or(ManifestError::ExpectedSelectNodes)?; - Ok(Prompt::Select(Select { - name: name(nodes)?, + ActionSingle::Prompt(Prompt::Select(Select { + name: name(node)?, hint: hint(nodes)?, options: options(nodes), + default: default_string(nodes), })) }, | "confirm" => { let nodes = node.children().ok_or(ManifestError::ExpectedConfirmNodes)?; - let default = nodes.get("default").and_then(|node| node.get_bool(0)); - Ok(Prompt::Confirm(Confirm { - name: name(nodes)?, + ActionSingle::Prompt(Prompt::Confirm(Confirm { + name: name(node)?, hint: hint(nodes)?, - default, + default: default_bool(nodes), })) }, - | kind => Err(ManifestError::UnknownPrompt(kind.into())), - } + | "replace" => { + let replacements = node + .children() + .map(|children| { + children + .nodes() + .iter() + .map(|node| node.name().value().to_string()) + .collect() + }) + .unwrap_or_default(); + + let glob = node.get_string("in"); + + ActionSingle::Replace(Replace { replacements, glob }) + }, + // Fallback. + | action => ActionSingle::Unknown(Unknown { name: action.into() }), + }; + + Ok(action) } } diff --git a/src/manifest/prompts.rs b/src/manifest/prompts.rs index a430b5d..98836e0 100644 --- a/src/manifest/prompts.rs +++ b/src/manifest/prompts.rs @@ -16,6 +16,8 @@ pub struct Select { pub hint: String, /// List of options. pub options: Vec, + /// Default value (if prompt was canceled). + pub default: Option, } #[derive(Debug)] From e35f9d9a36ef2c78426ef72d77c4176ad9d9db70 Mon Sep 17 00:00:00 2001 From: Vladislav Mamon Date: Thu, 7 Mar 2024 19:33:50 +0300 Subject: [PATCH 59/77] refactor(actions): adjust styling for suite names and prompts --- src/actions/actions.rs | 2 +- src/actions/executor.rs | 20 +++++++++++++++----- src/actions/prompts.rs | 1 - 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/src/actions/actions.rs b/src/actions/actions.rs index 4416205..424aad6 100644 --- a/src/actions/actions.rs +++ b/src/actions/actions.rs @@ -278,7 +278,7 @@ impl Replace { impl Unknown { pub async fn execute(&self) -> anyhow::Result<()> { - let name = self.name.as_str().underline_yellow(); + let name = self.name.as_str().yellow(); let message = format!("! Unknown action: {name}").yellow(); Ok(println!("{message}")) diff --git a/src/actions/executor.rs b/src/actions/executor.rs index 17cb816..983827d 100644 --- a/src/actions/executor.rs +++ b/src/actions/executor.rs @@ -77,15 +77,25 @@ impl Executor { let mut state = State::new(); for ActionSuite { name, actions, .. } in suites { - let symbol = "+".blue().bold(); - let title = "Suite".blue(); + let hint = "Suite:".cyan(); let name = name.clone().green(); - println!("{symbol} {title}: {name}\n"); + println!("[{hint} {name}]\n"); - for action in actions { + // Man, I hate how peekable iterators work in Rust. + let mut it = actions.iter().peekable(); + + while let Some(action) = it.next() { self.single(action, &mut state).await?; - println!(); + + // Do not print a trailing newline if the current and the next actions are prompts to + // slightly improve visual clarity. Essentially, this way prompts are grouped. + if !matches!( + (action, it.peek()), + (ActionSingle::Prompt(_), Some(ActionSingle::Prompt(_))) + ) { + println!(); + } } } diff --git a/src/actions/prompts.rs b/src/actions/prompts.rs index 04b03e2..4270c9a 100644 --- a/src/actions/prompts.rs +++ b/src/actions/prompts.rs @@ -25,7 +25,6 @@ impl Inquirer { default .with_prompt_prefix(prompt_prefix) .with_answered_prompt_prefix(answered_prefix) - .with_answer(stylesheet.with_fg(Color::White)) .with_default_value(stylesheet.with_fg(Color::DarkGrey)) } From ae05d31f92c8ae8cf93739f9f11891053468323a Mon Sep 17 00:00:00 2001 From: Vladislav Mamon Date: Thu, 7 Mar 2024 20:21:56 +0300 Subject: [PATCH 60/77] refactor(app): add info prints, adjust style a bit --- src/app.rs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/app.rs b/src/app.rs index d4d64d4..df969c6 100644 --- a/src/app.rs +++ b/src/app.rs @@ -2,6 +2,7 @@ use std::fs; use std::path::PathBuf; use clap::{Parser, Subcommand}; +use crossterm::style::Stylize; use crate::actions::Executor; use crate::manifest::{Manifest, ManifestOptionsOverrides}; @@ -142,13 +143,23 @@ impl App { // Copy the directory. local.copy(&destination)?; + + println!("{}", "~ Cloned repository".dark_grey()); + + // Checkout the ref. local.checkout(&destination)?; - // Delete inner .git. + println!( + "{} {}", + "~ Checked out ref:".dark_grey(), + local.meta.0.dark_grey() + ); + + // Delete inner .git directory. let inner_git = destination.join(".git"); if let Ok(true) = inner_git.try_exists() { - println!("Removing {}\n", inner_git.display()); + println!("{}", "~ Removed inner .git directory\n".dark_grey()); fs::remove_dir_all(inner_git)?; } From e1b0ae90f8d45601681a47cbce3a4e3c30d47f1f Mon Sep 17 00:00:00 2001 From: Vladislav Mamon Date: Sun, 10 Mar 2024 02:27:31 +0300 Subject: [PATCH 61/77] feat: implement fancy error reporting via `miette` --- Cargo.lock | 143 +++++++++++++-- Cargo.toml | 3 +- src/actions/actions.rs | 138 +++++++++++--- src/actions/executor.rs | 35 +++- src/actions/prompts.rs | 8 +- src/app.rs | 59 ++++-- src/main.rs | 23 ++- src/manifest/manifest.rs | 376 ++++++++++++++++++++++++++------------- src/repository.rs | 9 +- src/unpacker.rs | 3 +- 10 files changed, 598 insertions(+), 199 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b32df35..00216c6 100755 --- a/Cargo.lock +++ b/Cargo.lock @@ -85,6 +85,7 @@ dependencies = [ "indicatif", "inquire", "kdl", + "miette", "reqwest", "run_script", "tar", @@ -115,6 +116,15 @@ dependencies = [ "rustc-demangle", ] +[[package]] +name = "backtrace-ext" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537beee3be4a18fb023b570f80e3ae28003db9167a751266b259926e25539d50" +dependencies = [ + "backtrace", +] + [[package]] name = "base64" version = "0.21.5" @@ -197,7 +207,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.41", + "syn 2.0.52", ] [[package]] @@ -562,6 +572,12 @@ dependencies = [ "libc", ] +[[package]] +name = "hermit-abi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" + [[package]] name = "http" version = "0.2.8" @@ -700,6 +716,23 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "879d54834c8c76457ef4293a689b2a8c59b076067ad77b15efafbb05f92a592b" +[[package]] +name = "is-terminal" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f23ff5ef2b80d608d61efee834934d862cd92461afc0560dedf493e4c033738b" +dependencies = [ + "hermit-abi 0.3.9", + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "is_ci" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45" + [[package]] name = "itoa" version = "1.0.3" @@ -841,8 +874,17 @@ version = "5.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "59bb584eaeeab6bd0226ccf3509a69d7936d148cf3d036ad350abe35e8c6856e" dependencies = [ + "backtrace", + "backtrace-ext", + "is-terminal", "miette-derive", "once_cell", + "owo-colors", + "supports-color", + "supports-hyperlinks", + "supports-unicode", + "terminal_size", + "textwrap", "thiserror", "unicode-width", ] @@ -855,7 +897,7 @@ checksum = "49e7bc1560b95a3c4a25d03de42fe76ca718ab92d1a22a55b9b4cf67b3ae635c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.41", + "syn 2.0.52", ] [[package]] @@ -934,7 +976,7 @@ version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19e64526ebdee182341572e50e9ad03965aa510cd94427a4549448f285e957a1" dependencies = [ - "hermit-abi", + "hermit-abi 0.1.19", "libc", ] @@ -1010,6 +1052,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "owo-colors" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1b04fb49957986fdce4d6ee7a65027d55d4b6d2265e5848bbb507b58ccfdb6f" + [[package]] name = "parking_lot" version = "0.12.1" @@ -1071,18 +1119,18 @@ checksum = "eb9f9e6e233e5c4a35559a617bf40a4ec447db2e84c20b55a6f83167b7e57872" [[package]] name = "proc-macro2" -version = "1.0.70" +version = "1.0.78" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39278fbbf5fb4f646ce651690877f89d1c5811a3d4acb27700c1cb3cdb78fd3b" +checksum = "e2422ad645d89c99f8f3e6b88a9fdeca7fabeac836b1002371c4367c8f984aae" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.33" +version = "1.0.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5267fca4496028628a95160fc423a33e8b2e6af8a5302579e322e4b520293cae" +checksum = "291ec9ab5efd934aaf503a6466c5d5251535d108ee747472c3977cc5acc868ef" dependencies = [ "proc-macro2", ] @@ -1349,6 +1397,12 @@ version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6ecd384b10a64542d77071bd64bd7b231f4ed5940fba55e98c3de13824cf3d7" +[[package]] +name = "smawk" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" + [[package]] name = "socket2" version = "0.4.10" @@ -1375,6 +1429,34 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" +[[package]] +name = "supports-color" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6398cde53adc3c4557306a96ce67b302968513830a77a95b2b17305d9719a89" +dependencies = [ + "is-terminal", + "is_ci", +] + +[[package]] +name = "supports-hyperlinks" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84231692eb0d4d41e4cdd0cabfdd2e6cd9e255e65f80c9aa7c98dd502b4233d" +dependencies = [ + "is-terminal", +] + +[[package]] +name = "supports-unicode" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f850c19edd184a205e883199a261ed44471c81e39bd95b1357f5febbef00e77a" +dependencies = [ + "is-terminal", +] + [[package]] name = "syn" version = "1.0.99" @@ -1388,9 +1470,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.41" +version = "2.0.52" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c8b28c477cc3bf0e7966561e3460130e1255f7a1cf71931075f1c5e7a7e269" +checksum = "b699d15b36d1f02c3e7c69f8ffef53de37aefae075d8488d4ba1a7788d574a07" dependencies = [ "proc-macro2", "quote", @@ -1443,24 +1525,45 @@ dependencies = [ "winapi", ] +[[package]] +name = "terminal_size" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633c1a546cee861a1a6d0dc69ebeca693bf4296661ba7852b9d21d159e0506df" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "textwrap" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7b3e525a49ec206798b40326a44121291b530c963cfb01018f63e135bac543d" +dependencies = [ + "smawk", + "unicode-linebreak", + "unicode-width", +] + [[package]] name = "thiserror" -version = "1.0.51" +version = "1.0.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f11c217e1416d6f036b870f14e0413d480dbf28edbee1f877abaf0206af43bb7" +checksum = "1e45bcbe8ed29775f228095caf2cd67af7a4ccf756ebff23a306bf3e8b47b24b" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.51" +version = "1.0.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01742297787513b79cf8e29d1056ede1313e2420b7b3b15d0a768b4921f549df" +checksum = "a953cb265bef375dae3de6663da4d3804eee9682ea80d8e2542529b73c531c81" dependencies = [ "proc-macro2", "quote", - "syn 2.0.41", + "syn 2.0.52", ] [[package]] @@ -1513,7 +1616,7 @@ checksum = "5b8a1e28f2deaa14e508979454cb3a223b10b938b45af148bc0986de36f1923b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.41", + "syn 2.0.52", ] [[package]] @@ -1584,6 +1687,12 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4f5b37a154999a8f3f98cc23a628d850e154479cd94decf3414696e12e31aaf" +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + [[package]] name = "unicode-normalization" version = "0.1.21" @@ -1601,9 +1710,9 @@ checksum = "d4c87d22b6e3f4a18d4d40ef354e97c90fcb14dd91d7dc0aa9d8a1172ebf7202" [[package]] name = "unicode-width" -version = "0.1.9" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ed742d4ea2bd1176e236172c8429aaf54486e7ac098db29ffe6529e0ce50973" +checksum = "e51733f11c9c4f72aa0c160008246859e340b00807569a0da0e7a1079b27ba85" [[package]] name = "unindent" diff --git a/Cargo.toml b/Cargo.toml index fd40f8d..0c7236f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,7 +17,8 @@ git2 = { version = "0.18.1", features = ["vendored-libgit2"] } glob-match = { version = "0.2.1" } indicatif = "0.17.8" inquire = { version = "0.7.0", features = ["editor"] } -kdl = { version = "4.6.0" } +kdl = "=4.6.0" +miette = { version = "=5.10.0", features = ["fancy"] } reqwest = { version = "0.11.22", features = ["json"] } run_script = { version = "0.10.1" } tar = { version = "0.4.40" } diff --git a/src/actions/actions.rs b/src/actions/actions.rs index 424aad6..0836446 100644 --- a/src/actions/actions.rs +++ b/src/actions/actions.rs @@ -4,19 +4,31 @@ use std::thread; use std::time::{Duration, Instant}; use crossterm::style::Stylize; +use miette::Diagnostic; use run_script::ScriptOptions; +use thiserror::Error; use tokio::fs::{self, File, OpenOptions}; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::io::{self, AsyncReadExt, AsyncWriteExt}; use unindent::Unindent; use crate::actions::{State, Value}; use crate::manifest::actions::*; -use crate::path::PathClean; -use crate::path::Traverser; +use crate::path::{PathClean, Traverser}; use crate::spinner::Spinner; +#[derive(Debug, Diagnostic, Error)] +pub enum ActionError { + #[error("{message}")] + #[diagnostic(code(arx::actions::io))] + Io { + message: String, + #[source] + source: io::Error, + }, +} + impl Copy { - pub async fn execute

(&self, root: P) -> anyhow::Result<()> + pub async fn execute

(&self, root: P) -> miette::Result<()> where P: AsRef, { @@ -36,7 +48,7 @@ impl Copy { let name = matched .path .file_name() - .ok_or(anyhow::anyhow!("Path should end with valid file name"))?; + .ok_or_else(|| miette::miette!("Path should end with valid file name."))?; let target = destination.join(name).clean(); @@ -45,8 +57,26 @@ impl Copy { } if let Some(parent) = target.parent() { - fs::create_dir_all(parent).await?; - fs::copy(&matched.path, &target).await?; + fs::create_dir_all(parent).await.map_err(|source| { + ActionError::Io { + message: format!( + "Failed to create directory structure for '{}'.", + parent.display() + ), + source, + } + })?; + + fs::copy(&matched.path, &target).await.map_err(|source| { + ActionError::Io { + message: format!( + "Failed to copy from '{}' to '{}'.", + matched.path.display(), + target.display() + ), + source, + } + })?; } println!("└─ {} ╌╌ {}", &matched.path.display(), &target.display()); @@ -57,7 +87,7 @@ impl Copy { } impl Move { - pub async fn execute

(&self, root: P) -> anyhow::Result<()> + pub async fn execute

(&self, root: P) -> miette::Result<()> where P: AsRef, { @@ -77,7 +107,7 @@ impl Move { let name = matched .path .file_name() - .ok_or(anyhow::anyhow!("Path should end with valid file name"))?; + .ok_or_else(|| miette::miette!("Path should end with valid file name."))?; let target = destination.join(name).clean(); @@ -88,8 +118,26 @@ impl Move { } if let Some(parent) = target.parent() { - fs::create_dir_all(parent).await?; - fs::rename(&matched.path, &target).await?; + fs::create_dir_all(parent).await.map_err(|source| { + ActionError::Io { + message: format!( + "Failed to create directory structure for '{}'.", + parent.display() + ), + source, + } + })?; + + fs::rename(&matched.path, &target).await.map_err(|source| { + ActionError::Io { + message: format!( + "Failed to move from '{}' to '{}'.", + matched.path.display(), + target.display() + ), + source, + } + })?; } println!("└─ {} ╌╌ {}", &matched.path.display(), &target.display()); @@ -100,7 +148,7 @@ impl Move { } impl Delete { - pub async fn execute

(&self, root: P) -> anyhow::Result<()> + pub async fn execute

(&self, root: P) -> miette::Result<()> where P: AsRef, { @@ -115,9 +163,19 @@ impl Delete { let target = &matched.path.clean(); if matched.is_file() { - fs::remove_file(target).await?; + fs::remove_file(target).await.map_err(|source| { + ActionError::Io { + message: format!("Failed to delete file '{}'.", target.display()), + source, + } + })?; } else if matched.is_dir() { - fs::remove_dir_all(target).await?; + fs::remove_dir_all(target).await.map_err(|source| { + ActionError::Io { + message: format!("Failed to delete directory '{}'.", target.display()), + source, + } + })?; } else { continue; } @@ -130,7 +188,7 @@ impl Delete { } impl Echo { - pub async fn execute(&self, state: &State) -> anyhow::Result<()> { + pub async fn execute(&self, state: &State) -> miette::Result<()> { let message = if self.trim { self.message.trim() } else { @@ -152,7 +210,7 @@ impl Echo { } impl Run { - pub async fn execute

(&self, root: P, state: &State) -> anyhow::Result<()> + pub async fn execute

(&self, root: P, state: &State) -> miette::Result<()> where P: Into + AsRef, { @@ -189,7 +247,9 @@ impl Run { spinner.set_message(format!("{}", name.clone().grey())); // Actually run the script. - let (code, output, err) = run_script::run_script!(command, options)?; + let (code, output, err) = run_script::run_script!(command, options) + .map_err(|_| miette::miette!("Failed to run script."))?; + let has_failed = code > 0; // Re-format depending on the exit code. @@ -211,7 +271,7 @@ impl Run { } impl Prompt { - pub async fn execute(&self, state: &mut State) -> anyhow::Result<()> { + pub async fn execute(&self, state: &mut State) -> miette::Result<()> { match self { | Self::Confirm(prompt) => prompt.execute(state).await, | Self::Input(prompt) => prompt.execute(state).await, @@ -222,7 +282,7 @@ impl Prompt { } impl Replace { - pub async fn execute

(&self, root: P, state: &State) -> anyhow::Result<()> + pub async fn execute

(&self, root: P, state: &State) -> miette::Result<()> where P: AsRef, { @@ -242,9 +302,20 @@ impl Replace { for matched in traverser.iter().flatten() { let mut buffer = String::new(); - let mut file = File::open(&matched.path).await?; - file.read_to_string(&mut buffer).await?; + let mut file = File::open(&matched.path).await.map_err(|source| { + ActionError::Io { + message: format!("Failed to open file '{}'.", &matched.path.display()), + source, + } + })?; + + file.read_to_string(&mut buffer).await.map_err(|source| { + ActionError::Io { + message: format!("Failed to read file '{}'.", &matched.path.display()), + source, + } + })?; for replacement in &self.replacements { if let Some(Value::String(value)) = state.get(replacement) { @@ -256,9 +327,26 @@ impl Replace { .write(true) .truncate(true) .open(&matched.path) - .await?; - - result.write_all(buffer.as_bytes()).await?; + .await + .map_err(|source| { + ActionError::Io { + message: format!( + "Failed to open file '{}' for writing.", + &matched.path.display() + ), + source, + } + })?; + + result + .write_all(buffer.as_bytes()) + .await + .map_err(|source| { + ActionError::Io { + message: format!("Failed to write to the file '{}'.", &matched.path.display()), + source, + } + })?; } // Add artificial delay if replacements were performed too fast. @@ -277,7 +365,7 @@ impl Replace { } impl Unknown { - pub async fn execute(&self) -> anyhow::Result<()> { + pub async fn execute(&self) -> miette::Result<()> { let name = self.name.as_str().yellow(); let message = format!("! Unknown action: {name}").yellow(); diff --git a/src/actions/executor.rs b/src/actions/executor.rs index 983827d..ec7d93e 100644 --- a/src/actions/executor.rs +++ b/src/actions/executor.rs @@ -1,10 +1,24 @@ use std::collections::HashMap; +use std::io; use crossterm::style::Stylize; +use miette::Diagnostic; +use thiserror::Error; use tokio::fs; use crate::manifest::{ActionSingle, ActionSuite, Actions, Manifest}; +#[derive(Debug, Diagnostic, Error)] +pub enum ExecutorError { + #[error("{message}")] + #[diagnostic(code(arx::actions::executor::io))] + Io { + message: String, + #[source] + source: io::Error, + }, +} + /// Replacement value. #[derive(Debug)] pub enum Value { @@ -57,7 +71,7 @@ impl Executor { } /// Execute the actions. - pub async fn execute(&self) -> anyhow::Result<()> { + pub async fn execute(&self) -> miette::Result<()> { match &self.manifest.actions { | Actions::Suite(suites) => self.suite(suites).await?, | Actions::Flat(actions) => self.flat(actions).await?, @@ -66,21 +80,28 @@ impl Executor { // Delete the config file if needed. if self.manifest.options.delete { - fs::remove_file(&self.manifest.config).await?; + fs::remove_file(&self.manifest.config) + .await + .map_err(|source| { + ExecutorError::Io { + message: "Failed to delete config file.".to_string(), + source, + } + })?; } Ok(()) } /// Execute a suite of actions. - async fn suite(&self, suites: &[ActionSuite]) -> anyhow::Result<()> { + async fn suite(&self, suites: &[ActionSuite]) -> miette::Result<()> { let mut state = State::new(); for ActionSuite { name, actions, .. } in suites { - let hint = "Suite:".cyan(); + let hint = "Suite".cyan(); let name = name.clone().green(); - println!("[{hint} {name}]\n"); + println!("[{hint}: {name}]\n"); // Man, I hate how peekable iterators work in Rust. let mut it = actions.iter().peekable(); @@ -103,7 +124,7 @@ impl Executor { } /// Execute a flat list of actions. - async fn flat(&self, actions: &[ActionSingle]) -> anyhow::Result<()> { + async fn flat(&self, actions: &[ActionSingle]) -> miette::Result<()> { let mut state = State::new(); for action in actions { @@ -115,7 +136,7 @@ impl Executor { } /// Execute a single action. - async fn single(&self, action: &ActionSingle, state: &mut State) -> anyhow::Result<()> { + async fn single(&self, action: &ActionSingle, state: &mut State) -> miette::Result<()> { let root = &self.manifest.root; match action { diff --git a/src/actions/prompts.rs b/src/actions/prompts.rs index 4270c9a..e66d133 100644 --- a/src/actions/prompts.rs +++ b/src/actions/prompts.rs @@ -68,7 +68,7 @@ impl Inquirer { impl prompts::Confirm { /// Execute the prompt and populate the state. - pub async fn execute(&self, state: &mut State) -> anyhow::Result<()> { + pub async fn execute(&self, state: &mut State) -> miette::Result<()> { let (name, hint, help) = Inquirer::messages(&self.name, &self.hint); let mut prompt = Confirm::new(&hint) @@ -90,7 +90,7 @@ impl prompts::Confirm { impl prompts::Input { /// Execute the prompt and populate the state. - pub async fn execute(&self, state: &mut State) -> anyhow::Result<()> { + pub async fn execute(&self, state: &mut State) -> miette::Result<()> { let (name, hint, help) = Inquirer::messages(&self.name, &self.hint); let mut prompt = Text::new(&hint) @@ -115,7 +115,7 @@ impl prompts::Input { impl prompts::Select { /// Execute the prompt and populate the state. - pub async fn execute(&self, state: &mut State) -> anyhow::Result<()> { + pub async fn execute(&self, state: &mut State) -> miette::Result<()> { let (name, hint, help) = Inquirer::messages(&self.name, &self.hint); let options = self.options.iter().map(String::to_string).collect(); @@ -135,7 +135,7 @@ impl prompts::Select { impl prompts::Editor { /// Execute the prompt and populate the state. - pub async fn execute(&self, state: &mut State) -> anyhow::Result<()> { + pub async fn execute(&self, state: &mut State) -> miette::Result<()> { let (name, hint, help) = Inquirer::messages(&self.name, &self.hint); let mut prompt = Editor::new(&hint) diff --git a/src/app.rs b/src/app.rs index df969c6..ca95ab2 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,14 +1,28 @@ use std::fs; +use std::io; use std::path::PathBuf; use clap::{Parser, Subcommand}; use crossterm::style::Stylize; +use miette::Diagnostic; +use thiserror::Error; use crate::actions::Executor; use crate::manifest::{Manifest, ManifestOptionsOverrides}; use crate::repository::{LocalRepository, RemoteRepository}; use crate::unpacker::Unpacker; +#[derive(Debug, Diagnostic, Error)] +pub enum AppError { + #[error("{message}")] + #[diagnostic(code(actions::app::io))] + Io { + message: String, + #[source] + source: io::Error, + }, +} + #[derive(Parser, Debug)] #[command(version, about, long_about = None)] pub struct Cli { @@ -62,7 +76,18 @@ impl App { Self { cli: Cli::parse() } } - pub async fn run(self) -> anyhow::Result<()> { + pub async fn run(self) -> miette::Result<()> { + // Slightly tweak miette. + miette::set_hook(Box::new(|_| { + Box::new( + miette::MietteHandlerOpts::new() + .terminal_links(false) + .context_lines(3) + .tab_width(4) + .build(), + ) + }))?; + // Load the manifest. let manifest = match self.cli.command { | BaseCommand::Remote { src, path, meta, delete } => { @@ -88,7 +113,7 @@ impl App { path: Option, meta: Option, overrides: ManifestOptionsOverrides, - ) -> anyhow::Result { + ) -> miette::Result { // Parse repository. let remote = RemoteRepository::new(src, meta)?; @@ -97,7 +122,10 @@ impl App { // Check if destination already exists before downloading. if let Ok(true) = &destination.try_exists() { - anyhow::bail!("{} already exists", destination.display()); + miette::bail!( + "Failed to scaffold: '{}' already exists.", + destination.display() + ); } // Fetch the tarball as bytes (compressed). @@ -122,7 +150,7 @@ impl App { path: Option, meta: Option, overrides: ManifestOptionsOverrides, - ) -> anyhow::Result { + ) -> miette::Result { // Create repository. let local = LocalRepository::new(src, meta); @@ -138,29 +166,34 @@ impl App { // Check if destination already exists before performing local clone. if let Ok(true) = &destination.try_exists() { - anyhow::bail!("{} already exists", destination.display()); + miette::bail!( + "Failed to scaffold: '{}' already exists.", + destination.display() + ); } // Copy the directory. local.copy(&destination)?; - println!("{}", "~ Cloned repository".dark_grey()); + println!("{}", "~ Cloned repository".dim()); // Checkout the ref. local.checkout(&destination)?; - println!( - "{} {}", - "~ Checked out ref:".dark_grey(), - local.meta.0.dark_grey() - ); + println!("{} {}", "~ Checked out ref:".dim(), local.meta.0.dim()); // Delete inner .git directory. let inner_git = destination.join(".git"); if let Ok(true) = inner_git.try_exists() { - println!("{}", "~ Removed inner .git directory\n".dark_grey()); - fs::remove_dir_all(inner_git)?; + fs::remove_dir_all(inner_git).map_err(|source| { + AppError::Io { + message: "Failed to remove inner .git directory.".to_string(), + source, + } + })?; + + println!("{}", "~ Removed inner .git directory\n".dim()); } // Now we need to read the manifest (if it is present). diff --git a/src/main.rs b/src/main.rs index 94dc2b3..b8de888 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,25 @@ use arx::app::App; +use crossterm::style::Stylize; +use miette::Severity; #[tokio::main] -async fn main() -> anyhow::Result<()> { - App::new().run().await +async fn main() { + let app = App::new(); + + if let Err(err) = app.run().await { + let severity = match err.severity().unwrap_or(Severity::Error) { + | Severity::Advice => "Advice:".cyan(), + | Severity::Warning => "Warning:".yellow(), + | Severity::Error => "Error:".red(), + }; + + if err.code().is_some() { + eprintln!("{severity} {err:?}"); + } else { + eprintln!("{severity}\n"); + eprintln!("{err:?}"); + } + + std::process::exit(1); + } } diff --git a/src/manifest/manifest.rs b/src/manifest/manifest.rs index 00d92c9..f85e43e 100644 --- a/src/manifest/manifest.rs +++ b/src/manifest/manifest.rs @@ -1,8 +1,10 @@ use std::fs; -use std::io; +use std::io::Error as IoError; use std::path::{Path, PathBuf}; +use std::sync::Arc; -use kdl::{KdlDocument, KdlNode}; +use kdl::{KdlDocument, KdlError, KdlNode}; +use miette::{Diagnostic, LabeledSpan, NamedSource, Report}; use thiserror::Error; use crate::manifest::actions::*; @@ -11,32 +13,34 @@ use crate::manifest::KdlUtils; const MANIFEST_NAME: &str = "arx.kdl"; -#[derive(Debug, Error)] +/// Helper macro to create a [ManifestError::Diagnostic] in a slightly less verbose way. +macro_rules! diagnostic { + ($source:ident = $code:expr, $($key:ident = $value:expr,)* $fmt:literal $($arg:tt)*) => { + ManifestError::Diagnostic( + miette::Report::from( + miette::diagnostic!($($key = $value,)* $fmt $($arg)*) + ).with_source_code(Arc::clone($code)) + ) + }; +} + +#[derive(Debug, Diagnostic, Error)] pub enum ManifestError { - #[error("Couldn't read the manifest.")] - ReadFail(io::Error), - #[error("Couldn't parse the manifest.")] - ParseFail(kdl::KdlError), - #[error("You can use either suites of actions or a flat list of single actions, not both.")] - MixedActions, - #[error("Expected a suite name.")] - ExpectedSuiteName, - #[error("Expected attribute '{0}' to be present and not empty.")] - ExpectedAttribute(String), - #[error("Expected argument.")] - ExpectedArgument, - #[error("Expected name argument.")] - ExpectedNameArgument, - #[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, + #[error("{message}")] + #[diagnostic(code(arx::manifest::io))] + Io { + message: String, + #[source] + source: IoError, + }, + + #[error(transparent)] + #[diagnostic(transparent)] + Kdl(KdlError), + + #[error("{0}")] + #[diagnostic(transparent)] + Diagnostic(Report), } /// Manifest options. These may be overriden from the CLI. @@ -123,6 +127,8 @@ pub enum ActionSingle { pub struct Manifest { /// Manifest directory. pub root: PathBuf, + /// Source. Wrapped in an [Arc] for cheap clones. + pub source: Arc, /// Manifest file path. pub config: PathBuf, /// Manifest options. @@ -135,11 +141,21 @@ impl Manifest { /// Creates a new manifest from the given path and options. pub fn new(root: &Path) -> Self { let root = root.to_path_buf(); + let config = root.join(MANIFEST_NAME); + + // NOTE: Creating dummy source first, will be overwritten with actual data on load. This is done + // because of some limitations around `NamedSource` and related entities like `SourceCode` which + // I couldn't figure out. + let source = Arc::new(NamedSource::new( + config.display().to_string(), + String::default(), + )); Self { - config: root.join(MANIFEST_NAME), + config, options: ManifestOptions::default(), actions: Actions::Empty, + source, root, } } @@ -155,8 +171,8 @@ impl Manifest { pub fn load(&mut self) -> Result<(), ManifestError> { if self.exists() { let doc = self.parse()?; - self.options = self.get_options(&doc)?; - self.actions = self.get_actions(&doc)?; + self.options = self.get_manifest_options(&doc)?; + self.actions = self.get_manifest_actions(&doc)?; } Ok(()) @@ -168,17 +184,26 @@ impl Manifest { } /// Reads and parses the manifest into a [KdlDocument]. - fn parse(&self) -> Result { + fn parse(&mut 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)?; + let contents = fs::read_to_string(&filename).map_err(|source| { + ManifestError::Io { + message: "Failed to read the manifest.".to_string(), + source, + } + })?; + + let document = contents.parse().map_err(ManifestError::Kdl)?; + + // Replace dummy source with actual data. + self.source = Arc::new(NamedSource::new(filename.display().to_string(), contents)); Ok(document) } /// Tries to parse options from the manifest. - fn get_options(&self, doc: &KdlDocument) -> Result { + fn get_manifest_options(&self, doc: &KdlDocument) -> Result { let options = doc .get("options") .and_then(KdlNode::children) @@ -187,13 +212,21 @@ impl Manifest { let mut defaults = ManifestOptions::default(); for node in nodes { - let name = node.name().to_string().to_ascii_lowercase(); + let option = node.name().to_string().to_ascii_lowercase(); - match name.as_str() { + match option.as_str() { | "delete" => { - defaults.delete = node - .get_bool(0) - .ok_or(ManifestError::ExpectedArgumentFor("delete".into()))?; + defaults.delete = node.get_bool(0).ok_or_else(|| { + diagnostic!( + source = &self.source, + code = "arx::manifest::options", + labels = vec![LabeledSpan::at( + node.span().to_owned(), + "this node requires a boolean argument" + )], + "Missing required argument." + ) + })?; }, | _ => { continue; @@ -212,7 +245,7 @@ impl Manifest { } /// Tries to parse actions from the manifest. - fn get_actions(&self, doc: &KdlDocument) -> Result { + fn get_manifest_actions(&self, doc: &KdlDocument) -> Result { #[inline] fn is_suite(node: &KdlNode) -> bool { node.name().value() == "suite" @@ -253,7 +286,10 @@ impl Manifest { } // Otherwise we have invalid actions block. else { - Err(ManifestError::MixedActions) + Err(ManifestError::Diagnostic(miette::miette!( + code = "arx::manifest::actions", + "You can use either suites of actions or a flat list of single actions, not both." + ))) } }); @@ -268,7 +304,7 @@ impl Manifest { let mut actions = Vec::new(); // Fail if we stumbled upon a nameless suite. - let name = node.get_string(0).ok_or(ManifestError::ExpectedSuiteName)?; + let name = self.get_arg_string(node)?; if let Some(children) = node.children() { for children in children.nodes() { @@ -283,79 +319,28 @@ impl Manifest { fn get_action_single(&self, node: &KdlNode) -> Result { let kind = node.name().to_string().to_ascii_lowercase(); - #[inline] - fn name(node: &KdlNode) -> Result { - node - .get_string(0) - .ok_or(ManifestError::ExpectedNameArgument) - } - - #[inline] - fn hint(nodes: &KdlDocument) -> Result { - nodes - .get("hint") - .and_then(|node| node.get_string(0)) - .ok_or(ManifestError::ExpectedArgumentFor("hint".into())) - } - - #[inline] - fn options(nodes: &KdlDocument) -> Vec { - nodes - .get_args("options") - .into_iter() - .filter_map(|arg| arg.as_string().map(str::to_string)) - .collect() - } - - #[inline] - fn default_string(nodes: &KdlDocument) -> Option { - nodes.get("default").and_then(|node| node.get_string(0)) - } - - #[inline] - fn default_bool(nodes: &KdlDocument) -> Option { - nodes.get("default").and_then(|node| node.get_bool(0)) - } - let action = match kind.as_str() { // Actions for manipulating files and directories. | "cp" => { - let from = node - .get_string("from") - .ok_or(ManifestError::ExpectedAttribute("from".into()))?; - - let to = node - .get_string("to") - .ok_or(ManifestError::ExpectedAttribute("to".into()))?; - - let overwrite = node.get_bool("overwrite").unwrap_or(true); - - ActionSingle::Copy(Copy { from, to, overwrite }) + ActionSingle::Copy(Copy { + from: self.get_attr_string(node, "from")?, + to: self.get_attr_string(node, "to")?, + overwrite: node.get_bool("overwrite").unwrap_or(true), + }) }, | "mv" => { - let from = node - .get_string("from") - .ok_or(ManifestError::ExpectedAttribute("from".into()))?; - - let to = node - .get_string("to") - .ok_or(ManifestError::ExpectedAttribute("to".into()))?; - - let overwrite = node.get_bool("overwrite").unwrap_or(true); - - ActionSingle::Move(Move { from, to, overwrite }) - }, - | "rm" => { - let target = node.get_string(0).ok_or(ManifestError::ExpectedArgument)?; - - ActionSingle::Delete(Delete { target }) + ActionSingle::Move(Move { + from: self.get_attr_string(node, "from")?, + to: self.get_attr_string(node, "to")?, + overwrite: node.get_bool("overwrite").unwrap_or(true), + }) }, + | "rm" => ActionSingle::Delete(Delete { target: self.get_arg_string(node)? }), // Actions for running commands and echoing output. | "echo" => { - let message = node - .get_string(0) - .ok_or(ManifestError::ExpectedAttribute("message".into()))?; + let message = self.get_arg_string(node)?; + // TODO: Verify injects have valid type (string values). let injects = node.children().map(|children| { children .get_args("inject") @@ -370,7 +355,7 @@ impl Manifest { }, | "run" => { let name = node.get_string("name"); - let command = node.get_string(0).ok_or(ManifestError::ExpectedArgument)?; + let command = self.get_arg_string(node)?; let injects = node.children().map(|children| { children @@ -384,40 +369,40 @@ impl Manifest { }, // Actions for prompts and replacements. | "input" => { - let nodes = node.children().ok_or(ManifestError::ExpectedInputNodes)?; + let nodes = self.get_children(node, vec!["hint"])?; ActionSingle::Prompt(Prompt::Input(Input { - name: name(node)?, - hint: hint(nodes)?, - default: default_string(nodes), + name: self.get_arg_string(node)?, + hint: self.get_hint(node, nodes)?, + default: self.get_default_string(nodes), })) }, | "editor" => { - let nodes = node.children().ok_or(ManifestError::ExpectedEditorNodes)?; + let nodes = self.get_children(node, vec!["hint"])?; ActionSingle::Prompt(Prompt::Editor(Editor { - name: name(node)?, - hint: hint(nodes)?, - default: default_string(nodes), + name: self.get_arg_string(node)?, + hint: self.get_hint(node, nodes)?, + default: self.get_default_string(nodes), })) }, | "select" => { - let nodes = node.children().ok_or(ManifestError::ExpectedSelectNodes)?; + let nodes = self.get_children(node, vec!["hint", "options"])?; ActionSingle::Prompt(Prompt::Select(Select { - name: name(node)?, - hint: hint(nodes)?, - options: options(nodes), - default: default_string(nodes), + name: self.get_arg_string(node)?, + hint: self.get_hint(node, nodes)?, + options: self.get_options(node, nodes)?, + default: self.get_default_string(nodes), })) }, | "confirm" => { - let nodes = node.children().ok_or(ManifestError::ExpectedConfirmNodes)?; + let nodes = self.get_children(node, vec!["hint"])?; ActionSingle::Prompt(Prompt::Confirm(Confirm { - name: name(node)?, - hint: hint(nodes)?, - default: default_bool(nodes), + name: self.get_arg_string(node)?, + hint: self.get_hint(node, nodes)?, + default: self.get_default_bool(nodes), })) }, | "replace" => { @@ -437,9 +422,150 @@ impl Manifest { ActionSingle::Replace(Replace { replacements, glob }) }, // Fallback. - | action => ActionSingle::Unknown(Unknown { name: action.into() }), + | action => ActionSingle::Unknown(Unknown { name: action.to_string() }), }; Ok(action) } + + fn get_arg_string(&self, node: &KdlNode) -> Result { + let start = node.span().offset(); + let end = start + node.name().len(); + + node.get_string(0).ok_or_else(|| { + diagnostic!( + source = &self.source, + code = "arx::manifest::actions", + labels = vec![ + LabeledSpan::at(start..end, "this node requires a string argument"), + LabeledSpan::at_offset(end, "argument should be here") + ], + "Missing required argument." + ) + }) + } + + fn get_attr_string(&self, node: &KdlNode, key: &str) -> Result { + node.get_string(key).ok_or_else(|| { + diagnostic!( + source = &self.source, + code = "arx::manifest::actions", + labels = vec![LabeledSpan::at( + node.span().to_owned(), + format!("this node requires the `{key}` attribute") + )], + "Missing required attribute: `{key}`." + ) + }) + } + + fn get_children<'kdl>( + &self, + node: &'kdl KdlNode, + nodes: Vec<&str>, + ) -> Result<&'kdl KdlDocument, ManifestError> { + let suffix = if nodes.len() > 1 { "s" } else { "" }; + let nodes = nodes + .iter() + .map(|node| format!("`{node}`")) + .collect::>() + .join(", "); + + let message = format!("Missing required child node{suffix}: {nodes}."); + + node.children().ok_or_else(|| { + diagnostic!( + source = &self.source, + code = "arx::manifest::actions", + labels = vec![LabeledSpan::at( + node.span().to_owned(), + format!("this node requires the following child nodes: {nodes}") + )], + "{message}" + ) + }) + } + + fn get_hint(&self, parent: &KdlNode, nodes: &KdlDocument) -> Result { + let hint = nodes.get("hint").ok_or_else(|| { + diagnostic!( + source = &self.source, + code = "arx::manifest::actions", + labels = vec![LabeledSpan::at( + parent.span().to_owned(), + "prompts require a `hint` child node" + )], + "Missing prompt hint." + ) + })?; + + self.get_arg_string(hint) + } + + fn get_options( + &self, + parent: &KdlNode, + nodes: &KdlDocument, + ) -> Result, ManifestError> { + let options = nodes.get("options").ok_or_else(|| { + diagnostic!( + source = &self.source, + code = "arx::manifest::actions", + labels = vec![LabeledSpan::at( + parent.span().to_owned(), + "select prompts require the `options` child node" + )], + "Missing select prompt options." + ) + })?; + + let mut variants = Vec::new(); + + for entry in options.entries() { + let value = entry.value(); + let span = entry.span().to_owned(); + + let value = if value.is_float_value() { + value.as_f64().as_ref().map(f64::to_string) + } else if value.is_i64_value() { + value.as_i64().as_ref().map(i64::to_string) + } else if value.is_string_value() { + value.as_string().map(str::to_string) + } else { + return Err(diagnostic!( + source = &self.source, + code = "arx::manifest::actions", + labels = vec![LabeledSpan::at( + span, + "option values can be either strings or numbers" + )], + "Invalid select option type." + )); + }; + + let option = value.ok_or_else(|| { + diagnostic!( + source = &self.source, + code = "arx::manifest::actions", + labels = vec![LabeledSpan::at( + span, + "failed to converted this value to a string" + )], + "Failed to convert option value." + ) + })?; + + variants.push(option); + } + + Ok(variants) + } + + fn get_default_string(&self, nodes: &KdlDocument) -> Option { + nodes.get("default").and_then(|node| node.get_string(0)) + } + + fn get_default_bool(&self, nodes: &KdlDocument) -> Option { + nodes.get("default").and_then(|node| node.get_bool(0)) + } } diff --git a/src/repository.rs b/src/repository.rs index eb586ce..a4312e3 100644 --- a/src/repository.rs +++ b/src/repository.rs @@ -5,11 +5,12 @@ use std::str::FromStr; use git2::build::CheckoutBuilder; use git2::Repository as GitRepository; +use miette::Diagnostic; use thiserror::Error; use crate::path::Traverser; -#[derive(Debug, Error, PartialEq)] +#[derive(Debug, Diagnostic, Error, PartialEq)] pub enum ParseError { #[error("Host must be one of: github/gh, gitlab/gl, or bitbucket/bb.")] InvalidHost, @@ -23,7 +24,7 @@ pub enum ParseError { MultipleSlash, } -#[derive(Debug, Error, PartialEq)] +#[derive(Debug, Diagnostic, Error, PartialEq)] pub enum FetchError { #[error("Request failed.")] RequestFailed, @@ -33,7 +34,7 @@ pub enum FetchError { RequestBodyFailed, } -#[derive(Debug, Error)] +#[derive(Debug, Diagnostic, Error)] pub enum CopyError { #[error("Failed to create directory.")] CreateDirFailed(io::Error), @@ -41,7 +42,7 @@ pub enum CopyError { CopyFailed(io::Error), } -#[derive(Debug, Error)] +#[derive(Debug, Diagnostic, Error)] pub enum CheckoutError { #[error("Failed to open the git repository.")] OpenFailed(git2::Error), diff --git a/src/unpacker.rs b/src/unpacker.rs index d5fba11..68dc077 100644 --- a/src/unpacker.rs +++ b/src/unpacker.rs @@ -2,6 +2,7 @@ use std::fs; use std::path::{Path, PathBuf}; use flate2::bufread::GzDecoder; +use miette::Diagnostic; use tar::Archive; use thiserror::Error; @@ -17,7 +18,7 @@ const USE_PERMISSIONS: bool = false; #[cfg(not(target_os = "windows"))] const USE_PERMISSIONS: bool = true; -#[derive(Debug, Error, PartialEq)] +#[derive(Debug, Diagnostic, Error, PartialEq)] pub enum UnpackError { #[error("Couldn't get entries from the tarball.")] UnableGetEntries, From 8a3e6331b423861f44f640384f3cd65fd7fe1ee0 Mon Sep 17 00:00:00 2001 From: Vladislav Mamon Date: Sun, 10 Mar 2024 11:58:10 +0300 Subject: [PATCH 62/77] refactor(unpacker): refactor error handling in tar unpacker --- src/unpacker.rs | 50 ++++++++++++++++++++++++++++++++----------------- 1 file changed, 33 insertions(+), 17 deletions(-) diff --git a/src/unpacker.rs b/src/unpacker.rs index 68dc077..c20d470 100644 --- a/src/unpacker.rs +++ b/src/unpacker.rs @@ -1,4 +1,5 @@ use std::fs; +use std::io; use std::path::{Path, PathBuf}; use flate2::bufread::GzDecoder; @@ -18,16 +19,15 @@ const USE_PERMISSIONS: bool = false; #[cfg(not(target_os = "windows"))] const USE_PERMISSIONS: bool = true; -#[derive(Debug, Diagnostic, Error, PartialEq)] +#[derive(Debug, Diagnostic, Error)] 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, + #[error("{message}")] + #[diagnostic(code(arx::unpack::io))] + Io { + message: String, + #[source] + source: io::Error, + }, } pub struct Unpacker { @@ -45,24 +45,40 @@ impl Unpacker { let mut written_paths = Vec::new(); // Get iterator over the entries. - let raw_entries = archive - .entries() - .map_err(|_| UnpackError::UnableGetEntries)?; + let raw_entries = archive.entries().map_err(|source| { + UnpackError::Io { + message: "Couldn't get entries from the tarball.".to_string(), + source, + } + })?; // Create output structure (if necessary). - fs::create_dir_all(path).map_err(|_| UnpackError::UnableCreateStructure)?; + fs::create_dir_all(path).map_err(|source| { + UnpackError::Io { + message: "Couldn't create the output structure.".to_string(), + source, + } + })?; for mut entry in raw_entries.flatten() { - let entry_path = entry.path().map_err(|_| UnpackError::UnableGetEntryPath)?; + let entry_path = entry.path().map_err(|source| { + UnpackError::Io { + message: "Couldn't get the entry's path.".to_string(), + source, + } + })?; 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)?; + entry.unpack(&fixed_path).map_err(|source| { + UnpackError::Io { + message: "Couldn't unpack the entry.".to_string(), + source, + } + })?; written_paths.push(fixed_path); } From c6faf4adac63801c99a78b1dfb159660e285a73b Mon Sep 17 00:00:00 2001 From: Vladislav Mamon Date: Sun, 10 Mar 2024 12:02:46 +0300 Subject: [PATCH 63/77] refactor(config): replace `Error as IoError` alias with qualified import --- src/manifest/manifest.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/manifest/manifest.rs b/src/manifest/manifest.rs index f85e43e..5b62421 100644 --- a/src/manifest/manifest.rs +++ b/src/manifest/manifest.rs @@ -1,9 +1,9 @@ use std::fs; -use std::io::Error as IoError; +use std::io; use std::path::{Path, PathBuf}; use std::sync::Arc; -use kdl::{KdlDocument, KdlError, KdlNode}; +use kdl::{KdlDocument, KdlNode}; use miette::{Diagnostic, LabeledSpan, NamedSource, Report}; use thiserror::Error; @@ -31,12 +31,12 @@ pub enum ManifestError { Io { message: String, #[source] - source: IoError, + source: io::Error, }, #[error(transparent)] #[diagnostic(transparent)] - Kdl(KdlError), + Kdl(kdl::KdlError), #[error("{0}")] #[diagnostic(transparent)] From 426d7d17033cd1a89f6f6884a13e3330ec9d9413 Mon Sep 17 00:00:00 2001 From: Vladislav Mamon Date: Sun, 10 Mar 2024 12:14:26 +0300 Subject: [PATCH 64/77] refactor: rename manifest to config Because `arx.kdl` is essentially a config file, and manifest has a different meaning and purpose. --- src/actions/actions.rs | 2 +- src/actions/executor.rs | 18 +-- src/actions/prompts.rs | 2 +- src/app.rs | 40 +++--- src/{manifest => config}/actions.rs | 2 +- .../manifest.rs => config/config.rs} | 128 +++++++++--------- src/{manifest => config}/mod.rs | 4 +- src/{manifest => config}/prompts.rs | 0 src/{manifest => config}/utils.rs | 0 src/lib.rs | 2 +- 10 files changed, 97 insertions(+), 101 deletions(-) rename src/{manifest => config}/actions.rs (98%) rename src/{manifest/manifest.rs => config/config.rs} (81%) rename src/{manifest => config}/mod.rs (65%) rename src/{manifest => config}/prompts.rs (100%) rename src/{manifest => config}/utils.rs (100%) diff --git a/src/actions/actions.rs b/src/actions/actions.rs index 0836446..a9158d9 100644 --- a/src/actions/actions.rs +++ b/src/actions/actions.rs @@ -12,7 +12,7 @@ use tokio::io::{self, AsyncReadExt, AsyncWriteExt}; use unindent::Unindent; use crate::actions::{State, Value}; -use crate::manifest::actions::*; +use crate::config::actions::*; use crate::path::{PathClean, Traverser}; use crate::spinner::Spinner; diff --git a/src/actions/executor.rs b/src/actions/executor.rs index ec7d93e..a32f826 100644 --- a/src/actions/executor.rs +++ b/src/actions/executor.rs @@ -6,7 +6,7 @@ use miette::Diagnostic; use thiserror::Error; use tokio::fs; -use crate::manifest::{ActionSingle, ActionSuite, Actions, Manifest}; +use crate::config::{ActionSingle, ActionSuite, Actions, Config}; #[derive(Debug, Diagnostic, Error)] pub enum ExecutorError { @@ -60,27 +60,27 @@ impl Default for State { /// An executor. #[derive(Debug)] pub struct Executor { - /// The manifest to use for execution. - manifest: Manifest, + /// The config to use for execution. + config: Config, } impl Executor { /// Create a new executor. - pub fn new(manifest: Manifest) -> Self { - Self { manifest } + pub fn new(config: Config) -> Self { + Self { config } } /// Execute the actions. pub async fn execute(&self) -> miette::Result<()> { - match &self.manifest.actions { + match &self.config.actions { | Actions::Suite(suites) => self.suite(suites).await?, | Actions::Flat(actions) => self.flat(actions).await?, | Actions::Empty => println!("No actions found."), }; // Delete the config file if needed. - if self.manifest.options.delete { - fs::remove_file(&self.manifest.config) + if self.config.options.delete { + fs::remove_file(&self.config.config) .await .map_err(|source| { ExecutorError::Io { @@ -137,7 +137,7 @@ impl Executor { /// Execute a single action. async fn single(&self, action: &ActionSingle, state: &mut State) -> miette::Result<()> { - let root = &self.manifest.root; + let root = &self.config.root; match action { | ActionSingle::Copy(action) => action.execute(root).await, diff --git a/src/actions/prompts.rs b/src/actions/prompts.rs index e66d133..7bcc8c6 100644 --- a/src/actions/prompts.rs +++ b/src/actions/prompts.rs @@ -8,7 +8,7 @@ use inquire::ui::{Color, RenderConfig, StyleSheet, Styled}; use inquire::{Confirm, Editor, InquireError, Select, Text}; use crate::actions::{State, Value}; -use crate::manifest::prompts; +use crate::config::prompts; /// Helper struct holding useful static methods. struct Inquirer; diff --git a/src/app.rs b/src/app.rs index ca95ab2..ed46168 100644 --- a/src/app.rs +++ b/src/app.rs @@ -8,7 +8,7 @@ use miette::Diagnostic; use thiserror::Error; use crate::actions::Executor; -use crate::manifest::{Manifest, ManifestOptionsOverrides}; +use crate::config::{Config, ConfigOptionsOverrides}; use crate::repository::{LocalRepository, RemoteRepository}; use crate::unpacker::Unpacker; @@ -88,20 +88,20 @@ impl App { ) }))?; - // Load the manifest. - let manifest = match self.cli.command { + // Load the config. + let config = match self.cli.command { | BaseCommand::Remote { src, path, meta, delete } => { - let options = ManifestOptionsOverrides { delete }; + let options = ConfigOptionsOverrides { delete }; Self::remote(src, path, meta, options).await? }, | BaseCommand::Local { src, path, meta, delete } => { - let options = ManifestOptionsOverrides { delete }; + let options = ConfigOptionsOverrides { delete }; Self::local(src, path, meta, options).await? }, }; // Create executor and kick off execution. - let executor = Executor::new(manifest); + let executor = Executor::new(config); executor.execute().await?; Ok(()) @@ -112,8 +112,8 @@ impl App { src: String, path: Option, meta: Option, - overrides: ManifestOptionsOverrides, - ) -> miette::Result { + overrides: ConfigOptionsOverrides, + ) -> miette::Result { // Parse repository. let remote = RemoteRepository::new(src, meta)?; @@ -135,13 +135,13 @@ impl App { let unpacker = Unpacker::new(tarball); unpacker.unpack_to(&destination)?; - // Now we need to read the manifest (if it is present). - let mut manifest = Manifest::new(&destination); + // Now we need to read the config (if it is present). + let mut config = Config::new(&destination); - manifest.load()?; - manifest.override_with(overrides); + config.load()?; + config.override_with(overrides); - Ok(manifest) + Ok(config) } /// Preparation flow for local repositories. @@ -149,8 +149,8 @@ impl App { src: String, path: Option, meta: Option, - overrides: ManifestOptionsOverrides, - ) -> miette::Result { + overrides: ConfigOptionsOverrides, + ) -> miette::Result { // Create repository. let local = LocalRepository::new(src, meta); @@ -196,13 +196,13 @@ impl App { println!("{}", "~ Removed inner .git directory\n".dim()); } - // Now we need to read the manifest (if it is present). - let mut manifest = Manifest::new(&destination); + // Now we need to read the config (if it is present). + let mut config = Config::new(&destination); - manifest.load()?; - manifest.override_with(overrides); + config.load()?; + config.override_with(overrides); - Ok(manifest) + Ok(config) } } diff --git a/src/manifest/actions.rs b/src/config/actions.rs similarity index 98% rename from src/manifest/actions.rs rename to src/config/actions.rs index d56480b..ed87457 100644 --- a/src/manifest/actions.rs +++ b/src/config/actions.rs @@ -1,6 +1,6 @@ use std::collections::HashSet; -use crate::manifest::prompts::*; +use crate::config::prompts::*; /// Copies a file or directory. Glob-friendly. Overwrites by default. #[derive(Debug)] diff --git a/src/manifest/manifest.rs b/src/config/config.rs similarity index 81% rename from src/manifest/manifest.rs rename to src/config/config.rs index 5b62421..adc9d2d 100644 --- a/src/manifest/manifest.rs +++ b/src/config/config.rs @@ -7,16 +7,16 @@ use kdl::{KdlDocument, KdlNode}; use miette::{Diagnostic, LabeledSpan, NamedSource, Report}; use thiserror::Error; -use crate::manifest::actions::*; -use crate::manifest::prompts::*; -use crate::manifest::KdlUtils; +use crate::config::actions::*; +use crate::config::prompts::*; +use crate::config::KdlUtils; -const MANIFEST_NAME: &str = "arx.kdl"; +const CONFIG_NAME: &str = "arx.kdl"; -/// Helper macro to create a [ManifestError::Diagnostic] in a slightly less verbose way. +/// Helper macro to create a [ConfigError::Diagnostic] in a slightly less verbose way. macro_rules! diagnostic { ($source:ident = $code:expr, $($key:ident = $value:expr,)* $fmt:literal $($arg:tt)*) => { - ManifestError::Diagnostic( + ConfigError::Diagnostic( miette::Report::from( miette::diagnostic!($($key = $value,)* $fmt $($arg)*) ).with_source_code(Arc::clone($code)) @@ -25,9 +25,9 @@ macro_rules! diagnostic { } #[derive(Debug, Diagnostic, Error)] -pub enum ManifestError { +pub enum ConfigError { #[error("{message}")] - #[diagnostic(code(arx::manifest::io))] + #[diagnostic(code(arx::config::io))] Io { message: String, #[source] @@ -43,27 +43,27 @@ pub enum ManifestError { Diagnostic(Report), } -/// Manifest options. These may be overriden from the CLI. +/// Config options. These may be overriden from the CLI. #[derive(Debug)] -pub struct ManifestOptions { - /// Whether to delete the manifest after we (successfully) done running. +pub struct ConfigOptions { + /// Whether to delete the config after we (successfully) done running. pub delete: bool, } -impl Default for ManifestOptions { +impl Default for ConfigOptions { fn default() -> Self { Self { delete: true } } } -/// Manifest options that may override parsed options. +/// Config options that may override parsed options. #[derive(Debug, Default)] -pub struct ManifestOptionsOverrides { - /// Whether to delete the manifest after we (successfully) done running. +pub struct ConfigOptionsOverrides { + /// Whether to delete the config after we (successfully) done running. pub delete: Option, } -/// Represents a manifest actions set that can be a vec of [ActionSuite] *or* [ActionSingle]. +/// Represents a config actions set that can be a vec of [ActionSuite] *or* [ActionSingle]. /// /// So, actions should be defined either like this: /// @@ -122,26 +122,26 @@ pub enum ActionSingle { Unknown(Unknown), } -/// Arx manifest (config). +/// Arx config. #[derive(Debug)] -pub struct Manifest { - /// Manifest directory. +pub struct Config { + /// Config directory. pub root: PathBuf, /// Source. Wrapped in an [Arc] for cheap clones. pub source: Arc, - /// Manifest file path. + /// Config file path. pub config: PathBuf, - /// Manifest options. - pub options: ManifestOptions, + /// Config options. + pub options: ConfigOptions, /// Actions. pub actions: Actions, } -impl Manifest { - /// Creates a new manifest from the given path and options. +impl Config { + /// Creates a new config from the given path and options. pub fn new(root: &Path) -> Self { let root = root.to_path_buf(); - let config = root.join(MANIFEST_NAME); + let config = root.join(CONFIG_NAME); // NOTE: Creating dummy source first, will be overwritten with actual data on load. This is done // because of some limitations around `NamedSource` and related entities like `SourceCode` which @@ -153,48 +153,48 @@ impl Manifest { Self { config, - options: ManifestOptions::default(), + options: ConfigOptions::default(), actions: Actions::Empty, source, root, } } - /// Tries to apply the given overrides to the manifest options. - pub fn override_with(&mut self, overrides: ManifestOptionsOverrides) { + /// Tries to apply the given overrides to the config options. + pub fn override_with(&mut self, overrides: ConfigOptionsOverrides) { if let Some(delete) = overrides.delete { self.options.delete = delete; } } - /// Tries to load and parse the manifest. - pub fn load(&mut self) -> Result<(), ManifestError> { + /// Tries to load and parse the config. + pub fn load(&mut self) -> Result<(), ConfigError> { if self.exists() { let doc = self.parse()?; - self.options = self.get_manifest_options(&doc)?; - self.actions = self.get_manifest_actions(&doc)?; + self.options = self.get_config_options(&doc)?; + self.actions = self.get_config_actions(&doc)?; } Ok(()) } - /// Checks if the manifest exists under `self.root`. + /// Checks if the config exists under `self.root`. fn exists(&self) -> bool { self.config.try_exists().unwrap_or(false) } - /// Reads and parses the manifest into a [KdlDocument]. - fn parse(&mut self) -> Result { - let filename = self.root.join(MANIFEST_NAME); + /// Reads and parses the config into a [KdlDocument]. + fn parse(&mut self) -> Result { + let filename = self.root.join(CONFIG_NAME); let contents = fs::read_to_string(&filename).map_err(|source| { - ManifestError::Io { - message: "Failed to read the manifest.".to_string(), + ConfigError::Io { + message: "Failed to read the config.".to_string(), source, } })?; - let document = contents.parse().map_err(ManifestError::Kdl)?; + let document = contents.parse().map_err(ConfigError::Kdl)?; // Replace dummy source with actual data. self.source = Arc::new(NamedSource::new(filename.display().to_string(), contents)); @@ -202,14 +202,14 @@ impl Manifest { Ok(document) } - /// Tries to parse options from the manifest. - fn get_manifest_options(&self, doc: &KdlDocument) -> Result { + /// Tries to parse options from the config. + fn get_config_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(); + let mut defaults = ConfigOptions::default(); for node in nodes { let option = node.name().to_string().to_ascii_lowercase(); @@ -219,7 +219,7 @@ impl Manifest { defaults.delete = node.get_bool(0).ok_or_else(|| { diagnostic!( source = &self.source, - code = "arx::manifest::options", + code = "arx::config::options", labels = vec![LabeledSpan::at( node.span().to_owned(), "this node requires a boolean argument" @@ -240,12 +240,12 @@ impl Manifest { match options { | Some(Ok(options)) => Ok(options), | Some(Err(err)) => Err(err), - | None => Ok(ManifestOptions::default()), + | None => Ok(ConfigOptions::default()), } } - /// Tries to parse actions from the manifest. - fn get_manifest_actions(&self, doc: &KdlDocument) -> Result { + /// Tries to parse actions from the config. + fn get_config_actions(&self, doc: &KdlDocument) -> Result { #[inline] fn is_suite(node: &KdlNode) -> bool { node.name().value() == "suite" @@ -286,8 +286,8 @@ impl Manifest { } // Otherwise we have invalid actions block. else { - Err(ManifestError::Diagnostic(miette::miette!( - code = "arx::manifest::actions", + Err(ConfigError::Diagnostic(miette::miette!( + code = "arx::config::actions", "You can use either suites of actions or a flat list of single actions, not both." ))) } @@ -300,7 +300,7 @@ impl Manifest { } } - fn get_action_suite(&self, node: &KdlNode) -> Result { + fn get_action_suite(&self, node: &KdlNode) -> Result { let mut actions = Vec::new(); // Fail if we stumbled upon a nameless suite. @@ -316,7 +316,7 @@ impl Manifest { Ok(ActionSuite { name, actions }) } - fn get_action_single(&self, node: &KdlNode) -> Result { + fn get_action_single(&self, node: &KdlNode) -> Result { let kind = node.name().to_string().to_ascii_lowercase(); let action = match kind.as_str() { @@ -428,14 +428,14 @@ impl Manifest { Ok(action) } - fn get_arg_string(&self, node: &KdlNode) -> Result { + fn get_arg_string(&self, node: &KdlNode) -> Result { let start = node.span().offset(); let end = start + node.name().len(); node.get_string(0).ok_or_else(|| { diagnostic!( source = &self.source, - code = "arx::manifest::actions", + code = "arx::config::actions", labels = vec![ LabeledSpan::at(start..end, "this node requires a string argument"), LabeledSpan::at_offset(end, "argument should be here") @@ -445,11 +445,11 @@ impl Manifest { }) } - fn get_attr_string(&self, node: &KdlNode, key: &str) -> Result { + fn get_attr_string(&self, node: &KdlNode, key: &str) -> Result { node.get_string(key).ok_or_else(|| { diagnostic!( source = &self.source, - code = "arx::manifest::actions", + code = "arx::config::actions", labels = vec![LabeledSpan::at( node.span().to_owned(), format!("this node requires the `{key}` attribute") @@ -463,7 +463,7 @@ impl Manifest { &self, node: &'kdl KdlNode, nodes: Vec<&str>, - ) -> Result<&'kdl KdlDocument, ManifestError> { + ) -> Result<&'kdl KdlDocument, ConfigError> { let suffix = if nodes.len() > 1 { "s" } else { "" }; let nodes = nodes .iter() @@ -476,7 +476,7 @@ impl Manifest { node.children().ok_or_else(|| { diagnostic!( source = &self.source, - code = "arx::manifest::actions", + code = "arx::config::actions", labels = vec![LabeledSpan::at( node.span().to_owned(), format!("this node requires the following child nodes: {nodes}") @@ -486,11 +486,11 @@ impl Manifest { }) } - fn get_hint(&self, parent: &KdlNode, nodes: &KdlDocument) -> Result { + fn get_hint(&self, parent: &KdlNode, nodes: &KdlDocument) -> Result { let hint = nodes.get("hint").ok_or_else(|| { diagnostic!( source = &self.source, - code = "arx::manifest::actions", + code = "arx::config::actions", labels = vec![LabeledSpan::at( parent.span().to_owned(), "prompts require a `hint` child node" @@ -502,15 +502,11 @@ impl Manifest { self.get_arg_string(hint) } - fn get_options( - &self, - parent: &KdlNode, - nodes: &KdlDocument, - ) -> Result, ManifestError> { + fn get_options(&self, parent: &KdlNode, nodes: &KdlDocument) -> Result, ConfigError> { let options = nodes.get("options").ok_or_else(|| { diagnostic!( source = &self.source, - code = "arx::manifest::actions", + code = "arx::config::actions", labels = vec![LabeledSpan::at( parent.span().to_owned(), "select prompts require the `options` child node" @@ -534,7 +530,7 @@ impl Manifest { } else { return Err(diagnostic!( source = &self.source, - code = "arx::manifest::actions", + code = "arx::config::actions", labels = vec![LabeledSpan::at( span, "option values can be either strings or numbers" @@ -546,7 +542,7 @@ impl Manifest { let option = value.ok_or_else(|| { diagnostic!( source = &self.source, - code = "arx::manifest::actions", + code = "arx::config::actions", labels = vec![LabeledSpan::at( span, "failed to converted this value to a string" diff --git a/src/manifest/mod.rs b/src/config/mod.rs similarity index 65% rename from src/manifest/mod.rs rename to src/config/mod.rs index fc0f6d5..6f4d2e8 100644 --- a/src/manifest/mod.rs +++ b/src/config/mod.rs @@ -1,8 +1,8 @@ -pub use manifest::*; +pub use config::*; pub use utils::*; pub mod actions; pub mod prompts; -mod manifest; +mod config; mod utils; diff --git a/src/manifest/prompts.rs b/src/config/prompts.rs similarity index 100% rename from src/manifest/prompts.rs rename to src/config/prompts.rs diff --git a/src/manifest/utils.rs b/src/config/utils.rs similarity index 100% rename from src/manifest/utils.rs rename to src/config/utils.rs diff --git a/src/lib.rs b/src/lib.rs index 43cd610..b585c68 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,7 +2,7 @@ pub(crate) mod actions; pub mod app; -pub(crate) mod manifest; +pub(crate) mod config; pub(crate) mod path; pub(crate) mod repository; pub(crate) mod spinner; From 538345475490e06cc40d56f90998fe294d225249 Mon Sep 17 00:00:00 2001 From: Vladislav Mamon Date: Sun, 10 Mar 2024 12:31:39 +0300 Subject: [PATCH 65/77] refactor(repository): refactor error handling in repository --- src/repository.rs | 46 +++++++++++++++++++++++++++++++++++----------- 1 file changed, 35 insertions(+), 11 deletions(-) diff --git a/src/repository.rs b/src/repository.rs index a4312e3..9846c32 100644 --- a/src/repository.rs +++ b/src/repository.rs @@ -10,7 +10,19 @@ use thiserror::Error; use crate::path::Traverser; +#[derive(Debug, Diagnostic, Error)] +pub enum RepositoryError { + #[error("{message}")] + #[diagnostic(code(arx::repository::io))] + Io { + message: String, + #[source] + source: io::Error, + }, +} + #[derive(Debug, Diagnostic, Error, PartialEq)] +#[diagnostic(code(arx::repository::parse))] pub enum ParseError { #[error("Host must be one of: github/gh, gitlab/gl, or bitbucket/bb.")] InvalidHost, @@ -25,6 +37,7 @@ pub enum ParseError { } #[derive(Debug, Diagnostic, Error, PartialEq)] +#[diagnostic(code(arx::repository::fetch))] pub enum FetchError { #[error("Request failed.")] RequestFailed, @@ -35,14 +48,7 @@ pub enum FetchError { } #[derive(Debug, Diagnostic, Error)] -pub enum CopyError { - #[error("Failed to create directory.")] - CreateDirFailed(io::Error), - #[error("Failed to copy file.")] - CopyFailed(io::Error), -} - -#[derive(Debug, Diagnostic, Error)] +#[diagnostic(code(arx::repository::checkout))] pub enum CheckoutError { #[error("Failed to open the git repository.")] OpenFailed(git2::Error), @@ -227,7 +233,7 @@ impl LocalRepository { } /// Copies the repository into the `destination` directory. - pub fn copy(&self, destination: &Path) -> Result<(), CopyError> { + pub fn copy(&self, destination: &Path) -> Result<(), RepositoryError> { let traverser = Traverser::new(self.source.to_owned()) .pattern("**/*") .ignore_dirs(true) @@ -237,8 +243,26 @@ impl LocalRepository { 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)?; + fs::create_dir_all(parent).map_err(|source| { + RepositoryError::Io { + message: format!( + "Failed to create directory structure for '{}'.", + parent.display() + ), + source, + } + })?; + + fs::copy(&matched.path, &target).map_err(|source| { + RepositoryError::Io { + message: format!( + "Failed to copy from '{}' to '{}'.", + matched.path.display(), + target.display() + ), + source, + } + })?; } } From 75c3721e9e25da7633f75cbe71c47a24116f7e72 Mon Sep 17 00:00:00 2001 From: Vladislav Mamon Date: Sun, 10 Mar 2024 16:07:53 +0300 Subject: [PATCH 66/77] feat(repository): implement fancy error reporting for parser --- src/repository.rs | 172 ++++++++++++++++++++++++++++++++-------------- 1 file changed, 120 insertions(+), 52 deletions(-) diff --git a/src/repository.rs b/src/repository.rs index 9846c32..47f8d6d 100644 --- a/src/repository.rs +++ b/src/repository.rs @@ -5,11 +5,22 @@ use std::str::FromStr; use git2::build::CheckoutBuilder; use git2::Repository as GitRepository; -use miette::Diagnostic; +use miette::{Diagnostic, LabeledSpan, Report}; use thiserror::Error; use crate::path::Traverser; +/// Helper macro to create a [ParseError] in a slightly less verbose way. +macro_rules! parse_error { + ($source:ident = $code:expr, $($key:ident = $value:expr,)* $fmt:literal $($arg:tt)*) => { + ParseError( + miette::Report::from( + miette::diagnostic!($($key = $value,)* $fmt $($arg)*) + ).with_source_code($code) + ) + }; +} + #[derive(Debug, Diagnostic, Error)] pub enum RepositoryError { #[error("{message}")] @@ -21,20 +32,10 @@ pub enum RepositoryError { }, } -#[derive(Debug, Diagnostic, Error, PartialEq)] -#[diagnostic(code(arx::repository::parse))] -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, Diagnostic, Error)] +#[error("{0}")] +#[diagnostic(transparent)] +pub struct ParseError(Report); #[derive(Debug, Diagnostic, Error, PartialEq)] #[diagnostic(code(arx::repository::fetch))] @@ -52,15 +53,15 @@ pub enum FetchError { pub enum CheckoutError { #[error("Failed to open the git repository.")] OpenFailed(git2::Error), - #[error("Failed to parse revision string '{0}'.")] + #[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}'.")] + #[error("Failed to set HEAD to `{0}`.")] SetHeadFailed(String), - #[error("Failed to detach HEAD to '{0}'.")] + #[error("Failed to detach HEAD to `{0}`.")] DetachHeadFailed(String), } @@ -163,45 +164,85 @@ impl FromStr for RemoteRepository { is_valid_user(ch) || ch == '.' } - let input = input.trim(); + let source = 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), + let (host, (input, offset)) = if let Some((host, rest)) = source.split_once(':') { + let host = host.to_ascii_lowercase(); + let next_offset = host.len() + 1; + + match host.as_str() { + | "github" | "gh" => (RepositoryHost::GitHub, (rest, next_offset)), + | "gitlab" | "gl" => (RepositoryHost::GitLab, (rest, next_offset)), + | "bitbucket" | "bb" => (RepositoryHost::BitBucket, (rest, next_offset)), + | _ => { + return Err(parse_error!( + source = source.to_string(), + code = "arx::repository::parse", + labels = vec![LabeledSpan::at( + (0, host.len()), + "must be one of: github/gh, gitlab/gl, or bitbucket/bb" + )], + "Invalid host: `{host}`." + )); + }, } } else { - (RepositoryHost::default(), input) + (RepositoryHost::default(), (source, 0)) }; // Parse user name. - let (user, input) = if let Some((user, rest)) = input.split_once('/') { + let (user, (input, offset)) = if let Some((user, rest)) = input.split_once('/') { + let next_offset = offset + user.len() + 1; + if user.chars().all(is_valid_user) { - (user.to_string(), rest) + (user.to_string(), (rest, next_offset)) } else { - return Err(ParseError::InvalidUserName); + return Err(parse_error!( + source = source.to_string(), + code = "arx::repository::parse", + labels = vec![LabeledSpan::at( + (offset, user.len()), + "only ASCII alphanumeric characters, _ and - allowed" + )], + "Invalid user name: `{user}`." + )); } } else { - return Err(ParseError::MissingRepositoryName); + return Err(ParseError(miette::miette!("Missing repository name."))); }; - // Parse repository name. - let (repo, input) = if let Some((repo, rest)) = input.split_once('#') { - if repo.contains("//") { - return Err(ParseError::MultipleSlash); + // Short-circuit if the rest of the input contains another /. + if let Some(slash_idx) = input.find('/') { + // Ensure we are not triggering false-positive in case there's a ref (after #) with a branch + // name containing slashes. + if matches!(input.find('#'), Some(hash_idx) if slash_idx < hash_idx) { + return Err(parse_error!( + source = source.to_string(), + code = "arx::repository::parse", + labels = vec![LabeledSpan::at((offset + slash_idx, 1), "remove this")], + "Multiple slashes in the input." + )); } + } - if repo.chars().all(is_valid_repo) { - (repo.to_string(), Some(rest)) - } else { - return Err(ParseError::InvalidRepositoryName); - } - } else { - (input.to_string(), None) - }; + // Parse repository name. + let (repo, input) = input.split_once('#').map_or_else( + || (input.to_string(), None), + |(repo, rest)| (repo.to_string(), Some(rest)), + ); + + if !repo.chars().all(is_valid_repo) { + return Err(parse_error!( + source = source.to_string(), + code = "arx::repository::parse", + labels = vec![LabeledSpan::at( + (offset, repo.len()), + "only ASCII alphanumeric characters, _, - and . allowed" + ),], + "Invalid repository name: `{repo}`." + )); + } // Produce meta if anything left from the input. Empty meta is accepted but ignored, default // value is used then. @@ -344,7 +385,7 @@ mod tests { #[test] fn parse_remote_default() { assert_eq!( - RemoteRepository::from_str("foo/bar"), + RemoteRepository::from_str("foo/bar").map_err(|report| report.to_string()), Ok(RemoteRepository { host: RepositoryHost::GitHub, user: "foo".to_string(), @@ -355,18 +396,45 @@ mod tests { } #[test] - fn parse_remote_invalid_userrepo() { + fn parse_remote_missing_reponame() { + assert_eq!( + RemoteRepository::from_str("foo-bar").map_err(|report| report.to_string()), + Err("Missing repository name.".to_string()) + ); + } + + #[test] + fn parse_remote_invalid_username() { + assert_eq!( + RemoteRepository::from_str("foo@bar/baz").map_err(|report| report.to_string()), + Err("Invalid user name: `foo@bar`.".to_string()) + ); + } + + #[test] + fn parse_remote_invalid_reponame() { assert_eq!( - RemoteRepository::from_str("foo-bar"), - Err(ParseError::MissingRepositoryName) + RemoteRepository::from_str("foo-bar/b@z").map_err(|report| report.to_string()), + Err("Invalid repository name: `b@z`.".to_string()) ); } #[test] fn parse_remote_invalid_host() { assert_eq!( - RemoteRepository::from_str("srht:foo/bar"), - Err(ParseError::InvalidHost) + RemoteRepository::from_str("srht:foo/bar").map_err(|report| report.to_string()), + Err( + parse_error!( + source = "srht:foo/bar", + code = "arx::repository::parse", + labels = vec![LabeledSpan::at( + (0, 5), + "must be one of: github/gh, gitlab/gl, or bitbucket/bb" + )], + "Invalid host: `srht`." + ) + .to_string() + ) ); } @@ -384,7 +452,7 @@ mod tests { for (input, meta) in cases { assert_eq!( - RemoteRepository::from_str(input), + RemoteRepository::from_str(input).map_err(|report| report.to_string()), Ok(RemoteRepository { host: RepositoryHost::GitHub, user: "foo".to_string(), @@ -408,7 +476,7 @@ mod tests { for (input, host) in cases { assert_eq!( - RemoteRepository::from_str(input), + RemoteRepository::from_str(input).map_err(|report| report.to_string()), Ok(RemoteRepository { host, user: "foo".to_string(), @@ -422,7 +490,7 @@ mod tests { #[test] fn test_remote_empty_meta() { assert_eq!( - RemoteRepository::from_str("foo/bar#"), + RemoteRepository::from_str("foo/bar#").map_err(|report| report.to_string()), Ok(RemoteRepository { host: RepositoryHost::GitHub, user: "foo".to_string(), @@ -445,7 +513,7 @@ mod tests { for (input, user, repo) in cases { assert_eq!( - RemoteRepository::from_str(input), + RemoteRepository::from_str(input).map_err(|report| report.to_string()), Ok(RemoteRepository { host: RepositoryHost::default(), user: user.to_string(), From 14f296ba159772753d7621e1bdb188a3932f807c Mon Sep 17 00:00:00 2001 From: Vladislav Mamon Date: Sun, 10 Mar 2024 16:34:39 +0300 Subject: [PATCH 67/77] refactor(repository): slightly improve error report for fetching repository --- src/repository.rs | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/repository.rs b/src/repository.rs index 47f8d6d..71c787f 100644 --- a/src/repository.rs +++ b/src/repository.rs @@ -37,13 +37,13 @@ pub enum RepositoryError { #[diagnostic(transparent)] pub struct ParseError(Report); -#[derive(Debug, Diagnostic, Error, PartialEq)] +#[derive(Debug, Diagnostic, Error)] #[diagnostic(code(arx::repository::fetch))] pub enum FetchError { #[error("Request failed.")] RequestFailed, - #[error("Repository download ({0}) failed with code {1}.")] - RequestFailedWithCode(String, u16), + #[error("Repository download failed with code {code}.\n\n{url}")] + RequestFailedWithCode { code: u16, url: Report }, #[error("Couldn't get the response body as bytes.")] RequestBodyFailed, } @@ -131,14 +131,20 @@ impl RemoteRepository { let response = reqwest::get(&url).await.map_err(|err| { err.status().map_or(FetchError::RequestFailed, |status| { - FetchError::RequestFailedWithCode(url.clone(), status.as_u16()) + FetchError::RequestFailedWithCode { + code: status.as_u16(), + url: miette::miette!("URL: {}", url.clone()), + } }) })?; let status = response.status(); if !status.is_success() { - return Err(FetchError::RequestFailedWithCode(url, status.as_u16())); + return Err(FetchError::RequestFailedWithCode { + code: status.as_u16(), + url: miette::miette!("URL: {}", url), + }); } response From 60f29c92c86867f0e052e632b20db3dd48815e29 Mon Sep 17 00:00:00 2001 From: Vladislav Mamon Date: Sun, 10 Mar 2024 18:47:56 +0300 Subject: [PATCH 68/77] refactor(actions/prompts): move static helper methods from struct into mod --- src/actions/prompts.rs | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/src/actions/prompts.rs b/src/actions/prompts.rs index 7bcc8c6..4bc3338 100644 --- a/src/actions/prompts.rs +++ b/src/actions/prompts.rs @@ -10,10 +10,10 @@ use inquire::{Confirm, Editor, InquireError, Select, Text}; use crate::actions::{State, Value}; use crate::config::prompts; -/// Helper struct holding useful static methods. -struct Inquirer; +/// Helper module holding useful functions. +mod helpers { + use super::*; -impl Inquirer { /// Returns configured theme. pub fn theme<'r>() -> RenderConfig<'r> { let default = RenderConfig::default(); @@ -69,11 +69,11 @@ impl Inquirer { impl prompts::Confirm { /// Execute the prompt and populate the state. pub async fn execute(&self, state: &mut State) -> miette::Result<()> { - let (name, hint, help) = Inquirer::messages(&self.name, &self.hint); + let (name, hint, help) = helpers::messages(&self.name, &self.hint); let mut prompt = Confirm::new(&hint) .with_help_message(&help) - .with_render_config(Inquirer::theme()); + .with_render_config(helpers::theme()); if let Some(default) = self.default { prompt = prompt.with_default(default); @@ -81,7 +81,7 @@ impl prompts::Confirm { match prompt.prompt() { | Ok(value) => state.set(name, Value::Bool(value)), - | Err(err) => Inquirer::handle_interruption(err), + | Err(err) => helpers::handle_interruption(err), } Ok(()) @@ -91,12 +91,12 @@ impl prompts::Confirm { impl prompts::Input { /// Execute the prompt and populate the state. pub async fn execute(&self, state: &mut State) -> miette::Result<()> { - let (name, hint, help) = Inquirer::messages(&self.name, &self.hint); + let (name, hint, help) = helpers::messages(&self.name, &self.hint); let mut prompt = Text::new(&hint) .with_help_message(&help) - .with_formatter(Inquirer::empty_formatter()) - .with_render_config(Inquirer::theme()); + .with_formatter(helpers::empty_formatter()) + .with_render_config(helpers::theme()); if let Some(default) = &self.default { prompt = prompt.with_default(default); @@ -106,7 +106,7 @@ impl prompts::Input { match prompt.prompt() { | Ok(value) => state.set(name, Value::String(value)), - | Err(err) => Inquirer::handle_interruption(err), + | Err(err) => helpers::handle_interruption(err), } Ok(()) @@ -116,17 +116,17 @@ impl prompts::Input { impl prompts::Select { /// Execute the prompt and populate the state. pub async fn execute(&self, state: &mut State) -> miette::Result<()> { - let (name, hint, help) = Inquirer::messages(&self.name, &self.hint); + let (name, hint, help) = helpers::messages(&self.name, &self.hint); let options = self.options.iter().map(String::to_string).collect(); let prompt = Select::new(&hint, options) .with_help_message(&help) - .with_render_config(Inquirer::theme()); + .with_render_config(helpers::theme()); match prompt.prompt() { | Ok(value) => state.set(name, Value::String(value)), - | Err(err) => Inquirer::handle_interruption(err), + | Err(err) => helpers::handle_interruption(err), } Ok(()) @@ -136,11 +136,11 @@ impl prompts::Select { impl prompts::Editor { /// Execute the prompt and populate the state. pub async fn execute(&self, state: &mut State) -> miette::Result<()> { - let (name, hint, help) = Inquirer::messages(&self.name, &self.hint); + let (name, hint, help) = helpers::messages(&self.name, &self.hint); let mut prompt = Editor::new(&hint) .with_help_message(&help) - .with_render_config(Inquirer::theme()); + .with_render_config(helpers::theme()); if let Some(default) = &self.default { prompt = prompt.with_predefined_text(default); @@ -148,7 +148,7 @@ impl prompts::Editor { match prompt.prompt() { | Ok(value) => state.set(name, Value::String(value)), - | Err(err) => Inquirer::handle_interruption(err), + | Err(err) => helpers::handle_interruption(err), } Ok(()) From 674f0bba4fb3fb64f025916e0a6f77f1abe6fda1 Mon Sep 17 00:00:00 2001 From: Vladislav Mamon Date: Sun, 10 Mar 2024 19:45:46 +0300 Subject: [PATCH 69/77] refactor(config): do not parse `default` for select prompts --- src/config/config.rs | 1 - src/config/prompts.rs | 2 -- 2 files changed, 3 deletions(-) diff --git a/src/config/config.rs b/src/config/config.rs index adc9d2d..8ab266c 100644 --- a/src/config/config.rs +++ b/src/config/config.rs @@ -393,7 +393,6 @@ impl Config { name: self.get_arg_string(node)?, hint: self.get_hint(node, nodes)?, options: self.get_options(node, nodes)?, - default: self.get_default_string(nodes), })) }, | "confirm" => { diff --git a/src/config/prompts.rs b/src/config/prompts.rs index 98836e0..a430b5d 100644 --- a/src/config/prompts.rs +++ b/src/config/prompts.rs @@ -16,8 +16,6 @@ pub struct Select { pub hint: String, /// List of options. pub options: Vec, - /// Default value (if prompt was canceled). - pub default: Option, } #[derive(Debug)] From 51ff257b91c723bb2efe4308868519d96b0792e9 Mon Sep 17 00:00:00 2001 From: Vladislav Mamon Date: Sun, 10 Mar 2024 21:24:38 +0300 Subject: [PATCH 70/77] feat(prompts): implement `number` prompt, slightly restructure --- src/actions/actions.rs | 6 +++-- src/actions/executor.rs | 11 +-------- src/actions/prompts.rs | 52 +++++++++++++++++++++++++++++++---------- src/config/actions.rs | 9 +++---- src/config/config.rs | 22 +++++++++++++---- src/config/mod.rs | 2 ++ src/config/prompts.rs | 20 ++++++++++++---- src/config/utils.rs | 16 +++++++++++++ src/config/value.rs | 50 +++++++++++++++++++++++++++++++++++++++ 9 files changed, 152 insertions(+), 36 deletions(-) create mode 100644 src/config/value.rs diff --git a/src/actions/actions.rs b/src/actions/actions.rs index a9158d9..e62ffe4 100644 --- a/src/actions/actions.rs +++ b/src/actions/actions.rs @@ -11,8 +11,9 @@ use tokio::fs::{self, File, OpenOptions}; use tokio::io::{self, AsyncReadExt, AsyncWriteExt}; use unindent::Unindent; -use crate::actions::{State, Value}; +use crate::actions::State; use crate::config::actions::*; +use crate::config::value::*; use crate::path::{PathClean, Traverser}; use crate::spinner::Spinner; @@ -274,9 +275,10 @@ impl Prompt { pub async fn execute(&self, state: &mut State) -> miette::Result<()> { match self { | Self::Confirm(prompt) => prompt.execute(state).await, + | Self::Editor(prompt) => prompt.execute(state).await, | Self::Input(prompt) => prompt.execute(state).await, + | Self::Number(prompt) => prompt.execute(state).await, | Self::Select(prompt) => prompt.execute(state).await, - | Self::Editor(prompt) => prompt.execute(state).await, } } } diff --git a/src/actions/executor.rs b/src/actions/executor.rs index a32f826..dce5bbe 100644 --- a/src/actions/executor.rs +++ b/src/actions/executor.rs @@ -6,7 +6,7 @@ use miette::Diagnostic; use thiserror::Error; use tokio::fs; -use crate::config::{ActionSingle, ActionSuite, Actions, Config}; +use crate::config::{ActionSingle, ActionSuite, Actions, Config, Value}; #[derive(Debug, Diagnostic, Error)] pub enum ExecutorError { @@ -19,15 +19,6 @@ pub enum ExecutorError { }, } -/// Replacement value. -#[derive(Debug)] -pub enum Value { - /// A string value. - String(String), - /// A boolean value. - Bool(bool), -} - #[derive(Debug)] pub struct State { /// A map of replacements and associated values. diff --git a/src/actions/prompts.rs b/src/actions/prompts.rs index 4bc3338..486b5af 100644 --- a/src/actions/prompts.rs +++ b/src/actions/prompts.rs @@ -3,12 +3,13 @@ use std::process; use crossterm::style::Stylize; use inquire::formatter::StringFormatter; -use inquire::required; use inquire::ui::{Color, RenderConfig, StyleSheet, Styled}; +use inquire::{required, CustomType}; use inquire::{Confirm, Editor, InquireError, Select, Text}; -use crate::actions::{State, Value}; -use crate::config::prompts; +use crate::actions::State; +use crate::config::prompts::*; +use crate::config::{Number, Value}; /// Helper module holding useful functions. mod helpers { @@ -52,7 +53,7 @@ mod helpers { } /// Handle interruption/cancelation events. - pub fn handle_interruption(err: InquireError) { + pub fn interrupt(err: InquireError) { match err { | InquireError::OperationCanceled => { process::exit(0); @@ -66,7 +67,7 @@ mod helpers { } } -impl prompts::Confirm { +impl ConfirmPrompt { /// Execute the prompt and populate the state. pub async fn execute(&self, state: &mut State) -> miette::Result<()> { let (name, hint, help) = helpers::messages(&self.name, &self.hint); @@ -81,14 +82,14 @@ impl prompts::Confirm { match prompt.prompt() { | Ok(value) => state.set(name, Value::Bool(value)), - | Err(err) => helpers::handle_interruption(err), + | Err(err) => helpers::interrupt(err), } Ok(()) } } -impl prompts::Input { +impl InputPrompt { /// Execute the prompt and populate the state. pub async fn execute(&self, state: &mut State) -> miette::Result<()> { let (name, hint, help) = helpers::messages(&self.name, &self.hint); @@ -106,14 +107,41 @@ impl prompts::Input { match prompt.prompt() { | Ok(value) => state.set(name, Value::String(value)), - | Err(err) => helpers::handle_interruption(err), + | Err(err) => helpers::interrupt(err), } Ok(()) } } -impl prompts::Select { +impl NumberPrompt { + /// Execute the prompt and populate the state. + pub async fn execute(&self, state: &mut State) -> miette::Result<()> { + let (name, hint, help) = helpers::messages(&self.name, &self.hint); + + let mut prompt = CustomType::::new(&hint) + .with_help_message(&help) + .with_formatter(&|input| input.to_string()) + .with_render_config(helpers::theme()); + + if let Some(default) = &self.default { + prompt = prompt.with_default(default.to_owned()); + } else { + // NOTE: This is a bit confusing, but essentially this message will be showed when no input + // was provided by the user. + prompt = prompt.with_error_message("This field is required."); + } + + match prompt.prompt() { + | Ok(value) => state.set(name, Value::Number(value)), + | Err(err) => helpers::interrupt(err), + } + + Ok(()) + } +} + +impl SelectPrompt { /// Execute the prompt and populate the state. pub async fn execute(&self, state: &mut State) -> miette::Result<()> { let (name, hint, help) = helpers::messages(&self.name, &self.hint); @@ -126,14 +154,14 @@ impl prompts::Select { match prompt.prompt() { | Ok(value) => state.set(name, Value::String(value)), - | Err(err) => helpers::handle_interruption(err), + | Err(err) => helpers::interrupt(err), } Ok(()) } } -impl prompts::Editor { +impl EditorPrompt { /// Execute the prompt and populate the state. pub async fn execute(&self, state: &mut State) -> miette::Result<()> { let (name, hint, help) = helpers::messages(&self.name, &self.hint); @@ -148,7 +176,7 @@ impl prompts::Editor { match prompt.prompt() { | Ok(value) => state.set(name, Value::String(value)), - | Err(err) => helpers::handle_interruption(err), + | Err(err) => helpers::interrupt(err), } Ok(()) diff --git a/src/config/actions.rs b/src/config/actions.rs index ed87457..4cab53c 100644 --- a/src/config/actions.rs +++ b/src/config/actions.rs @@ -77,10 +77,11 @@ pub struct Run { /// Prompt actions. #[derive(Debug)] pub enum Prompt { - Input(Input), - Select(Select), - Confirm(Confirm), - Editor(Editor), + Input(InputPrompt), + Number(NumberPrompt), + Select(SelectPrompt), + Confirm(ConfirmPrompt), + Editor(EditorPrompt), } /// Execute given replacements using values provided by prompts. Optionally, only apply diff --git a/src/config/config.rs b/src/config/config.rs index 8ab266c..bbbcafd 100644 --- a/src/config/config.rs +++ b/src/config/config.rs @@ -9,6 +9,7 @@ use thiserror::Error; use crate::config::actions::*; use crate::config::prompts::*; +use crate::config::value::*; use crate::config::KdlUtils; const CONFIG_NAME: &str = "arx.kdl"; @@ -371,16 +372,25 @@ impl Config { | "input" => { let nodes = self.get_children(node, vec!["hint"])?; - ActionSingle::Prompt(Prompt::Input(Input { + ActionSingle::Prompt(Prompt::Input(InputPrompt { name: self.get_arg_string(node)?, hint: self.get_hint(node, nodes)?, default: self.get_default_string(nodes), })) }, + | "number" => { + let nodes = self.get_children(node, vec!["hint"])?; + + ActionSingle::Prompt(Prompt::Number(NumberPrompt { + name: self.get_arg_string(node)?, + hint: self.get_hint(node, nodes)?, + default: self.get_default_number(nodes), + })) + }, | "editor" => { let nodes = self.get_children(node, vec!["hint"])?; - ActionSingle::Prompt(Prompt::Editor(Editor { + ActionSingle::Prompt(Prompt::Editor(EditorPrompt { name: self.get_arg_string(node)?, hint: self.get_hint(node, nodes)?, default: self.get_default_string(nodes), @@ -389,7 +399,7 @@ impl Config { | "select" => { let nodes = self.get_children(node, vec!["hint", "options"])?; - ActionSingle::Prompt(Prompt::Select(Select { + ActionSingle::Prompt(Prompt::Select(SelectPrompt { name: self.get_arg_string(node)?, hint: self.get_hint(node, nodes)?, options: self.get_options(node, nodes)?, @@ -398,7 +408,7 @@ impl Config { | "confirm" => { let nodes = self.get_children(node, vec!["hint"])?; - ActionSingle::Prompt(Prompt::Confirm(Confirm { + ActionSingle::Prompt(Prompt::Confirm(ConfirmPrompt { name: self.get_arg_string(node)?, hint: self.get_hint(node, nodes)?, default: self.get_default_bool(nodes), @@ -563,4 +573,8 @@ impl Config { fn get_default_bool(&self, nodes: &KdlDocument) -> Option { nodes.get("default").and_then(|node| node.get_bool(0)) } + + fn get_default_number(&self, nodes: &KdlDocument) -> Option { + nodes.get("default").and_then(|node| node.get_number(0)) + } } diff --git a/src/config/mod.rs b/src/config/mod.rs index 6f4d2e8..9b44067 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -1,8 +1,10 @@ pub use config::*; pub use utils::*; +pub use value::*; pub mod actions; pub mod prompts; +pub mod value; mod config; mod utils; diff --git a/src/config/prompts.rs b/src/config/prompts.rs index a430b5d..434ef2f 100644 --- a/src/config/prompts.rs +++ b/src/config/prompts.rs @@ -1,5 +1,7 @@ +use crate::config::value::Number; + #[derive(Debug)] -pub struct Input { +pub struct InputPrompt { /// Name of the variable that will store the answer. pub name: String, /// Short description. @@ -9,7 +11,17 @@ pub struct Input { } #[derive(Debug)] -pub struct Select { +pub struct NumberPrompt { + /// Name of the variable that will store the answer. + pub name: String, + /// Short description. + pub hint: String, + /// Default value if input is empty. + pub default: Option, +} + +#[derive(Debug)] +pub struct SelectPrompt { /// Name of the variable that will store the answer. pub name: String, /// Short description. @@ -19,7 +31,7 @@ pub struct Select { } #[derive(Debug)] -pub struct Confirm { +pub struct ConfirmPrompt { /// Name of the variable that will store the answer. pub name: String, /// Short description of the prompt. @@ -29,7 +41,7 @@ pub struct Confirm { } #[derive(Debug)] -pub struct Editor { +pub struct EditorPrompt { /// Name of the variable that will store the answer. pub name: String, /// Short description. diff --git a/src/config/utils.rs b/src/config/utils.rs index 0b16d8c..79696e5 100644 --- a/src/config/utils.rs +++ b/src/config/utils.rs @@ -1,9 +1,14 @@ use kdl::{KdlNode, NodeKey}; +use crate::config::Number; + pub trait KdlUtils { /// Gets an entry by key and tries to map to a [String]. fn get_string(&self, key: K) -> Option; + /// Gets an entry by key and tries to map it to a [NumberValue]. + fn get_number(&self, key: K) -> Option; + /// Gets an entry by key and tries to map it to a [bool]. fn get_bool(&self, key: K) -> Option; } @@ -18,6 +23,17 @@ where .and_then(|entry| entry.value().as_string().map(str::to_string)) } + fn get_number(&self, key: K) -> Option { + self.get(key).and_then(|entry| { + let value = entry.value(); + + value + .as_i64() + .map(Number::Integer) + .or_else(|| value.as_f64().map(Number::Float)) + }) + } + fn get_bool(&self, key: K) -> Option { self.get(key).and_then(|entry| entry.value().as_bool()) } diff --git a/src/config/value.rs b/src/config/value.rs new file mode 100644 index 0000000..75a6d6a --- /dev/null +++ b/src/config/value.rs @@ -0,0 +1,50 @@ +use std::fmt::{self, Display}; +use std::str::FromStr; + +use miette::Diagnostic; +use thiserror::Error; + +#[derive(Debug, Diagnostic, Error)] +#[error("`{0}` is not a valid number.")] +#[diagnostic(code(arx::config::prompts::parse))] +pub struct NumberParseError(pub String); + +/// Value of a number prompt. +#[derive(Clone, Debug)] +pub enum Number { + /// Integer value. + Integer(i64), + /// Floating point value. + Float(f64), +} + +impl Display for Number { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + | Self::Integer(int) => write!(f, "{int}"), + | Self::Float(float) => write!(f, "{float}"), + } + } +} + +impl FromStr for Number { + type Err = NumberParseError; + + fn from_str(s: &str) -> Result { + s.parse::() + .map(Self::Integer) + .or_else(|_| s.parse::().map(Self::Float)) + .map_err(|_| NumberParseError(s.to_string())) + } +} + +/// Replacement value. +#[derive(Debug)] +pub enum Value { + /// A string value. + String(String), + // A number value. + Number(Number), + /// A boolean value. + Bool(bool), +} From 8b3425d833ecdaf438faa810fb8e4caa7bc4af93 Mon Sep 17 00:00:00 2001 From: Vladislav Mamon Date: Sun, 10 Mar 2024 21:26:46 +0300 Subject: [PATCH 71/77] refactor(actions): remove artificial delay when performing replacements --- src/actions/actions.rs | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/src/actions/actions.rs b/src/actions/actions.rs index e62ffe4..70698fa 100644 --- a/src/actions/actions.rs +++ b/src/actions/actions.rs @@ -1,7 +1,5 @@ use std::path::{Path, PathBuf}; use std::process; -use std::thread; -use std::time::{Duration, Instant}; use crossterm::style::Stylize; use miette::Diagnostic; @@ -289,7 +287,6 @@ impl Replace { P: AsRef, { let spinner = Spinner::new(); - let start = Instant::now(); // If no glob pattern specified, traverse all files. let pattern = self.glob.clone().unwrap_or("**/*".to_string()); @@ -351,14 +348,6 @@ impl Replace { })?; } - // Add artificial delay if replacements were performed too fast. - let elapsed = start.elapsed(); - - // This way we spent at least 1 second before stopping the spinner. - if elapsed < Duration::from_millis(750) { - thread::sleep(Duration::from_millis(1_000) - elapsed); - } - spinner.stop_with_message("Successfully performed replacements\n"); } From 7fab26c41b7d04f4ecedf3441271a65afb7f51e5 Mon Sep 17 00:00:00 2001 From: Vladislav Mamon Date: Sun, 10 Mar 2024 21:35:47 +0300 Subject: [PATCH 72/77] refactor(config): slightly change error message --- src/config/config.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/config/config.rs b/src/config/config.rs index bbbcafd..b0f9f1a 100644 --- a/src/config/config.rs +++ b/src/config/config.rs @@ -289,7 +289,8 @@ impl Config { else { Err(ConfigError::Diagnostic(miette::miette!( code = "arx::config::actions", - "You can use either suites of actions or a flat list of single actions, not both." + "You can use either suites of actions or a flat list of single actions. \ + Right now you have a mix of both." ))) } }); From 536d998eee2606c3ad2a5fa096a27198abd8d208 Mon Sep 17 00:00:00 2001 From: Vladislav Mamon Date: Sun, 10 Mar 2024 21:48:58 +0300 Subject: [PATCH 73/77] refactor(config): factor out parsing of `inject` nodes --- src/config/config.rs | 49 ++++++++++++++++++++------------------------ 1 file changed, 22 insertions(+), 27 deletions(-) diff --git a/src/config/config.rs b/src/config/config.rs index b0f9f1a..f230152 100644 --- a/src/config/config.rs +++ b/src/config/config.rs @@ -1,3 +1,4 @@ +use std::collections::HashSet; use std::fs; use std::io; use std::path::{Path, PathBuf}; @@ -340,34 +341,18 @@ impl Config { | "rm" => ActionSingle::Delete(Delete { target: self.get_arg_string(node)? }), // Actions for running commands and echoing output. | "echo" => { - let message = self.get_arg_string(node)?; - - // TODO: Verify injects have valid type (string values). - let injects = node.children().map(|children| { - children - .get_args("inject") - .into_iter() - .filter_map(|arg| arg.as_string().map(str::to_string)) - .collect() - }); - - let trim = node.get_bool("trim").unwrap_or(true); - - ActionSingle::Echo(Echo { message, injects, trim }) + ActionSingle::Echo(Echo { + message: self.get_arg_string(node)?, + injects: self.get_injects(node), + trim: node.get_bool("trim").unwrap_or(true), + }) }, | "run" => { - let name = node.get_string("name"); - let command = self.get_arg_string(node)?; - - let injects = node.children().map(|children| { - children - .get_args("inject") - .into_iter() - .filter_map(|arg| arg.as_string().map(str::to_string)) - .collect() - }); - - ActionSingle::Run(Run { name, command, injects }) + ActionSingle::Run(Run { + name: node.get_string("name"), + command: self.get_arg_string(node)?, + injects: self.get_injects(node), + }) }, // Actions for prompts and replacements. | "input" => { @@ -474,13 +459,13 @@ impl Config { node: &'kdl KdlNode, nodes: Vec<&str>, ) -> Result<&'kdl KdlDocument, ConfigError> { - let suffix = if nodes.len() > 1 { "s" } else { "" }; let nodes = nodes .iter() .map(|node| format!("`{node}`")) .collect::>() .join(", "); + let suffix = if nodes.len() > 1 { "s" } else { "" }; let message = format!("Missing required child node{suffix}: {nodes}."); node.children().ok_or_else(|| { @@ -512,6 +497,16 @@ impl Config { self.get_arg_string(hint) } + fn get_injects(&self, node: &KdlNode) -> Option> { + node.children().map(|children| { + children + .get_args("inject") + .into_iter() + .filter_map(|arg| arg.as_string().map(str::to_string)) + .collect() + }) + } + fn get_options(&self, parent: &KdlNode, nodes: &KdlDocument) -> Result, ConfigError> { let options = nodes.get("options").ok_or_else(|| { diagnostic!( From 658148d5ca318b00871fde3dcffa3d9a6c78b9dc Mon Sep 17 00:00:00 2001 From: Vladislav Mamon Date: Sun, 10 Mar 2024 21:55:48 +0300 Subject: [PATCH 74/77] chore(deps): remove unused deps --- Cargo.lock | 57 ------------------------------------------------------ Cargo.toml | 2 -- 2 files changed, 59 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 00216c6..95092ef 100755 --- a/Cargo.lock +++ b/Cargo.lock @@ -65,20 +65,12 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "anyhow" -version = "1.0.76" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59d2a3357dde987206219e78ecfbbb6e8dad06cbb65292758d3270e6254f7355" - [[package]] name = "arx" version = "0.1.0" dependencies = [ - "anyhow", "clap", "crossterm 0.27.0", - "dirs", "flate2", "git2", "glob-match", @@ -301,27 +293,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "dirs" -version = "5.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" -dependencies = [ - "dirs-sys", -] - -[[package]] -name = "dirs-sys" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" -dependencies = [ - "libc", - "option-ext", - "redox_users", - "windows-sys 0.48.0", -] - [[package]] name = "dunce" version = "1.0.2" @@ -794,17 +765,6 @@ dependencies = [ "pkg-config", ] -[[package]] -name = "libredox" -version = "0.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85c833ca1e66078851dba29046874e38f08b2c883700aa29a03ddd3b23814ee8" -dependencies = [ - "bitflags 2.4.1", - "libc", - "redox_syscall 0.4.1", -] - [[package]] name = "libssh2-sys" version = "0.3.0" @@ -1046,12 +1006,6 @@ dependencies = [ "vcpkg", ] -[[package]] -name = "option-ext" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" - [[package]] name = "owo-colors" version = "3.5.0" @@ -1183,17 +1137,6 @@ dependencies = [ "bitflags 1.3.2", ] -[[package]] -name = "redox_users" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a18479200779601e498ada4e8c1e1f50e3ee19deb0259c25825a98b5603b2cb4" -dependencies = [ - "getrandom", - "libredox", - "thiserror", -] - [[package]] name = "remove_dir_all" version = "0.5.3" diff --git a/Cargo.toml b/Cargo.toml index 0c7236f..a7ffcd0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,10 +8,8 @@ repository = "https://github.com/norskeld/arx" publish = false [dependencies] -anyhow = { version = "1.0.76" } clap = { version = "4.4.11", features = ["cargo", "derive"] } crossterm = "0.27.0" -dirs = "5.0.1" flate2 = { version = "1.0.28" } git2 = { version = "0.18.1", features = ["vendored-libgit2"] } glob-match = { version = "0.2.1" } From dc00e12e25f28115a5725f57f06388f181b3d1f1 Mon Sep 17 00:00:00 2001 From: Vladislav Mamon Date: Sun, 10 Mar 2024 22:20:58 +0300 Subject: [PATCH 75/77] chore: update README --- README.md | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 359e8cb..3a19e06 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,25 @@ Simple and user-friendly command-line tool for declarative scaffolding. ## Status -WIP. +> [!NOTE] +> +> This is an MVP. +> +> - [Spec] was fleshed out and implemented, but there's no thorough test coverage yet. +> - [Spec] is thoroughly commented and temporarily serves as a reference. +> - Bugs and uncovered edge cases are to be expected. + +## Installation + +Right now **arx** can only be installed from source via **Cargo**. + +### From source (Cargo) + +Make sure to [install Rust toolchain][rust-toolchain] first. After that you can install arx using **Cargo**: + +```shell +cargo install --locked --git https://github.com/norskeld/arx +``` ## Acknowledgements @@ -14,9 +32,10 @@ Thanks to [Rich Harris][rich-harris] and his [degit] for inspiration. `:^)` ## License -[MIT](./LICENSE) +[MIT](LICENSE) +[spec]: spec.kdl [degit]: https://github.com/Rich-Harris/degit [rich-harris]: https://github.com/Rich-Harris From d8edda9988d402a1d65d40f87520543d744c7463 Mon Sep 17 00:00:00 2001 From: Vladislav Mamon Date: Sun, 10 Mar 2024 22:25:59 +0300 Subject: [PATCH 76/77] chore(spec): update spec --- spec.kdl | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/spec.kdl b/spec.kdl index 70e867d..08d4605 100644 --- a/spec.kdl +++ b/spec.kdl @@ -52,7 +52,12 @@ actions { select "repo_pm" { hint "Package manager of choice" options "npm" "pnpm" "yarn" "bun" - default "npm" + } + + // Number prompt. Accepts both integers and floats. + number "magic_number" { + hint "Magic number" + default 42 } // If no default value provided, prompt will become required. From 77e7e17a888ccf7c69b45088c571031edede8847 Mon Sep 17 00:00:00 2001 From: Vladislav Mamon Date: Sun, 10 Mar 2024 22:26:06 +0300 Subject: [PATCH 77/77] chore: update README --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 3a19e06..eb2ff09 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,10 @@ Simple and user-friendly command-line tool for declarative scaffolding. > > This is an MVP. > -> - [Spec] was fleshed out and implemented, but there's no thorough test coverage yet. -> - [Spec] is thoroughly commented and temporarily serves as a reference. +> - [Spec] was fleshed out and (mostly) implemented. +> - [Spec] is thoroughly commented and temporarily serves as a reference/documentation. > - Bugs and uncovered edge cases are to be expected. +> - Test coverage is lacking. ## Installation