use crate::{
cli::InitCommandSchemaVersion, config::files::MdsfConfigFiles, error::MdsfError,
terminal::print_config_not_found,
};
mod files;
#[allow(clippy::trivially_copy_pass_by_ref)]
#[inline]
const fn is_false(b: &bool) -> bool {
!(*b)
}
#[allow(clippy::struct_excessive_bools)]
#[derive(serde::Serialize, serde::Deserialize, Hash, Debug, PartialEq, Eq, Default)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct MdsfConfigRunners {
#[serde(default, skip_serializing_if = "is_false")]
pub bunx: bool,
#[serde(default, skip_serializing_if = "is_false")]
pub deno: bool,
#[serde(default, skip_serializing_if = "is_false")]
pub dotnet: bool,
#[serde(default, skip_serializing_if = "is_false")]
pub dub: bool,
#[serde(default, skip_serializing_if = "is_false")]
pub gem_exec: bool,
#[serde(default, skip_serializing_if = "is_false")]
pub npx: bool,
#[serde(default, skip_serializing_if = "is_false")]
pub pipx: bool,
#[serde(default, skip_serializing_if = "is_false")]
pub pnpm: bool,
#[serde(default, skip_serializing_if = "is_false")]
pub uv: bool,
#[serde(default, skip_serializing_if = "is_false")]
pub yarn: bool,
}
impl MdsfConfigRunners {
#[inline]
fn is_default(&self) -> bool {
*self == Self::default()
}
#[inline]
pub const fn all() -> Self {
Self {
bunx: true,
deno: true,
dotnet: true,
dub: true,
gem_exec: true,
npx: true,
pipx: true,
pnpm: true,
uv: true,
yarn: true,
}
}
}
#[derive(
Clone, Copy, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize, Hash, Default,
)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub enum Newline {
#[default]
#[serde(rename = "lf")]
Lf,
#[serde(rename = "cr")]
Cr,
#[serde(rename = "crlf")]
CrLf,
}
pub const LF_NEWLINE_CHAR: char = '\n';
impl Newline {
#[inline]
pub const fn as_str(&self) -> &'static str {
match self {
Self::Lf => "\n",
Self::Cr => "\r",
Self::CrLf => "\r\n",
}
}
#[allow(clippy::trivially_copy_pass_by_ref)]
#[inline]
fn is_default(&self) -> bool {
*self == Self::default()
}
#[inline]
pub fn normalize(self, input: String) -> String {
if self == Self::Lf && !input.contains('\r') {
input
} else {
input.lines().collect::<Vec<_>>().join(self.as_str())
}
}
}
#[derive(Debug, serde::Serialize, serde::Deserialize, Hash, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(untagged)]
pub enum MdsfTool {
Preset(crate::tools::Tooling),
Custom(crate::custom::CustomTool),
}
impl core::fmt::Display for MdsfTool {
#[inline]
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Self::Preset(t) => t.fmt(f),
Self::Custom(t) => t.fmt(f),
}
}
}
#[derive(serde::Serialize, serde::Deserialize, Hash, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct MdsfConfig {
#[serde(rename = "$schema", default = "default_schema_location")]
pub schema: String,
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub custom_file_extensions: std::collections::BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "MdsfConfigFiles::is_default")]
pub files: MdsfConfigFiles,
#[serde(default, skip_serializing_if = "is_false")]
pub format_finished_document: bool,
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub language_aliases: std::collections::BTreeMap<String, String>,
#[serde(default)]
pub languages: std::collections::BTreeMap<String, crate::execution::MdsfToolWrapper<MdsfTool>>,
#[serde(default, skip_serializing_if = "Newline::is_default")]
pub newline: Newline,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub on_missing_language_definition: Option<crate::cli::OnMissingLanguageDefinition>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub on_missing_tool_binary: Option<crate::cli::OnMissingToolBinary>,
#[serde(default, skip_serializing_if = "MdsfConfigRunners::is_default")]
pub runners: MdsfConfigRunners,
}
impl Default for MdsfConfig {
#[inline]
fn default() -> Self {
Self {
schema: default_schema_location(),
custom_file_extensions: std::collections::BTreeMap::default(),
files: MdsfConfigFiles::default(),
format_finished_document: false,
language_aliases: std::collections::BTreeMap::default(),
languages: crate::languages::default_tools(),
newline: Newline::default(),
on_missing_language_definition: None,
on_missing_tool_binary: None,
runners: MdsfConfigRunners::default(),
}
}
}
impl MdsfConfig {
#[inline]
pub const fn supported_file_name() -> [&'static str; 12] {
[
"mdsf.json",
".mdsf.json",
"mdsf.jsonc",
".mdsf.jsonc",
"mdsf.json5",
".mdsf.json5",
"mdsf.toml",
".mdsf.toml",
"mdsf.yml",
".mdsf.yml",
"mdsf.yaml",
".mdsf.yaml",
]
}
#[inline]
pub fn auto_load() -> Result<Self, MdsfError> {
let dir = std::env::current_dir()?;
for name in Self::supported_file_name() {
let path = dir.join(name);
let c = Self::load(path);
if let Err(MdsfError::ConfigNotFound(_)) = c {
continue;
}
return c;
}
print_config_not_found(&dir);
Ok(Self::default())
}
#[inline]
pub fn load(path: impl AsRef<std::path::Path>) -> Result<Self, MdsfError> {
let path = path.as_ref();
let contents = std::fs::read_to_string(path).map_err(|error| {
if error.kind() == std::io::ErrorKind::NotFound {
MdsfError::ConfigNotFound(path.to_path_buf())
} else {
MdsfError::Io(error)
}
})?;
Self::parse(&contents, path)
}
#[inline]
fn parse(input: &str, path: &std::path::Path) -> Result<Self, MdsfError> {
match path.extension().and_then(|ext| ext.to_str()) {
Some("json" | "jsonc" | "json5") => Self::parse_json(input)
.map_err(|err| MdsfError::ConfigParseJson((path.to_path_buf(), err))),
Some("toml") => Self::parse_toml(input)
.map_err(|err| MdsfError::ConfigParseToml((path.to_path_buf(), err))),
Some("yml" | "yaml") => Self::parse_yaml(input)
.map_err(|err| MdsfError::ConfigParseYaml((path.to_path_buf(), err))),
_ => Self::parse_json(input)
.map_err(|_| Self::parse_toml(input))
.map_err(|_| Self::parse_yaml(input))
.map_err(|_| MdsfError::ConfigParseUnknownFormat(path.to_path_buf())),
}
}
#[inline]
fn parse_json(input: &str) -> Result<Self, json5::Error> {
json5::from_str(input)
}
#[inline]
fn parse_toml(input: &str) -> Result<Self, toml::de::Error> {
toml::from_str(input)
}
#[inline]
fn parse_yaml(input: &str) -> Result<Self, serde_yaml::Error> {
serde_yaml::from_str(input)
}
#[inline]
pub fn setup_language_aliases(&mut self) -> Result<(), MdsfError> {
if !self.language_aliases.is_empty() {
let mut seen_languages: std::collections::HashMap<String, String> =
std::collections::HashMap::new();
for (language, alias) in &self.language_aliases {
if let Some(already_set_by) = seen_languages.get(language) {
return Err(MdsfError::LanguageAliasClash(
language.to_owned(),
alias.to_owned(),
already_set_by.to_owned(),
));
}
if self.languages.contains_key(language) {
return Err(MdsfError::LanguageAliasLanguagesContainsLanguage(
language.to_owned(),
));
}
let tools = self
.languages
.get(alias)
.ok_or_else(|| MdsfError::LanguageAliasMissingTools(alias.to_owned()))?;
self.languages.insert(language.to_owned(), tools.clone());
seen_languages.insert(language.to_owned(), alias.to_owned());
}
}
Ok(())
}
#[inline]
pub fn parse_schema_version(&self) -> Option<(&str, bool)> {
let package_version = env!("CARGO_PKG_VERSION");
if self.schema == default_schema_location() {
return Some((package_version, true));
}
let start = "https://raw.githubusercontent.com/hougesen/mdsf/main/schemas/";
let end = "/mdsf.schema.json";
if self.schema.starts_with(start)
&& self.schema.ends_with(end)
&& let Some((_, remaining)) = self.schema.split_once(start)
&& !remaining.is_empty()
&& let Some((version, _)) = remaining.rsplit_once(end)
&& !version.is_empty()
{
return Some((version, version == package_version));
}
None
}
}
#[inline]
pub fn schema_url(v: InitCommandSchemaVersion) -> String {
format!(
"https://raw.githubusercontent.com/hougesen/mdsf/main/schemas/{maybe_v}{package_version}/mdsf.schema.json",
maybe_v = if v == InitCommandSchemaVersion::Locked {
"v"
} else {
""
},
package_version = match v {
InitCommandSchemaVersion::Locked => env!("CARGO_PKG_VERSION"),
InitCommandSchemaVersion::Stable => "stable",
InitCommandSchemaVersion::Development => "development",
}
)
}
#[inline]
fn default_schema_location() -> String {
schema_url(InitCommandSchemaVersion::Locked)
}
#[cfg(test)]
mod test_config {
use super::MdsfConfig;
use crate::{error::MdsfError, execution::setup_snippet};
#[test]
fn schema_should_be_serializable() -> Result<(), serde_json::Error> {
let config = MdsfConfig::default();
let json = serde_json::to_string_pretty(&config)?;
let loaded = serde_json::from_str::<MdsfConfig>(&json)?;
assert_eq!(config, loaded);
Ok(())
}
#[test]
#[cfg(feature = "json-schema")]
fn json_schema_should_be_serializable() -> Result<(), serde_json::Error> {
serde_json::to_string_pretty(&schemars::schema_for!(MdsfConfig))?;
Ok(())
}
#[test]
fn it_should_ignore_comments() -> Result<(), json5::Error> {
let r = r#"{
// this is a slash comment
"javascript": ["prettier"],
"rust": "rustfmt",
/* this is a multiline comment
"roc": {
"enabled": false
}
*/
"go": "gofmt" // hello world
}"#;
MdsfConfig::parse_json(r)?;
Ok(())
}
#[test]
fn test_config_load_works() -> Result<(), Box<dyn core::error::Error>> {
let f = tempfile::Builder::new().rand_bytes(24).tempfile()?;
let default_config = MdsfConfig::default();
std::fs::write(f.path(), serde_json::to_string(&default_config)?)?;
let loaded = MdsfConfig::load(f.path())?;
assert_eq!(default_config, loaded);
Ok(())
}
#[test]
fn test_config_load_return_error_if_not_found() {
let before = MdsfConfig::load(std::path::Path::new(
"ifthispathexiststhereissomethingwrong",
))
.expect_err("Expect it to return file not found");
assert!(matches!(before, MdsfError::ConfigNotFound(_)));
}
#[test]
fn it_should_error_on_broken_config() -> Result<(), std::io::Error> {
let input = "{thisisnotvalidjson}";
let file = setup_snippet(input, ".json")?;
let output = MdsfConfig::load(file.path()).expect_err("it should return an error");
assert!(matches!(output, MdsfError::ConfigParseJson((_, _))));
Ok(())
}
}