mod delete;
mod evoke;
mod imprint;
mod lang;
mod list;
mod version;
pub use delete::*;
pub use evoke::*;
pub use imprint::*;
pub use lang::Language;
pub use list::*;
use version::*;
use crate::config::Config;
use crate::content::RecipeItem;
use crate::fs_wrappers;
use crate::mkdev_error::{Context, Error, Subject};
use crate::warning;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use dirs::data_dir;
use rust_i18n::t;
use serde::{Deserialize, Serialize};
use tempfile::TempDir;
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
pub struct Recipe {
pub name: String,
#[serde(default = "String::new")]
pub description: String,
pub languages: Vec<Language>,
pub contents: Vec<RecipeItem>,
}
impl Recipe {
pub fn shortname(&self) -> &str {
self.name
.rsplit_once("::")
.map(|(_, sn)| sn)
.unwrap_or(&self.name)
}
pub fn namespace(&self) -> Option<&str> {
self.name.rsplit_once("::").map(|(ns, _)| ns)
}
pub fn gather() -> Result<HashMap<String, Recipe>, Error> {
let data_dir = recipe_dir()?;
let files = fs_wrappers::read_dir(data_dir, Context::Gather)?;
let mut recipes: Vec<Recipe> = Vec::new();
for file in files {
let path = file.map_err(|e| crate::borked!(e)).unwrap().path();
let is_toml = path.is_file() && path.extension().is_some_and(|ext| ext == "toml");
if is_toml {
let file_contents = fs_wrappers::read_to_string(&path, Context::Gather)?;
let recipe = deserialise_recipe(&file_contents);
match recipe {
Some(recipe) => {
recipes.push(recipe);
}
None => {
warning!("{}", t!("warnings.invalid_recipe", path => path.display()));
}
}
}
}
let recipes = recipes
.iter()
.map(|r| (r.name.clone(), r.to_owned()))
.collect();
Ok(recipes)
}
pub fn pick<'recipes>(
map: &'recipes HashMap<String, Recipe>,
name: &str,
) -> Result<&'recipes Recipe, Error> {
if let Some(exact) = map.get(name) {
return Ok(exact);
}
if let Some(ns_default) = map.get(&format!("{name}::default")) {
return Ok(ns_default);
}
let potential_matches: Vec<_> = map.values().filter(|r| r.shortname() == name).collect();
if potential_matches.len() > 1 {
return Err(Error::AmbiguousShortRecipe {
query: name.to_string(),
possibilities: potential_matches
.into_iter()
.map(|r| r.name.clone())
.collect(),
});
}
potential_matches
.into_iter()
.next()
.ok_or_else(|| Error::Invalid {
subject: Subject::Recipe,
examples: Some(vec![name.into()]),
})
}
pub fn pick_many<'recipes, S>(
map: &'recipes HashMap<String, Recipe>,
names: &[S],
) -> Result<Vec<&'recipes Recipe>, Error>
where
S: AsRef<str>,
{
let mut out = Vec::with_capacity(names.len());
let mut invalid = Vec::new();
for name in names {
match Self::pick(map, name.as_ref()) {
Ok(r) => out.push(r),
Err(Error::Invalid { .. }) => {
invalid.push(name.as_ref().to_string());
}
Err(e) => return Err(e),
}
}
if !invalid.is_empty() {
return Err(Error::Invalid {
subject: Subject::from_count(invalid.len()),
examples: Some(invalid),
});
}
Ok(out)
}
pub fn replace_content(&mut self, old_name: &Path, new_item: RecipeItem) -> bool {
use crate::set_if_changed;
match self
.contents
.iter_mut()
.find(|item| &item.name() == old_name)
{
Some(item) => match (item, new_item) {
(RecipeItem::File(f), RecipeItem::File(other)) => {
set_if_changed!(f.name, other.name) || set_if_changed!(f.content, other.content)
}
(RecipeItem::Directory(d), RecipeItem::Directory(other)) => {
set_if_changed!(*d, other)
}
_ => false,
},
None => false,
}
}
pub fn languages<P>(dir: P) -> Vec<Language>
where
P: AsRef<Path>,
{
let mut breakdown: Vec<_> = hyperpolyglot::get_language_breakdown(dir)
.iter()
.map(|(lang, files)| (*lang, files.len()))
.collect();
breakdown.sort_by_key(|b| std::cmp::Reverse(b.1));
breakdown
.iter()
.map(|(lang, _)| {
hyperpolyglot::Language::try_from(*lang)
.expect("detected language come pre-validated.")
})
.map(Language::from)
.collect()
}
pub fn materialise(&self, maybe_dir: Option<&Path>) -> Result<TempDir, Error> {
let maybe_temp = match maybe_dir {
Some(ref dir) => tempfile::tempdir_in(dir),
None => tempfile::tempdir(),
};
let temp_dir = maybe_temp.map_err(|_| Error::FsDenied {
which: maybe_dir
.map(|p| p.into())
.unwrap_or_else(std::env::temp_dir),
context: Context::Tempfile,
})?;
instantiate_contents(
temp_dir.path(),
&self.contents,
OnConflict::Overwrite,
false,
)?;
Ok(temp_dir)
}
pub fn dwelling(&self) -> Result<PathBuf, Error> {
Ok(recipe_dir()?.join(format!("{}.toml", self.name)))
}
}
pub fn instantiate_contents(
dir: &Path,
contents: &[RecipeItem],
on_conflict: OnConflict,
verbose: bool,
) -> Result<(), Error> {
contents.iter().try_for_each(|content| {
let dest = dir.join(content.name());
ensure_parent(&dest)?;
if verbose {
eprintln!("{}", &dest.display());
}
match content {
RecipeItem::File(file) => {
match on_conflict {
OnConflict::Guard if dest.is_file() => Err(Error::DestructionWarning {
name: dest.to_string_lossy().into(),
}),
_ => fs_wrappers::write(&dest, &file.content, Context::Evoke),
}
}
RecipeItem::Directory(_) => fs_wrappers::create_dir_all(&dest, Context::Evoke),
}
})
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum OnConflict {
Overwrite,
Guard,
}
fn ensure_parent(path: &Path) -> Result<(), Error> {
let parent = match path.parent() {
Some(p) => p,
None => return Ok(()),
};
if !parent.is_dir() {
fs_wrappers::create_dir_all(parent, Context::Evoke)?;
}
Ok(())
}
fn recipe_dir() -> Result<PathBuf, Error> {
let cfg = Config::get()?;
let data_dir = match &cfg.recipe_dir {
Some(dir) => dir.clone(),
None => {
let mut temp = data_dir().expect("$HOME is not set; cannot determine data directory.");
temp.push("mkdev");
temp
}
};
if !data_dir.is_dir() {
fs_wrappers::create_dir_all(&data_dir, Context::Gather)?;
}
Ok(data_dir)
}