use std::{
collections::HashMap,
fs, io,
path::{Path, PathBuf},
};
use just_fmt::snake_case;
use just_template::Template;
use mingling::{
Grouped, LazyRes, RenderResult, Routable,
macros::{arg, chain, command, metadata, pack, pack_err, r_println, renderer, routeify},
metadata::Description,
picker::EntryPicker,
res::ResCurrentDir,
};
use crate::{Entry, Next, config::ResMlingConfig, eprintln_cargo, hprintln_cargo, println_cargo};
use super::rule_solver::{
eval_rule, parse_checklist, parse_rules, resolve_answers, validate_mutexes,
};
use super::template_source::{
DEFAULT_TMPL_SOURCE, TemplateSource, cache_dir, normalize_source, resolve_git,
};
const CHECKLIST_FILENAME: &str = "checklist.toml";
const RULE_FILENAME: &str = "rule.toml";
const CACHE_DIR_NAME: &str = "tmpl-cache";
pack!(StateProjectGenerate = ());
pack!(StateProjectChecklistReady = Vec<String>);
#[derive(Debug, Default, Grouped)]
pub struct ResultProjectChecklistReady {
pub checklist: PathBuf,
}
#[derive(Debug, Default, Grouped)]
pub struct ResultProjectGenerate {
pub generated: Vec<PathBuf>,
pub hidden: Vec<PathBuf>,
}
pack_err!(ErrorTemplateNotProvided = ());
pack_err!(ErrorTemplateCopyFailed = String);
pack_err!(ErrorTemplateFetchFailed = String);
pack_err!(ErrorChecklistMissing = String);
pack_err!(ErrorRuleParseFailed = String);
pack_err!(ErrorTemplateExpandFailed = String);
#[command(node = "proj-init", routeify)]
pub fn proj_init(args: Entry, cwd: &ResCurrentDir) -> Next {
if cwd.join(CHECKLIST_FILENAME).exists() {
StateProjectGenerate::new(()).into()
} else {
StateProjectChecklistReady::new(args.inner).into()
}
}
#[chain(routeify)]
pub fn handle_state_proj_checklist_ready(
args: StateProjectChecklistReady,
cwd: &ResCurrentDir,
config: &mut LazyRes<ResMlingConfig>,
) -> Next {
let source: TemplateSource = args
.pick_or_route(&arg![TemplateSource], || {
ErrorTemplateNotProvided::new(()).to_chain()
})
.to_result()?;
let template_root: PathBuf = match source {
TemplateSource::FsDir(dir) => dir,
TemplateSource::Git { reference, variant } => {
let configured = config.get_ref().get("tmpl-source");
let source_url = normalize_source(if configured.is_empty() {
DEFAULT_TMPL_SOURCE
} else {
configured
});
resolve_git(&source_url, &reference, &variant, &cache_dir())
.map_err(ErrorTemplateFetchFailed::new)?
}
};
let tmpl_cache = cwd.join(".mling").join(CACHE_DIR_NAME);
fs::create_dir_all(&tmpl_cache).map_err(|e| {
ErrorTemplateCopyFailed::new(format!("failed to create {}: {e}", tmpl_cache.display()))
})?;
copy_dir_contents(&template_root, &tmpl_cache)
.map_err(|e| ErrorTemplateCopyFailed::new(e.to_string()))?;
let checklist_src = tmpl_cache.join(CHECKLIST_FILENAME);
if !checklist_src.is_file() {
return ErrorChecklistMissing::new(format!(
"no checklist.toml found inside {}",
template_root.display()
))
.to_chain();
}
let checklist_dst = cwd.join(CHECKLIST_FILENAME);
fs::rename(&checklist_src, &checklist_dst).map_err(|e| {
ErrorTemplateCopyFailed::new(format!(
"failed to move checklist.toml to {}: {e}",
checklist_dst.display()
))
})?;
ResultProjectChecklistReady {
checklist: checklist_dst,
}
.to_chain()
}
#[chain(routeify)]
pub fn handle_state_project_generate(_: StateProjectGenerate, cwd: &ResCurrentDir) -> Next {
let tmpl_cache = cwd.join(".mling").join(CACHE_DIR_NAME);
if !tmpl_cache.is_dir() {
return ErrorChecklistMissing::new(format!(
"template cache not found at {}; run `mling proj-init` with a template directory first",
tmpl_cache.display()
))
.to_chain();
}
let checklist_path = cwd.join(CHECKLIST_FILENAME);
let checklist_content = fs::read_to_string(&checklist_path).map_err(|e| {
ErrorChecklistMissing::new(format!("failed to read {}: {e}", checklist_path.display()))
})?;
let answers = parse_checklist(&checklist_content)
.map_err(|e| ErrorRuleParseFailed::new(format!("invalid checklist.toml: {e}")))?;
let rule_content = fs::read_to_string(tmpl_cache.join(RULE_FILENAME))
.map_err(|e| ErrorRuleParseFailed::new(format!("failed to read rule.toml: {e}")))?;
let rules = parse_rules(&rule_content)
.map_err(|e| ErrorRuleParseFailed::new(format!("invalid rule.toml: {e}")))?;
let answers = resolve_answers(&answers, &rules);
validate_mutexes(&answers, &rules).map_err(ErrorRuleParseFailed::new)?;
let mut params: HashMap<String, String> = answers.clone();
if let Some(program_name) = answers.get("program_name") {
params.insert("program_crate_name".to_string(), snake_case!(program_name));
}
for display in &rules.display {
if eval_rule(&display.rule, &answers) {
params.insert(display.name.clone(), String::new());
}
}
let mut generated = Vec::new();
expand_tree(&tmpl_cache, cwd, ¶ms, &mut generated, true)
.map_err(ErrorTemplateExpandFailed::new)?;
let mut hidden = Vec::new();
for hide in &rules.hide_files {
if !eval_rule(&hide.rule, &answers) {
continue;
}
let target = cwd.join(hide.file.trim_start_matches("./"));
remove_path(&target).map_err(|e| {
ErrorTemplateExpandFailed::new(format!("failed to hide {}: {e}", target.display()))
})?;
hidden.push(target);
}
for hide in &rules.hide_dirs {
if !eval_rule(&hide.rule, &answers) {
continue;
}
let target = cwd.join(hide.dir.trim_start_matches("./"));
remove_path(&target).map_err(|e| {
ErrorTemplateExpandFailed::new(format!("failed to hide {}: {e}", target.display()))
})?;
hidden.push(target);
}
fs::remove_dir_all(&tmpl_cache).map_err(|e| {
ErrorTemplateExpandFailed::new(format!("failed to remove {}: {e}", tmpl_cache.display()))
})?;
remove_path(&checklist_path).map_err(|e| {
ErrorTemplateExpandFailed::new(format!(
"failed to remove {}: {e}",
checklist_path.display()
))
})?;
ResultProjectGenerate { generated, hidden }.to_chain()
}
fn copy_dir_contents(src: &Path, dst: &Path) -> io::Result<()> {
fs::create_dir_all(dst)?;
for entry in fs::read_dir(src)? {
let entry = entry?;
let from = entry.path();
let to = dst.join(entry.file_name());
if from.is_dir() {
copy_dir_contents(&from, &to)?;
} else if from.is_file() {
fs::copy(&from, &to)?;
}
}
Ok(())
}
fn expand_tree(
src_root: &Path,
dst_root: &Path,
params: &HashMap<String, String>,
generated: &mut Vec<PathBuf>,
exclude_meta: bool,
) -> Result<(), String> {
for entry in fs::read_dir(src_root).map_err(|e| e.to_string())? {
let entry = entry.map_err(|e| e.to_string())?;
let name = entry.file_name().to_string_lossy().into_owned();
let src = entry.path();
if exclude_meta && (name == RULE_FILENAME || name == CHECKLIST_FILENAME) {
continue;
}
let dst = dst_root.join(&name);
if src.is_dir() {
fs::create_dir_all(&dst).map_err(|e| e.to_string())?;
expand_tree(&src, &dst, params, generated, false)?;
} else if src.is_file() {
let content = fs::read_to_string(&src).map_err(|e| e.to_string())?;
let mut tmpl = Template::from(content);
for (key, value) in params {
tmpl.insert_param(key.clone(), value.clone());
}
let expanded = tmpl
.expand()
.ok_or_else(|| format!("failed to expand template: {}", src.display()))?;
if let Some(parent) = dst.parent() {
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
fs::write(&dst, expanded).map_err(|e| e.to_string())?;
generated.push(dst);
}
}
Ok(())
}
fn remove_path(path: &Path) -> io::Result<()> {
if path.is_dir() {
fs::remove_dir_all(path)
} else if path.is_file() {
fs::remove_file(path)
} else {
Ok(())
}
}
#[renderer]
pub fn render_result_project_checklist_ready(result: ResultProjectChecklistReady) -> RenderResult {
let mut r = RenderResult::new();
r_println!(r, "Template copied.");
r_println!(r, "");
hprintln_cargo!(
r,
"Fill out {} and run `mling proj-init` again to generate the project.",
result.checklist.display()
);
r
}
#[renderer]
pub fn render_result_project_generate(result: ResultProjectGenerate) -> RenderResult {
let mut r = RenderResult::new();
for file in &result.generated {
println_cargo!(r, "Generated: {}", file.display());
}
for file in &result.hidden {
println_cargo!(r, "Hidden: {}", file.display());
}
r
}
#[renderer]
pub fn render_error_template_not_provided(_err: ErrorTemplateNotProvided) -> RenderResult {
let mut r = RenderResult::new();
eprintln_cargo!(
r,
"no template directory provided; pass the path to a mingling template directory"
);
r
}
#[renderer]
pub fn render_error_template_copy_failed(err: ErrorTemplateCopyFailed) -> RenderResult {
let mut r = RenderResult::new();
eprintln_cargo!(r, "failed to copy template: {}", err.info);
r
}
#[renderer]
pub fn render_error_template_fetch_failed(err: ErrorTemplateFetchFailed) -> RenderResult {
let mut r = RenderResult::new();
eprintln_cargo!(r, "failed to fetch template: {}", err.info);
r
}
#[renderer]
pub fn render_error_checklist_missing(err: ErrorChecklistMissing) -> RenderResult {
let mut r = RenderResult::new();
eprintln_cargo!(r, "{}", err.info);
r
}
#[renderer]
pub fn render_error_rule_parse_failed(err: ErrorRuleParseFailed) -> RenderResult {
let mut r = RenderResult::new();
eprintln_cargo!(r, "{}", err.info);
r
}
#[renderer]
pub fn render_error_template_expand_failed(err: ErrorTemplateExpandFailed) -> RenderResult {
let mut r = RenderResult::new();
eprintln_cargo!(r, "{}", err.info);
r
}
#[metadata(EntryProjInit)]
pub fn desc_proj_init() -> Description {
"Guided creation of a Mingling project".into()
}