use std::{
collections::HashMap,
env,
path::{Path, PathBuf},
};
use crate::absolute_path::AbsolutePathExt;
use console::style;
use crate::{
app_config::{AppConfig, FavoriteConfig},
template_variables::CrateType,
GenerateArgs, Vcs,
};
use log::warn;
#[derive(Debug)]
#[cfg(test)]
pub struct UserParsedInputBuilder {
subject: UserParsedInput,
}
#[cfg(test)]
impl UserParsedInputBuilder {
#[cfg(test)]
pub(crate) fn for_testing() -> Self {
use crate::TemplatePath;
Self {
subject: UserParsedInput::try_from_args_and_config(
AppConfig::default(),
&GenerateArgs {
destination: Some(Path::new("/tmp/dest/").to_path_buf()),
template_path: TemplatePath {
path: Some("/tmp".to_string()),
..TemplatePath::default()
},
..GenerateArgs::default()
},
)
.expect("a `--path` template needs no git support"),
}
}
pub const fn with_force(mut self) -> Self {
self.subject.force = true;
self
}
pub fn build(self) -> UserParsedInput {
self.subject
}
}
#[derive(Debug)]
pub struct UserParsedInput {
name: Option<String>,
template_location: Source,
destination: PathBuf,
subfolder: Option<String>,
template_values: HashMap<String, toml::Value>,
vcs: Vcs,
pub init: bool,
overwrite: bool,
crate_type: CrateType,
allow_commands: bool,
silent: bool,
force: bool,
test: bool,
force_git_init: bool,
}
impl UserParsedInput {
pub fn try_from_args_and_config(
app_config: AppConfig,
args: &GenerateArgs,
) -> anyhow::Result<Self> {
#[cfg(feature = "git")]
const DEFAULT_VCS: Vcs = Vcs::Git;
#[cfg(not(feature = "git"))]
const DEFAULT_VCS: Vcs = Vcs::None;
let destination = args
.destination
.as_ref()
.map(|p| {
p.as_absolute()
.expect("cannot get the absolute path of the destination folder")
.to_path_buf()
})
.unwrap_or_else(|| env::current_dir().unwrap_or_else(|_| ".".into()));
let cwd = env::current_dir().unwrap_or_else(|_| ".".into());
let mut default_values = app_config.values.clone().unwrap_or_default();
let ssh_identity = app_config
.defaults
.as_ref()
.and_then(|dcfg| dcfg.ssh_identity.clone())
.or_else(|| {
args.ssh_identity.as_ref().cloned().or_else(|| {
app_config
.defaults
.as_ref()
.and_then(|defaults| defaults.ssh_identity.clone())
})
});
if let Some(git_url) = args.template_path.git() {
let template_location = git_source_from_args(
git_url.as_ref(),
args,
&app_config,
&cwd,
ssh_identity.clone(),
)?;
return Ok(Self {
name: args.name.clone(),
template_location,
subfolder: args
.template_path
.subfolder()
.map(|s| s.as_ref().to_owned()),
template_values: default_values,
vcs: args.vcs.unwrap_or(DEFAULT_VCS),
init: args.init,
overwrite: args.overwrite,
crate_type: CrateType::from(args),
allow_commands: args.allow_commands,
silent: args.silent,
destination,
force: args.force,
test: args.template_path.test,
force_git_init: args.force_git_init,
});
}
if let Some(path) = args.template_path.path() {
return Ok(Self {
name: args.name.clone(),
template_location: path.as_ref().into(),
subfolder: args
.template_path
.subfolder()
.map(|s| s.as_ref().to_owned()),
template_values: default_values,
vcs: args.vcs.unwrap_or(DEFAULT_VCS),
init: args.init,
overwrite: args.overwrite,
crate_type: CrateType::from(args),
allow_commands: args.allow_commands,
silent: args.silent,
destination,
force: args.force,
test: args.template_path.test,
force_git_init: args.force_git_init,
});
}
let fav_name = args.template_path.any_path();
if let Some(fav_cfg) = app_config.get_favorite_cfg(fav_name) {
assert!(fav_cfg.git.is_none() || fav_cfg.path.is_none());
let temp_location = match fav_cfg.git.as_ref() {
Some(git_url) => git_source_from_favorite(git_url, fav_cfg, args, ssh_identity)?,
None => Source::from(fav_cfg.path.as_ref().unwrap()),
};
if let Some(fav_default_values) = &fav_cfg.values {
default_values.extend(fav_default_values.clone());
}
return Ok(Self {
name: args.name.clone(),
template_location: temp_location,
subfolder: args
.template_path
.subfolder()
.map(|s| s.as_ref().to_owned())
.or_else(|| fav_cfg.subfolder.clone()),
template_values: default_values,
vcs: args.vcs.or(fav_cfg.vcs).unwrap_or(DEFAULT_VCS),
init: args
.init
.then_some(true)
.or(fav_cfg.init)
.unwrap_or_default(),
overwrite: args
.overwrite
.then_some(true)
.or(fav_cfg.overwrite)
.unwrap_or_default(),
crate_type: CrateType::from(args),
allow_commands: args.allow_commands,
silent: args.silent,
destination,
force: args.force,
test: args.template_path.test,
force_git_init: args.force_git_init,
});
}
let source = crate::template_source::TemplateSource::classify(fav_name, &app_config, &cwd);
let clone_opts = clone_opts_from_args(args, ssh_identity);
let temp_location = source.into_source(&clone_opts)?;
let location_msg = match &temp_location {
#[cfg(feature = "git")]
Source::Git(git_user_input) => {
format!("git repository: {}", style(git_user_input.url()).bold())
}
Source::Local(path) => {
format!("local path: {}", style(path.display()).bold())
}
};
warn!(
"Favorite `{}` not found in config, using it as a {}",
style(&fav_name).bold(),
location_msg
);
Ok(Self {
name: args.name.clone(),
template_location: temp_location,
subfolder: args
.template_path
.subfolder()
.map(|s| s.as_ref().to_owned()),
template_values: default_values,
vcs: args.vcs.unwrap_or(DEFAULT_VCS),
init: args.init,
overwrite: args.overwrite,
crate_type: CrateType::from(args),
allow_commands: args.allow_commands,
silent: args.silent,
destination,
force: args.force,
test: args.template_path.test,
force_git_init: args.force_git_init,
})
}
pub fn ensure_git_feature_available(&self) -> anyhow::Result<()> {
if self.vcs == Vcs::Git {
crate::git::ensure_available(
"git VCS initialization (`Vcs::Git`, or `vcs` in a favorite's config)",
)?;
}
if self.force_git_init {
crate::git::ensure_available("`GenerateArgs::force_git_init`")?;
}
Ok(())
}
pub fn name(&self) -> Option<&str> {
self.name.as_deref()
}
pub const fn location(&self) -> &Source {
&self.template_location
}
pub fn subfolder(&self) -> Option<&str> {
self.subfolder.as_deref()
}
pub const fn template_values(&self) -> &HashMap<String, toml::Value> {
&self.template_values
}
pub const fn template_values_mut(&mut self) -> &mut HashMap<String, toml::Value> {
&mut self.template_values
}
pub const fn vcs(&self) -> Vcs {
self.vcs
}
pub const fn init(&self) -> bool {
self.init
}
pub const fn overwrite(&self) -> bool {
self.overwrite
}
pub const fn crate_type(&self) -> CrateType {
self.crate_type
}
pub const fn allow_commands(&self) -> bool {
self.allow_commands
}
pub const fn silent(&self) -> bool {
self.silent
}
pub fn destination(&self) -> &Path {
self.destination.as_path()
}
pub const fn force(&self) -> bool {
self.force
}
pub const fn test(&self) -> bool {
self.test
}
pub const fn force_git_init(&self) -> bool {
self.force_git_init
}
}
#[cfg(feature = "git")]
#[derive(Debug)]
pub struct GitSource {
url: String,
branch: Option<String>,
tag: Option<String>,
revision: Option<String>,
identity: Option<PathBuf>,
gitconfig: Option<PathBuf>,
_force_init: bool,
pub skip_submodules: bool,
}
#[cfg(feature = "git")]
impl GitSource {
#[allow(clippy::too_many_arguments)]
fn new(
url: &impl AsRef<str>,
branch: Option<&impl AsRef<str>>,
tag: Option<&impl AsRef<str>>,
revision: Option<&impl AsRef<str>>,
identity: Option<PathBuf>,
gitconfig: Option<PathBuf>,
force_init: bool,
skip_submodules: bool,
) -> Self {
Self {
url: url.as_ref().to_owned(),
branch: branch.map(|s| s.as_ref().to_owned()),
tag: tag.map(|s| s.as_ref().to_owned()),
revision: revision.map(|s| s.as_ref().to_owned()),
identity,
gitconfig,
_force_init: force_init,
skip_submodules,
}
}
pub fn with_url_and_clone_opts(
url: String,
opts: &crate::template_source::CloneOptions,
) -> Self {
Self::new(
&url,
opts.branch.as_ref(),
opts.tag.as_ref(),
opts.revision.as_ref(),
opts.ssh_identity.clone(),
opts.gitconfig.clone(),
opts.force_git_init,
opts.skip_submodules,
)
}
pub fn url(&self) -> &str {
self.url.as_ref()
}
pub fn branch(&self) -> Option<&str> {
self.branch.as_deref()
}
pub fn tag(&self) -> Option<&str> {
self.tag.as_deref()
}
pub fn revision(&self) -> Option<&str> {
self.revision.as_deref()
}
pub fn identity(&self) -> Option<&Path> {
self.identity.as_deref()
}
pub fn gitconfig(&self) -> Option<&Path> {
self.gitconfig.as_deref()
}
}
#[cfg(feature = "git")]
fn git_source_from_args(
git_url: &str,
args: &GenerateArgs,
app_config: &AppConfig,
cwd: &Path,
ssh_identity: Option<PathBuf>,
) -> anyhow::Result<Source> {
let source = crate::template_source::TemplateSource::classify(git_url, app_config, cwd);
let clone_opts = clone_opts_from_args(args, ssh_identity);
source.into_git_source(&clone_opts)
}
#[cfg(not(feature = "git"))]
fn git_source_from_args(
_git_url: &str,
_args: &GenerateArgs,
_app_config: &AppConfig,
_cwd: &Path,
_ssh_identity: Option<PathBuf>,
) -> anyhow::Result<Source> {
Err(crate::git::feature_disabled("`TemplatePath::git`"))
}
#[cfg(feature = "git")]
fn git_source_from_favorite(
git_url: &str,
fav_cfg: &FavoriteConfig,
args: &GenerateArgs,
ssh_identity: Option<PathBuf>,
) -> anyhow::Result<Source> {
let branch = args
.template_path
.branch()
.map(|s| s.as_ref().to_owned())
.or_else(|| fav_cfg.branch.clone());
let tag = args
.template_path
.tag()
.map(|s| s.as_ref().to_owned())
.or_else(|| fav_cfg.tag.clone());
let revision = args
.template_path
.revision()
.map(|s| s.as_ref().to_owned())
.or_else(|| fav_cfg.revision.clone());
Ok(Source::from(GitSource::new(
&git_url,
branch.as_ref(),
tag.as_ref(),
revision.as_ref(),
ssh_identity,
None,
args.force_git_init,
args.skip_submodules,
)))
}
#[cfg(not(feature = "git"))]
fn git_source_from_favorite(
_git_url: &str,
_fav_cfg: &FavoriteConfig,
_args: &GenerateArgs,
_ssh_identity: Option<PathBuf>,
) -> anyhow::Result<Source> {
Err(crate::git::feature_disabled(
"a favorite configured with a `git` URL",
))
}
fn clone_opts_from_args(
args: &GenerateArgs,
ssh_identity: Option<PathBuf>,
) -> crate::template_source::CloneOptions {
crate::template_source::CloneOptions {
branch: args.template_path.branch().map(|s| s.as_ref().to_owned()),
tag: args.template_path.tag().map(|s| s.as_ref().to_owned()),
revision: args.template_path.revision().map(|s| s.as_ref().to_owned()),
ssh_identity,
gitconfig: args.gitconfig.clone(),
force_git_init: args.force_git_init,
skip_submodules: args.skip_submodules,
}
}
#[derive(Debug)]
pub enum Source {
#[cfg(feature = "git")]
Git(GitSource),
Local(PathBuf),
}
#[cfg(feature = "git")]
impl From<GitSource> for Source {
fn from(source: GitSource) -> Self {
Self::Git(source)
}
}
impl From<PathBuf> for Source {
fn from(source: PathBuf) -> Self {
Self::Local(source)
}
}
impl From<&PathBuf> for Source {
fn from(source: &PathBuf) -> Self {
Self::Local(source.clone())
}
}
impl From<&std::path::Path> for Source {
fn from(source: &std::path::Path) -> Self {
Self::Local(PathBuf::from(source))
}
}
impl From<&str> for Source {
fn from(source: &str) -> Self {
Self::Local(PathBuf::from(source))
}
}
impl From<String> for Source {
fn from(source: String) -> Self {
Self::Local(PathBuf::from(source))
}
}
impl From<&String> for Source {
fn from(source: &String) -> Self {
Self::Local(PathBuf::from(source))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(feature = "git")]
fn resolve_git_flag(git_value: &str) -> String {
let args = GenerateArgs {
destination: Some(PathBuf::from("/tmp")),
template_path: crate::TemplatePath {
git: Some(git_value.to_owned()),
..crate::TemplatePath::default()
},
..GenerateArgs::default()
};
let parsed =
UserParsedInput::try_from_args_and_config(AppConfig::default(), &args).unwrap();
match parsed.location() {
Source::Git(git) => git.url().to_owned(),
Source::Local(p) => panic!("expected Git location, got Path: {p:?}"),
}
}
#[cfg(feature = "git")]
#[test]
fn git_flag_full_url() {
assert_eq!(
resolve_git_flag("https://github.com/username-on-github/mytemplate.git"),
"https://github.com/username-on-github/mytemplate.git"
);
}
#[cfg(feature = "git")]
#[test]
fn git_flag_org_repo_shorthand() {
assert_eq!(
resolve_git_flag("username-on-github/mytemplate"),
"https://github.com/username-on-github/mytemplate.git"
);
}
#[cfg(feature = "git")]
#[test]
fn git_flag_gh_prefix() {
assert_eq!(
resolve_git_flag("gh:username-on-github/mytemplate"),
"https://github.com/username-on-github/mytemplate.git"
);
}
#[cfg(feature = "git")]
#[test]
fn git_flag_gl_prefix() {
assert_eq!(
resolve_git_flag("gl:username-on-gitlab/mytemplate"),
"https://gitlab.com/username-on-gitlab/mytemplate.git"
);
}
#[cfg(feature = "git")]
#[test]
fn git_flag_bb_prefix() {
assert_eq!(
resolve_git_flag("bb:username-on-bitbucket/mytemplate"),
"https://bitbucket.org/username-on-bitbucket/mytemplate.git"
);
}
#[cfg(feature = "git")]
#[test]
fn git_flag_sr_prefix() {
assert_eq!(
resolve_git_flag("sr:username-on-sourcehut/mytemplate"),
"https://git.sr.ht/~username-on-sourcehut/mytemplate"
);
}
#[cfg(feature = "git")]
#[test]
fn git_flag_relative_path_resolves_to_local_directory() {
let args = GenerateArgs {
template_path: crate::TemplatePath {
git: Some("./example-templates/hooks".to_owned()),
..crate::TemplatePath::default()
},
..GenerateArgs::default()
};
let parsed =
UserParsedInput::try_from_args_and_config(AppConfig::default(), &args).unwrap();
match parsed.location() {
Source::Git(git) => {
assert!(
git.url().ends_with("example-templates/hooks"),
"expected url ending in example-templates/hooks, got {}",
git.url()
);
}
Source::Local(p) => panic!("expected Git location, got Path: {p:?}"),
}
}
#[cfg(feature = "git")]
#[test]
fn git_flag_local_path_takes_precedence_over_org_repo() {
let args = GenerateArgs {
template_path: crate::TemplatePath {
git: Some("example-templates/hooks".to_owned()),
..crate::TemplatePath::default()
},
..GenerateArgs::default()
};
let parsed =
UserParsedInput::try_from_args_and_config(AppConfig::default(), &args).unwrap();
match parsed.location() {
Source::Git(git) => {
assert!(
git.url().ends_with("example-templates/hooks"),
"expected url ending in example-templates/hooks, got {}",
git.url()
);
}
Source::Local(p) => panic!("expected Git location, got Path: {p:?}"),
}
}
#[cfg(not(feature = "git"))]
#[test]
fn git_url_bails_without_the_git_feature() {
let args = GenerateArgs {
template_path: crate::TemplatePath {
git: Some("https://github.com/o/r.git".into()),
..crate::TemplatePath::default()
},
..GenerateArgs::default()
};
let err = UserParsedInput::try_from_args_and_config(AppConfig::default(), &args)
.unwrap_err()
.to_string();
assert!(
err.contains("TemplatePath::git"),
"names the field that was set: {err}"
);
assert!(err.contains("`git` cargo feature"), "{err}");
}
#[cfg(not(feature = "git"))]
#[test]
fn remote_bare_argument_bails_without_the_git_feature() {
let args = GenerateArgs {
template_path: crate::TemplatePath {
auto_path: Some("https://github.com/o/r.git".into()),
..crate::TemplatePath::default()
},
..GenerateArgs::default()
};
let err = UserParsedInput::try_from_args_and_config(AppConfig::default(), &args)
.unwrap_err()
.to_string();
assert!(err.contains("`git` cargo feature"), "{err}");
assert!(
!err.contains("TemplatePath::git"),
"the caller set auto_path, not git: {err}"
);
}
#[cfg(not(feature = "git"))]
#[test]
fn git_backed_favorite_bails_without_the_git_feature() {
use crate::app_config::FavoriteConfig;
use std::collections::HashMap;
let mut favorites = HashMap::new();
favorites.insert(
"myfave".to_owned(),
FavoriteConfig {
git: Some("https://github.com/o/r.git".to_owned()),
..FavoriteConfig::default()
},
);
let app_config = AppConfig {
favorites: Some(favorites),
..AppConfig::default()
};
let args = GenerateArgs {
template_path: crate::TemplatePath {
favorite: Some("myfave".into()),
..crate::TemplatePath::default()
},
..GenerateArgs::default()
};
let err = UserParsedInput::try_from_args_and_config(app_config, &args)
.unwrap_err()
.to_string();
assert!(err.contains("favorite"), "names the favorite: {err}");
assert!(err.contains("`git` cargo feature"), "{err}");
}
#[cfg(not(feature = "git"))]
#[test]
fn explicit_vcs_git_bails_without_the_git_feature() {
let args = GenerateArgs {
template_path: crate::TemplatePath {
path: Some(".".into()),
..crate::TemplatePath::default()
},
vcs: Some(Vcs::Git),
..GenerateArgs::default()
};
let parsed =
UserParsedInput::try_from_args_and_config(AppConfig::default(), &args).unwrap();
assert!(parsed.ensure_git_feature_available().is_err());
}
#[cfg(not(feature = "git"))]
#[test]
fn force_git_init_bails_without_the_git_feature() {
let args = GenerateArgs {
template_path: crate::TemplatePath {
path: Some(".".into()),
..crate::TemplatePath::default()
},
force_git_init: true,
..Default::default()
};
let parsed =
UserParsedInput::try_from_args_and_config(AppConfig::default(), &args).unwrap();
let err = parsed
.ensure_git_feature_available()
.unwrap_err()
.to_string();
assert!(
err.contains("force_git_init"),
"names the field that was set: {err}"
);
assert!(err.contains("`git` cargo feature"), "{err}");
}
#[cfg(not(feature = "git"))]
#[test]
fn local_path_is_fine_without_the_git_feature() {
let args = GenerateArgs {
template_path: crate::TemplatePath {
path: Some(".".into()),
..crate::TemplatePath::default()
},
..GenerateArgs::default()
};
let parsed =
UserParsedInput::try_from_args_and_config(AppConfig::default(), &args).unwrap();
assert_eq!(parsed.vcs(), Vcs::None, "default VCS degrades to None");
parsed.ensure_git_feature_available().unwrap();
}
#[cfg(feature = "git")]
#[test]
fn git_url_is_fine_with_the_git_feature() {
let args = GenerateArgs {
template_path: crate::TemplatePath {
git: Some("https://github.com/o/r.git".into()),
..crate::TemplatePath::default()
},
..GenerateArgs::default()
};
let parsed =
UserParsedInput::try_from_args_and_config(AppConfig::default(), &args).unwrap();
parsed.ensure_git_feature_available().unwrap();
}
}