diff --git a/Cargo.lock b/Cargo.lock index 85ca70a..9f75dcf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -230,6 +230,17 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "atty" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" +dependencies = [ + "hermit-abi 0.1.19", + "libc", + "winapi", +] + [[package]] name = "autocfg" version = "1.3.0" @@ -424,6 +435,19 @@ dependencies = [ "crossbeam-utils", ] +[[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 = "cookie" version = "0.18.1" @@ -533,6 +557,19 @@ dependencies = [ "syn", ] +[[package]] +name = "dialoguer" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "658bce805d770f407bc62102fca7c2c64ceef2fbcb2b8bd19d2765ce093980de" +dependencies = [ + "console", + "shell-words", + "tempfile", + "thiserror", + "zeroize", +] + [[package]] name = "digest" version = "0.10.7" @@ -549,6 +586,12 @@ version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" +[[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.34" @@ -610,7 +653,7 @@ dependencies = [ "atomic 0.6.0", "pear", "serde", - "toml", + "toml 0.8.19", "uncased", "version_check", ] @@ -621,6 +664,17 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "fronma" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da11047dc6b6b3f21012056a0f9177e435a0ce4f34e1c5e7990b01342c0d4e49" +dependencies = [ + "serde", + "serde_yaml", + "toml 0.5.11", +] + [[package]] name = "fuchsia-cprng" version = "0.1.1" @@ -789,13 +843,19 @@ dependencies = [ "futures-sink", "futures-util", "http 0.2.12", - "indexmap", + "indexmap 2.2.6", "slab", "tokio", "tokio-util", "tracing", ] +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + [[package]] name = "hashbrown" version = "0.14.5" @@ -808,6 +868,15 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" +dependencies = [ + "libc", +] + [[package]] name = "hermit-abi" version = "0.3.9" @@ -937,6 +1006,16 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", +] + [[package]] name = "indexmap" version = "2.2.6" @@ -944,7 +1023,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "168fb715dda47215e360912c096649d23d58bf392ac62f73919e831745e40f26" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.14.5", "serde", ] @@ -1004,6 +1083,12 @@ version = "0.2.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ec2a862134d2a7d32d7983ddcdd1c4923530833c9f2ea1a44fc5fa473989058" +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" + [[package]] name = "linux-raw-sys" version = "0.4.14" @@ -1559,7 +1644,7 @@ dependencies = [ "either", "figment", "futures", - "indexmap", + "indexmap 2.2.6", "log", "memchr", "multer", @@ -1591,7 +1676,7 @@ checksum = "575d32d7ec1a9770108c879fc7c47815a80073f96ca07ff9525a94fcede1dd46" dependencies = [ "devise", "glob", - "indexmap", + "indexmap 2.2.6", "proc-macro2", "quote", "rocket_http", @@ -1611,7 +1696,7 @@ dependencies = [ "futures", "http 0.2.12", "hyper", - "indexmap", + "indexmap 2.2.6", "log", "memchr", "pear", @@ -1746,13 +1831,25 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "0.6.6" +version = "0.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79e674e01f999af37c49f70a6ede167a8a60b2503e56c5599532a65baa5969a0" +checksum = "eb5b1b31579f3811bf615c144393417496f152e12ac8b7663bf664f4a815306d" dependencies = [ "serde", ] +[[package]] +name = "serde_yaml" +version = "0.8.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "578a7433b776b56a35785ed5ce9a7e777ac0598aac5a6dd1b4b18a307c7fc71b" +dependencies = [ + "indexmap 1.9.3", + "ryu", + "serde", + "yaml-rust", +] + [[package]] name = "sha2" version = "0.10.8" @@ -1773,6 +1870,12 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shell-words" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde" + [[package]] name = "signal-hook-registry" version = "1.4.2" @@ -1830,6 +1933,7 @@ dependencies = [ "async-process", "async-stream", "colored", + "fronma", "getrandom", "polyjuice", "serde", @@ -1838,7 +1942,7 @@ dependencies = [ "tera", "tokio", "tokio-stream", - "toml", + "toml 0.8.19", "tracing", "tracing-subscriber", "users", @@ -1849,12 +1953,16 @@ dependencies = [ name = "spackle-cli" version = "0.1.0" dependencies = [ + "atty", "clap", "colored", + "dialoguer", + "fronma", "rocket", "rust-embed", "spackle", "tera", + "toml 0.8.19", ] [[package]] @@ -2071,9 +2179,18 @@ dependencies = [ [[package]] name = "toml" -version = "0.8.14" +version = "0.5.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f49eb2ab21d2f26bd6db7bf383edc527a7ebaee412d17af4d40fdccd442f335" +checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234" +dependencies = [ + "serde", +] + +[[package]] +name = "toml" +version = "0.8.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1ed1f98e3fdc28d6d910e6737ae6ab1a93bf1985935a1193e68f93eeb68d24e" dependencies = [ "serde", "serde_spanned", @@ -2083,20 +2200,20 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "0.6.6" +version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4badfd56924ae69bcc9039335b2e017639ce3f9b001c393c1b2d1ef846ce2cbf" +checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41" dependencies = [ "serde", ] [[package]] name = "toml_edit" -version = "0.22.15" +version = "0.22.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d59a3a72298453f564e2b111fa896f8d07fabb36f51f06d7e875fc5e0b5a3ef1" +checksum = "583c44c02ad26b0c3f3066fe629275e50627026c51ac2e595cca4c230ce1ce1d" dependencies = [ - "indexmap", + "indexmap 2.2.6", "serde", "serde_spanned", "toml_datetime", @@ -2263,6 +2380,12 @@ version = "1.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" +[[package]] +name = "unicode-width" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0336d538f7abc86d282a4189614dfaa90810dfc2c6f6427eaf88e16311dd225d" + [[package]] name = "unicode-xid" version = "0.2.4" @@ -2566,13 +2689,22 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "winnow" -version = "0.6.13" +version = "0.6.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59b5e5f6c299a3c7890b876a2a587f3115162487e704907d9b6cd29473052ba1" +checksum = "68a9bda4691f099d435ad181000724da8e5899daa10713c2d432552b9ccd3a6f" dependencies = [ "memchr", ] +[[package]] +name = "yaml-rust" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85" +dependencies = [ + "linked-hash-map", +] + [[package]] name = "yansi" version = "1.0.1" @@ -2581,3 +2713,9 @@ checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" dependencies = [ "is-terminal", ] + +[[package]] +name = "zeroize" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" diff --git a/Cargo.toml b/Cargo.toml index 2a394c4..9c5112f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,6 +38,7 @@ install-updater = true async-process = "2.2.3" async-stream = "0.3.5" colored = "2.1.0" +fronma = { version = "0.2.0", features = ["toml"] } getrandom = { version = "0.2.15", features = ["js"] } polyjuice = { git = "https://github.com/a2-ai/polyjuice" } serde = { version = "1.0.202", features = ["derive"] } diff --git a/README.md b/README.md index d084ef8..030a687 100644 --- a/README.md +++ b/README.md @@ -17,10 +17,10 @@ Commands: help Print this message or the help of the given subcommand(s) Options: - -p, --project-dir The directory of the spackle project. Defaults to the current directory [default: .] - -v, --verbose Whether to run in verbose mode - -h, --help Print help - -V, --version Print version + -p, --project The spackle project to use (either a directory or a single file). Defaults to the current directory [default: .] + -v, --verbose Whether to run in verbose mode + -h, --help Print help + -V, --version Print version ``` ## Project configuration diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 4f05bb2..3ec4f38 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -14,3 +14,7 @@ colored = "2.1.0" rocket = { version = "0.5.1", features = ["json"] } rust-embed = "8.4.0" tera = "1.20.0" +dialoguer = "0.11.0" +atty = "0.2.14" +toml = "0.8.19" +fronma = { version = "0.2.0", features = ["toml"] } diff --git a/cli/src/check.rs b/cli/src/check.rs index be01659..febeee9 100644 --- a/cli/src/check.rs +++ b/cli/src/check.rs @@ -11,7 +11,7 @@ pub fn run(project: &Project) { let start_time = Instant::now(); - match template::validate(&project.dir, &project.config.slots) { + match template::validate(&project.path, &project.config.slots) { Ok(()) => { println!(" 👌 {}\n", "Template files are valid".bright_green()); } diff --git a/cli/src/fill.rs b/cli/src/fill.rs index 40bda3d..71c12de 100644 --- a/cli/src/fill.rs +++ b/cli/src/fill.rs @@ -1,24 +1,23 @@ +use crate::{check, Cli}; use colored::Colorize; +use dialoguer::{theme::ColorfulTheme, Input}; +use fronma::parser::parse_with_engine; use rocket::{futures::StreamExt, tokio}; use spackle::{ + config::{self}, hook::{self, HookError, HookResult, HookResultKind, HookStreamResult}, - slot, Project, + slot::{self, Slot, SlotType}, + Project, }; - use std::{collections::HashMap, fs, path::PathBuf, process::exit, time::Instant}; +use tera::{Context, Tera}; use tokio::pin; -use crate::{check, Cli}; - -pub fn run(data: &Vec, overwrite: bool, out_dir: &PathBuf, project: &Project, cli: &Cli) { - // First, run spackle check - check::run(project); - - println!(); - - let data = data +/// Collects all required data (slot and hook data) and prompts the user for any slots or hooks that need data that are not passed via the command line +fn collect_data(flag_data: &Vec, slots: &Vec) -> HashMap { + let mut collected = flag_data .iter() - .filter_map(|data| match data.split_once('=') { + .filter_map(|e| match e.split_once('=') { Some((key, value)) => Some((key.to_string(), value.to_string())), None => { eprintln!( @@ -31,13 +30,81 @@ pub fn run(data: &Vec, overwrite: bool, out_dir: &PathBuf, project: &Pro }) .collect::>(); - let slot_data = data - .clone() - .into_iter() - .filter(|(key, _)| project.config.slots.iter().any(|s| s.key == key.as_str())) - .collect::>(); + // at this point we've collected all the flags, so we should identify + // if any additional slots are needed and if we're in a tty context prompt + // for more slot info before validating + if atty::is(atty::Stream::Stdout) { + let missing_slots: Vec<&Slot> = slots + .iter() + .filter(|slot| !collected.contains_key(&slot.key)) + .collect(); + + missing_slots.iter().for_each(|slot| { + match &slot.r#type { + SlotType::String => { + let input = Input::with_theme(&ColorfulTheme::default()) + .with_prompt(&format!("{} ({})", slot.get_name(), slot.r#type)) + .interact_text() + .unwrap(); + + collected.insert(slot.key.clone(), input); + } + SlotType::Boolean => { + let input = Input::with_theme(&ColorfulTheme::default()) + .with_prompt(&format!("{} ({})", slot.get_name(), slot.r#type)) + .validate_with(|input: &String| -> Result<(), &str> { + // ensure input is a boolean + if input.parse::().is_err() { + return Err("Input must be a boolean".into()); + } + Ok(()) + }) + .interact() + .unwrap(); + + collected.insert(slot.key.clone(), input); + } + SlotType::Number => { + let input = Input::with_theme(&ColorfulTheme::default()) + .with_prompt(&format!("{} ({})", slot.get_name(), slot.r#type)) + .validate_with(|input: &String| -> Result<(), &str> { + if input.parse::().is_err() { + return Err("Input must be a number".into()); + } + Ok(()) + }) + .interact_text() + .unwrap(); - if let Err(e) = slot::validate_data(&slot_data, &project.config.slots) { + collected.insert(slot.key.clone(), input); + } + } + }); + } + + println!(); + + // TODO collect missing hooks + + collected +} + +pub fn run( + data: &Vec, + overwrite: &bool, + out_path: &Option, + project: &Project, + cli: &Cli, +) { + // First, run spackle check + check::run(project); + + println!(""); + + let mut data = collect_data(data, &project.config.slots); + + // TODO filter slot data + if let Err(e) = slot::validate_data(&data, &project.config.slots) { eprintln!( "{}\n{}", "❌ Error with supplied slot data".bright_red(), @@ -59,11 +126,13 @@ pub fn run(data: &Vec, overwrite: bool, out_dir: &PathBuf, project: &Pro exit(1); } - let hook_data = data - .clone() - .into_iter() - .filter(|(key, _)| project.config.hooks.iter().any(|h| h.key == key.as_str())) - .collect::>(); + // TODO filter hook data + + let hook_data: HashMap = data + .iter() + .filter(|(key, _)| project.config.hooks.iter().any(|hook| hook.key == **key)) + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); if let Err(e) = hook::validate_data(&hook_data, &project.config.hooks) { eprintln!( @@ -75,42 +144,71 @@ pub fn run(data: &Vec, overwrite: bool, out_dir: &PathBuf, project: &Pro exit(1); } - let start_time = Instant::now(); - - let mut data = data.clone(); - data.insert("project_name".to_string(), project.get_name()); - - println!("🖨️ Creating project files\n"); + data.insert("_project_name".to_string(), project.get_name()); - println!( - "{}", - format!(" 📁 {}\n", out_dir.to_string_lossy().bold()).dimmed() - ); + let out_path = match &out_path { + Some(path) => path, + None => &Input::with_theme(&ColorfulTheme::default()) + .with_prompt("Enter the output path") + .interact_text() + .map(|p: String| PathBuf::from(p)) + .unwrap_or_else(|e| { + eprintln!("❌ {}", e.to_string().red()); + exit(1); + }), + }; - // Ensure the output directory is not the same as the project directory - if out_dir == &cli.project_dir { - eprintln!( - "{}\n{}", - "❌ Output directory cannot be the same as project directory".bright_red(), - "Please choose a different output directory.".red() - ); - exit(2); - } + println!(""); - if overwrite { + // Ensure the output path doesn't exist + if *overwrite { println!( "{}\n", - format!(" ⚠️ Overwriting existing directory").yellow() + format!("⚠️ Overwriting existing output path").yellow() ); - } else if out_dir.exists() { + } else if out_path.exists() { eprintln!( "{}\n{}", - " ❌ Directory already exists".bright_red(), - " Please remove the directory before running spackle again".red() + "❌ Directory already exists".bright_red(), + "Please remove the directory before running spackle again".red() ); - exit(1); + exit(2); + } + + if cli.project_path.is_dir() { + run_multi(&data, out_path, cli, project); + } else { + run_single(&data, out_path, cli); } +} + +pub fn run_multi(data: &HashMap, out_dir: &PathBuf, cli: &Cli, project: &Project) { + let hook_data: HashMap<&String, bool> = data + .iter() + .filter_map(|(key, value)| match value.parse::() { + Ok(v) => Some((key, v)), + Err(_) => { + eprintln!( + "{} {}\n", + "❌", + "Invalid hook argument, must be a boolean. Skipping.".bright_red() + ); + None + } + }) + .collect(); + + let start_time = Instant::now(); + + let mut data = data.clone(); + data.insert("_project_name".to_string(), project.get_name()); + + println!("🖨️ Creating project files\n"); + println!( + "{}", + format!(" 📁 {}\n", out_dir.to_string_lossy().bold()).dimmed() + ); match project.copy_files(out_dir, &data) { Ok(r) => { @@ -272,8 +370,16 @@ pub fn run(data: &Vec, overwrite: bool, out_dir: &PathBuf, project: &Pro if cli.verbose { if let HookError::CommandExited { stdout, stderr, .. } = error { - eprintln!("\n {}\n{}", "stdout".bold().dimmed(), stdout); - eprintln!(" {}\n{}", "stderr".bold().dimmed(), stderr); + eprintln!( + "\n {}\n{}", + "stdout".bold().dimmed(), + String::from_utf8_lossy(&stdout) + ); + eprintln!( + " {}\n{}", + "stderr".bold().dimmed(), + String::from_utf8_lossy(&stderr) + ); } } @@ -289,8 +395,16 @@ pub fn run(data: &Vec, overwrite: bool, out_dir: &PathBuf, project: &Pro ); if cli.verbose { - println!(" {}\n{}", "stdout".bold().dimmed(), stdout); - println!(" {}\n{}", "stderr".bold().dimmed(), stderr); + println!( + " {}\n{}", + "stdout".bold().dimmed(), + String::from_utf8_lossy(&stdout) + ); + println!( + " {}\n{}", + "stderr".bold().dimmed(), + String::from_utf8_lossy(&stderr) + ); } } HookResult { @@ -306,3 +420,74 @@ pub fn run(data: &Vec, overwrite: bool, out_dir: &PathBuf, project: &Pro } }); } + +pub fn run_single(slot_data: &HashMap, out_path: &PathBuf, cli: &Cli) { + let start_time = Instant::now(); + + let file_contents = match fs::read_to_string(&cli.project_path) { + Ok(o) => o, + Err(e) => { + eprintln!( + "❌ {}\n{}", + "Error reading project file".bright_red(), + e.to_string().red() + ); + exit(1); + } + }; + + let body = match parse_with_engine::(&file_contents) { + Ok(result) => result, + Err(e) => { + eprintln!("❌ {}\n{:#?}", "Error parsing project file".bright_red(), e); + exit(1); + } + } + .body; + + let context = match Context::from_serialize(slot_data) { + Ok(context) => context, + Err(e) => { + eprintln!( + "❌ {}\n{}", + "Error parsing context".bright_red(), + e.to_string().red() + ); + exit(1); + } + }; + + let result = match Tera::one_off(body, &context, false) { + Ok(result) => result, + Err(e) => { + eprintln!( + "❌ {}\n{}", + "Error rendering template".bright_red(), + e.to_string().red() + ); + exit(1); + } + }; + + match fs::write(&out_path, result.clone()) { + Ok(_) => {} + Err(e) => { + eprintln!( + "❌ {}\n{}", + "Error writing output file".bright_red(), + e.to_string().red() + ); + exit(1); + } + } + + println!( + "⛽ Rendered file {}\n {}", + format!("in {:?}", start_time.elapsed()).dimmed(), + out_path.to_string_lossy().bold() + ); + + if cli.verbose { + println!("\n{}\n{}", "contents".dimmed(), result); + } +} diff --git a/cli/src/main.rs b/cli/src/main.rs index fa032bf..51b22d7 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -2,7 +2,6 @@ use clap::{command, Parser, Subcommand}; use colored::Colorize; use spackle::Project; use std::{path::PathBuf, process::exit}; - mod check; mod fill; mod info; @@ -13,9 +12,9 @@ struct Cli { #[command(subcommand)] command: Commands, - /// The directory of the spackle project. Defaults to the current directory. - #[arg(short, long, default_value = ".", global = true)] - project_dir: PathBuf, + /// The spackle project to use (either a directory or a single file). Defaults to the current directory. + #[arg(short = 'p', long = "project", default_value = ".", global = true)] + project_path: PathBuf, /// Whether to run in verbose mode. #[arg(short, long, global = true)] @@ -33,13 +32,13 @@ enum Commands { #[arg(short, long)] data: Vec, - /// The directory to render to. Defaults to 'render' within the current directory. Cannot be the same as the project directory. - #[arg(short, long, default_value = "render", global = true)] - out_dir: PathBuf, - /// Whether to overwrite existing files #[arg(short = 'O', long)] overwrite: bool, + + /// The location the output should be written to. If the project is a single file, this is the output file. If the project is a directory, this is the output directory. + #[arg(short = 'o', long = "out", global = true)] + out_path: Option, }, /// Checks the validity of a spackle project Check, @@ -50,20 +49,7 @@ fn main() { let cli = Cli::parse(); - let project_dir = cli.project_dir.clone(); - - // Check if the project directory is a spackle project - if !project_dir.join("spackle.toml").exists() { - eprintln!( - "{}\n{}", - "❌ Provided directory is not a spackle project".bright_red(), - "Valid projects must have a spackle.toml file.".red() - ); - exit(1); - } - - // Load the config - let project = match spackle::load_project(&project_dir) { + let project = match spackle::load_project(&cli.project_path) { Ok(p) => p, Err(e) => { eprintln!( @@ -79,12 +65,12 @@ fn main() { match &cli.command { Commands::Check => check::run(&project), - Commands::Info {} => info::run(&project.config), + Commands::Info => info::run(&project.config), Commands::Fill { data, - out_dir, overwrite, - } => fill::run(data, *overwrite, &out_dir, &project, &cli), + out_path, + } => fill::run(data, overwrite, out_path, &project, &cli), } } @@ -93,7 +79,7 @@ fn print_project_info(project: &Project) { println!( " {}", - format!("📁 {}", project.dir.to_string_lossy()).dimmed() + format!("📁 {}", project.path.to_string_lossy()).dimmed() ); println!( diff --git a/docs/configuration.md b/docs/configuration.md index e491b81..3cd5b76 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -10,8 +10,8 @@ A spackle project is defined by a `spackle.toml` file at the root directory. Bel Universal slots are available in all slot environments (`.j2` file contents, file names, {s} fields). -- project_name `string` - - The name of the project +- `_project_name` `string` + - The name of the project, defined by the name of the output directory ## Project-level config diff --git a/justfile b/justfile index 0b26234..9471ba7 100644 --- a/justfile +++ b/justfile @@ -1,6 +1,5 @@ setup: lefthook install - cd frontend && bun install run *args="": cargo run -p spackle-cli {{args}} diff --git a/src/config.rs b/src/config.rs index 2e1b10d..dcb086a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,5 +1,6 @@ +use fronma::{engines::Toml, parser::parse_with_engine}; use serde::Deserialize; -use std::{collections::HashSet, fmt::Display, fs, io, path::Path}; +use std::{collections::HashSet, fs, io, path::Path}; use crate::{hook::Hook, slot::Slot}; @@ -18,38 +19,50 @@ pub const CONFIG_FILE: &str = "spackle.toml"; #[derive(Debug)] pub enum Error { - ErrorReading(io::Error), - ErrorParsing(toml::de::Error), + ReadError(io::Error), + ParseError(toml::de::Error), + FronmaError(fronma::error::Error), DuplicateKey(String), } -impl Display for Error { +impl std::fmt::Display for Error { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - Error::ErrorReading(e) => write!(f, "Error reading file\n{}", e), - Error::ErrorParsing(e) => write!(f, "Error parsing contents\n{}", e), - Error::DuplicateKey(key) => write!(f, "Duplicate key: {}", key), + Error::ReadError(e) => write!(f, "Error reading file\n{}", e), + Error::ParseError(e) => write!(f, "Error parsing contents\n{}", e), + Error::FronmaError(e) => write!(f, "Error parsing single file\n{:?}", e), + Error::DuplicateKey(e) => write!(f, "Duplicate keys found\n{}", e), } } } +pub fn load(path: impl AsRef) -> Result { + if path.as_ref().is_dir() { + return load_dir(path); + } + + load_file(path) +} + // Loads the config for the given directory -pub fn load(dir: &Path) -> Result { - let config_path = dir.join(CONFIG_FILE); +pub fn load_dir(dir: impl AsRef) -> Result { + let config_path = dir.as_ref().join(CONFIG_FILE); - let config_str = match fs::read_to_string(config_path) { - Ok(o) => o, - Err(e) => return Err(Error::ErrorReading(e)), - }; + let config_str = fs::read_to_string(config_path).map_err(Error::ReadError)?; - let config = match toml::from_str(&config_str) { - Ok(o) => o, - Err(e) => return Err(Error::ErrorParsing(e)), - }; + let config = toml::from_str(&config_str).map_err(Error::ParseError)?; Ok(config) } +pub fn load_file(file: impl AsRef) -> Result { + let file_contents = fs::read_to_string(file).map_err(Error::ReadError)?; + + parse_with_engine::(&file_contents) + .map(|parsed| parsed.headers) + .map_err(Error::FronmaError) +} + impl Config { pub fn validate(&self) -> Result<(), Error> { let hook_keys: HashSet<&String> = self.hooks.iter().map(|hook| &hook.key).collect(); @@ -97,7 +110,7 @@ mod tests { fs::write(dir.join("spackle.toml"), "").unwrap(); - let result = load(&dir); + let result = load_dir(&dir); assert!(result.is_ok()); } @@ -106,7 +119,7 @@ mod tests { fn dup_key() { let dir = Path::new("tests/data/conf_dup_key"); - let config = load(dir).expect("Expected ok"); + let config = load_dir(dir).expect("Expected ok"); config.validate().expect_err("Expected error"); } diff --git a/src/copy.rs b/src/copy.rs index 8046b0c..a0482b9 100644 --- a/src/copy.rs +++ b/src/copy.rs @@ -210,7 +210,7 @@ mod tests { fs::write( src_dir.join(format!("{}.tmpl", "{{template_name}}")), // copy will not do any replacement so contents should remain as is - "{{project_name}}", + "{{_project_name}}", ) .unwrap(); assert!(src_dir.join("{{template_name}}.tmpl").exists()); @@ -221,7 +221,7 @@ mod tests { &vec![], &HashMap::from([ ("template_name".to_string(), "template".to_string()), - ("project_name".to_string(), "foo".to_string()), + ("_project_name".to_string(), "foo".to_string()), ]), ) .unwrap(); diff --git a/src/hook.rs b/src/hook.rs index 10a4282..7021dd4 100644 --- a/src/hook.rs +++ b/src/hook.rs @@ -142,7 +142,7 @@ pub struct HookResult { #[derive(Serialize, Debug)] pub enum HookResultKind { Skipped(SkipReason), - Completed { stdout: String, stderr: String }, + Completed { stdout: Vec, stderr: Vec }, Failed(HookError), } @@ -159,13 +159,14 @@ impl Display for HookResultKind { } #[derive(Serialize, Debug)] +#[serde(tag = "type")] pub enum HookError { ConditionalFailed(ConditionalError), CommandLaunchFailed(#[serde(skip)] io::Error), CommandExited { exit_code: i32, - stdout: String, - stderr: String, + stdout: Vec, + stderr: Vec, }, } @@ -368,8 +369,8 @@ pub fn run_hooks_stream( hook: hook.clone(), kind: HookResultKind::Failed(HookError::CommandExited { exit_code: output.status.code().unwrap_or(1), - stdout: String::from_utf8_lossy(&output.stdout).to_string(), - stderr: String::from_utf8_lossy(&output.stderr).to_string(), + stdout: output.stdout, + stderr: output.stderr, }), }); continue; @@ -380,8 +381,8 @@ pub fn run_hooks_stream( yield HookStreamResult::HookDone(HookResult { hook: hook.clone(), kind: HookResultKind::Completed { - stdout: String::from_utf8_lossy(&output.stdout).into(), - stderr: String::from_utf8_lossy(&output.stderr).into(), + stdout: output.stdout, + stderr: output.stderr, } }); } @@ -421,7 +422,7 @@ pub fn run_hooks( Ok(results) } -#[derive(Debug)] +#[derive(Serialize, Debug)] pub enum ValidateError { UnknownKey(String), NotOptional(String), @@ -712,11 +713,18 @@ mod tests { #[test] fn templated_cmd() { - let hooks = vec![Hook { - key: "echo".to_string(), - command: vec!["{{ field_1 }}".to_string(), "{{ field_2 }}".to_string()], - ..Hook::default() - }]; + let hooks = vec![ + Hook { + key: "1".to_string(), + command: vec!["{{ field_1 }}".to_string(), "{{ field_2 }}".to_string()], + ..Hook::default() + }, + Hook { + key: "2".to_string(), + command: vec!["echo".to_string(), "{{ _project_name }}".to_string()], + ..Hook::default() + }, + ]; let results = run_hooks( &hooks, @@ -730,13 +738,25 @@ mod tests { ) .expect("run_hooks failed, should have succeeded"); + assert!( + results.iter().all(|x| matches!( + x, + HookResult { + kind: HookResultKind::Completed { .. }, + .. + } + )), + "Expected all hooks to be completed, but got: {:?}", + results + ); + assert!( results.iter().any(|x| match x { HookResult { hook, kind: HookResultKind::Completed { stdout, .. }, .. - } if hook.key == "echo" => stdout.trim() == "test", + } if hook.key == "2" => String::from_utf8_lossy(stdout).trim() == "spackle", _ => false, }), "Hook 'echo' should output 'test', got {:?}", diff --git a/src/lib.rs b/src/lib.rs index cbeed1a..106677b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -14,9 +14,36 @@ mod needs; pub mod slot; pub mod template; -pub struct Project { - pub config: config::Config, - pub dir: PathBuf, +#[derive(Debug)] +pub enum GenerateError { + AlreadyExists(PathBuf), + BadConfig(config::Error), + CopyError(copy::Error), + TemplateError, +} + +impl Display for GenerateError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + GenerateError::AlreadyExists(dir) => { + write!(f, "Directory already exists: {}", dir.display()) + } + GenerateError::BadConfig(e) => write!(f, "Error loading config: {}", e), + GenerateError::TemplateError => write!(f, "Error rendering template"), + GenerateError::CopyError(e) => write!(f, "Error copying files: {}", e), + } + } +} + +impl std::error::Error for GenerateError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + GenerateError::AlreadyExists(_) => None, + GenerateError::BadConfig(_) => None, + GenerateError::TemplateError => None, + GenerateError::CopyError(e) => Some(e), + } + } } #[derive(Debug)] @@ -34,18 +61,23 @@ impl Display for RunHooksError { } } -// Loads the config from the project directory and validates it -pub fn load_project(dir: &PathBuf) -> Result { - let config = config::load(dir)?; +// Loads the project from the specified directory or path and validates it +pub fn load_project(path: &PathBuf) -> Result { + let config = config::load(path)?; config.validate()?; Ok(Project { config, - dir: dir.to_owned(), + path: path.to_owned(), }) } +pub struct Project { + pub config: config::Config, + pub path: PathBuf, +} + impl Project { /// Gets the name of the project or if one isn't specified, from the directory name pub fn get_name(&self) -> String { @@ -53,20 +85,20 @@ impl Project { return name.clone(); } - let path = match self.dir.canonicalize() { + let path = match self.path.canonicalize() { Ok(path) => path, Err(_) => return "".to_string(), }; return path - .file_name() + .file_stem() .unwrap_or_default() .to_string_lossy() .into_owned(); } pub fn validate(&self) -> Result<(), template::ValidateError> { - template::validate(&self.dir, &self.config.slots) + template::validate(&self.path, &self.config.slots) } pub fn copy_files( @@ -75,9 +107,9 @@ impl Project { data: &HashMap, ) -> Result { let mut data = data.clone(); - data.insert("project_name".to_string(), self.get_name()); + data.insert("_project_name".to_string(), self.get_name()); - copy::copy(&self.dir, out_dir, &self.config.ignore, &data) + copy::copy(&self.path, out_dir, &self.config.ignore, &data) } pub fn render_templates( @@ -86,9 +118,9 @@ impl Project { data: &HashMap, ) -> Result>, tera::Error> { let mut data = data.clone(); - data.insert("project_name".to_string(), self.get_name()); + data.insert("_project_name".to_string(), self.get_name()); - template::fill(&self.dir, out_dir, &data) + template::fill(&self.path, out_dir, &data) } /// Runs the hooks in the generated spackle project. @@ -101,7 +133,7 @@ impl Project { run_as_user: Option, ) -> Result, RunHooksError> { let mut data = slot_data.clone(); - data.insert("project_name".to_string(), self.get_name()); + data.insert("_project_name".to_string(), self.get_name()); let result = hook::run_hooks_stream( out_dir.to_owned(), @@ -125,7 +157,7 @@ impl Project { run_as_user: Option, ) -> Result, hook::Error> { let mut data = data.clone(); - data.insert("project_name".to_string(), self.get_name()); + data.insert("_project_name".to_string(), self.get_name()); let result = hook::run_hooks( &self.config.hooks, @@ -154,7 +186,7 @@ mod tests { name: Some("some_name".to_string()), ..Default::default() }, - dir: PathBuf::from("."), + path: PathBuf::from("."), }; assert_eq!(project.get_name(), "some_name"); @@ -164,7 +196,7 @@ mod tests { fn project_get_name_inferred() { let project = Project { config: Config::default(), - dir: PathBuf::from("tests/data/templated"), + path: PathBuf::from("tests/data/templated"), }; assert_eq!(project.get_name(), "templated"); @@ -178,7 +210,7 @@ mod tests { let project = Project { config: Config::default(), - dir: PathBuf::from("."), + path: PathBuf::from("."), }; assert_eq!(project.get_name(), "templated"); diff --git a/src/slot.rs b/src/slot.rs index 983ee99..ee319e2 100644 --- a/src/slot.rs +++ b/src/slot.rs @@ -4,7 +4,7 @@ use std::{collections::HashMap, fmt::Display}; use crate::needs::{is_satisfied, Needy}; -#[derive(Serialize, Deserialize, Debug)] +#[derive(Serialize, Deserialize, Debug, Clone)] pub struct Slot { pub key: String, #[serde(default)] @@ -90,6 +90,12 @@ impl Display for Error { } } +impl Slot { + pub fn get_name(&self) -> String { + self.name.clone().unwrap_or(self.key.clone()) + } +} + pub fn validate_data(data: &HashMap, slots: &Vec) -> Result<(), Error> { for entry in data.iter() { // Check if the data is assigned to a slot diff --git a/src/template.rs b/src/template.rs index afe81d0..edf6f56 100644 --- a/src/template.rs +++ b/src/template.rs @@ -147,7 +147,7 @@ pub fn validate(dir: &PathBuf, slots: &Vec) -> Result<(), ValidateError> { .collect::>(), ) .map_err(ValidateError::TeraError)?; - context.insert("project_name".to_string(), &"".to_string()); + context.insert("_project_name".to_string(), &"".to_string()); let errors = tera .get_template_names() diff --git a/tests/data/proj1/{{project_name}} b/tests/data/proj1/{{_project_name}} similarity index 100% rename from tests/data/proj1/{{project_name}} rename to tests/data/proj1/{{_project_name}} diff --git a/tests/data/project_name/spackle.toml b/tests/data/project_name/spackle.toml new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tests/data/project_name/spackle.toml @@ -0,0 +1 @@ + diff --git a/tests/data/project_name/{{_project_name}}.j2 b/tests/data/project_name/{{_project_name}}.j2 new file mode 100644 index 0000000..7c1d086 --- /dev/null +++ b/tests/data/project_name/{{_project_name}}.j2 @@ -0,0 +1 @@ +Hi my name is {{_project_name}} \ No newline at end of file diff --git a/tests/data/single_file.j2t b/tests/data/single_file.j2t new file mode 100644 index 0000000..c9f799c --- /dev/null +++ b/tests/data/single_file.j2t @@ -0,0 +1,14 @@ +--- +[[slots]] +key = "cmd" +type = "String" +name = "command name" +description = "name of the command" + +[[slots]] +key = "description" +type = "String" +name = "command description" +description = "description of the command" +--- +{{ cmd }} {{ description }} \ No newline at end of file diff --git a/tests/data/templated/file.j2 b/tests/data/templated/file.j2 index 28c7384..e18d6b2 100644 --- a/tests/data/templated/file.j2 +++ b/tests/data/templated/file.j2 @@ -1,2 +1,2 @@ {{ slot_1 }} -{{ project_name }} \ No newline at end of file +{{ _project_name }} \ No newline at end of file diff --git a/tests/data/types/spackle.toml b/tests/data/types/spackle.toml new file mode 100644 index 0000000..016f978 --- /dev/null +++ b/tests/data/types/spackle.toml @@ -0,0 +1,17 @@ +[[slots]] +key = "string_slot" +type = "String" +name = "String slot" +description = "This is a string slot description" + +[[slots]] +key = "number_slot" +type = "Number" +name = "Number slot" +description = "This is a number slot description" + +[[slots]] +key = "boolean_slot" +type = "Boolean" +name = "Boolean slot" +description = "This is a boolean slot description"