use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Deserializer};
use crate::error::{Error, Result};
use crate::probe::Probe;
use crate::resolve::GlobGroup;
pub const CONFIG_DIR: &str = ".mmz";
const CONFIG_NAMES: [&str; 2] = ["config.yaml", "config.yml"];
pub(crate) const fn default_gitignore() -> bool {
true
}
pub(crate) fn default_cache_dir() -> String {
".mmz/cache".to_owned()
}
pub(crate) fn default_probe_shell() -> Vec<String> {
crate::probe::DEFAULT_SHELL
.iter()
.map(|part| (*part).to_owned())
.collect()
}
fn normalize_tags<'de, D>(deserializer: D) -> std::result::Result<Vec<String>, D::Error>
where
D: Deserializer<'de>,
{
let raw = Vec::<String>::deserialize(deserializer)?;
Ok(raw
.into_iter()
.map(|tag| tag.trim().to_owned())
.filter(|tag| !tag.is_empty())
.collect())
}
#[derive(Debug, Clone, Deserialize)]
#[serde(try_from = "ScopeSpelling")]
pub struct Scope {
pub globs: Vec<String>,
pub gitignore: Option<bool>,
}
impl Scope {
#[must_use]
pub fn honours_gitignore(&self, inherited: bool) -> bool {
self.gitignore.unwrap_or(inherited)
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
enum ScopeSpelling {
Patterns(Vec<String>),
Object(ScopeObject),
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
struct ScopeObject {
#[serde(default)]
globs: Option<Vec<String>>,
#[serde(default)]
gitignore: Option<bool>,
}
impl TryFrom<ScopeSpelling> for Scope {
type Error = String;
fn try_from(spelling: ScopeSpelling) -> std::result::Result<Self, Self::Error> {
let object = match spelling {
ScopeSpelling::Patterns(globs) => {
return Ok(Self {
globs,
gitignore: None,
});
}
ScopeSpelling::Object(object) => object,
};
let globs = object
.globs
.ok_or_else(|| "a scope object must list its patterns under `globs`".to_owned())?;
if globs.is_empty() {
return Err("a scope object's `globs` must list at least one pattern".to_owned());
}
Ok(Self {
globs,
gitignore: object.gitignore,
})
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MatchMode {
#[default]
Prefix,
Exact,
}
#[path = "manifest_strict.rs"]
mod strict;
pub use strict::{StrictCase, StrictPolicy};
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Manifest {
#[serde(default)]
pub scopes: BTreeMap<String, Scope>,
#[serde(default)]
pub probes: BTreeMap<String, Probe>,
#[serde(default)]
pub commands: Vec<Command>,
#[serde(default = "default_gitignore")]
pub gitignore: bool,
#[serde(default = "default_cache_dir")]
pub cache_dir: String,
#[serde(default = "StrictPolicy::all")]
pub strict: StrictPolicy,
#[serde(default = "default_probe_shell")]
pub probe_shell: Vec<String>,
#[serde(default)]
pub on_hit: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Command {
pub name: String,
#[serde(default)]
pub inputs: Vec<String>,
#[serde(default)]
pub outputs: Vec<PathBuf>,
#[serde(rename = "match", default)]
pub match_mode: MatchMode,
#[serde(default, deserialize_with = "normalize_tags")]
pub tags: Vec<String>,
#[serde(default)]
pub on_hit: Option<String>,
}
impl Manifest {
pub fn load(path: &Path) -> Result<Self> {
Ok(Located::at(path)?.manifest)
}
pub fn validate(&self) -> Result<()> {
crate::probe::validate(&self.probes, &self.scopes, &self.probe_shell)?;
let mut seen: Vec<&str> = Vec::new();
for (index, command) in self.commands.iter().enumerate() {
if command.name.trim().is_empty() {
return Err(Error::EmptyCommandName(index + 1));
}
if seen.contains(&command.name.as_str()) {
return Err(Error::DuplicateCommand(command.name.clone()));
}
seen.push(command.name.as_str());
if let Some(mac) = crate::parametric::parse(&command.name)? {
if !self.scopes.contains_key(&mac.scope) {
return Err(Error::UnknownScope {
command: command.name.clone(),
scope: mac.scope,
});
}
}
for input in &command.inputs {
if !self.scopes.contains_key(input) && !self.probes.contains_key(input) {
return Err(Error::UnknownInput {
command: command.name.clone(),
input: input.clone(),
});
}
}
crate::outputs::validate(&command.name, &command.outputs)?;
let mut seen_tags: Vec<&str> = Vec::new();
for tag in &command.tags {
if seen_tags.contains(&tag.as_str()) {
return Err(Error::DuplicateTag {
command: command.name.clone(),
tag: tag.clone(),
});
}
seen_tags.push(tag.as_str());
}
}
Ok(())
}
pub fn glob_groups(&self, command: &Command) -> Result<Vec<GlobGroup>> {
let mut honoured: Vec<String> = Vec::new();
let mut opted_out: Vec<String> = Vec::new();
for name in &command.inputs {
let Some(scope) = self.scopes.get(name) else {
if self.probes.contains_key(name) {
continue;
}
return Err(Error::UnknownInput {
command: command.name.clone(),
input: name.clone(),
});
};
let bucket = if scope.honours_gitignore(self.gitignore) {
&mut honoured
} else {
&mut opted_out
};
for glob in &scope.globs {
if !bucket.contains(glob) {
bucket.push(glob.clone());
}
}
}
Ok([(true, honoured), (false, opted_out)]
.into_iter()
.filter(|(_, globs)| !globs.is_empty())
.map(|(gitignore, globs)| GlobGroup { globs, gitignore })
.collect())
}
#[must_use]
pub fn discover(start: &Path) -> Option<PathBuf> {
let mut dir = Some(start);
while let Some(current) = dir {
for name in CONFIG_NAMES {
let candidate = current.join(CONFIG_DIR).join(name);
if candidate.is_file() {
return Some(candidate);
}
}
dir = current.parent();
}
None
}
pub fn locate(cwd: &Path) -> Result<Located> {
let discovered = Self::discover(cwd).ok_or_else(|| Error::NoManifest {
start: cwd.to_path_buf(),
})?;
Located::at(&discovered)
}
}
#[path = "manifest_located.rs"]
mod located;
pub use located::Located;
#[cfg(test)]
#[path = "manifest_tests.rs"]
mod tests;