use std::path::Path;
use serde::Deserialize;
use crate::error::{MindError, Result};
use crate::git::validate_ref_value;
use crate::source::Pin;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HookEvent {
Install,
Uninstall,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Hook {
pub run: String,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub optional: bool,
#[serde(default)]
pub event: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedHook {
pub run: String,
pub name: Option<String>,
pub optional: bool,
pub event: HookEvent,
}
impl ResolvedHook {
pub fn label(&self) -> &str {
match self.name.as_deref() {
Some(n) if !n.trim().is_empty() => n,
_ => &self.run,
}
}
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MindToml {
#[serde(default)]
pub source: SourceMeta,
#[serde(default)]
pub items: Vec<ItemDecl>,
pub discover: Option<Discover>,
#[serde(default)]
pub hooks: Vec<Hook>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SourceMeta {
pub description: Option<String>,
pub install: Option<String>,
pub prefix: Option<String>,
#[serde(rename = "min-mind-version")]
pub min_mind_version: Option<String>,
#[serde(rename = "follow-branch")]
pub follow_branch: Option<String>,
#[serde(rename = "pin-tag")]
pub pin_tag: Option<String>,
#[serde(rename = "pin-ref")]
pub pin_ref: Option<String>,
#[serde(default)]
pub roots: Option<Vec<String>>,
}
impl SourceMeta {
pub fn pin_directive(&self, toml_path: &Path) -> Result<Option<Pin>> {
let mut set: Vec<(&str, Pin)> = Vec::new();
if let Some(b) = &self.follow_branch {
validate_ref_value(b)?;
set.push(("follow-branch", Pin::FollowBranch(b.clone())));
}
if let Some(t) = &self.pin_tag {
validate_ref_value(t)?;
set.push(("pin-tag", Pin::Tag(t.clone())));
}
if let Some(r) = &self.pin_ref {
validate_ref_value(r)?;
set.push(("pin-ref", Pin::Ref(r.clone())));
}
match set.len() {
0 => Ok(None),
1 => Ok(Some(set.remove(0).1)),
_ => {
let names: Vec<&str> = set.iter().map(|(k, _)| *k).collect();
Err(MindError::MindToml {
path: toml_path.to_path_buf(),
msg: format!(
"conflicting pin directives: {}; declare at most one of follow-branch, pin-tag, pin-ref",
names.join(", ")
),
})
}
}
}
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ItemDecl {
pub kind: String,
pub name: String,
pub path: String,
pub link: Option<String>,
pub description: Option<String>,
pub bin: Option<String>,
pub build: Option<String>,
pub install: Option<String>,
pub uninstall: Option<String>,
#[serde(default)]
pub hooks: Vec<Hook>,
}
impl ItemDecl {
pub fn resolved_item_hooks(&self, toml_path: &std::path::Path) -> Result<Vec<ResolvedHook>> {
let mut out: Vec<ResolvedHook> = Vec::new();
for (scalar, event) in [
(&self.install, HookEvent::Install),
(&self.uninstall, HookEvent::Uninstall),
] {
if let Some(cmd) = scalar {
let trimmed = cmd.trim();
if !trimmed.is_empty() {
out.push(ResolvedHook {
run: trimmed.to_owned(),
name: None,
optional: false,
event,
});
}
}
}
out.extend(self.resolved_item_array_hooks(toml_path)?);
Ok(out)
}
fn resolved_item_array_hooks(&self, toml_path: &std::path::Path) -> Result<Vec<ResolvedHook>> {
resolve_hook_array(&self.hooks, toml_path)
}
}
fn resolve_hook_array(hooks: &[Hook], toml_path: &std::path::Path) -> Result<Vec<ResolvedHook>> {
let mut out: Vec<ResolvedHook> = Vec::new();
for hook in hooks {
let run = hook.run.trim();
if run.is_empty() {
continue; }
let event = match hook.event.as_deref() {
None | Some("install") => HookEvent::Install,
Some("uninstall") => HookEvent::Uninstall,
Some(e) => {
return Err(MindError::MindToml {
path: toml_path.to_path_buf(),
msg: format!("unknown hook event '{e}'; expected 'install' or 'uninstall'"),
});
}
};
out.push(ResolvedHook {
run: run.to_owned(),
name: hook.name.clone(),
optional: hook.optional,
event,
});
}
Ok(out)
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Discover {
#[serde(default)]
pub skills: KindGlobs,
#[serde(default)]
pub agents: KindGlobs,
#[serde(default)]
pub rules: KindGlobs,
#[serde(default)]
pub tools: KindGlobs,
#[serde(default)]
pub sources: Vec<NestedSource>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct KindGlobs {
#[serde(default)]
pub include: Vec<String>,
#[serde(default)]
pub exclude: Vec<String>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct NestedSource {
pub source: String,
#[serde(rename = "as", default)]
pub alias: Option<String>,
#[serde(default)]
pub install: bool,
#[serde(rename = "install-items", default)]
pub install_items: Option<Vec<String>>,
#[serde(rename = "follow-branch", default)]
pub follow_branch: Option<String>,
#[serde(rename = "pin-tag", default)]
pub pin_tag: Option<String>,
#[serde(rename = "pin-ref", default)]
pub pin_ref: Option<String>,
#[serde(default)]
pub roots: Option<Vec<String>>,
#[serde(default)]
pub hooks: Vec<Hook>,
}
impl NestedSource {
pub fn validate(&self, toml_path: &Path) -> Result<()> {
if self.install
&& self
.install_items
.as_ref()
.is_some_and(|items| !items.is_empty())
{
return Err(MindError::MindToml {
path: toml_path.to_path_buf(),
msg: format!(
"nested source '{}': install = true and install-items are mutually exclusive; \
use install-items alone to offer a subset, or install = true to offer all",
self.source
),
});
}
Ok(())
}
pub fn pin_directive(&self, toml_path: &Path) -> Result<Option<Pin>> {
let mut set: Vec<(&str, Pin)> = Vec::new();
if let Some(b) = &self.follow_branch {
validate_ref_value(b)?;
set.push(("follow-branch", Pin::FollowBranch(b.clone())));
}
if let Some(t) = &self.pin_tag {
validate_ref_value(t)?;
set.push(("pin-tag", Pin::Tag(t.clone())));
}
if let Some(r) = &self.pin_ref {
validate_ref_value(r)?;
set.push(("pin-ref", Pin::Ref(r.clone())));
}
match set.len() {
0 => Ok(None),
1 => Ok(Some(set.remove(0).1)),
_ => {
let names: Vec<&str> = set.iter().map(|(k, _)| *k).collect();
Err(MindError::MindToml {
path: toml_path.to_path_buf(),
msg: format!(
"nested source '{}': conflicting pin directives: {}; declare at most one of follow-branch, pin-tag, pin-ref",
self.source,
names.join(", ")
),
})
}
}
}
pub fn resolved_hooks(&self, toml_path: &std::path::Path) -> Result<Vec<ResolvedHook>> {
resolve_hook_array(&self.hooks, toml_path)
}
}
impl Discover {
pub fn has_item_globs(&self) -> bool {
!self.skills.include.is_empty()
|| !self.agents.include.is_empty()
|| !self.rules.include.is_empty()
|| !self.tools.include.is_empty()
}
}
pub fn version_at_least(running: &str, required: &str) -> bool {
let parse = |v: &str| -> Vec<u64> {
v.split('.')
.map(|c| c.trim().parse::<u64>().unwrap_or(0))
.collect()
};
let r = parse(running);
let req = parse(required);
for i in 0..r.len().max(req.len()) {
let a = r.get(i).copied().unwrap_or(0);
let b = req.get(i).copied().unwrap_or(0);
if a != b {
return a > b;
}
}
true
}
impl MindToml {
pub fn load(root: &Path) -> Result<Option<MindToml>> {
let file = root.join("mind.toml");
match std::fs::read_to_string(&file) {
Ok(text) => {
let parsed = toml::from_str(&text).map_err(|e| MindError::Toml {
path: file.clone(),
source: e,
})?;
Ok(Some(parsed))
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(MindError::io(&file, e)),
}
}
pub fn is_authoritative(&self) -> bool {
!self.items.is_empty() || self.discover.as_ref().is_some_and(|d| d.has_item_globs())
}
pub fn resolved_hooks(&self, toml_path: &std::path::Path) -> Result<Vec<ResolvedHook>> {
let mut out: Vec<ResolvedHook> = Vec::new();
if let Some(cmd) = &self.source.install {
let trimmed = cmd.trim();
if !trimmed.is_empty() {
out.push(ResolvedHook {
run: trimmed.to_owned(),
name: None,
optional: false,
event: HookEvent::Install,
});
}
}
out.extend(resolve_hook_array(&self.hooks, toml_path)?);
Ok(out)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
#[test]
fn version_comparison_orders_dotted_components() {
assert!(version_at_least("0.2.0", "0.2"));
assert!(version_at_least("0.2", "0.2.0"));
assert!(version_at_least("1.0.0", "0.9.9"));
assert!(version_at_least("0.10.0", "0.9.0"));
assert!(!version_at_least("0.1.0", "0.2"));
assert!(!version_at_least("0.1.0", "0.1.1"));
assert!(version_at_least("0.2.0-rc1", "0.2"));
}
#[test]
fn source_install_hook_parses() {
let toml = r#"
[source]
description = "tools"
install = "make build && make install"
"#;
let parsed: MindToml = toml::from_str(toml).expect("parse");
assert_eq!(
parsed.source.install.as_deref(),
Some("make build && make install")
);
let none: MindToml = toml::from_str("[source]\ndescription = \"x\"\n").unwrap();
assert_eq!(none.source.install, None);
}
#[test]
fn pin_directive_none_when_no_fields_set() {
let meta = SourceMeta::default();
let pin = meta
.pin_directive(Path::new("mind.toml"))
.expect("should not error");
assert!(pin.is_none(), "no directive set => None");
}
#[test]
fn pin_directive_follow_branch() {
let meta = SourceMeta {
follow_branch: Some("develop".into()),
..Default::default()
};
let pin = meta
.pin_directive(Path::new("mind.toml"))
.expect("no error");
assert_eq!(pin, Some(Pin::FollowBranch("develop".into())));
}
#[test]
fn pin_directive_tag() {
let meta = SourceMeta {
pin_tag: Some("v2.0".into()),
..Default::default()
};
let pin = meta
.pin_directive(Path::new("mind.toml"))
.expect("no error");
assert_eq!(pin, Some(Pin::Tag("v2.0".into())));
}
#[test]
fn pin_directive_ref() {
let meta = SourceMeta {
pin_ref: Some("abc123".into()),
..Default::default()
};
let pin = meta
.pin_directive(Path::new("mind.toml"))
.expect("no error");
assert_eq!(pin, Some(Pin::Ref("abc123".into())));
}
#[test]
fn pin_directive_conflict_is_an_error() {
let meta = SourceMeta {
follow_branch: Some("main".into()),
pin_tag: Some("v1.0".into()),
..Default::default()
};
let result = meta.pin_directive(Path::new("/repo/mind.toml"));
assert!(result.is_err(), "conflicting directives must error");
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("conflicting pin"),
"expected 'conflicting pin' in: {err_msg}"
);
}
#[test]
fn hooks_parse_fields_correctly() {
let toml = r#"
[[hooks]]
run = "make build"
name = "Build step"
optional = true
event = "install"
[[hooks]]
run = "make clean"
event = "uninstall"
"#;
let parsed: MindToml = toml::from_str(toml).expect("parse");
assert_eq!(parsed.hooks.len(), 2);
let h0 = &parsed.hooks[0];
assert_eq!(h0.run, "make build");
assert_eq!(h0.name.as_deref(), Some("Build step"));
assert!(h0.optional);
assert_eq!(h0.event.as_deref(), Some("install"));
let h1 = &parsed.hooks[1];
assert_eq!(h1.run, "make clean");
assert_eq!(h1.name, None);
assert!(!h1.optional);
assert_eq!(h1.event.as_deref(), Some("uninstall"));
}
#[test]
fn hooks_preserve_declaration_order() {
let toml = r#"
[[hooks]]
run = "first"
[[hooks]]
run = "second"
[[hooks]]
run = "third"
"#;
let parsed: MindToml = toml::from_str(toml).expect("parse");
let dummy = Path::new("mind.toml");
let resolved = parsed.resolved_hooks(dummy).expect("resolve");
assert_eq!(resolved.len(), 3);
assert_eq!(resolved[0].run, "first");
assert_eq!(resolved[1].run, "second");
assert_eq!(resolved[2].run, "third");
}
#[test]
fn legacy_install_folds_in_as_first_required_install_hook() {
let toml = r#"
[source]
install = "make legacy"
[[hooks]]
run = "npm install"
event = "install"
"#;
let parsed: MindToml = toml::from_str(toml).expect("parse");
let dummy = Path::new("mind.toml");
let resolved = parsed.resolved_hooks(dummy).expect("resolve");
assert_eq!(resolved.len(), 2);
assert_eq!(resolved[0].run, "make legacy");
assert_eq!(resolved[0].event, HookEvent::Install);
assert!(!resolved[0].optional);
assert_eq!(resolved[0].name, None);
assert_eq!(resolved[1].run, "npm install");
assert_eq!(resolved[1].event, HookEvent::Install);
}
#[test]
fn legacy_install_only_no_hooks_table() {
let toml = r#"
[source]
install = "make build && make install"
"#;
let parsed: MindToml = toml::from_str(toml).expect("parse");
let dummy = Path::new("mind.toml");
let resolved = parsed.resolved_hooks(dummy).expect("resolve");
assert_eq!(resolved.len(), 1);
assert_eq!(resolved[0].run, "make build && make install");
assert_eq!(resolved[0].event, HookEvent::Install);
assert!(!resolved[0].optional);
}
#[test]
fn default_event_is_install() {
let toml = r#"
[[hooks]]
run = "setup.sh"
"#;
let parsed: MindToml = toml::from_str(toml).expect("parse");
let dummy = Path::new("mind.toml");
let resolved = parsed.resolved_hooks(dummy).expect("resolve");
assert_eq!(resolved.len(), 1);
assert_eq!(resolved[0].event, HookEvent::Install);
}
#[test]
fn explicit_uninstall_event_resolves() {
let toml = r#"
[[hooks]]
run = "teardown.sh"
event = "uninstall"
"#;
let parsed: MindToml = toml::from_str(toml).expect("parse");
let dummy = Path::new("mind.toml");
let resolved = parsed.resolved_hooks(dummy).expect("resolve");
assert_eq!(resolved.len(), 1);
assert_eq!(resolved[0].event, HookEvent::Uninstall);
}
#[test]
fn unknown_event_returns_mind_toml_error() {
let toml = r#"
[[hooks]]
run = "do-something.sh"
event = "build"
"#;
let parsed: MindToml = toml::from_str(toml).expect("parse");
let toml_path = Path::new("/repo/mind.toml");
let result = parsed.resolved_hooks(toml_path);
assert!(result.is_err(), "unknown event must error");
let err = result.unwrap_err();
match err {
MindError::MindToml { path, msg } => {
assert_eq!(path, toml_path);
assert!(
msg.contains("build"),
"error message should mention the bad value: {msg}"
);
assert!(
msg.contains("install") && msg.contains("uninstall"),
"error message should mention valid values: {msg}"
);
}
other => panic!("expected MindError::MindToml, got: {other:?}"),
}
}
#[test]
fn empty_run_in_hooks_is_dropped() {
let toml = r#"
[[hooks]]
run = ""
[[hooks]]
run = " "
[[hooks]]
run = "real-command"
"#;
let parsed: MindToml = toml::from_str(toml).expect("parse");
let dummy = Path::new("mind.toml");
let resolved = parsed.resolved_hooks(dummy).expect("resolve");
assert_eq!(resolved.len(), 1);
assert_eq!(resolved[0].run, "real-command");
}
#[test]
fn whitespace_legacy_install_contributes_nothing() {
let toml = r#"
[source]
install = " "
[[hooks]]
run = "npm ci"
"#;
let parsed: MindToml = toml::from_str(toml).expect("parse");
let dummy = Path::new("mind.toml");
let resolved = parsed.resolved_hooks(dummy).expect("resolve");
assert_eq!(resolved.len(), 1);
assert_eq!(resolved[0].run, "npm ci");
}
#[test]
fn resolved_hook_label_returns_name_when_set() {
let hook = ResolvedHook {
run: "make build".into(),
name: Some("Build step".into()),
optional: false,
event: HookEvent::Install,
};
assert_eq!(hook.label(), "Build step");
}
#[test]
fn resolved_hook_label_falls_back_to_command() {
let hook = ResolvedHook {
run: "make build".into(),
name: None,
optional: false,
event: HookEvent::Install,
};
assert_eq!(hook.label(), "make build");
}
#[test]
fn label_treats_empty_and_whitespace_name_as_absent() {
let make_hook = |name: Option<&str>| ResolvedHook {
run: "make build".into(),
name: name.map(str::to_owned),
optional: false,
event: HookEvent::Install,
};
assert_eq!(make_hook(Some("Build")).label(), "Build");
assert_eq!(make_hook(Some("")).label(), "make build");
assert_eq!(make_hook(Some(" ")).label(), "make build");
assert_eq!(make_hook(None).label(), "make build");
}
#[test]
fn hooks_whitespace_run_is_trimmed() {
let toml = r#"
[[hooks]]
run = " npm install "
"#;
let parsed: MindToml = toml::from_str(toml).expect("parse");
let dummy = Path::new("mind.toml");
let resolved = parsed.resolved_hooks(dummy).expect("resolve");
assert_eq!(resolved.len(), 1);
assert_eq!(resolved[0].run, "npm install");
}
fn first_item_hooks(toml: &str) -> Result<Vec<ResolvedHook>> {
let parsed: MindToml = toml::from_str(toml).expect("parse");
let item = &parsed.items[0];
item.resolved_item_hooks(Path::new("/repo/mind.toml"))
}
#[test]
fn item_hooks_array_parses_and_preserves_order() {
let toml = r#"
[[items]]
kind = "tool"
name = "helper"
path = "tools/helper"
[[items.hooks]]
run = "first"
name = "Step one"
[[items.hooks]]
run = "second"
optional = true
event = "install"
[[items.hooks]]
run = "teardown"
event = "uninstall"
"#;
let resolved = first_item_hooks(toml).expect("resolve");
assert_eq!(resolved.len(), 3);
assert_eq!(resolved[0].run, "first");
assert_eq!(resolved[0].name.as_deref(), Some("Step one"));
assert!(!resolved[0].optional);
assert_eq!(resolved[0].event, HookEvent::Install);
assert_eq!(resolved[1].run, "second");
assert!(resolved[1].optional);
assert_eq!(resolved[1].event, HookEvent::Install);
assert_eq!(resolved[2].run, "teardown");
assert_eq!(resolved[2].event, HookEvent::Uninstall);
}
#[test]
fn item_scalar_install_uninstall_fold_in_ahead_of_array() {
let toml = r#"
[[items]]
kind = "tool"
name = "helper"
path = "tools/helper"
install = "scalar-install"
uninstall = "scalar-uninstall"
[[items.hooks]]
run = "array-install"
event = "install"
[[items.hooks]]
run = "array-uninstall"
event = "uninstall"
"#;
let resolved = first_item_hooks(toml).expect("resolve");
assert_eq!(resolved.len(), 4);
assert_eq!(resolved[0].run, "scalar-install");
assert_eq!(resolved[0].event, HookEvent::Install);
assert!(!resolved[0].optional);
assert_eq!(resolved[0].name, None);
assert_eq!(resolved[1].run, "scalar-uninstall");
assert_eq!(resolved[1].event, HookEvent::Uninstall);
assert!(!resolved[1].optional);
assert_eq!(resolved[2].run, "array-install");
assert_eq!(resolved[3].run, "array-uninstall");
}
#[test]
fn item_scalar_only_yields_one_required_hook_each() {
let toml = r#"
[[items]]
kind = "rule"
name = "style"
path = "guidelines/style.md"
install = "set-up"
uninstall = "tear-down"
"#;
let resolved = first_item_hooks(toml).expect("resolve");
assert_eq!(resolved.len(), 2);
assert_eq!(resolved[0].run, "set-up");
assert_eq!(resolved[0].event, HookEvent::Install);
assert_eq!(resolved[1].run, "tear-down");
assert_eq!(resolved[1].event, HookEvent::Uninstall);
}
#[test]
fn item_hooks_unknown_event_is_mind_toml_error() {
let toml = r#"
[[items]]
kind = "tool"
name = "helper"
path = "tools/helper"
[[items.hooks]]
run = "do-something"
event = "build"
"#;
let err = first_item_hooks(toml).unwrap_err();
match err {
MindError::MindToml { path, msg } => {
assert_eq!(path, Path::new("/repo/mind.toml"));
assert!(msg.contains("build"), "names the bad value: {msg}");
assert!(
msg.contains("install") && msg.contains("uninstall"),
"names the legal set: {msg}"
);
}
other => panic!("expected MindError::MindToml, got: {other:?}"),
}
}
#[test]
fn item_hooks_empty_run_is_dropped() {
let toml = r#"
[[items]]
kind = "tool"
name = "helper"
path = "tools/helper"
[[items.hooks]]
run = ""
[[items.hooks]]
run = " "
[[items.hooks]]
run = "real-command"
"#;
let resolved = first_item_hooks(toml).expect("resolve");
assert_eq!(resolved.len(), 1);
assert_eq!(resolved[0].run, "real-command");
}
#[test]
fn item_scalar_whitespace_contributes_nothing() {
let toml = r#"
[[items]]
kind = "tool"
name = "helper"
path = "tools/helper"
install = " "
[[items.hooks]]
run = "array-install"
"#;
let resolved = first_item_hooks(toml).expect("resolve");
assert_eq!(resolved.len(), 1);
assert_eq!(resolved[0].run, "array-install");
}
#[test]
fn nested_source_parses_all_curator_fields() {
let toml = r#"
[[discover.sources]]
source = "github:owner/repo"
as = "or"
install = true
follow-branch = "main"
roots = ["packages", "tools"]
[[discover.sources.hooks]]
run = "make setup"
name = "Setup"
optional = true
event = "install"
[[discover.sources.hooks]]
run = "make teardown"
event = "uninstall"
"#;
let parsed: MindToml = toml::from_str(toml).expect("parse");
let ns = &parsed.discover.as_ref().unwrap().sources[0];
assert_eq!(ns.source, "github:owner/repo");
assert_eq!(ns.alias.as_deref(), Some("or"));
assert!(ns.install);
assert_eq!(ns.follow_branch.as_deref(), Some("main"));
assert_eq!(
ns.roots.as_deref(),
Some(&["packages".to_owned(), "tools".to_owned()][..])
);
assert_eq!(ns.hooks.len(), 2);
let h0 = &ns.hooks[0];
assert_eq!(h0.run, "make setup");
assert_eq!(h0.name.as_deref(), Some("Setup"));
assert!(h0.optional);
assert_eq!(h0.event.as_deref(), Some("install"));
let h1 = &ns.hooks[1];
assert_eq!(h1.run, "make teardown");
assert_eq!(h1.event.as_deref(), Some("uninstall"));
}
#[test]
fn nested_source_deny_unknown_fields_still_rejects_typo() {
let toml = r#"
[[discover.sources]]
source = "github:owner/repo"
follow_branch = "main"
"#;
let result: std::result::Result<MindToml, _> = toml::from_str(toml);
assert!(
result.is_err(),
"underscore variant of follow-branch must be rejected"
);
}
#[test]
fn nested_source_unknown_top_level_key_rejected() {
let toml = r#"
[[discover.sources]]
source = "github:owner/repo"
typo-key = "value"
"#;
let result: std::result::Result<MindToml, _> = toml::from_str(toml);
assert!(result.is_err(), "unknown keys must be rejected");
}
#[test]
fn nested_source_pin_directive_returns_follow_branch_pin() {
let ns = NestedSource {
source: "x".into(),
alias: None,
install: false,
install_items: None,
follow_branch: Some("develop".into()),
pin_tag: None,
pin_ref: None,
roots: None,
hooks: vec![],
};
assert_eq!(
ns.pin_directive(Path::new("mind.toml")).expect("no error"),
Some(Pin::FollowBranch("develop".into()))
);
}
#[test]
fn nested_source_pin_directive_none_when_unset() {
let ns = NestedSource {
source: "x".into(),
alias: None,
install: false,
install_items: None,
follow_branch: None,
pin_tag: None,
pin_ref: None,
roots: None,
hooks: vec![],
};
assert!(
ns.pin_directive(Path::new("mind.toml"))
.expect("no error")
.is_none()
);
}
#[test]
fn nested_source_resolved_hooks_order_preserved() {
let toml = r#"
[[discover.sources]]
source = "github:owner/repo"
[[discover.sources.hooks]]
run = "first"
[[discover.sources.hooks]]
run = "second"
[[discover.sources.hooks]]
run = "third"
"#;
let parsed: MindToml = toml::from_str(toml).expect("parse");
let ns = &parsed.discover.as_ref().unwrap().sources[0];
let resolved = ns.resolved_hooks(Path::new("mind.toml")).expect("resolve");
assert_eq!(resolved.len(), 3);
assert_eq!(resolved[0].run, "first");
assert_eq!(resolved[1].run, "second");
assert_eq!(resolved[2].run, "third");
}
#[test]
fn nested_source_resolved_hooks_default_event_is_install() {
let toml = r#"
[[discover.sources]]
source = "github:owner/repo"
[[discover.sources.hooks]]
run = "setup.sh"
"#;
let parsed: MindToml = toml::from_str(toml).expect("parse");
let ns = &parsed.discover.as_ref().unwrap().sources[0];
let resolved = ns.resolved_hooks(Path::new("mind.toml")).expect("resolve");
assert_eq!(resolved.len(), 1);
assert_eq!(resolved[0].event, HookEvent::Install);
}
#[test]
fn nested_source_resolved_hooks_explicit_uninstall() {
let toml = r#"
[[discover.sources]]
source = "github:owner/repo"
[[discover.sources.hooks]]
run = "teardown.sh"
event = "uninstall"
"#;
let parsed: MindToml = toml::from_str(toml).expect("parse");
let ns = &parsed.discover.as_ref().unwrap().sources[0];
let resolved = ns.resolved_hooks(Path::new("mind.toml")).expect("resolve");
assert_eq!(resolved.len(), 1);
assert_eq!(resolved[0].event, HookEvent::Uninstall);
}
#[test]
fn nested_source_resolved_hooks_unknown_event_is_mind_toml_error() {
let toml = r#"
[[discover.sources]]
source = "github:owner/repo"
[[discover.sources.hooks]]
run = "do-something"
event = "build"
"#;
let parsed: MindToml = toml::from_str(toml).expect("parse");
let ns = &parsed.discover.as_ref().unwrap().sources[0];
let toml_path = Path::new("/repo/mind.toml");
let err = ns.resolved_hooks(toml_path).unwrap_err();
match err {
MindError::MindToml { path, msg } => {
assert_eq!(path, toml_path);
assert!(msg.contains("build"), "names the bad value: {msg}");
assert!(
msg.contains("install") && msg.contains("uninstall"),
"names the legal set: {msg}"
);
}
other => panic!("expected MindError::MindToml, got: {other:?}"),
}
}
#[test]
fn nested_source_resolved_hooks_blank_run_dropped() {
let toml = r#"
[[discover.sources]]
source = "github:owner/repo"
[[discover.sources.hooks]]
run = ""
[[discover.sources.hooks]]
run = " "
[[discover.sources.hooks]]
run = "real-command"
"#;
let parsed: MindToml = toml::from_str(toml).expect("parse");
let ns = &parsed.discover.as_ref().unwrap().sources[0];
let resolved = ns.resolved_hooks(Path::new("mind.toml")).expect("resolve");
assert_eq!(resolved.len(), 1);
assert_eq!(resolved[0].run, "real-command");
}
#[test]
fn nested_source_resolved_hooks_run_trimmed() {
let toml = r#"
[[discover.sources]]
source = "github:owner/repo"
[[discover.sources.hooks]]
run = " npm install "
"#;
let parsed: MindToml = toml::from_str(toml).expect("parse");
let ns = &parsed.discover.as_ref().unwrap().sources[0];
let resolved = ns.resolved_hooks(Path::new("mind.toml")).expect("resolve");
assert_eq!(resolved.len(), 1);
assert_eq!(resolved[0].run, "npm install");
}
#[test]
fn nested_source_no_legacy_install_fold_in() {
let ns = NestedSource {
source: "x".into(),
alias: None,
install: false,
install_items: None,
follow_branch: None,
pin_tag: None,
pin_ref: None,
roots: None,
hooks: vec![Hook {
run: "array-only".into(),
name: None,
optional: false,
event: None,
}],
};
let resolved = ns.resolved_hooks(Path::new("mind.toml")).expect("resolve");
assert_eq!(resolved.len(), 1);
assert_eq!(resolved[0].run, "array-only");
}
#[test]
fn nested_source_follow_branch_pin_directive_is_always_follow_branch_variant() {
for branch in ["main", "develop", "v1", "release/2.0", "abc123"] {
let ns = NestedSource {
source: "x".into(),
alias: None,
install: false,
install_items: None,
follow_branch: Some(branch.into()),
pin_tag: None,
pin_ref: None,
roots: None,
hooks: vec![],
};
match ns.pin_directive(Path::new("mind.toml")).expect("no error") {
Some(Pin::FollowBranch(b)) => assert_eq!(b, branch),
other => panic!("expected Pin::FollowBranch({branch:?}); got {other:?}"),
}
}
}
#[test]
fn multiple_nested_sources_keep_independent_hook_arrays() {
let toml = r#"
[[discover.sources]]
source = "github:owner/first"
[[discover.sources.hooks]]
run = "first-hook"
[[discover.sources]]
source = "github:owner/second"
[[discover.sources.hooks]]
run = "second-hook"
"#;
let parsed: MindToml = toml::from_str(toml).expect("parse");
let sources = &parsed.discover.as_ref().unwrap().sources;
assert_eq!(sources.len(), 2);
let dummy = Path::new("mind.toml");
let first = sources[0].resolved_hooks(dummy).expect("resolve");
let second = sources[1].resolved_hooks(dummy).expect("resolve");
assert_eq!(first.len(), 1, "first entry sees only its own hook");
assert_eq!(first[0].run, "first-hook");
assert_eq!(second.len(), 1, "second entry sees only its own hook");
assert_eq!(second[0].run, "second-hook");
assert!(
first.iter().all(|h| h.run != "second-hook"),
"second entry's hook leaked into the first"
);
assert!(
second.iter().all(|h| h.run != "first-hook"),
"first entry's hook leaked into the second"
);
}
#[test]
fn nested_source_explicit_empty_roots_is_some_empty_not_none() {
let explicit = r#"
[[discover.sources]]
source = "github:owner/repo"
roots = []
"#;
let parsed: MindToml = toml::from_str(explicit).expect("parse");
let ns = &parsed.discover.as_ref().unwrap().sources[0];
assert_eq!(
ns.roots.as_deref(),
Some(&[][..]),
"explicit roots = [] must be Some(empty), not None"
);
let unset = r#"
[[discover.sources]]
source = "github:owner/repo"
"#;
let parsed: MindToml = toml::from_str(unset).expect("parse");
let ns = &parsed.discover.as_ref().unwrap().sources[0];
assert!(
ns.roots.is_none(),
"unset roots must be None, distinct from the empty list"
);
}
#[test]
fn install_items_absent_parses_to_none() {
let toml = r#"
[[discover.sources]]
source = "github:owner/repo"
install = true
"#;
let parsed: MindToml = toml::from_str(toml).expect("parse");
let ns = &parsed.discover.as_ref().unwrap().sources[0];
assert!(
ns.install_items.is_none(),
"absent install-items must be None"
);
assert!(ns.install, "install = true must still parse");
}
#[test]
fn install_items_empty_list_parses_to_some_empty() {
let toml = r#"
[[discover.sources]]
source = "github:owner/repo"
install-items = []
"#;
let parsed: MindToml = toml::from_str(toml).expect("parse");
let ns = &parsed.discover.as_ref().unwrap().sources[0];
assert_eq!(
ns.install_items.as_deref(),
Some(&[][..]),
"install-items = [] must be Some(empty)"
);
}
#[test]
fn install_items_non_empty_list_parses_correctly() {
let toml = r#"
[[discover.sources]]
source = "github:owner/repo"
install-items = ["skill:review", "agent:dev"]
"#;
let parsed: MindToml = toml::from_str(toml).expect("parse");
let ns = &parsed.discover.as_ref().unwrap().sources[0];
let items = ns.install_items.as_deref().expect("must be Some");
assert_eq!(items.len(), 2);
assert_eq!(items[0], "skill:review");
assert_eq!(items[1], "agent:dev");
}
#[test]
fn install_items_three_way_distinction() {
let absent: MindToml = toml::from_str("[[discover.sources]]\nsource = \"x\"\n").unwrap();
let empty: MindToml =
toml::from_str("[[discover.sources]]\nsource = \"x\"\ninstall-items = []\n").unwrap();
let nonempty: MindToml = toml::from_str(
"[[discover.sources]]\nsource = \"x\"\ninstall-items = [\"skill:foo\"]\n",
)
.unwrap();
let ns_absent = &absent.discover.as_ref().unwrap().sources[0];
let ns_empty = &empty.discover.as_ref().unwrap().sources[0];
let ns_nonempty = &nonempty.discover.as_ref().unwrap().sources[0];
assert!(ns_absent.install_items.is_none(), "absent => None");
assert_eq!(
ns_empty.install_items.as_deref(),
Some(&[][..]),
"empty => Some([])"
);
assert_eq!(
ns_nonempty.install_items.as_deref().unwrap().len(),
1,
"non-empty => Some([..])"
);
}
#[test]
fn install_true_with_nonempty_install_items_is_mind_toml_error() {
let ns = NestedSource {
source: "github:owner/repo".into(),
alias: None,
install: true,
install_items: Some(vec!["skill:review".into()]),
follow_branch: None,
pin_tag: None,
pin_ref: None,
roots: None,
hooks: vec![],
};
let toml_path = Path::new("/super/mind.toml");
let err = ns.validate(toml_path).unwrap_err();
match err {
MindError::MindToml { path, msg } => {
assert_eq!(path, toml_path);
assert!(
msg.contains("mutually exclusive"),
"error must say mutually exclusive: {msg}"
);
assert!(
msg.contains("github:owner/repo"),
"error must name the source: {msg}"
);
}
other => panic!("expected MindError::MindToml, got: {other:?}"),
}
}
#[test]
fn install_true_with_empty_install_items_is_ok() {
let ns = NestedSource {
source: "github:owner/repo".into(),
alias: None,
install: true,
install_items: Some(vec![]), follow_branch: None,
pin_tag: None,
pin_ref: None,
roots: None,
hooks: vec![],
};
assert!(
ns.validate(Path::new("mind.toml")).is_ok(),
"install = true + install-items = [] must not error (both say 'install nothing')"
);
}
#[test]
fn install_false_with_nonempty_install_items_is_ok() {
let ns = NestedSource {
source: "github:owner/repo".into(),
alias: None,
install: false,
install_items: Some(vec!["skill:review".into(), "agent:dev".into()]),
follow_branch: None,
pin_tag: None,
pin_ref: None,
roots: None,
hooks: vec![],
};
assert!(
ns.validate(Path::new("mind.toml")).is_ok(),
"install-items alone (install = false) must not error"
);
}
#[test]
fn nested_pin_directive_none_when_no_pin_set() {
let toml = "[[discover.sources]]\nsource = \"github:owner/repo\"\n";
let parsed: MindToml = toml::from_str(toml).expect("parse");
let ns = &parsed.discover.as_ref().unwrap().sources[0];
let pin = ns
.pin_directive(Path::new("mind.toml"))
.expect("no conflict");
assert!(pin.is_none(), "no pin set => None");
}
#[test]
fn nested_pin_directive_follow_branch() {
let toml =
"[[discover.sources]]\nsource = \"github:owner/repo\"\nfollow-branch = \"main\"\n";
let parsed: MindToml = toml::from_str(toml).expect("parse");
let ns = &parsed.discover.as_ref().unwrap().sources[0];
let pin = ns
.pin_directive(Path::new("mind.toml"))
.expect("no conflict");
assert_eq!(pin, Some(Pin::FollowBranch("main".into())));
}
#[test]
fn nested_pin_directive_pin_tag_parses_and_resolves() {
let toml = "[[discover.sources]]\nsource = \"github:owner/repo\"\npin-tag = \"v2.0\"\n";
let parsed: MindToml = toml::from_str(toml).expect("parse");
let ns = &parsed.discover.as_ref().unwrap().sources[0];
let pin = ns
.pin_directive(Path::new("mind.toml"))
.expect("no conflict");
assert_eq!(pin, Some(Pin::Tag("v2.0".into())));
}
#[test]
fn nested_pin_directive_pin_ref_parses_and_resolves() {
let sha = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
let toml =
format!("[[discover.sources]]\nsource = \"github:owner/repo\"\npin-ref = \"{sha}\"\n");
let parsed: MindToml = toml::from_str(&toml).expect("parse");
let ns = &parsed.discover.as_ref().unwrap().sources[0];
let pin = ns
.pin_directive(Path::new("mind.toml"))
.expect("no conflict");
assert_eq!(pin, Some(Pin::Ref(sha.into())));
}
#[test]
fn nested_pin_directive_conflict_follow_branch_and_pin_tag_is_error() {
let toml = "[[discover.sources]]\nsource = \"github:owner/repo\"\nfollow-branch = \"main\"\npin-tag = \"v1\"\n";
let parsed: MindToml = toml::from_str(toml).expect("parse");
let ns = &parsed.discover.as_ref().unwrap().sources[0];
let result = ns.pin_directive(Path::new("/super/mind.toml"));
assert!(result.is_err(), "conflicting pin directives must error");
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("conflicting pin"),
"error must mention 'conflicting pin': {err_msg}"
);
}
#[test]
fn nested_pin_directive_conflict_all_three_is_error() {
let toml = "[[discover.sources]]\nsource = \"github:owner/repo\"\nfollow-branch = \"main\"\npin-tag = \"v1\"\npin-ref = \"abc123\"\n";
let parsed: MindToml = toml::from_str(toml).expect("parse");
let ns = &parsed.discover.as_ref().unwrap().sources[0];
let result = ns.pin_directive(Path::new("/super/mind.toml"));
assert!(result.is_err(), "three pin directives must error");
}
#[test]
fn source_meta_pin_rejects_leading_dash_in_follow_branch() {
let meta = SourceMeta {
follow_branch: Some("--upload-pack=touch /tmp/pwned".into()),
..Default::default()
};
let err = meta.pin_directive(Path::new("mind.toml")).unwrap_err();
assert!(
matches!(err, crate::error::MindError::InvalidRef { .. }),
"expected InvalidRef, got: {err}"
);
}
#[test]
fn source_meta_pin_rejects_leading_dash_in_pin_tag() {
let meta = SourceMeta {
pin_tag: Some("-malicious".into()),
..Default::default()
};
let err = meta.pin_directive(Path::new("mind.toml")).unwrap_err();
assert!(
matches!(err, crate::error::MindError::InvalidRef { .. }),
"expected InvalidRef, got: {err}"
);
}
#[test]
fn source_meta_pin_rejects_leading_dash_in_pin_ref() {
let meta = SourceMeta {
pin_ref: Some("--no-tags".into()),
..Default::default()
};
let err = meta.pin_directive(Path::new("mind.toml")).unwrap_err();
assert!(
matches!(err, crate::error::MindError::InvalidRef { .. }),
"expected InvalidRef, got: {err}"
);
}
#[test]
fn source_meta_pin_rejects_empty_follow_branch() {
let meta = SourceMeta {
follow_branch: Some("".into()),
..Default::default()
};
let err = meta.pin_directive(Path::new("mind.toml")).unwrap_err();
assert!(
matches!(err, crate::error::MindError::InvalidRef { .. }),
"expected InvalidRef, got: {err}"
);
}
#[test]
fn source_meta_pin_accepts_valid_values() {
for (field, pin) in [
("follow-branch", "main"),
("pin-tag", "v2.0"),
("pin-ref", "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"),
] {
let meta = match field {
"follow-branch" => SourceMeta {
follow_branch: Some(pin.into()),
..Default::default()
},
"pin-tag" => SourceMeta {
pin_tag: Some(pin.into()),
..Default::default()
},
_ => SourceMeta {
pin_ref: Some(pin.into()),
..Default::default()
},
};
assert!(
meta.pin_directive(Path::new("mind.toml")).is_ok(),
"expected ok for {field}={pin:?}"
);
}
}
#[test]
fn nested_source_pin_rejects_leading_dash_in_follow_branch() {
let ns = NestedSource {
source: "x".into(),
alias: None,
install: false,
install_items: None,
follow_branch: Some("--upload-pack=touch /tmp/pwned".into()),
pin_tag: None,
pin_ref: None,
roots: None,
hooks: vec![],
};
let err = ns.pin_directive(Path::new("mind.toml")).unwrap_err();
assert!(
matches!(err, crate::error::MindError::InvalidRef { .. }),
"expected InvalidRef, got: {err}"
);
}
#[test]
fn nested_source_pin_rejects_leading_dash_in_pin_tag() {
let ns = NestedSource {
source: "x".into(),
alias: None,
install: false,
install_items: None,
follow_branch: None,
pin_tag: Some("-evil".into()),
pin_ref: None,
roots: None,
hooks: vec![],
};
let err = ns.pin_directive(Path::new("mind.toml")).unwrap_err();
assert!(
matches!(err, crate::error::MindError::InvalidRef { .. }),
"expected InvalidRef, got: {err}"
);
}
#[test]
fn nested_source_pin_rejects_leading_dash_in_pin_ref() {
let ns = NestedSource {
source: "x".into(),
alias: None,
install: false,
install_items: None,
follow_branch: None,
pin_tag: None,
pin_ref: Some("--depth=1".into()),
roots: None,
hooks: vec![],
};
let err = ns.pin_directive(Path::new("mind.toml")).unwrap_err();
assert!(
matches!(err, crate::error::MindError::InvalidRef { .. }),
"expected InvalidRef, got: {err}"
);
}
#[test]
fn nested_source_pin_rejects_whitespace_in_ref() {
let ns = NestedSource {
source: "x".into(),
alias: None,
install: false,
install_items: None,
follow_branch: Some("main branch".into()),
pin_tag: None,
pin_ref: None,
roots: None,
hooks: vec![],
};
let err = ns.pin_directive(Path::new("mind.toml")).unwrap_err();
assert!(
matches!(err, crate::error::MindError::InvalidRef { .. }),
"expected InvalidRef for whitespace in branch name, got: {err}"
);
}
#[test]
fn nested_source_pin_rejects_dotdot_in_ref() {
let ns = NestedSource {
source: "x".into(),
alias: None,
install: false,
install_items: None,
follow_branch: None,
pin_tag: None,
pin_ref: Some("main..HEAD".into()),
roots: None,
hooks: vec![],
};
let err = ns.pin_directive(Path::new("mind.toml")).unwrap_err();
assert!(
matches!(err, crate::error::MindError::InvalidRef { .. }),
"expected InvalidRef for '..' in ref, got: {err}"
);
}
#[test]
fn nested_source_pin_accepts_valid_values() {
let ns = NestedSource {
source: "x".into(),
alias: None,
install: false,
install_items: None,
follow_branch: None,
pin_tag: None,
pin_ref: Some("cafebabecafebabecafebabecafebabecafebabe".into()),
roots: None,
hooks: vec![],
};
assert!(
ns.pin_directive(Path::new("mind.toml")).is_ok(),
"valid SHA must pass pin validation"
);
}
fn write_mind_toml(text: &str) -> std::path::PathBuf {
use std::sync::atomic::{AtomicU32, Ordering};
static N: AtomicU32 = AtomicU32::new(0);
let n = N.fetch_add(1, Ordering::SeqCst);
let dir = std::env::temp_dir().join(format!("mind-dsc66-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("mind.toml"), text).unwrap();
dir
}
#[test]
fn untrusted_source_mind_toml_pin_is_rejected_before_any_git_call() {
for (field, payload) in [
("follow-branch", "--upload-pack=touch /tmp/pwned"),
("pin-tag", "--upload-pack=touch /tmp/pwned"),
("pin-ref", "--upload-pack=touch /tmp/pwned"),
] {
let dir = write_mind_toml(&format!("[source]\n{field} = \"{payload}\"\n"));
let parsed = MindToml::load(&dir)
.expect("load ok")
.expect("mind.toml present");
let err = parsed
.source
.pin_directive(&dir.join("mind.toml"))
.expect_err("malicious pin must be rejected");
assert!(
matches!(err, MindError::InvalidRef { .. }),
"expected InvalidRef for {field}={payload:?}, got: {err}"
);
let _ = std::fs::remove_dir_all(&dir);
}
}
#[test]
fn untrusted_nested_source_mind_toml_pin_is_rejected_before_any_git_call() {
for (field, payload) in [
("follow-branch", "--upload-pack=touch /tmp/pwned"),
("pin-tag", "-x"),
("pin-ref", "main..HEAD"),
] {
let dir = write_mind_toml(&format!(
"[[discover.sources]]\nsource = \"github:owner/repo\"\n{field} = \"{payload}\"\n"
));
let parsed = MindToml::load(&dir)
.expect("load ok")
.expect("mind.toml present");
let ns = &parsed.discover.as_ref().unwrap().sources[0];
let err = ns
.pin_directive(&dir.join("mind.toml"))
.expect_err("malicious nested pin must be rejected");
assert!(
matches!(err, MindError::InvalidRef { .. }),
"expected InvalidRef for nested {field}={payload:?}, got: {err}"
);
let _ = std::fs::remove_dir_all(&dir);
}
}
#[test]
fn untrusted_mind_toml_with_legitimate_pin_loads_and_resolves() {
let cases = [
(
"follow-branch = \"release/2.0\"",
Pin::FollowBranch("release/2.0".into()),
),
("pin-tag = \"v1.2.3\"", Pin::Tag("v1.2.3".into())),
(
"pin-ref = \"deadbeefdeadbeefdeadbeefdeadbeefdeadbeef\"",
Pin::Ref("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef".into()),
),
];
for (line, expect) in cases {
let dir = write_mind_toml(&format!("[source]\n{line}\n"));
let parsed = MindToml::load(&dir).unwrap().unwrap();
let pin = parsed
.source
.pin_directive(&dir.join("mind.toml"))
.expect("legitimate pin must resolve");
assert_eq!(pin, Some(expect), "for line {line:?}");
let _ = std::fs::remove_dir_all(&dir);
}
}
#[test]
fn nested_pin_directive_pin_ref_parses_back_from_dump_output() {
let sha = "cafebabecafebabecafebabecafebabecafebabe";
let toml_text = format!(
"[source]\ndescription = \"Generated by mind dump.\"\n\
[[discover.sources]]\nsource = \"/path/to/repo\"\npin-ref = \"{sha}\"\ninstall = false\n"
);
let parsed: MindToml = toml::from_str(&toml_text).expect("parse");
let ns = &parsed.discover.as_ref().unwrap().sources[0];
let pin = ns
.pin_directive(Path::new("mind.toml"))
.expect("no conflict");
assert_eq!(
pin,
Some(Pin::Ref(sha.into())),
"pin-ref from dump output must round-trip as Pin::Ref"
);
}
}