use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::path::{Path, PathBuf};
use serde::Deserialize;
use crate::error::{Error, Result};
pub const CONFIG_DIR: &str = ".mmz";
const CONFIG_NAMES: [&str; 2] = ["config.yaml", "config.yml"];
const fn default_gitignore() -> bool {
true
}
fn default_cache_dir() -> String {
".mmz/cache".to_owned()
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MatchMode {
#[default]
Prefix,
Exact,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum StrictCase {
NoMatch,
NoInputs,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(transparent)]
pub struct StrictPolicy {
cases: BTreeSet<StrictCase>,
}
impl StrictPolicy {
#[must_use]
pub fn all() -> Self {
Self {
cases: [StrictCase::NoMatch, StrictCase::NoInputs]
.into_iter()
.collect(),
}
}
#[must_use]
pub fn enforces(&self, case: StrictCase) -> bool {
self.cases.contains(&case)
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Manifest {
#[serde(default)]
pub scopes: BTreeMap<String, Vec<String>>,
#[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)]
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(rename = "match", default)]
pub match_mode: MatchMode,
#[serde(default)]
pub on_hit: Option<String>,
}
impl Manifest {
pub fn load(path: &Path) -> Result<Self> {
let text = fs::read_to_string(path)?;
let manifest: Self =
serde_yaml_ng::from_str(&text).map_err(|source| Error::ManifestParse {
path: path.to_path_buf(),
source: Box::new(source),
})?;
manifest.validate()?;
Ok(manifest)
}
pub fn validate(&self) -> Result<()> {
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());
for scope in &command.inputs {
if !self.scopes.contains_key(scope) {
return Err(Error::UnknownScope {
command: command.name.clone(),
scope: scope.clone(),
});
}
}
}
Ok(())
}
pub fn globs_for(&self, command: &Command) -> Result<Vec<String>> {
let mut out: Vec<String> = Vec::new();
for scope in &command.inputs {
let globs = self.scopes.get(scope).ok_or_else(|| Error::UnknownScope {
command: command.name.clone(),
scope: scope.clone(),
})?;
for glob in globs {
if !out.contains(glob) {
out.push(glob.clone());
}
}
}
Ok(out)
}
#[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 path = Self::discover(cwd).ok_or_else(|| Error::NoManifest {
start: cwd.to_path_buf(),
})?;
let root = path
.parent()
.and_then(Path::parent)
.ok_or_else(|| Error::Internal("config path has no project root".to_owned()))?
.to_path_buf();
let manifest = Self::load(&path)?;
Ok(Located {
path,
root,
manifest,
})
}
}
pub struct Located {
pub path: PathBuf,
pub root: PathBuf,
pub manifest: Manifest,
}
#[cfg(test)]
mod tests {
use super::{Manifest, MatchMode, StrictCase};
fn parse(text: &str) -> Manifest {
serde_yaml_ng::from_str(text).expect("parse")
}
#[test]
fn cache_dir_defaults_and_overrides() {
assert_eq!(parse("commands: []\n").cache_dir, ".mmz/cache");
assert_eq!(
parse("commands: []\ncache_dir: .cache/mmz\n").cache_dir,
".cache/mmz"
);
}
#[test]
fn match_mode_defaults_to_prefix_and_parses_exact() {
let manifest =
parse("commands:\n - name: cargo test\n - name: cargo build\n match: exact\n");
let prefix = manifest.commands.first().expect("first rule");
let exact = manifest.commands.get(1).expect("second rule");
assert_eq!(prefix.match_mode, MatchMode::Prefix, "default");
assert_eq!(exact.match_mode, MatchMode::Exact, "explicit");
}
#[test]
fn strict_defaults_to_all_cases() {
let manifest = parse("commands: []\n");
assert!(manifest.strict.enforces(StrictCase::NoMatch));
assert!(manifest.strict.enforces(StrictCase::NoInputs));
}
#[test]
fn strict_list_selects_a_subset() {
let manifest = parse("commands: []\nstrict: [no_match]\n");
assert!(manifest.strict.enforces(StrictCase::NoMatch));
assert!(
!manifest.strict.enforces(StrictCase::NoInputs),
"unlisted case relaxed"
);
let none = parse("commands: []\nstrict: []\n");
assert!(!none.strict.enforces(StrictCase::NoMatch));
assert!(!none.strict.enforces(StrictCase::NoInputs));
}
#[test]
fn strict_rejects_unknown_case() {
let parsed: Result<Manifest, _> = serde_yaml_ng::from_str("strict: [bogus]\n");
assert!(parsed.is_err(), "unknown strict case is rejected");
}
#[test]
fn on_hit_parses_global_and_per_command_and_defaults_none() {
let manifest = parse(
"on_hit: \"global note\"\ncommands:\n - name: cargo test\n on_hit: \"rule note\"\n - name: cargo build\n",
);
assert_eq!(manifest.on_hit.as_deref(), Some("global note"));
let overridden = manifest.commands.first().expect("first rule");
let inherits = manifest.commands.get(1).expect("second rule");
assert_eq!(
overridden.on_hit.as_deref(),
Some("rule note"),
"per-command override"
);
assert_eq!(inherits.on_hit, None, "absent per-command on_hit is None");
assert_eq!(
parse("commands: []\n").on_hit,
None,
"absent global on_hit is None"
);
}
#[test]
fn parses_scopes_and_commands() {
let manifest = parse(
"scopes:\n rust: [\"**/*.rs\"]\ncommands:\n - name: cargo test\n inputs: [rust]\n",
);
assert_eq!(manifest.commands.len(), 1);
let command = manifest.commands.first().expect("command");
assert_eq!(command.name, "cargo test");
assert_eq!(
manifest.globs_for(command).expect("globs"),
vec!["**/*.rs".to_owned()]
);
assert!(manifest.gitignore, "gitignore defaults on");
}
#[test]
fn rejects_unknown_fields() {
let parsed: Result<Manifest, _> =
serde_yaml_ng::from_str("scopes: {}\ncommands: []\nbogus: 1\n");
assert!(parsed.is_err(), "unknown top-level fields are rejected");
}
#[test]
fn validate_rejects_blank_and_duplicate_names() {
let blank = parse("commands:\n - name: \" \"\n");
assert!(blank.validate().is_err(), "blank name rejected");
let dup = parse("commands:\n - name: sh\n - name: sh\n");
assert!(dup.validate().is_err(), "duplicate name rejected");
}
#[test]
fn validate_rejects_unknown_scope() {
let manifest = parse("commands:\n - name: sh\n inputs: [ghost]\n");
assert!(manifest.validate().is_err(), "missing scope rejected");
}
#[test]
fn load_validates_from_disk() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("config.yaml");
std::fs::write(&path, "commands:\n - name: sh\n inputs: [ghost]\n").expect("write");
assert!(Manifest::load(&path).is_err(), "load runs validation");
}
fn write_config(root: &std::path::Path, body: &str) -> std::path::PathBuf {
let dir = root.join(".mmz");
std::fs::create_dir_all(&dir).expect("mkdir .mmz");
let path = dir.join("config.yaml");
std::fs::write(&path, body).expect("write config");
path
}
#[test]
fn discovers_walking_upwards() {
let dir = tempfile::tempdir().expect("tempdir");
let nested = dir.path().join("a/b");
std::fs::create_dir_all(&nested).expect("mkdir");
let path = write_config(dir.path(), "commands: []\n");
assert_eq!(Manifest::discover(&nested), Some(path));
}
#[test]
fn locate_roots_at_the_parent_of_dot_mmz() {
let dir = tempfile::tempdir().expect("tempdir");
let nested = dir.path().join("a/b");
std::fs::create_dir_all(&nested).expect("mkdir");
write_config(dir.path(), "commands: []\n");
let located = Manifest::locate(&nested).expect("locate");
assert_eq!(located.root, dir.path(), "root is the parent of .mmz");
assert_eq!(
located.root.join(&located.manifest.cache_dir),
dir.path().join(".mmz/cache"),
"cache_dir resolves under the project root, not inside .mmz",
);
}
}