pub use crate::command::create::error::Error;
pub use crate::command::create::source::Source;
pub use crate::command::create::types::BlueprintType;
use crate::foundry::FoundryToolchain;
use clap::Args;
use std::collections::HashMap;
use types::BlueprintVariant;
pub mod error;
pub mod source;
pub mod types;
const TANGLE_REQUIRED_KEYS: [&str; 2] = ["project-description", "project-authors"];
#[derive(Debug, Clone, Default, Args)]
pub struct TemplateVariables {
#[arg(long = "gh-username", value_name = "USERNAME")]
pub gh_username: Option<String>,
#[arg(long = "gh-repo", value_name = "REPO")]
pub gh_repo: Option<String>,
#[arg(long = "gh-organization", value_name = "ORG")]
pub gh_organization: Option<String>,
#[arg(long = "project-description", value_name = "TEXT")]
pub project_description: Option<String>,
#[arg(long = "project-authors", value_name = "AUTHORS")]
pub project_authors: Option<String>,
#[arg(long = "project-homepage", value_name = "URL")]
pub project_homepage: Option<String>,
#[arg(long = "flakes", value_name = "BOOL")]
pub flakes: Option<bool>,
#[arg(long = "container", value_name = "BOOL")]
pub container: Option<bool>,
#[arg(long = "base-image", value_name = "IMAGE")]
pub base_image: Option<String>,
#[arg(long = "container-registry", value_name = "REGISTRY")]
pub container_registry: Option<String>,
#[arg(long = "ci", value_name = "BOOL")]
pub ci: Option<bool>,
#[arg(long = "rust-ci", value_name = "BOOL")]
pub rust_ci: Option<bool>,
#[arg(long = "release-ci", value_name = "BOOL")]
pub release_ci: Option<bool>,
}
impl TemplateVariables {
pub fn merge_into(self, define: &mut Vec<String>) {
Self::push("gh-username", self.gh_username, define);
Self::push("gh-repo", self.gh_repo, define);
Self::push("gh-organization", self.gh_organization, define);
Self::push("project-description", self.project_description, define);
Self::push("project-authors", self.project_authors, define);
Self::push("project-homepage", self.project_homepage, define);
Self::push("flakes", self.flakes, define);
Self::push("container", self.container, define);
Self::push("base-image", self.base_image, define);
Self::push("container-registry", self.container_registry, define);
Self::push("ci", self.ci, define);
Self::push("rust-ci", self.rust_ci, define);
Self::push("release-ci", self.release_ci, define);
}
fn push<T: ToString>(key: &str, value: Option<T>, define: &mut Vec<String>) {
if let Some(value) = value {
define.push(format!("{key}={}", value.to_string()));
}
}
}
fn ensure_default_bool(define: &mut Vec<String>, key: &str, default: bool) {
let key_eq = format!("{key}=");
if define.iter().any(|entry| entry.starts_with(&key_eq)) {
return;
}
define.push(format!("{key}={}", default));
}
fn missing_required_template_variables(define: &[String]) -> Vec<&'static str> {
let provided = build_define_map(define);
let mut missing = Vec::new();
for key in &TANGLE_REQUIRED_KEYS {
if !provided.contains_key(*key) {
missing.push(*key);
}
}
missing
}
fn build_define_map(define: &[String]) -> HashMap<String, String> {
let mut map = HashMap::new();
for entry in define {
if let Some((key, value)) = entry.split_once('=') {
map.insert(key.to_string(), value.to_string());
}
}
map
}
const INIT_ALLOWED_ENTRIES: [&str; 2] = [".git", ".gitignore"];
pub(crate) fn ensure_init_dir_is_empty(dir: &std::path::Path) -> Result<(), Error> {
let mut unexpected: Vec<String> = Vec::new();
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let file_name = entry.file_name().to_string_lossy().to_string();
if INIT_ALLOWED_ENTRIES.contains(&file_name.as_str()) {
continue;
}
unexpected.push(file_name);
}
if unexpected.is_empty() {
return Ok(());
}
unexpected.sort();
let shown = unexpected.len().min(5);
let mut listing = unexpected[..shown].join(", ");
if unexpected.len() > shown {
listing.push_str(&format!(" (and {} more)", unexpected.len() - shown));
}
Err(Error::InitDirNotEmpty(dir.display().to_string(), listing))
}
pub fn new_blueprint(
name: &str,
source: Option<Source>,
blueprint_type: Option<BlueprintType>,
mut define: Vec<String>,
template_variables: TemplateVariables,
template_values_file: &Option<String>,
skip_prompts: bool,
init: bool,
) -> Result<(), Error> {
let force_git_init = init && !std::path::Path::new(".git").exists();
if init {
let cwd = std::env::current_dir()?;
println!(
"Generating blueprint in current directory: {}",
cwd.display()
);
ensure_init_dir_is_empty(&cwd)?;
}
println!("Generating blueprint with name: {name}");
let source = source.unwrap_or_default();
let blueprint_variant = blueprint_type.map(|t| t.get_type()).unwrap_or_default();
let template_path_opt: Option<cargo_generate::TemplatePath> = source.into();
let template_path = template_path_opt.unwrap_or_else(|| {
let template_repo: String = match blueprint_variant {
Some(BlueprintVariant::Tangle) | None => {
"https://github.com/tangle-network/blueprint-template".into()
}
};
cargo_generate::TemplatePath {
git: Some(template_repo),
branch: Some(String::from("main")),
..Default::default()
}
});
template_variables.merge_into(&mut define);
ensure_default_bool(&mut define, "flakes", true);
ensure_default_bool(&mut define, "ci", true);
ensure_default_bool(&mut define, "rust-ci", true);
ensure_default_bool(&mut define, "release-ci", true);
if skip_prompts {
println!(
"Skipping prompts; all template variables must be provided via CLI flags when using --skip-prompts."
);
let missing = missing_required_template_variables(&define);
if !missing.is_empty() {
let missing_list = missing.join(", ");
return Err(Error::MissingTemplateVariables(missing_list));
}
} else {
println!("Running in interactive mode - will prompt for template variables as needed");
}
if !define.is_empty() {
println!("Using template variables: {:?}", define);
}
let (silent, template_values_file) = if let Some(file) = &template_values_file {
println!("Using template values file: {}", file);
(true, Some(file.clone()))
} else {
(false, None)
};
let path = cargo_generate::generate(cargo_generate::GenerateArgs {
template_path,
list_favorites: false,
name: Some(name.to_string()),
force: false,
verbose: false,
template_values_file,
silent,
config: None,
vcs: Some(cargo_generate::Vcs::Git),
lib: false,
bin: false,
ssh_identity: None,
gitconfig: None,
define,
init,
destination: None,
force_git_init,
allow_commands: false,
overwrite: false,
skip_submodules: false,
other_args: Option::default(),
continue_on_error: false,
quiet: false,
no_workspace: false,
})
.map_err(Error::GenerationFailed)?;
println!("Blueprint generated at: {}", path.display());
let foundry = FoundryToolchain::new();
if !foundry.forge.is_installed() {
blueprint_core::warn!("Forge not installed, skipping dependencies");
blueprint_core::warn!("NOTE: See <https://getfoundry.sh>");
blueprint_core::warn!(
"NOTE: After installing Forge, you can run `forge soldeer update -d` to install dependencies"
);
return Ok(());
}
std::env::set_current_dir(path)?;
if let Err(e) = foundry.forge.install_dependencies() {
blueprint_core::error!("{e}");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn init_dir_accepts_empty_dir() {
let dir = tempfile::tempdir().unwrap();
assert!(ensure_init_dir_is_empty(dir.path()).is_ok());
}
#[test]
fn init_dir_accepts_git_and_gitignore() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join(".git")).unwrap();
std::fs::write(dir.path().join(".gitignore"), "target\n").unwrap();
assert!(ensure_init_dir_is_empty(dir.path()).is_ok());
}
#[test]
fn init_dir_rejects_populated_dir() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("Cargo.toml"), "[package]\n").unwrap();
let err = ensure_init_dir_is_empty(dir.path()).unwrap_err();
assert!(
matches!(&err, Error::InitDirNotEmpty(_, listing) if listing == "Cargo.toml"),
"unexpected error: {err:?}"
);
}
#[test]
fn init_dir_rejects_dir_next_to_allowed_entries() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join(".git")).unwrap();
std::fs::write(dir.path().join("README.md"), "hi\n").unwrap();
assert!(ensure_init_dir_is_empty(dir.path()).is_err());
}
#[test]
fn init_dir_error_lists_sorted_and_truncates() {
let dir = tempfile::tempdir().unwrap();
for name in ["zeta", "alpha", "beta", "gamma", "delta", "epsilon"] {
std::fs::write(dir.path().join(name), "").unwrap();
}
let Error::InitDirNotEmpty(_, listing) = ensure_init_dir_is_empty(dir.path()).unwrap_err()
else {
panic!("expected InitDirNotEmpty");
};
assert_eq!(listing, "alpha, beta, delta, epsilon, gamma (and 1 more)");
}
#[test]
fn init_dir_does_not_create_the_directory() {
let parent = tempfile::tempdir().unwrap();
let target = parent.path().join("not-yet");
let err = ensure_init_dir_is_empty(&target).unwrap_err();
assert!(matches!(err, Error::Io(_)), "unexpected error: {err:?}");
assert!(!target.exists());
}
}