use std::path::PathBuf;
use std::process::ExitStatus;
pub type Result<T> = std::result::Result<T, MindError>;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum ItemKind {
Skill,
Agent,
Rule,
Tool,
}
impl ItemKind {
pub fn as_str(self) -> &'static str {
match self {
ItemKind::Skill => "skill",
ItemKind::Agent => "agent",
ItemKind::Rule => "rule",
ItemKind::Tool => "tool",
}
}
pub fn parse(s: &str) -> Option<Self> {
match s {
"skill" => Some(ItemKind::Skill),
"agent" => Some(ItemKind::Agent),
"rule" => Some(ItemKind::Rule),
"tool" => Some(ItemKind::Tool),
_ => None,
}
}
pub fn dir(self) -> &'static str {
match self {
ItemKind::Skill => "skills",
ItemKind::Agent => "agents",
ItemKind::Rule => "rules",
ItemKind::Tool => "tools",
}
}
pub fn from_dir(s: &str) -> Option<Self> {
match s {
"skills" => Some(ItemKind::Skill),
"agents" => Some(ItemKind::Agent),
"rules" => Some(ItemKind::Rule),
"tools" => Some(ItemKind::Tool),
_ => None,
}
}
pub const LINKABLE: [ItemKind; 3] = [ItemKind::Skill, ItemKind::Agent, ItemKind::Rule];
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, 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}: {source}")]
Toml {
path: PathBuf,
#[source]
source: toml::de::Error,
},
#[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)")]
UnknownLobe { path: String },
#[error("'{kind}' is not a valid item kind (expected one of: skill, agent, rule, tool)")]
UnknownKind { kind: String },
#[error(
"'{name}' is not a known lobe preset (expected one of: gemini, codex, antigravity, antigravity-cli, universal)"
)]
UnknownPreset { name: String },
#[error("`config lobes add` needs a path or `--preset <name>`")]
LobeTargetRequired,
#[error("mind.toml at {path}: {msg}")]
MindToml { path: PathBuf, msg: String },
#[error(
"'{spec}' is not a valid repo spec (expected 'owner/repo', a github shorthand, or a git URL)"
)]
InvalidRepoSpec { spec: String },
#[error(
"'{name}' is not a valid item ref (expected 'name', 'skill:name', 'agent:name', 'rule:name', or 'owner/repo#name')"
)]
InvalidItemRef { name: String },
#[error(
"'{prefix}' cannot be used as a namespace prefix: it is a reserved item-kind word (skill, agent, rule, tool), which would make a prefixed name indistinguishable from a kind-qualified ref"
)]
ReservedPrefix { 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 source named '{name}' is melded")]
SourceNotFound { name: 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(
"no item matches '{query}' across {sources} melded source(s); run `mind sync` then `mind probe`"
)]
ItemNotFound { query: String, sources: usize },
#[error("'{query}' is ambiguous; matches: {}", candidates.join(", "))]
AmbiguousItem {
query: String,
candidates: Vec<String>,
},
#[error("'{name}' is not installed")]
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}; upgrade mind"
)]
IncompatibleVersion {
source_name: String,
required: String,
running: String,
},
#[error(
"{path} already exists and is not managed by mind; remove it (or `mind forget` the item) before installing"
)]
LinkOccupied { path: String },
#[error("{item}: reference {referent} does not match any item in source '{in_source}'")]
BadReference {
item: String,
referent: String,
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("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 of --follow-branch, --pin-tag, --pin-ref"
)]
ConflictingPin { first: String, second: String },
#[error("source '{source_name}': scan root '{root}' is not a directory in the clone")]
InvalidRoot { source_name: String, root: String },
#[error(
"source '{source_name}': {kind} '{name}' appears under more than one scan root; roots must not yield the same item"
)]
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(
"source '{identity}' must be pinned to a tag or ref: the managed policy forbids floating branches"
)]
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(
"install hook for source '{identity}' failed{}: {}\n command: {command}",
status_suffix(*status),
if *printed_output { "(see output above)" } else if stderr.is_empty() { "(no output)" } else { stderr.as_str() }
)]
HookFailed {
identity: String,
command: String,
status: Option<ExitStatus>,
stderr: String,
printed_output: 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("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 '{name}' from source '{incoming}' conflicts with the installed agent from \
'{existing}': both link as agents/{name}.md in the agent home -- \
run `mind forget agent:{name}` (or the prefixed name) to remove the existing agent first"
)]
AgentCollision {
name: String,
existing: String,
incoming: String,
},
}
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,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[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")
&& unknown_preset.contains("codex")
&& unknown_preset.contains("antigravity-cli")
&& unknown_preset.contains("universal"),
"UnknownPreset must list the valid 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 {
identity: "github.com/acme/tools".into(),
command: "make install".into(),
status: None,
stderr: "boom".into(),
printed_output: 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_silent_exit_renders_no_output() {
let e = MindError::HookFailed {
identity: "github.com/acme/tools".into(),
command: "exit 1".into(),
status: None,
stderr: String::new(),
printed_output: 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 {
identity: "github.com/acme/tools".into(),
command: "make install".into(),
status: None,
stderr: "some diagnostic".into(),
printed_output: 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_printed_output_renders_see_output_above_not_no_output() {
let e = MindError::HookFailed {
identity: "github.com/acme/tools".into(),
command: "make install".into(),
status: None,
stderr: String::new(),
printed_output: true,
};
let msg = e.to_string();
assert!(
msg.contains("(see output above)"),
"printed_output=true must say '(see output above)': {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 hook_failed_printed_output_priority_over_stderr_content() {
let e = MindError::HookFailed {
identity: "github.com/acme/tools".into(),
command: "make install".into(),
status: None,
stderr: "some content".into(),
printed_output: true,
};
let msg = e.to_string();
assert!(
msg.contains("(see output above)"),
"printed_output=true must take priority: {msg}"
);
assert!(
!msg.contains("(no output)"),
"must not say '(no output)': {msg}"
);
}
}