use std::collections::HashSet;
use std::io::Write;
use serde::Serialize;
use crate::catalog::{self, CatalogItem};
use crate::config::Config;
use crate::error::{ItemKind, MindError, Result};
use crate::git;
use crate::hash::hash_path;
use crate::install;
use crate::manifest::Manifest;
use crate::mindfile::AuthFailureAction;
use crate::mindfile::HookEvent;
use crate::mindfile::MindToml;
use crate::paths::Paths;
use crate::policy::Policy;
use crate::resolve::{
is_glob, parse_item_ref, resolve, select, select_by_bare_refs, select_installed,
source_matches, source_matches_glob,
};
use crate::source::{Pin, Registry, parse_spec};
#[allow(clippy::too_many_arguments)]
pub fn meld(
paths: &Paths,
repo: &str,
alias: Option<String>,
roots: Vec<String>,
follow_branch: Option<String>,
pin_tag: Option<String>,
pin_ref: Option<String>,
install_hook: Option<String>,
dangerously_skip_install_hook_check: bool,
) -> Result<Vec<String>> {
let consumer_pin = resolve_pin_flags(follow_branch, pin_tag, pin_ref)?;
paths.ensure_layout()?;
let policy = Policy::load()?;
let prefer_ssh = Config::load(paths)?.ssh;
let mut registry = Registry::load(paths)?;
let mut visited = HashSet::new();
let source_name = parse_spec(repo)
.map(|s| s.name)
.unwrap_or_else(|_| repo.to_string());
let before: HashSet<String> = registry.sources.iter().map(|s| s.name.clone()).collect();
let mut meld_skipped: Vec<SkippedEntry> = Vec::new();
let added = meld_recursive(
paths,
&mut registry,
repo,
alias,
roots,
consumer_pin,
true,
&mut visited,
policy.as_ref(),
install_hook,
dangerously_skip_install_hook_check,
prefer_ssh,
None, &mut meld_skipped,
)?;
let newly: Vec<String> = registry
.sources
.iter()
.map(|s| s.name.clone())
.filter(|n| !before.contains(n))
.collect();
registry.save(paths)?;
let out = crate::render::ctx();
if out.json {
let mut result = MutationResult::new("meld", &source_name, "melded");
result.count = Some(added);
result.skipped = meld_skipped;
print_json(&result)?;
return Ok(newly);
}
if added > 1 {
println!("melded {added} source(s)");
}
Ok(newly)
}
pub fn maybe_probe_hint(paths: &Paths, repo: &str) -> Result<()> {
let out = crate::render::ctx();
if out.json {
return Ok(());
}
let Ok(name) = parse_spec(repo).map(|s| s.name) else {
return Ok(());
};
let registry = Registry::load(paths)?;
let Some(source) = registry.find(&name) else {
return Ok(());
};
let curates = MindToml::load(&source.clone_dir(paths))?
.and_then(|m| m.discover)
.is_some_and(|d| !d.sources.is_empty());
if curates {
println!(
"note: this source curates other sources; run `mind probe` to browse and search what is available"
);
}
Ok(())
}
fn resolve_pin_flags(
follow_branch: Option<String>,
pin_tag: Option<String>,
pin_ref: Option<String>,
) -> Result<Option<Pin>> {
match (follow_branch, pin_tag, pin_ref) {
(None, None, None) => Ok(None),
(Some(b), None, None) => {
crate::git::validate_ref_value(&b)?;
Ok(Some(Pin::FollowBranch(b)))
}
(None, Some(t), None) => {
crate::git::validate_ref_value(&t)?;
Ok(Some(Pin::Tag(t)))
}
(None, None, Some(r)) => {
crate::git::validate_ref_value(&r)?;
Ok(Some(Pin::Ref(r)))
}
(b, t, r) => {
let mut names = Vec::new();
if b.is_some() {
names.push("--follow-branch");
}
if t.is_some() {
names.push("--pin-tag");
}
if r.is_some() {
names.push("--pin-ref");
}
Err(MindError::ConflictingPin {
first: names[0].to_string(),
second: names[1].to_string(),
})
}
}
}
fn pin_description(pin: &Pin) -> String {
match pin {
Pin::DefaultBranch => "default branch".to_string(),
Pin::FollowBranch(b) => format!("branch {b}"),
Pin::Tag(t) => format!("tag {t}"),
Pin::Ref(r) => format!("ref {r}"),
}
}
fn hook_rerun_warranted(source: &crate::source::Source) -> bool {
!source
.pending_install_hooks(source.commit.as_deref())
.is_empty()
}
fn hook_ran_at(source: &crate::source::Source, command: &str, current: Option<&str>) -> bool {
current.is_some()
&& source
.install_hooks
.iter()
.any(|r| r.command == command && r.ran_at.as_deref() == current)
}
fn record_install_hook(source: &mut crate::source::Source, command: &str, ran_at: Option<String>) {
if let Some(r) = source
.install_hooks
.iter_mut()
.find(|r| r.command == command)
{
r.ran_at = ran_at;
} else {
source.install_hooks.push(crate::source::RecordedHook {
command: command.to_string(),
ran_at,
});
}
}
enum HookOutcome {
Proceed,
Abort,
}
#[allow(clippy::too_many_arguments)]
fn run_install_hooks(
source: &mut crate::source::Source,
clone_dir: &std::path::Path,
mindfile: &Option<MindToml>,
toml_path: &std::path::Path,
install_override: Option<&str>,
dangerously_skip: bool,
force_rerun: bool,
extra_hooks: Vec<crate::mindfile::ResolvedHook>,
) -> Result<HookOutcome> {
let mut resolved = mindfile
.as_ref()
.map(|m| m.resolved_hooks(toml_path))
.transpose()?
.unwrap_or_default();
resolved.extend(extra_hooks);
let (hooks, replaced) = crate::hook::apply_install_override(resolved, install_override);
let pin_desc = pin_description(&source.pin);
let commit = source.commit.clone().unwrap_or_default();
let current = source.commit.clone();
let clone_path = clone_dir.display().to_string();
let name = source.name.clone();
for h in hooks.iter().filter(|h| h.event == HookEvent::Install) {
if !force_rerun && hook_ran_at(source, &h.run, current.as_deref()) {
continue;
}
let declared_override: Option<String> = replaced.as_ref().and_then(|cmds| {
if install_override.map(str::trim) == Some(h.run.as_str()) {
Some(cmds.join("; "))
} else {
None
}
});
let disclosure = crate::hook::hook_disclosure_text(
h.label(),
h.optional,
&name,
&pin_desc,
&commit,
&clone_path,
&h.run,
declared_override.as_deref(),
);
match crate::hook::decide(&disclosure, h.optional, dangerously_skip)? {
crate::hook::HookAct::Run => {
println!("running install hook '{}' for {}", h.label(), name);
crate::hook::run_hook(&h.run, clone_dir, &name, h.label())?;
record_install_hook(source, &h.run, current.clone());
}
crate::hook::HookAct::Skip => {
println!(
"note: skipped install hook '{}' for {}; its items may not work until it runs",
h.label(),
name
);
record_install_hook(source, &h.run, None);
}
crate::hook::HookAct::Abort => return Ok(HookOutcome::Abort),
}
}
Ok(HookOutcome::Proceed)
}
struct CuratedConfig {
pin: Option<Pin>,
roots: Option<Vec<String>>,
hooks: Vec<crate::mindfile::ResolvedHook>,
}
impl CuratedConfig {
fn has_gated_values(&self) -> bool {
self.roots.is_some() || !self.hooks.is_empty()
}
}
#[allow(clippy::too_many_arguments)]
fn meld_recursive(
paths: &Paths,
registry: &mut Registry,
repo: &str,
alias: Option<String>,
roots: Vec<String>,
consumer_pin: Option<Pin>,
top_level: bool,
visited: &mut HashSet<String>,
policy: Option<&Policy>,
install_hook: Option<String>,
dangerously_skip_hook_check: bool,
prefer_ssh: bool,
curated: Option<CuratedConfig>,
skipped: &mut Vec<SkippedEntry>,
) -> Result<usize> {
let out = crate::render::ctx();
let mut source = parse_spec(repo)?;
source.alias = alias;
source.prefer_ssh(prefer_ssh);
if !visited.insert(source.url.clone()) {
return Ok(0);
}
if let Some(existing) = registry.find(&source.name) {
if top_level {
return Err(MindError::SourceExists {
name: source.name.clone(),
url: existing.url.clone(),
});
}
if existing.url != source.url {
eprintln!(
"warning: source name '{}' already melded from {}; skipping {}",
source.name, existing.url, source.url
);
}
return Ok(0);
}
let mut dir = source.clone_dir(paths);
let is_local = source.is_local();
if !out.json {
println!(
"{} melding {} from {}",
out.bullet(),
source.name,
out.dim(&source.url)
);
}
if is_local {
if !dir.is_dir() {
return Err(MindError::NotADirectory {
path: dir.display().to_string(),
});
}
} else {
if dir.exists() {
std::fs::remove_dir_all(&dir).map_err(|e| MindError::io(&dir, e))?;
}
if let Some(parent) = dir.parent() {
crate::paths::mkdir_p(parent)?;
}
git::clone(&source.url, &dir)?;
}
let mut mindfile = MindToml::load(&dir)?;
source.description = mindfile.as_ref().and_then(|m| m.source.description.clone());
let curated = curated.unwrap_or(CuratedConfig {
pin: None,
roots: None,
hooks: Vec::new(),
});
let apply_curated = mindfile.is_none();
let curated_pin = curated.pin.clone();
if !apply_curated && curated.has_gated_values() {
eprintln!(
"warning: {} ships its own mind.toml; curator-supplied roots/hooks are ignored",
source.name
);
}
let curated_hooks = if apply_curated {
curated.hooks.clone()
} else {
Vec::new()
};
let toml_path = dir.join("mind.toml");
let directive_pin = mindfile
.as_ref()
.map(|m| m.source.pin_directive(&toml_path))
.transpose()?
.flatten();
let effective_pin = consumer_pin
.or(curated_pin)
.or(directive_pin)
.unwrap_or(Pin::DefaultBranch);
if let Some(policy) = policy {
let identity = source.name.clone();
let allowed = policy.allow_matches(&identity);
if policy.lock() && !allowed {
if !is_local {
let _ = std::fs::remove_dir_all(&dir);
}
return Err(MindError::SourceNotAllowed { identity });
}
if !policy.lock() && !allowed {
eprintln!(
"warning: source '{identity}' is not in the managed policy's allowlist (advisory; not enforced because [sources].lock is false)"
);
}
if policy.pinned() && matches!(effective_pin, Pin::DefaultBranch | Pin::FollowBranch(_)) {
if !is_local {
let _ = std::fs::remove_dir_all(&dir);
}
return Err(MindError::UnpinnedSourceForbidden { identity });
}
}
if effective_pin != Pin::DefaultBranch {
source.pin = effective_pin.clone();
let target = source.clone_dir(paths);
if target.exists() {
std::fs::remove_dir_all(&target).map_err(|e| MindError::io(&target, e))?;
}
if let Some(parent) = target.parent() {
crate::paths::mkdir_p(parent)?;
}
if let Err(e) = git::clone_at(&source.url, &target, &effective_pin) {
let _ = std::fs::remove_dir_all(&target);
return Err(e);
}
dir = target;
mindfile = MindToml::load(&dir)?;
source.description = mindfile.as_ref().and_then(|m| m.source.description.clone());
}
source.pin = effective_pin;
source.commit = if source.is_linked() {
git::head_commit(&source.url, &dir).ok()
} else {
Some(git::head_commit(&source.url, &dir)?)
};
let is_authoritative = mindfile.as_ref().is_some_and(|m| m.is_authoritative());
if !roots.is_empty() {
if is_authoritative {
if !out.json {
println!(
"note: {} uses an authoritative mind.toml; --root is ignored",
source.name
);
}
} else {
source.roots = Some(roots);
}
} else if apply_curated && curated.roots.is_some() {
source.roots = curated.roots.clone();
}
let mut items = match catalog::scan(paths, &single(&source)) {
Ok(items) => items,
Err(e) => {
if !source.is_linked() {
let _ = std::fs::remove_dir_all(&dir);
}
return Err(e);
}
};
if top_level
&& source.alias.is_none()
&& crate::hook::is_tty()
&& let Some(declared) = mindfile.as_ref().and_then(|m| m.source.prefix.clone())
&& !declared.is_empty()
{
let preview = if items.is_empty() {
String::new()
} else {
let names: Vec<String> = items.iter().map(|it| it.key()).collect();
format!("\n items would install as: {}", names.join(", "))
};
let answer = prompt_line(&format!(
"{} suggests the prefix '{declared}'.{preview}\n use it? [Y]es / type a different prefix / [n]o prefix: ",
source.name
))?;
let chosen = crate::namespace::prefix_choice(&answer);
if chosen != source.alias {
source.alias = chosen;
items = match catalog::scan(paths, &single(&source)) {
Ok(items) => items,
Err(e) => {
if !source.is_linked() {
let _ = std::fs::remove_dir_all(&dir);
}
return Err(e);
}
};
}
}
warn_unguarded_references(&items);
if !out.json {
println!(
"{} melded {} ({} item(s))",
out.ok(),
source.name,
items.len()
);
}
match run_install_hooks(
&mut source,
&dir,
&mindfile,
&toml_path,
install_hook.as_deref(),
dangerously_skip_hook_check,
false,
curated_hooks,
) {
Ok(HookOutcome::Proceed) => {}
Ok(HookOutcome::Abort) => {
if !source.is_linked() {
let _ = std::fs::remove_dir_all(&dir);
}
println!("aborted; nothing installed");
return Ok(0);
}
Err(e) => {
if !source.is_linked() {
let _ = std::fs::remove_dir_all(&dir);
}
return Err(e);
}
}
let super_source_name = source.name.clone();
registry.sources.push(source);
let mut added = 1;
if let Some(nested) = mindfile
.as_ref()
.and_then(|m| m.discover.as_ref())
.map(|d| &d.sources)
{
for entry in nested {
entry.validate(&toml_path)?;
let curated = CuratedConfig {
pin: entry.pin_directive(&toml_path)?,
roots: entry.roots.clone(),
hooks: entry.resolved_hooks(&toml_path)?,
};
match meld_recursive(
paths,
registry,
&entry.source,
entry.alias.clone(),
vec![], None, false,
visited,
policy,
None, dangerously_skip_hook_check,
prefer_ssh, Some(curated),
skipped,
) {
Ok(n) => added += n,
Err(e) if git::is_auth_failure(&e) => {
let entry_name = parse_spec(&entry.source)
.map(|s| s.name)
.unwrap_or_else(|_| entry.source.clone());
if registry.find(&entry_name).is_some() {
return Err(e);
}
let Some(cfg) = &entry.on_auth_failure else {
return Err(e);
};
for line in auth_failure_lines(&entry_name, cfg) {
eprintln!("{line}");
}
if cfg.action == AuthFailureAction::Skip {
skipped.push(SkippedEntry {
source: entry_name,
reason: "auth_failure".into(),
});
continue;
}
return Err(e);
}
Err(e) => return Err(e),
}
if let Some(refs) = &entry.install_items
&& !refs.is_empty()
&& let Ok(spec) = parse_spec(&entry.source)
&& let Some(nested_src) = registry.find(&spec.name)
{
let nested_items = catalog::scan(paths, &single(nested_src))?;
for item_ref in refs {
let parsed = crate::resolve::parse_item_ref(item_ref).map_err(|_| {
MindError::BadReference {
item: format!("install-items in '{super_source_name}'"),
referent: item_ref.clone(),
in_source: spec.name.clone(),
}
})?;
let found = nested_items.iter().any(|it| {
parsed.kind.is_none_or(|k| it.kind == k) && it.name == parsed.name
});
if !found {
return Err(MindError::BadReference {
item: format!("install-items in '{super_source_name}'"),
referent: item_ref.clone(),
in_source: spec.name.clone(),
});
}
}
}
}
}
Ok(added)
}
fn strip_ansi(s: &str) -> String {
let bytes = strip_ansi_escapes::strip(s);
String::from_utf8_lossy(&bytes)
.chars()
.filter(|&c| {
(('\x20'..='\x7e').contains(&c) || c > '\u{009f}')
&& !matches!(
c,
'\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}'
| '\u{2028}' | '\u{2029}'
)
})
.collect()
}
fn auth_failure_lines(entry_name: &str, cfg: &crate::mindfile::OnAuthFailure) -> Vec<String> {
let is_skip = cfg.action == AuthFailureAction::Skip;
let safe_name = strip_ansi(entry_name);
let mut lines = vec![format!(
"unable to meld source {} due to authentication failure{}",
safe_name,
if is_skip { " (skipping)" } else { "" }
)];
if let Some(msg) = &cfg.message {
let safe_msg = strip_ansi(msg);
lines.push(safe_msg);
}
lines
}
fn warn_unguarded_references(items: &[CatalogItem]) {
if !items.iter().any(|it| it.prefix.is_some()) {
return;
}
let siblings: std::collections::HashSet<String> =
items.iter().map(|it| it.name.clone()).collect();
for item in items {
let mut refs: Vec<String> = Vec::new();
for file in crate::review::item_files(item) {
let Ok(content) = std::fs::read_to_string(&file) else {
continue; };
for r in crate::namespace::unguarded_refs(&content, &siblings) {
if r != item.name && !refs.contains(&r) {
refs.push(r);
}
}
}
if !refs.is_empty() {
eprintln!(
"warning: {} references sibling(s) in prose: {}; prefixing may break them at runtime (use {{{{ns:name}}}})",
item.key(),
refs.join(", ")
);
}
}
}
fn siblings_of(items: &[CatalogItem], source: &str) -> Vec<CatalogItem> {
items
.iter()
.filter(|it| it.source == source)
.cloned()
.collect()
}
pub fn init_source(dir: Option<&str>, template: bool) -> Result<()> {
let dir = dir.unwrap_or(".");
let path = std::path::Path::new(dir);
if !path.is_dir() {
return Err(MindError::NotADirectory {
path: dir.to_string(),
});
}
let root = path.canonicalize().map_err(|e| MindError::io(path, e))?;
let source = parse_spec(&root.to_string_lossy())?;
let mut items: Vec<CatalogItem> = Vec::new();
catalog::scan_source_at(&root, &source, &mut items)?;
println!("init-source: {}", root.display());
if items.is_empty() {
println!(" no items found (skills/<name>/SKILL.md, agents/<name>.md, rules/<name>.md)");
} else {
println!(" {} item(s):", items.len());
for it in &items {
println!(" {} {}", it.kind, it.name);
}
}
let siblings: std::collections::HashSet<String> =
items.iter().map(|it| it.name.clone()).collect();
let prefix_in_force = items.iter().any(|it| it.prefix.is_some());
let mut findings: Vec<crate::review::Finding> = Vec::new();
for it in &items {
let content = read_item_text(it);
let tokens: Vec<String> = crate::namespace::referenced_names(&content)
.into_iter()
.filter(|n| n != &it.name)
.collect();
let bare: Vec<String> = crate::namespace::unguarded_refs(&content, &siblings)
.into_iter()
.filter(|n| n != &it.name)
.collect();
if !tokens.is_empty() {
println!(
" {} {} -> {} (tokenized)",
it.kind,
it.name,
tokens.join(", ")
);
}
if prefix_in_force && !bare.is_empty() {
findings.push(crate::review::Finding::advisory(
"unguarded-reference",
format!(
"{}: references sibling(s) in prose: {}; prefixing may break them at runtime (use {{{{ns:name}}}})",
it.key(),
bare.join(", ")
),
));
}
}
let has_unguarded = findings.iter().any(|f| f.kind == "unguarded-reference");
findings.extend(crate::review::duplicate_tooling_findings(&items));
crate::review::print_findings(&[], &findings);
if has_unguarded && !template {
println!(
"run `mind init-source {dir} --template` to wrap the bare references as {{{{ns:name}}}}"
);
}
let toml_path = root.join("mind.toml");
if toml_path.exists() {
println!(" mind.toml already exists; left unchanged");
} else {
let scaffold = concat!(
"[source]\n",
"description = \"\" # what this source offers\n",
"# prefix = \"prefix\" # namespace items as prefix-<name>\n",
"\n",
"# Declare hooks that run when a consumer melds or unmelds this source.\n",
"# Remove the leading `# ` to enable a hook.\n",
"#\n",
"# [[hooks]]\n",
"# run = \"make install\" # shell command to run\n",
"# name = \"Build\" # optional label shown in the prompt\n",
"# event = \"install\" # \"install\" (default) or \"uninstall\"\n",
"# optional = false # false = required (default). optional only lets the\n",
"# # user decline running it; a failure always aborts.\n",
"#\n",
"# [[hooks]]\n",
"# run = \"make clean\" # cleanup hook run at unmeld time\n",
"# name = \"Cleanup\"\n",
"# event = \"uninstall\"\n",
"# optional = true # the user may decline this step (its failure still aborts)\n",
);
std::fs::write(&toml_path, scaffold).map_err(|e| MindError::io(&toml_path, e))?;
println!(" wrote mind.toml");
}
if template {
let mut total = 0usize;
for it in &items {
let mut sibs = siblings.clone();
sibs.remove(&it.name);
for file in crate::review::item_files(it) {
if file.extension().and_then(|e| e.to_str()) != Some("md") {
continue;
}
let Ok(content) = std::fs::read_to_string(&file) else {
continue; };
let (rewritten, n) = crate::namespace::templatize(&content, &sibs);
if n > 0 {
std::fs::write(&file, &rewritten).map_err(|e| MindError::io(&file, e))?;
println!(" templated {n} reference(s) in {}", file.display());
total += n;
}
}
}
if total == 0 {
println!(" no bare references to template");
}
}
Ok(())
}
fn read_item_text(item: &CatalogItem) -> String {
let mut buf = String::new();
for file in crate::review::item_files(item) {
if let Ok(content) = std::fs::read_to_string(&file) {
buf.push_str(&content);
buf.push('\n');
}
}
buf
}
fn run_uninstall_hooks(
paths: &Paths,
registry: &Registry,
idx: usize,
source_name: &str,
uninstall_hook: Option<&str>,
dangerously_skip_hook_check: bool,
) -> Result<bool> {
let clone_dir = registry.sources[idx].clone_dir(paths);
let source_pin = registry.sources[idx].pin.clone();
let source_commit = registry.sources[idx].commit.clone();
let mindfile = MindToml::load(&clone_dir).unwrap_or_default();
let toml_path = clone_dir.join("mind.toml");
let resolved = mindfile
.as_ref()
.map(|m| m.resolved_hooks(&toml_path))
.transpose()?
.unwrap_or_default();
let (resolved, replaced) =
crate::hook::apply_hook_override(resolved, uninstall_hook, HookEvent::Uninstall);
let override_cmd = uninstall_hook.map(str::trim).filter(|s| !s.is_empty());
let replaced_note = replaced.map(|cmds| cmds.join("; "));
let pin_desc = pin_description(&source_pin);
let commit = source_commit.unwrap_or_default();
let clone_path = clone_dir.display().to_string();
for h in resolved.iter().filter(|h| h.event == HookEvent::Uninstall) {
let declared_override = match (&replaced_note, override_cmd) {
(Some(note), Some(cmd)) if h.run == cmd => Some(note.as_str()),
_ => None,
};
let disclosure = crate::hook::hook_disclosure_text(
h.label(),
h.optional,
source_name,
&pin_desc,
&commit,
&clone_path,
&h.run,
declared_override,
);
match crate::hook::decide(&disclosure, h.optional, dangerously_skip_hook_check)? {
crate::hook::HookAct::Run => {
println!("running uninstall hook '{}' for {}", h.label(), source_name);
crate::hook::run_hook(&h.run, &clone_dir, source_name, h.label())?;
}
crate::hook::HookAct::Skip => {
println!(
"note: skipped uninstall hook '{}' for {}",
h.label(),
source_name
);
}
crate::hook::HookAct::Abort => {
println!("aborted; source left in place");
return Ok(false);
}
}
}
Ok(true)
}
pub fn unmeld(
paths: &Paths,
name: &str,
unlink_only: bool,
yes: bool,
dangerously_skip_hook_check: bool,
uninstall_hook: Option<String>,
) -> Result<()> {
let out = crate::render::ctx();
let registry = Registry::load(paths)?;
crate::resolve::validate_source_selector(name)?;
let matched: Vec<usize> = if is_glob(name) {
registry
.sources
.iter()
.enumerate()
.filter(|(_, s)| source_matches_glob(&s.name, name))
.map(|(i, _)| i)
.collect()
} else {
let exact: Vec<usize> = registry
.sources
.iter()
.enumerate()
.filter(|(_, s)| source_matches(&s.name, name))
.map(|(i, _)| i)
.collect();
match exact.as_slice() {
[] => {
return Err(MindError::SourceNotFound {
name: name.to_string(),
});
}
[only] => vec![*only],
many => {
return Err(MindError::AmbiguousSource {
query: name.to_string(),
candidates: many
.iter()
.map(|i| registry.sources[*i].name.clone())
.collect(),
});
}
}
};
if matched.is_empty() {
return Err(MindError::SourceNotFound {
name: name.to_string(),
});
}
if matched.len() > 1 && !yes {
if !out.json {
println!("unmeld would remove {} source(s):", matched.len());
for i in &matched {
println!(" {} {}", out.warn(), registry.sources[*i].name);
}
}
if !crate::hook::is_tty() {
return Err(MindError::ConfirmationRequired {
action: format!("unmelding {} sources", matched.len()),
});
}
if !out.json && !confirm("remove these source(s)?")? {
println!("cancelled; nothing removed");
return Ok(());
}
}
let multi = matched.len() > 1;
let names: Vec<String> = matched
.iter()
.map(|i| registry.sources[*i].name.clone())
.collect();
drop(registry);
for source_name in names {
unmeld_one(
paths,
&source_name,
unlink_only,
yes || multi,
dangerously_skip_hook_check,
uninstall_hook.as_deref(),
)?;
}
Ok(())
}
fn unmeld_one(
paths: &Paths,
source_name: &str,
unlink_only: bool,
yes: bool,
dangerously_skip_hook_check: bool,
uninstall_hook: Option<&str>,
) -> Result<()> {
let out = crate::render::ctx();
let mut registry = Registry::load(paths)?;
let idx = match registry.sources.iter().position(|s| s.name == source_name) {
Some(i) => i,
None => {
return Err(MindError::SourceNotFound {
name: source_name.to_string(),
});
}
};
let source_name = source_name.to_string();
let mut manifest = Manifest::load(paths)?;
let item_keys: Vec<String> = manifest
.items
.values()
.filter(|it| it.source == source_name)
.map(|it| it.key())
.collect();
if unlink_only {
let proceed = run_uninstall_hooks(
paths,
®istry,
idx,
&source_name,
uninstall_hook,
dangerously_skip_hook_check,
)?;
if !proceed {
return Ok(());
}
let source = registry.sources.remove(idx);
let dir = source.clone_dir(paths);
if !source.is_linked() && dir.exists() {
std::fs::remove_dir_all(&dir).map_err(|e| MindError::io(&dir, e))?;
}
registry.save(paths)?;
if out.json {
let mut result = MutationResult::new("unmeld", &source_name, "unlinked");
result.count = Some(item_keys.len());
return print_json(&result);
}
if item_keys.is_empty() {
println!("{} unmelded {source_name}", out.ok());
} else {
println!(
"{} unmelded {source_name}; {} item(s) remain installed:",
out.ok(),
item_keys.len()
);
for k in &item_keys {
println!(" {} {k}", out.bullet());
}
println!("run `mind forget '{source_name}#*'` to remove them");
}
return Ok(());
}
if item_keys.len() > 1 && !yes {
if !out.json {
println!(
"unmelding {source_name} will remove {} installed item(s):",
item_keys.len()
);
for k in &item_keys {
println!(" {} {k}", out.warn());
}
}
if !crate::hook::is_tty() {
return Err(MindError::ConfirmationRequired {
action: format!(
"unmelding {source_name} (removing {} items)",
item_keys.len()
),
});
}
if !out.json && !confirm("remove these item(s) and unmeld the source?")? {
println!("cancelled; nothing removed");
return Ok(());
}
}
let source_ref = ®istry.sources[idx];
let mut item_catalog: Vec<CatalogItem> = Vec::new();
let _ = catalog::scan_source(paths, source_ref, &mut item_catalog);
let commit = source_ref.commit.clone().unwrap_or_default();
let mut forgotten = 0;
for key in &item_keys {
if let Some(item) = manifest.items.remove(key) {
let uninstall_hooks: Vec<&crate::mindfile::ResolvedHook> =
item_catalog_match(&item_catalog, &item)
.map(|c| c.uninstall_hooks())
.unwrap_or_default();
if let Err(e) = uninstall_item(
paths,
&item,
&uninstall_hooks,
&commit,
dangerously_skip_hook_check,
) {
manifest.items.insert(key.clone(), item);
manifest.save(paths)?;
registry.save(paths)?;
return Err(e);
}
forgotten += 1;
}
}
manifest.save(paths)?;
let proceed = run_uninstall_hooks(
paths,
®istry,
idx,
&source_name,
uninstall_hook,
dangerously_skip_hook_check,
)?;
if !proceed {
registry.save(paths)?;
return Ok(());
}
let source = registry.sources.remove(idx);
let dir = source.clone_dir(paths);
if !source.is_linked() && dir.exists() {
std::fs::remove_dir_all(&dir).map_err(|e| MindError::io(&dir, e))?;
}
registry.save(paths)?;
if out.json {
let mut result = MutationResult::new("unmeld", &source_name, "removed");
result.count = Some(forgotten);
return print_json(&result);
}
println!(
"{} unmelded {source_name} ({forgotten} installed item(s) removed)",
out.ok()
);
Ok(())
}
#[allow(dead_code)]
pub struct LearnPlan {
pub tree: String,
pub adds_dependencies: bool,
pub install_count: usize,
}
fn resolve_learn(
paths: &Paths,
item_ref: &str,
) -> Result<(Registry, Vec<CatalogItem>, crate::deps::Resolution)> {
let registry = Registry::load(paths)?;
let items = catalog::scan(paths, ®istry)?;
let parsed = parse_item_ref(item_ref)?;
let targets: Vec<&CatalogItem> = if is_glob(&parsed.name) {
let matches = select(&items, &parsed);
if matches.is_empty() {
return Err(MindError::ItemNotFound {
query: parsed.name.clone(),
sources: registry.sources.len(),
});
}
matches
} else {
vec![resolve(&items, &parsed, registry.sources.len())?]
};
let selected_idx: Vec<usize> = targets
.iter()
.filter_map(|t| {
items
.iter()
.position(|c| c.kind == t.kind && c.name == t.name && c.source == t.source)
})
.collect();
let manifest = Manifest::load(paths)?;
let installed: HashSet<String> = manifest.items.keys().cloned().collect();
let read = |item: &CatalogItem| -> String {
let mut parts: Vec<String> = Vec::new();
for file in crate::review::item_files(item) {
if let Ok(content) = std::fs::read_to_string(&file) {
parts.push(content);
}
}
parts.join("\n")
};
let resolution = crate::deps::resolve(&items, &selected_idx, &installed, read);
Ok((registry, items, resolution))
}
#[allow(dead_code)]
pub fn learn_preview(paths: &Paths, item_ref: &str) -> Result<LearnPlan> {
let (_registry, items, resolution) = resolve_learn(paths, item_ref)?;
Ok(LearnPlan {
tree: resolution.render_tree(&items),
adds_dependencies: resolution.adds_dependencies(),
install_count: resolution.install_order().len(),
})
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum Clobber {
Error,
Prompt,
Force,
}
#[derive(Clone, Copy)]
pub struct InstallFlow {
pub yes: bool,
pub clobber: Clobber,
pub dangerously_skip: bool,
}
pub fn learn(paths: &Paths, item_ref: &str, dry_run: bool, flow: InstallFlow) -> Result<()> {
let InstallFlow {
yes,
clobber,
dangerously_skip,
} = flow;
let policy = Policy::load()?;
let out = crate::render::ctx();
let (registry, items, resolution) = resolve_learn(paths, item_ref)?;
let order = resolution.install_order();
let closure: Vec<&CatalogItem> = order.iter().map(|&i| &items[i]).collect();
if let Some((key, sources)) = colliding_install(&closure) {
return Err(MindError::AmbiguousItem {
query: key,
candidates: sources,
});
}
if dry_run {
if out.json {
let mut result = MutationResult::new("learn", item_ref, "dry-run");
result.installed = closure.iter().map(|t| t.key()).collect();
return print_json(&result);
}
if resolution.adds_dependencies() {
print!("{}", resolution.render_tree(&items));
}
println!("would learn {} item(s):", closure.len());
let rows = closure
.iter()
.map(|t| vec![t.key(), t.source.clone()])
.collect::<Vec<_>>();
out.print_rows(&rows);
return Ok(());
}
if resolution.adds_dependencies() && !yes && !out.json {
print!("{}", resolution.render_tree(&items));
if !confirm("install this dependency closure?")? {
println!("cancelled; nothing installed");
return Ok(());
}
}
let mut manifest = Manifest::load(paths)?;
let mut failure = None;
let mut installed_keys: Vec<String> = Vec::new();
for target in &closure {
if let Some(policy) = policy.as_ref()
&& policy.lock()
&& !policy.allow_matches(&target.source)
{
if !out.json {
println!(
"{} skipping {} from {}: source not permitted by the managed policy's allowlist",
out.warn(),
target.key(),
target.source
);
}
continue;
}
let commit = match registry.find(&target.source) {
Some(s) => s.commit.clone().unwrap_or_default(),
None => {
failure = Some(MindError::SourceNotFound {
name: target.source.clone(),
});
break;
}
};
let siblings = siblings_of(&items, &target.source);
let force = clobber == Clobber::Force;
let mut result = install_item(paths, target, &commit, &siblings, force, dangerously_skip);
if let Err(MindError::LinkOccupied { path }) = &result
&& clobber == Clobber::Prompt
&& crate::hook::is_tty()
&& !out.json
{
let path = path.clone();
result = if confirm(&format!(
"{path} exists and is not managed by mind; overwrite it?"
))? {
install_item(paths, target, &commit, &siblings, true, dangerously_skip)
} else {
Err(MindError::LinkOccupied { path })
};
}
match result {
Ok(installed) => {
installed_keys.push(installed.key());
if !out.json {
println!(
"learned {} from {} ({})",
installed.key(),
installed.source,
out.green(&short(&installed.commit))
);
}
manifest.insert(installed);
}
Err(e) => {
failure = Some(e);
break;
}
}
}
manifest.save(paths)?;
match failure {
Some(e) => Err(e),
None => {
if out.json {
let mut result = MutationResult::new("learn", item_ref, "installed");
result.installed = installed_keys;
return print_json(&result);
}
Ok(())
}
}
}
pub fn install_melded_source(paths: &Paths, repo: &str, flow: InstallFlow) -> Result<()> {
install_source_items(paths, &parse_spec(repo)?.name, flow)
}
pub fn install_source_items(paths: &Paths, source_name: &str, flow: InstallFlow) -> Result<()> {
let item_ref = format!("{source_name}#*");
let plan = match learn_preview(paths, &item_ref) {
Ok(plan) => plan,
Err(MindError::ItemNotFound { .. }) => return Ok(()),
Err(e) => return Err(e),
};
if plan.install_count == 0 {
return Ok(());
}
if flow.yes {
return learn(paths, &item_ref, false, flow);
}
if !crate::hook::is_tty() {
if !json_mode() {
println!(
"note: {source_name} has {} item(s) to install; run `mind learn '{item_ref}'` (or re-meld with --yes)",
plan.install_count
);
}
return Ok(());
}
learn(paths, &item_ref, true, flow)?;
if confirm_default_yes(&format!(
"install these {} item(s) now?",
plan.install_count
))? {
learn(paths, &item_ref, false, InstallFlow { yes: true, ..flow })
} else {
println!("skipped; run `mind learn '{item_ref}'` to install later");
Ok(())
}
}
pub fn install_source_items_subset(
paths: &Paths,
source_name: &str,
bare_refs: &[String],
flow: InstallFlow,
) -> Result<()> {
if bare_refs.is_empty() {
return Ok(());
}
let registry = Registry::load(paths)?;
let Some(source) = registry.find(source_name) else {
return Ok(());
};
let source_items = catalog::scan(paths, &single(source))?;
let subset: Vec<&CatalogItem> = select_by_bare_refs(&source_items, bare_refs);
if subset.is_empty() {
return Ok(());
}
let manifest = crate::manifest::Manifest::load(paths)?;
let installed_keys: std::collections::HashSet<String> =
manifest.items.keys().cloned().collect();
let to_install: Vec<&CatalogItem> = subset
.into_iter()
.filter(|it| !installed_keys.contains(&it.key()))
.collect();
if to_install.is_empty() {
return Ok(());
}
let count = to_install.len();
let refs: Vec<String> = to_install
.iter()
.map(|it| format!("{source_name}#{}", it.key()))
.collect();
if flow.yes {
for item_ref in &refs {
learn(paths, item_ref, false, flow)?;
}
return Ok(());
}
if !crate::hook::is_tty() {
let ref_list = refs.join(", ");
if !json_mode() {
println!(
"note: {source_name} has {count} item(s) to install; run `mind learn '{}'` (or re-meld with --yes)",
if refs.len() == 1 {
refs[0].clone()
} else {
format!("{source_name}#*")
}
);
let _ = ref_list; }
return Ok(());
}
for item_ref in &refs {
learn(paths, item_ref, true, flow)?;
}
if confirm_default_yes(&format!("install these {count} item(s) now?"))? {
for item_ref in &refs {
learn(paths, item_ref, false, InstallFlow { yes: true, ..flow })?;
}
} else {
println!("skipped; run `mind learn '{source_name}#*'` to install later");
}
Ok(())
}
pub fn is_melded(paths: &Paths, repo: &str) -> Result<bool> {
let name = parse_spec(repo)?.name;
Ok(Registry::load(paths)?.find(&name).is_some())
}
pub fn remeld(
paths: &Paths,
repo: &str,
alias: Option<String>,
link_only: bool,
flow: InstallFlow,
recursive: bool,
) -> Result<()> {
let InstallFlow {
clobber,
dangerously_skip: dangerously_skip_hook_check,
..
} = flow;
let out = crate::render::ctx();
let source_name = parse_spec(repo)?.name;
if !out.json {
println!("{} {source_name} is already melded", out.bullet());
}
if let Some(new_alias) = alias {
let mut registry = Registry::load(paths)?;
if let Some(source) = registry.sources.iter_mut().find(|s| s.name == source_name) {
let current = source.alias.clone().unwrap_or_default();
if current != new_alias {
source.alias = Some(new_alias);
registry.save(paths)?;
let renamed = reprefix_source(paths, ®istry, &source_name)?;
if renamed == 0 && !out.json {
println!("prefix updated; no installed items to rename");
}
}
}
}
{
let mut registry = Registry::load(paths)?;
if let Some(idx) = registry.sources.iter().position(|s| s.name == source_name) {
let clone_dir = registry.sources[idx].clone_dir(paths);
let mindfile = MindToml::load(&clone_dir).unwrap_or_default();
let toml_path = clone_dir.join("mind.toml");
let force_rerun = clobber == Clobber::Force;
match run_install_hooks(
&mut registry.sources[idx],
&clone_dir,
&mindfile,
&toml_path,
None,
dangerously_skip_hook_check,
force_rerun,
Vec::new(),
) {
Ok(HookOutcome::Proceed) => registry.save(paths)?,
Ok(HookOutcome::Abort) => {
registry.save(paths)?; if !out.json {
println!("aborted; source left in place");
}
return Ok(());
}
Err(e) => return Err(e), }
}
}
if !link_only {
let item_ref = format!("{source_name}#*");
let to_install = match learn_preview(paths, &item_ref) {
Ok(plan) => plan.install_count,
Err(MindError::ItemNotFound { .. }) => 0,
Err(e) => return Err(e),
};
if to_install > 0 {
install_melded_source(paths, repo, flow)?;
install_curated_sources(paths, &source_name, recursive, flow)?;
return Ok(());
}
install_curated_sources(paths, &source_name, recursive, flow)?;
if recursive {
return Ok(());
}
}
if out.json {
return print_json(&MutationResult::new("meld", &source_name, "already-melded"));
}
source_status(paths, &source_name)
}
pub fn install_curated_sources(
paths: &Paths,
super_name: &str,
all: bool,
flow: InstallFlow,
) -> Result<()> {
let registry = Registry::load(paths)?;
let mut visited: HashSet<String> = HashSet::from([super_name.to_string()]);
let mut queue: Vec<String> = vec![super_name.to_string()];
while let Some(name) = queue.pop() {
let Some(source) = registry.find(&name) else {
continue;
};
let nested = MindToml::load(&source.clone_dir(paths))?
.and_then(|m| m.discover)
.map(|d| d.sources)
.unwrap_or_default();
for ns in nested {
let Ok(spec) = parse_spec(&ns.source) else {
continue;
};
if !visited.insert(spec.name.clone()) {
continue; }
if registry.find(&spec.name).is_some() {
if all {
install_source_items(paths, &spec.name, flow)?;
} else if let Some(refs) = &ns.install_items {
install_source_items_subset(paths, &spec.name, refs, flow)?;
} else if ns.install {
install_source_items(paths, &spec.name, flow)?;
}
queue.push(spec.name);
}
}
}
Ok(())
}
fn source_status(paths: &Paths, source_name: &str) -> Result<()> {
let out = crate::render::ctx();
let registry = Registry::load(paths)?;
let Some(source) = registry.find(source_name) else {
return Err(MindError::SourceNotFound {
name: source_name.to_string(),
});
};
let items = catalog::scan(paths, &single(source))?;
let manifest = Manifest::load(paths)?;
let head = source
.commit
.as_deref()
.map(short)
.unwrap_or_else(|| "?".to_string());
println!(
"{} {source_name}: {} item(s) (source @ {head})",
out.bullet(),
items.len()
);
for it in &items {
let installed = manifest
.items
.values()
.find(|m| m.source == it.source && m.kind == it.kind && m.bare_name == it.name);
match installed {
Some(m) => {
let hash_lag = hash_path(&it.path).map_or(true, |h| h != m.hash);
let rename_lag = it.effective_name() != m.name;
let stale = hash_lag || rename_lag;
let lag = if stale {
out.yellow(" (outdated; run `mind upgrade`)")
} else {
String::new()
};
let marker = if stale { out.stale() } else { out.ok() };
println!(
" {} {} installed @ {}{}",
marker,
it.key(),
out.green(&short(&m.commit)),
lag
);
}
None => println!(
" {} {} not installed (run `mind learn '{}'`)",
out.available(),
it.key(),
it.key()
),
}
}
Ok(())
}
fn reprefix_source(paths: &Paths, registry: &Registry, source_name: &str) -> Result<usize> {
let catalog = catalog::scan(paths, registry)?;
let mut manifest = Manifest::load(paths)?;
let installed: Vec<crate::manifest::InstalledItem> = manifest
.items
.values()
.filter(|it| it.source == source_name)
.cloned()
.collect();
let mut count = 0;
for old in installed {
let Some(cat) = catalog
.iter()
.find(|c| c.kind == old.kind && c.name == old.bare_name && c.source == old.source)
else {
continue; };
if cat.effective_name() == old.name {
continue; }
let siblings = siblings_of(&catalog, &old.source);
let new = install_item(paths, cat, &old.commit, &siblings, false, false)?;
uninstall_item(paths, &old, &cat.uninstall_hooks(), &old.commit, false)?;
manifest.items.remove(&old.key());
if !json_mode() {
let out = crate::render::ctx();
println!(
"{} renamed {} -> {}",
out.ok(),
old.key(),
out.green(&new.key())
);
}
manifest.insert(new);
count += 1;
}
manifest.save(paths)?;
Ok(count)
}
fn colliding_install(targets: &[&CatalogItem]) -> Option<(String, Vec<String>)> {
let mut by_key: std::collections::BTreeMap<String, Vec<String>> = Default::default();
for t in targets {
by_key.entry(t.key()).or_default().push(t.source.clone());
}
by_key.into_iter().find(|(_, sources)| sources.len() > 1)
}
fn install_item(
paths: &Paths,
item: &CatalogItem,
commit: &str,
siblings: &[CatalogItem],
force: bool,
dangerously_skip: bool,
) -> Result<crate::manifest::InstalledItem> {
let installed = install::install(paths, item, commit, siblings, force)?;
let install_hooks = item.install_hooks();
if !install_hooks.is_empty() {
let store = paths.mind_home.join(&installed.store);
if let Err(e) =
install::run_item_install_hooks(item, &install_hooks, &store, commit, dangerously_skip)
{
let _ = install::uninstall(paths, &installed);
return Err(e);
}
}
Ok(installed)
}
fn uninstall_item(
paths: &Paths,
item: &crate::manifest::InstalledItem,
uninstall_hooks: &[&crate::mindfile::ResolvedHook],
commit: &str,
dangerously_skip: bool,
) -> Result<()> {
if !uninstall_hooks.is_empty() {
let store = paths.mind_home.join(&item.store);
if store.exists() {
install::run_item_uninstall_hooks(
item,
uninstall_hooks,
&store,
commit,
dangerously_skip,
)?;
}
}
install::uninstall(paths, item)
}
fn item_catalog_match<'a>(
catalog: &'a [CatalogItem],
item: &crate::manifest::InstalledItem,
) -> Option<&'a CatalogItem> {
catalog
.iter()
.find(|c| c.kind == item.kind && c.name == item.bare_name && c.source == item.source)
}
fn convention_path_in_root(
root: &std::path::Path,
kind: ItemKind,
name: &str,
) -> std::path::PathBuf {
match kind {
ItemKind::Skill => root.join("skills").join(name),
ItemKind::Agent => root.join("agents").join(format!("{name}.md")),
ItemKind::Rule => root.join("rules").join(format!("{name}.md")),
ItemKind::Tool => panic!("tools are never unmanaged; absorb should not reach this"),
}
}
fn first_scan_root(dest_path: &std::path::Path) -> Result<std::path::PathBuf> {
let mindfile = crate::mindfile::MindToml::load(dest_path).unwrap_or_default();
let root_rel = mindfile
.as_ref()
.and_then(|m| m.source.roots.as_ref())
.and_then(|r| r.first())
.map(String::as_str)
.unwrap_or(".");
let candidate = dest_path.join(root_rel);
let canon_dest = std::fs::canonicalize(dest_path).unwrap_or_else(|_| dest_path.to_path_buf());
let canon_root =
std::fs::canonicalize(&candidate).unwrap_or_else(|_| normalize_path(&candidate));
if !canon_root.starts_with(&canon_dest) {
return Err(MindError::InvalidRoot {
source_name: dest_path.to_string_lossy().into_owned(),
root: root_rel.to_string(),
});
}
Ok(candidate)
}
fn normalize_path(path: &std::path::Path) -> std::path::PathBuf {
use std::path::Component;
let mut components: Vec<&std::ffi::OsStr> = Vec::new();
for comp in path.components() {
match comp {
Component::ParentDir => {
if components
.last()
.is_some_and(|c| *c != std::ffi::OsStr::new("/"))
{
components.pop();
}
}
Component::CurDir => {
}
_ => {
components.push(comp.as_os_str());
}
}
}
components.iter().collect()
}
fn dest_source_prefix(dest_path: &std::path::Path, registry: &Registry) -> Option<String> {
if let Ok(spec) = parse_spec(&dest_path.to_string_lossy())
&& let Some(src) = registry.find(&spec.name)
&& let Some(alias) = src.alias.as_deref().filter(|a| !a.is_empty())
{
return Some(alias.to_string());
}
let mindfile = crate::mindfile::MindToml::load(dest_path).unwrap_or_default();
mindfile
.as_ref()
.and_then(|m| m.source.prefix.clone())
.filter(|p| !p.is_empty())
}
pub fn absorb(
paths: &Paths,
item_ref_str: &str,
to: Option<String>,
force: bool,
yes: bool,
) -> Result<()> {
let out = crate::render::ctx();
let parsed = parse_item_ref(item_ref_str)?;
if is_glob(&parsed.name) {
return Err(MindError::InvalidItemRef {
name: item_ref_str.to_string(),
});
}
let manifest = Manifest::load(paths)?;
let unmanaged_items = crate::unmanaged::scan(paths, &manifest)?;
let item = crate::unmanaged::resolve(&unmanaged_items, &parsed)?;
if item.kind == ItemKind::Tool {
return Err(MindError::InvalidItemRef {
name: item_ref_str.to_string(),
});
}
let (dest_path, interactive_dest) = resolve_absorb_dest(paths, to, yes)?;
if !crate::git::is_repo(&dest_path) {
return Err(MindError::DestinationNotRepo {
path: dest_path.to_string_lossy().into_owned(),
});
}
if interactive_dest {
offer_save_absorb_to(paths, &dest_path, yes)?;
}
let scan_root = first_scan_root(&dest_path)?;
let dest_item_path = convention_path_in_root(&scan_root, item.kind, &item.name);
if dest_item_path.exists() && !force {
return Err(MindError::AbsorbCollision {
kind: item.kind.as_str().to_string(),
name: item.name.clone(),
dest_path: dest_item_path.to_string_lossy().into_owned(),
});
}
let source_lobe_path = item
.paths
.first()
.ok_or_else(|| MindError::NotInstalled { name: item.key() })?
.clone();
let stray_paths: Vec<&std::path::PathBuf> = item.paths.iter().skip(1).collect();
if !yes {
if !out.json {
println!("absorb will:");
println!(
" move {} -> {}",
source_lobe_path.display(),
dest_item_path.display()
);
for stray in &stray_paths {
println!(" delete (stray copy) {}", stray.display());
}
}
if !crate::hook::is_tty() || out.json {
return Err(MindError::ConfirmationRequired {
action: format!("absorbing {}", item.key()),
});
}
if !confirm("proceed with absorb?")? {
println!("cancelled; nothing changed");
return Ok(());
}
}
if let Some(parent) = dest_item_path.parent() {
std::fs::create_dir_all(parent).map_err(|e| MindError::io(parent, e))?;
}
if dest_item_path.exists() {
crate::install::remove_path(&dest_item_path)?;
}
copy_path_recursive(&source_lobe_path, &dest_item_path)?;
let git_err = (|| {
crate::git::add_all(&dest_path)?;
let commit_msg = format!("absorb {}:{}", item.kind.as_str(), item.name);
crate::git::commit(&dest_path, &commit_msg)
})();
if let Err(e) = git_err {
let _ = crate::install::remove_path(&dest_item_path);
return Err(e);
}
let dest_spec = dest_path.to_string_lossy().into_owned();
if !is_melded(paths, &dest_spec)? {
let meld_err = meld(
paths,
&dest_spec,
None,
vec![],
None,
None,
None,
None,
false,
);
if let Err(e) = meld_err {
let _ = crate::install::remove_path(&dest_item_path);
return Err(e);
}
}
let backup = paths
.tmp_dir()
.join("absorb-backup")
.join(item.kind.as_str())
.join(&item.name);
let _ = crate::install::remove_path(&backup);
if let Some(p) = backup.parent() {
std::fs::create_dir_all(p).map_err(|e| MindError::io(p, e))?;
}
copy_path_recursive(&source_lobe_path, &backup)?;
if let Err(e) = crate::install::remove_path(&source_lobe_path) {
let _ = crate::install::remove_path(&backup);
return Err(e);
}
let registry_for_prefix = Registry::load(paths)?;
let effective_prefix = dest_source_prefix(&dest_path, ®istry_for_prefix);
let effective_name = crate::namespace::apply(&item.name, &effective_prefix);
let effective_key = format!("{}:{}", item.kind.as_str(), effective_name);
let dest_source_name = parse_spec(&dest_spec)
.map(|s| s.name)
.unwrap_or_else(|_| dest_spec.clone());
let learn_ref = format!("{}:{}", item.kind.as_str(), effective_name);
let qualified_ref = format!("{dest_source_name}#{learn_ref}");
let learn_err = learn(
paths,
&qualified_ref,
false,
InstallFlow {
yes: true, clobber: Clobber::Force, dangerously_skip: false,
},
);
if let Err(e) = learn_err {
if let Some(parent) = source_lobe_path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let _ = copy_path_recursive(&backup, &source_lobe_path);
let _ = crate::install::remove_path(&backup);
return Err(e);
}
let _ = crate::install::remove_path(&backup);
if !out.json {
println!(
"{} absorbed {} -> managed as {effective_key}",
out.ok(),
item.key()
);
}
Ok(())
}
fn resolve_absorb_dest(
paths: &Paths,
to_flag: Option<String>,
yes: bool,
) -> Result<(std::path::PathBuf, bool)> {
if let Some(p) = to_flag {
let path = expand_tilde(&p);
return Ok((path, false));
}
if let Some(p) = std::env::var_os("MIND_ABSORB_TO") {
let path = expand_tilde(&p.to_string_lossy());
return Ok((path, false));
}
let config = Config::load(paths)?;
if let Some(p) = config.absorb_to {
let path = expand_tilde(&p);
return Ok((path, false));
}
if !crate::hook::is_tty() {
return Err(MindError::ConfirmationRequired {
action: "absorb (no destination configured; re-run with --to <path>)".to_string(),
});
}
let personal = paths.mind_home.join("personal");
let personal_str = personal.to_string_lossy();
let chosen = if yes {
personal.clone()
} else {
println!("No absorb destination configured.");
println!("Enter a path, or press Enter to use the built-in: {personal_str}");
print!("> ");
let _ = std::io::stdout().flush();
let mut line = String::new();
std::io::stdin()
.read_line(&mut line)
.map_err(|e| MindError::io("<stdin>", e))?;
let trimmed = line.trim();
if trimmed.is_empty() {
personal.clone()
} else {
expand_tilde(trimmed)
}
};
if chosen == personal && !personal.exists() {
if !out_ctx().json {
println!(
"Creating {} and initializing git repository",
personal.display()
);
}
crate::git::git_init(&personal)?;
}
Ok((chosen, true))
}
fn offer_save_absorb_to(paths: &Paths, dest: &std::path::Path, yes: bool) -> Result<()> {
if yes {
let mut config = Config::load(paths)?;
config.absorb_to = Some(dest.to_string_lossy().into_owned());
paths.ensure_layout()?;
config.save(paths)?;
return Ok(());
}
if !crate::hook::is_tty() {
return Ok(());
}
print!(
"\nSave '{}' as absorb_to in config.toml? [y/N] ",
dest.display()
);
let _ = std::io::stdout().flush();
let mut line = String::new();
std::io::stdin()
.read_line(&mut line)
.map_err(|e| MindError::io("<stdin>", e))?;
if parse_confirm(&line, false) {
let mut config = Config::load(paths)?;
config.absorb_to = Some(dest.to_string_lossy().into_owned());
paths.ensure_layout()?;
config.save(paths)?;
println!("Saved absorb_to = '{}'", dest.display());
}
Ok(())
}
fn out_ctx() -> crate::render::OutputCtx {
crate::render::ctx()
}
fn copy_path_recursive(src: &std::path::Path, dst: &std::path::Path) -> Result<()> {
if src.is_dir() {
std::fs::create_dir_all(dst).map_err(|e| MindError::io(dst, e))?;
let rd = std::fs::read_dir(src).map_err(|e| MindError::io(src, e))?;
for entry in rd.flatten() {
let from = entry.path();
let to = dst.join(entry.file_name());
copy_path_recursive(&from, &to)?;
}
} else {
std::fs::copy(src, dst).map_err(|e| MindError::io(src, e))?;
}
Ok(())
}
fn expand_tilde(path: &str) -> std::path::PathBuf {
if path == "~" {
return dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from("~"));
}
if let Some(rest) = path.strip_prefix("~/")
&& let Some(home) = dirs::home_dir()
{
return home.join(rest);
}
std::path::PathBuf::from(path)
}
pub fn forget(
paths: &Paths,
item_ref: Option<&str>,
unmanaged: bool,
yes: bool,
force: bool,
dangerously_skip: bool,
) -> Result<()> {
if unmanaged {
return forget_unmanaged_bulk(paths, item_ref, yes);
}
let item_ref = item_ref.expect("item_ref required when --unmanaged is not set");
let out = crate::render::ctx();
let mut manifest = Manifest::load(paths)?;
let parsed = parse_item_ref(item_ref)?;
let keys: Vec<String> = if is_glob(&parsed.name) {
let matches = select_installed(&manifest.items, &parsed);
if matches.is_empty() {
return Err(MindError::NotInstalled {
name: parsed.name.clone(),
});
}
matches.iter().map(|it| it.key()).collect()
} else {
match crate::resolve::resolve_installed(&manifest.items, &parsed) {
Ok(it) => vec![it.key()],
Err(MindError::NotInstalled { .. }) => {
let unmanaged_items = crate::unmanaged::scan(paths, &manifest)?;
let item = crate::unmanaged::resolve(&unmanaged_items, &parsed)?;
return forget_unmanaged_single(item, yes);
}
Err(e) => return Err(e),
}
};
if keys.len() > 1 && !yes {
if !out.json {
println!("forget would remove {} item(s):", keys.len());
for key in &keys {
println!(" {} {key}", out.warn());
}
}
if !crate::hook::is_tty() {
return Err(MindError::ConfirmationRequired {
action: format!("removing {} items", keys.len()),
});
}
if !out.json && !confirm("remove these item(s)?")? {
println!("cancelled; nothing removed");
return Ok(());
}
}
let registry = Registry::load(paths)?;
let catalog = catalog::scan(paths, ®istry).unwrap_or_default();
if keys.len() == 1 {
let removed_key = &keys[0];
let installed_keys: HashSet<String> = manifest.items.keys().cloned().collect();
let graph = crate::deps::installed_graph(&catalog, &installed_keys, read_item_text);
let dependents = graph.dependents(removed_key);
if !dependents.is_empty() && !yes && !force {
if !out.json {
println!(
"{} removing {removed_key} will break the following installed items that depend on it:",
out.warn()
);
for dep in &dependents {
println!(" {dep}");
}
}
if !crate::hook::is_tty() || out.json {
return Err(MindError::ConfirmationRequired {
action: format!(
"removing {removed_key} (has {} dependent(s))",
dependents.len()
),
});
}
if !confirm("remove anyway?")? {
println!("cancelled; nothing removed");
return Ok(());
}
}
}
let mut removed: Vec<String> = Vec::new();
for key in keys {
let item = manifest.items.remove(&key).expect("key from manifest");
let uninstall_hooks: Vec<&crate::mindfile::ResolvedHook> =
item_catalog_match(&catalog, &item)
.map(|c| c.uninstall_hooks())
.unwrap_or_default();
let commit = registry
.find(&item.source)
.and_then(|s| s.commit.clone())
.unwrap_or_default();
if let Err(e) = uninstall_item(paths, &item, &uninstall_hooks, &commit, dangerously_skip) {
manifest.items.insert(key.clone(), item);
manifest.save(paths)?;
return Err(e);
}
removed.push(key.clone());
if !out.json {
println!("{} forgot {key}", out.ok());
}
}
manifest.save(paths)?;
if out.json {
let mut result = MutationResult::new("forget", item_ref, "removed");
result.removed = removed;
return print_json(&result);
}
Ok(())
}
fn forget_unmanaged_single(item: &crate::unmanaged::UnmanagedItem, yes: bool) -> Result<()> {
let out = crate::render::ctx();
let where_ = item
.paths
.iter()
.map(|p| p.display().to_string())
.collect::<Vec<_>>()
.join(", ");
if !out.json {
println!(
"{} {} is not managed by mind: it is your own file or directory at {where_}, not a mind install. Removing it deletes it.",
out.warn(),
item.key()
);
}
if !yes {
if !crate::hook::is_tty() {
return Err(MindError::ConfirmationRequired {
action: format!("removing unmanaged {}", item.key()),
});
}
if !out.json && !confirm("remove this unmanaged item?")? {
println!("cancelled; nothing removed");
return Ok(());
}
}
for p in &item.paths {
crate::install::remove_path(p)?;
}
if out.json {
let mut result = MutationResult::new("forget", &item.key(), "removed");
result.removed = vec![item.key()];
return print_json(&result);
}
println!("{} forgot {} (unmanaged)", out.ok(), item.key());
Ok(())
}
fn forget_unmanaged_bulk(paths: &Paths, item_ref: Option<&str>, yes: bool) -> Result<()> {
let out = crate::render::ctx();
let manifest = Manifest::load(paths)?;
let scanned = crate::unmanaged::scan(paths, &manifest)?;
let parsed = item_ref.map(parse_item_ref).transpose()?;
let matched = crate::unmanaged::select(&scanned, parsed.as_ref());
let sentinel = item_ref.unwrap_or("*");
if matched.is_empty() {
return Err(MindError::NotInstalled {
name: sentinel.to_string(),
});
}
if !out.json {
println!(
"{} forget --unmanaged would remove {} unmanaged item(s):",
out.warn(),
matched.len()
);
for item in &matched {
println!(" {} {}", out.warn(), item.key());
}
println!(
"{} these items are NOT managed by mind: removing them deletes your own files or directories, not symlinks.",
out.warn()
);
}
if !yes {
if !crate::hook::is_tty() {
return Err(MindError::ConfirmationRequired {
action: format!("removing {} unmanaged items", matched.len()),
});
}
if !out.json && !confirm("remove these unmanaged items?")? {
println!("cancelled; nothing removed");
return Ok(());
}
}
let mut removed: Vec<String> = Vec::new();
for item in &matched {
for p in &item.paths {
crate::install::remove_path(p)?;
}
removed.push(item.key());
if !out.json {
println!("{} forgot {} (unmanaged)", out.ok(), item.key());
}
}
if out.json {
let mut result = MutationResult::new("forget", sentinel, "removed");
result.removed = removed;
return print_json(&result);
}
Ok(())
}
pub fn sync(paths: &Paths, then_upgrade: bool, dangerously_skip_hook_check: bool) -> Result<()> {
let out = crate::render::ctx();
let policy = Policy::load()?;
let prefer_ssh = Config::load(paths)?.ssh;
let mut registry = Registry::load(paths)?;
let mut sync_skipped: Vec<SkippedEntry> = Vec::new();
if let Some(policy) = policy.as_ref() {
paths.ensure_layout()?;
let mut visited = HashSet::new();
let mut provisioned = 0usize;
for am in policy.auto_meld() {
if let Ok(spec) = parse_spec(&am.repo)
&& registry.find(&spec.name).is_some()
{
continue;
}
provisioned += meld_recursive(
paths,
&mut registry,
&am.repo,
None,
vec![],
Some(am.pin.clone()),
false, &mut visited,
Some(policy),
None, false, prefer_ssh,
None, &mut sync_skipped,
)?;
}
if provisioned > 0 {
registry.save(paths)?;
}
}
if registry.sources.is_empty() {
if out.json {
return print_json(&MutationResult::new("sync", "", "no-op"));
}
println!("no sources melded; run `mind meld <owner/repo>`");
return Ok(());
}
let total = registry.sources.len();
let mut failures: Vec<String> = Vec::new();
let mut synced = 0usize;
for source in &mut registry.sources {
if let Some(policy) = policy.as_ref()
&& policy.lock()
&& !policy.allow_matches(&source.name)
{
if !out.json {
println!(
"{} skipping {}: source not permitted by the managed policy's allowlist",
out.warn(),
source.name
);
}
continue;
}
let dir = source.clone_dir(paths);
if !out.json {
print!("{} syncing {} ... ", out.bullet(), source.name);
let _ = std::io::stdout().flush();
}
let refreshed = (|| -> Result<(String, bool, Option<String>)> {
if source.is_linked() {
if !dir.is_dir() {
return Err(MindError::NotADirectory {
path: dir.display().to_string(),
});
}
let new_commit = git::head_commit(&source.url, &dir)
.ok()
.or_else(|| source.commit.clone())
.unwrap_or_default();
let changed = source.commit.as_deref() != Some(new_commit.as_str());
let desc = MindToml::load(&dir)?.and_then(|mt| mt.source.description);
return Ok((new_commit, changed, desc));
}
let pin = source.pin.clone();
if dir.join(".git").is_dir() {
git::sync_to_pin(&source.url, &dir, &pin)?;
} else {
if let Some(parent) = dir.parent() {
crate::paths::mkdir_p(parent)?;
}
git::clone_at(&source.url, &dir, &pin)?;
}
let new_commit = git::head_commit(&source.url, &dir)?;
let changed = source.commit.as_deref() != Some(new_commit.as_str());
let desc = MindToml::load(&dir)?.and_then(|mt| mt.source.description);
Ok((new_commit, changed, desc))
})();
match refreshed {
Ok((new_commit, changed, desc)) => {
source.commit = Some(new_commit.clone());
source.description = desc;
synced += 1;
if !out.json {
let label = if changed {
out.green("updated")
} else {
out.dim("up to date")
};
println!("{} ({})", label, short(&new_commit));
}
}
Err(e) => {
if !out.json {
println!("{}", out.red("failed"));
eprintln!(" {} {}: {e}", out.err(), source.name);
}
failures.push(source.name.clone());
}
}
}
registry.save(paths)?;
{
struct NestedTodo {
spec: String,
alias: Option<String>,
curated: CuratedConfig,
on_auth_failure: Option<crate::mindfile::OnAuthFailure>,
}
let mut nested: Vec<NestedTodo> = Vec::new();
for s in ®istry.sources {
let clone_dir = s.clone_dir(paths);
let toml_path = clone_dir.join("mind.toml");
let Some(mt) = MindToml::load(&clone_dir).ok().flatten() else {
continue;
};
let Some(discover) = mt.discover else {
continue;
};
for ns in discover.sources {
let curated = CuratedConfig {
pin: ns.pin_directive(&toml_path)?,
roots: ns.roots.clone(),
hooks: ns.resolved_hooks(&toml_path)?,
};
nested.push(NestedTodo {
spec: ns.source,
alias: ns.alias,
curated,
on_auth_failure: ns.on_auth_failure,
});
}
}
let mut visited: HashSet<String> = registry.sources.iter().map(|s| s.url.clone()).collect();
let mut discovered = 0usize;
for todo in nested {
if let Ok(s) = parse_spec(&todo.spec)
&& registry.find(&s.name).is_some()
{
continue;
}
match meld_recursive(
paths,
&mut registry,
&todo.spec,
todo.alias,
vec![],
None,
false,
&mut visited,
policy.as_ref(),
None, false, prefer_ssh,
Some(todo.curated),
&mut sync_skipped,
) {
Ok(n) => discovered += n,
Err(e) if git::is_auth_failure(&e) => {
let entry_name = parse_spec(&todo.spec)
.map(|s| s.name)
.unwrap_or_else(|_| todo.spec.clone());
if registry.find(&entry_name).is_some() {
return Err(e);
}
let Some(cfg) = &todo.on_auth_failure else {
return Err(e);
};
for line in auth_failure_lines(&entry_name, cfg) {
eprintln!("{line}");
}
if cfg.action == AuthFailureAction::Skip {
sync_skipped.push(SkippedEntry {
source: entry_name,
reason: "auth_failure".into(),
});
continue;
}
return Err(e);
}
Err(e) => return Err(e),
}
}
if discovered > 0 {
registry.save(paths)?;
}
}
if !failures.is_empty() {
return Err(MindError::SyncFailed {
failed: failures.len(),
total,
});
}
if out.json {
let mut result = MutationResult::new("sync", "", "synced");
result.count = Some(synced);
result.skipped = sync_skipped;
print_json(&result)?;
}
if then_upgrade {
upgrade(paths, false, None, dangerously_skip_hook_check)?;
}
Ok(())
}
pub fn upgrade(
paths: &Paths,
yes: bool,
item_ref: Option<&str>,
dangerously_skip_hook_check: bool,
) -> Result<()> {
let out = crate::render::ctx();
let policy = Policy::load()?;
let mut registry = Registry::load(paths)?;
let manifest = Manifest::load(paths)?;
let filter = item_ref.map(parse_item_ref).transpose()?;
let hook_scope: Option<HashSet<String>> = filter.as_ref().map(|f| {
manifest
.items
.values()
.filter(|it| crate::resolve::installed_matches(it, f))
.map(|it| it.source.clone())
.collect()
});
rerun_source_hooks(
paths,
&mut registry,
dangerously_skip_hook_check,
hook_scope.as_ref(),
policy.as_ref(),
)?;
let catalog = catalog::scan(paths, ®istry)?;
let mut pending: Vec<Upgrade> = Vec::new();
for installed in manifest.items.values() {
match upgrade_item_disposition(installed, filter.as_ref(), policy.as_ref()) {
UpgradeDisposition::OutOfScope => continue,
UpgradeDisposition::PolicyBlocked => {
if !out.json {
println!(
"{} skipping {} from {}: source not permitted by the managed policy's allowlist",
out.warn(),
installed.key(),
installed.source
);
}
continue;
}
UpgradeDisposition::Consider => {}
}
let Some(cat) = catalog.iter().find(|c| {
c.kind == installed.kind
&& c.name == installed.bare_name
&& c.source == installed.source
}) else {
continue;
};
let new_hash = hash_path(&cat.path)?;
let new_name = cat.effective_name();
let new_commit = registry
.find(&installed.source)
.and_then(|s| s.commit.clone())
.unwrap_or_default();
let renamed = new_name != installed.name;
if new_hash != installed.hash || renamed {
pending.push(Upgrade {
cat: cat.clone(),
old: installed.clone(),
new_commit,
new_hash,
new_name,
});
}
}
let target = item_ref.unwrap_or("all");
if pending.is_empty() {
if out.json {
return print_json(&MutationResult::new("upgrade", target, "up-to-date"));
}
println!("everything is up to date");
return Ok(());
}
if !out.json {
print_upgrade_report(®istry, &pending);
}
if !yes && !out.json && !confirm_default_yes("apply these upgrades?")? {
println!("aborted; nothing changed");
return Ok(());
}
let mut manifest = manifest;
let mut applied: Vec<String> = Vec::new();
let mut renamed = false;
for up in &pending {
let siblings = siblings_of(&catalog, &up.cat.source);
let installed = install_item(
paths,
&up.cat,
&up.new_commit,
&siblings,
false,
dangerously_skip_hook_check,
)?;
if up.new_name != up.old.name {
uninstall_item(
paths,
&up.old,
&up.cat.uninstall_hooks(),
&up.old.commit,
dangerously_skip_hook_check,
)?;
manifest.items.remove(&up.old.key());
renamed = true;
if !out.json {
println!(
"{} upgraded {} -> {}",
out.ok(),
up.old.key(),
out.green(&installed.key())
);
}
} else if !out.json {
println!("{} upgraded {}", out.ok(), out.green(&installed.key()));
}
applied.push(installed.key());
manifest.insert(installed);
}
manifest.save(paths)?;
if out.json {
let outcome = if renamed { "renamed" } else { "upgraded" };
let mut result = MutationResult::new("upgrade", target, outcome);
result.installed = applied;
return print_json(&result);
}
Ok(())
}
fn rerun_source_hooks(
paths: &Paths,
registry: &mut Registry,
dangerously_skip_hook_check: bool,
in_scope: Option<&HashSet<String>>,
policy: Option<&Policy>,
) -> Result<()> {
let mut changed = false;
for source in &mut registry.sources {
if !hook_rerun_warranted(source) {
continue;
}
if let Some(scope) = in_scope
&& !scope.contains(&source.name)
{
continue;
}
if let Some(policy) = policy
&& policy.lock()
&& !policy.allow_matches(&source.name)
{
if !json_mode() {
println!(
"skipping install hook for {}: source not permitted by the managed policy's allowlist",
source.name
);
}
continue;
}
let dir = source.clone_dir(paths);
let pin_desc = pin_description(&source.pin);
let commit = source.commit.clone().unwrap_or_default();
let clone_path = dir.display().to_string();
let pending_indices: Vec<usize> = source
.install_hooks
.iter()
.enumerate()
.filter(|(_, h)| h.ran_at.is_none() || h.ran_at.as_deref() != source.commit.as_deref())
.map(|(i, _)| i)
.collect();
for idx in pending_indices {
let cmd = source.install_hooks[idx].command.clone();
let run = if dangerously_skip_hook_check {
if !json_mode() {
println!(
"note: re-running install hook for {} without the safety prompt (--dangerously-skip-install-hook-check)",
source.name
);
}
true
} else if !crate::hook::is_tty() {
if !json_mode() {
println!(
"note: skipped re-running the install hook for {} (no TTY); its tooling may be out of date until the hook is re-run",
source.name
);
}
false
} else {
let disclosure = crate::hook::disclosure_text(
&source.name,
&pin_desc,
&commit,
&clone_path,
&cmd,
None,
);
matches!(
crate::hook::prompt_choice(&disclosure)?,
crate::hook::HookChoice::RunAndContinue
)
};
if run {
crate::hook::run_hook(&cmd, &dir, &source.name, &cmd)?;
source.install_hooks[idx].ran_at = source.commit.clone();
changed = true;
if !json_mode() {
println!("re-ran install hook for {}", source.name);
}
}
}
}
if changed {
registry.save(paths)?;
}
Ok(())
}
#[derive(Debug, PartialEq, Eq)]
enum UpgradeDisposition {
OutOfScope,
PolicyBlocked,
Consider,
}
fn upgrade_item_disposition(
installed: &crate::manifest::InstalledItem,
filter: Option<&crate::resolve::ItemRef>,
policy: Option<&Policy>,
) -> UpgradeDisposition {
if let Some(f) = filter
&& !crate::resolve::installed_matches(installed, f)
{
return UpgradeDisposition::OutOfScope;
}
if let Some(policy) = policy
&& policy.lock()
&& !policy.allow_matches(&installed.source)
{
return UpgradeDisposition::PolicyBlocked;
}
UpgradeDisposition::Consider
}
struct Upgrade {
cat: CatalogItem,
old: crate::manifest::InstalledItem,
new_commit: String,
new_hash: String,
new_name: String,
}
fn print_upgrade_report(registry: &Registry, pending: &[Upgrade]) {
let out = crate::render::ctx();
println!("{} item(s) have upstream changes:\n", pending.len());
for up in pending {
if up.new_name != up.old.name {
println!(
" {} {} {} {} rename {} -> {}",
out.warn(),
up.cat.kind,
up.cat.name,
out.dim(&format!("[{}]", up.cat.source)),
up.old.name,
out.green(&up.new_name)
);
} else {
println!(
" {} {} {}",
out.warn(),
up.cat.key(),
out.dim(&format!("[{}]", up.cat.source))
);
}
println!(
" {} {} -> {}",
out.dim("hash"),
short(&up.old.hash),
short(&up.new_hash)
);
println!(
" {} {} -> {}",
out.dim("commit"),
short(&up.old.commit),
short(&up.new_commit)
);
if let Some(src) = registry.find(&up.cat.source)
&& !up.old.commit.is_empty()
&& !up.new_commit.is_empty()
&& let Some(url) = src.compare_url(&up.old.commit, &up.new_commit)
{
println!(" {} {url}", out.dim("diff"));
}
println!();
}
}
pub fn recall(
paths: &Paths,
sources: bool,
item: Option<&str>,
kind: Option<ItemKind>,
source: Option<&str>,
json: bool,
tree: bool,
) -> Result<()> {
let out = crate::render::ctx();
if (sources || item.is_some()) && (kind.is_some() || source.is_some()) {
eprintln!(
"note: --kind/--source filter the item listing; ignored with --sources or a single item"
);
}
if tree && sources {
eprintln!("note: --tree shows the dependency forest; ignored with --sources");
}
if tree && !sources {
let manifest = Manifest::load(paths)?;
let registry = Registry::load(paths)?;
let catalog = catalog::scan(paths, ®istry).unwrap_or_default();
let installed_keys: HashSet<String> = manifest.items.keys().cloned().collect();
let graph = crate::deps::installed_graph(&catalog, &installed_keys, read_item_text);
if json {
if let Some(item_ref) = item {
let parsed = parse_item_ref(item_ref)?;
let found = crate::resolve::resolve_installed(&manifest.items, &parsed)?;
let key = found.key();
let node = graph
.subtree_node(&key)
.unwrap_or_else(|| crate::deps::DepNode::normal(key, vec![]));
return print_json(&node);
} else {
return print_json(&graph.forest_nodes());
}
}
if let Some(item_ref) = item {
let parsed = parse_item_ref(item_ref)?;
let found = crate::resolve::resolve_installed(&manifest.items, &parsed)?;
let key = found.key();
match graph.render_subtree(&key) {
Some(subtree) => print!("{subtree}"),
None => println!("{key}"),
}
} else {
let forest = graph.render_forest();
if forest.is_empty() {
println!("no installed items");
} else {
print!("{forest}");
}
}
return Ok(());
}
if let Some(s) = source {
crate::resolve::validate_source_selector(s)?;
}
if sources {
let registry = Registry::load(paths)?;
if json {
return print_json(®istry.sources);
}
if registry.sources.is_empty() {
println!("no sources melded");
return Ok(());
}
let rows = registry
.sources
.iter()
.map(|s| {
let commit = s
.commit
.as_deref()
.map(short)
.unwrap_or_else(|| "unsynced".into());
let ns = match &s.alias {
Some(a) => format!(" as:{a}"),
None => String::new(),
};
let hook = match s.install_hooks.len() {
0 => String::new(),
1 => " hook".to_string(),
n => format!(" hooks({n})"),
};
vec![
out.bullet(),
s.name.clone(),
out.dim(&s.url),
out.dim(&format!("[{commit}{ns}{hook}]")),
s.description.clone().unwrap_or_default(),
]
})
.collect::<Vec<_>>();
out.print_rows(&rows);
return Ok(());
}
let manifest = Manifest::load(paths)?;
if let Some(item_ref) = item {
let parsed = parse_item_ref(item_ref)?;
let found = crate::resolve::resolve_installed(&manifest.items, &parsed)?;
if json {
return print_json(found);
}
println!("{}", out.bold(&found.key()));
if let Some(d) = &found.description {
println!(" {}{d}", out.dim("desc "));
}
println!(" {}{}", out.dim("source "), found.source);
println!(" {}{}", out.dim("commit "), short(&found.commit));
println!(" {}{}", out.dim("hash "), short(&found.hash));
println!(
" {}{}",
out.dim("store "),
paths.mind_home.join(&found.store).display()
);
for link in &found.links {
println!(" {}{link}", out.dim("link "));
}
{
let registry = Registry::load(paths)?;
let catalog = catalog::scan(paths, ®istry)?;
if let Some(cat) = catalog.iter().find(|c| {
c.kind == found.kind && c.name == found.bare_name && c.source == found.source
}) {
let hash_lag = hash_path(&cat.path).map_or(true, |h| h != found.hash);
let rename_lag = cat.effective_name() != found.name;
if hash_lag || rename_lag {
println!(
" {}{}",
out.dim("status "),
out.yellow("out of date; run `mind upgrade`")
);
}
}
}
return Ok(());
}
let registry = Registry::load(paths)?;
let catalog = catalog::scan(paths, ®istry)?;
let filtering = kind.is_some() || source.is_some();
let cat_items = |s: &crate::source::Source| -> Vec<&CatalogItem> {
let mut v: Vec<&CatalogItem> = catalog
.iter()
.filter(|it| it.source == s.name && kind.is_none_or(|k| it.kind == k))
.collect();
v.sort_by_key(|x| x.key());
v
};
let orphans_of = |s: &crate::source::Source| -> Vec<&crate::manifest::InstalledItem> {
let mut v: Vec<&crate::manifest::InstalledItem> = manifest
.items
.values()
.filter(|m| {
m.source == s.name
&& kind.is_none_or(|k| m.kind == k)
&& !catalog.iter().any(|it| {
it.source == m.source && it.kind == m.kind && it.name == m.bare_name
})
})
.collect();
v.sort_by_key(|x| x.key());
v
};
let source_shown =
|s: &crate::source::Source| source.is_none_or(|q| source_matches_glob(&s.name, q));
if json {
let out: Vec<serde_json::Value> = registry
.sources
.iter()
.filter(|s| source_shown(s))
.map(|s| {
let items = cat_items(s);
let mut rows: Vec<serde_json::Value> = items
.iter()
.map(|it| {
let inst = manifest.items.values().find(|m| {
m.source == it.source && m.kind == it.kind && m.bare_name == it.name
});
serde_json::json!({
"key": it.key(),
"installed": inst.is_some(),
"commit": inst.map(|m| m.commit.clone()),
})
})
.collect();
for m in orphans_of(s) {
rows.push(serde_json::json!({
"key": m.key(),
"installed": true,
"commit": m.commit.clone(),
"orphaned": true,
}));
}
serde_json::json!({
"name": s.name,
"url": s.url,
"commit": s.commit,
"alias": s.alias,
"items": rows,
})
})
.collect();
return print_json(&out);
}
if registry.sources.is_empty() {
println!("no sources melded; `mind meld <repo>` to add one");
}
for s in ®istry.sources {
if !source_shown(s) {
continue;
}
let items = cat_items(s);
let orphans = orphans_of(s);
if items.is_empty() && orphans.is_empty() && filtering {
continue; }
let commit = s
.commit
.as_deref()
.map(short)
.unwrap_or_else(|| "unsynced".into());
let ns = match &s.alias {
Some(a) => format!(" as:{a}"),
None => String::new(),
};
let hook = match s.install_hooks.len() {
0 => String::new(),
1 => " hook".to_string(),
n => format!(" hooks({n})"),
};
println!(
"{} {} {}{}",
out.bullet(),
out.bold(&s.name),
out.dim(&format!("[{commit}{ns}{hook}]")),
s.description
.as_deref()
.map(|d| format!(" {d}"))
.unwrap_or_default()
);
let mut rows: Vec<Vec<String>> = Vec::new();
for it in items {
let key = it.key();
let installed = manifest
.items
.values()
.find(|m| m.source == it.source && m.kind == it.kind && m.bare_name == it.name);
match installed {
Some(m) => {
let hash_lag = hash_path(&it.path).map_or(true, |h| h != m.hash);
let rename_lag = it.effective_name() != m.name;
let lag = hash_lag || rename_lag;
let outdated = if lag {
format!(" {}", out.yellow("(outdated; run mind upgrade)"))
} else {
String::new()
};
let marker = if lag { out.stale() } else { out.ok() };
rows.push(vec![
format!(" {marker}"),
key,
format!("installed @ {}{}", out.green(&short(&m.commit)), outdated),
]);
}
None => rows.push(vec![
format!(" {}", out.available()),
out.dim(&key),
out.dim("available"),
]),
}
}
for m in orphans {
rows.push(vec![
format!(" {}", out.warn()),
m.key(),
format!(
"installed @ {} {}",
short(&m.commit),
out.yellow("(removed upstream)")
),
]);
}
out.print_rows(&rows);
}
if source.is_none() {
let unmanaged: Vec<crate::unmanaged::UnmanagedItem> =
crate::unmanaged::scan(paths, &manifest)?
.into_iter()
.filter(|u| kind.is_none_or(|k| u.kind == k))
.collect();
if !unmanaged.is_empty() {
println!(
"{} {}",
out.bullet(),
out.bold("unmanaged: not installed by mind")
);
let rows: Vec<Vec<String>> = unmanaged
.iter()
.map(|u| {
let where_ = u
.paths
.iter()
.map(|p| p.display().to_string())
.collect::<Vec<_>>()
.join(", ");
vec![format!(" {}", out.warn()), u.key(), out.dim(&where_)]
})
.collect();
out.print_rows(&rows);
}
}
Ok(())
}
pub fn probe(
paths: &Paths,
query: Option<&str>,
kind: Option<ItemKind>,
source: Option<&str>,
json: bool,
) -> Result<()> {
let out = crate::render::ctx();
if let Some(s) = source {
crate::resolve::validate_source_selector(s)?;
}
let registry = Registry::load(paths)?;
let items = catalog::scan(paths, ®istry)?;
let manifest = Manifest::load(paths)?;
let q = query.unwrap_or("");
let mut hits: Vec<&CatalogItem> = items
.iter()
.filter(|it| {
catalog::matches_query(it, q) && kind.is_none_or(|k| it.kind == k)
&& source.is_none_or(|s| source_matches_glob(&it.source, s))
})
.collect();
hits.sort_by_key(|a| a.key());
let installed = |it: &CatalogItem| {
manifest
.items
.values()
.any(|m| m.source == it.source && m.kind == it.kind && m.bare_name == it.name)
};
let mut unmanaged: Vec<crate::unmanaged::UnmanagedItem> = if source.is_none() {
let needle = q.to_lowercase();
crate::unmanaged::scan(paths, &manifest)?
.into_iter()
.filter(|u| kind.is_none_or(|k| u.kind == k) && u.name.to_lowercase().contains(&needle))
.collect()
} else {
Vec::new()
};
unmanaged.sort_by_key(|u| u.key());
if json {
let mut rows: Vec<ProbeRow> = hits
.iter()
.map(|it| {
let dependencies = crate::deps::direct_dependency_keys(it, &items, &read_item_text);
ProbeRow {
installed: installed(it),
kind: it.kind.as_str(),
name: it.effective_name(),
source: &it.source,
hash: hash_path(&it.path).ok(),
description: it.description.as_deref(),
unmanaged: false,
dependencies,
}
})
.collect();
for u in &unmanaged {
rows.push(ProbeRow {
installed: false,
kind: u.kind.as_str(),
name: u.name.clone(),
source: "",
hash: None,
description: None,
unmanaged: true,
dependencies: Vec::new(),
});
}
return print_json(&rows);
}
if hits.is_empty() && unmanaged.is_empty() {
if registry.sources.is_empty() {
println!("no sources melded; run `mind meld <owner/repo>`");
} else {
println!("no items match '{q}'");
}
return Ok(());
}
let all_catalog_keys: HashSet<String> = items.iter().map(|it| it.key()).collect();
let catalog_graph = crate::deps::installed_graph(&items, &all_catalog_keys, read_item_text);
let mut rows = Vec::new();
for it in &hits {
let cur = hash_path(&it.path).ok();
let hash = cur.as_deref().map(short).unwrap_or_else(|| "-".into());
let m = manifest
.items
.values()
.find(|m| m.source == it.source && m.kind == it.kind && m.bare_name == it.name);
let outdated = m.is_some_and(|m| {
let hash_drift = cur.as_deref().is_none_or(|h| h != m.hash);
let rename_drift = it.effective_name() != m.name;
hash_drift || rename_drift
});
let marker = if m.is_some() {
out.green("*")
} else {
String::new()
};
let mut desc = summary(it.description.as_deref(), 60);
if outdated {
desc = format!("{desc} {}", out.yellow("(outdated; run `mind upgrade`)"));
}
rows.push(vec![
marker,
it.key(),
out.dim(&it.source),
out.dim(&hash),
desc,
]);
if let Some(subtree) = catalog_graph.render_subtree(&it.key()) {
for line in subtree.lines().skip(1) {
rows.push(vec![
String::new(),
line.to_string(),
String::new(),
String::new(),
String::new(),
]);
}
}
}
for u in &unmanaged {
let where_ = u
.paths
.iter()
.map(|p| p.display().to_string())
.collect::<Vec<_>>()
.join(", ");
rows.push(vec![
String::new(),
u.key(),
out.dim("(unmanaged)"),
out.dim("-"),
out.dim(&where_),
]);
}
out.print_rows(&rows);
Ok(())
}
#[derive(Serialize)]
struct ProbeRow<'a> {
installed: bool,
kind: &'a str,
name: String,
source: &'a str,
hash: Option<String>,
description: Option<&'a str>,
#[serde(skip_serializing_if = "std::ops::Not::not")]
unmanaged: bool,
#[serde(skip_serializing_if = "Vec::is_empty")]
dependencies: Vec<String>,
}
#[derive(Serialize)]
struct Issue {
kind: &'static str,
target: String,
message: String,
}
pub fn introspect(paths: &Paths, fix: bool, json: bool) -> Result<()> {
let registry = Registry::load(paths)?;
let catalog = catalog::scan(paths, ®istry)?;
let manifest = Manifest::load(paths)?;
let mut issues: Vec<Issue> = Vec::new();
let mut repaired: Vec<String> = Vec::new();
for s in ®istry.sources {
if !s.clone_dir(paths).join(".git").is_dir() {
issues.push(Issue {
kind: "no-clone",
target: s.name.clone(),
message: format!("source '{}' has no clone on disk; run `mind sync`", s.name),
});
} else if s.commit.is_none() {
issues.push(Issue {
kind: "never-synced",
target: s.name.clone(),
message: format!("source '{}' was never synced; run `mind sync`", s.name),
});
}
}
for it in manifest.items.values() {
let missing: Vec<&String> = it
.links
.iter()
.filter(|link| std::fs::symlink_metadata(link).is_err())
.collect();
if !missing.is_empty() {
let n = if fix { install::relink(paths, it)? } else { 0 };
if n > 0 {
repaired.push(format!("{}: relinked {n} missing symlink(s)", it.key()));
}
for link in &missing {
if std::fs::symlink_metadata(link).is_err() {
issues.push(Issue {
kind: "missing-link",
target: it.key(),
message: format!("{}: symlink missing at {link}", it.key()),
});
}
}
}
match catalog
.iter()
.find(|c| c.kind == it.kind && c.name == it.bare_name && c.source == it.source)
{
None => issues.push(Issue {
kind: "removed-upstream",
target: it.key(),
message: format!("{}: no longer present in source '{}'", it.key(), it.source),
}),
Some(cat) => {
if cat.effective_name() != it.name {
issues.push(Issue {
kind: "namespace-changed",
target: it.key(),
message: format!(
"{}: namespace changed to '{}'; run `mind upgrade`",
it.key(),
cat.effective_name()
),
});
} else if let Ok(h) = hash_path(&cat.path)
&& h != it.hash
{
issues.push(Issue {
kind: "drifted",
target: it.key(),
message: format!("{}: upstream changed; run `mind upgrade`", it.key()),
});
}
}
}
}
if json {
#[derive(Serialize)]
struct Report<'a> {
issues: &'a [Issue],
sources: usize,
items: usize,
}
return print_json(&Report {
issues: &issues,
sources: registry.sources.len(),
items: manifest.items.len(),
});
}
let out = crate::render::ctx();
for note in &repaired {
println!("{} {note}", out.ok());
}
for issue in &issues {
println!("{} {}", out.warn(), issue.message);
}
if issues.is_empty() {
println!(
"{} all good: {} source(s), {} item(s) installed",
out.ok(),
registry.sources.len(),
manifest.items.len()
);
} else {
println!("\n{} {} issue(s) found", out.err(), issues.len());
}
Ok(())
}
pub fn review(paths: &Paths, target: &str, alias: Option<String>, fix: bool) -> Result<()> {
let result = crate::review::review(paths, target, alias, fix)?;
crate::review::print_findings(&result.hard, &result.advisory);
let out = crate::render::ctx();
for f in &result.fixed {
println!("{} fixed {f}", out.ok());
}
if result.hard.is_empty() {
if result.advisory.is_empty() {
println!("{} review: no issues found", out.ok());
} else {
println!(
"{} review: {} advisory finding(s); source is publishable",
out.warn(),
result.advisory.len()
);
}
Ok(())
} else {
println!(
"\n{} review: {} hard error(s), {} advisory finding(s)",
out.err(),
result.hard.len(),
result.advisory.len()
);
Err(crate::error::MindError::ReviewFailed {
hard: result.hard.len(),
})
}
}
pub fn config_show(paths: &Paths) -> Result<()> {
let out = crate::render::ctx();
paths.ensure_config()?;
let file = paths.config_file();
let cfg = Config::load(paths)?;
if out.json {
return print_json(&serde_json::json!({
"config_file": file.display().to_string(),
"lobes": cfg.lobes,
"default_lobe": paths.claude_home.display().to_string(),
"ssh": cfg.ssh,
}));
}
println!("{} config file: {}", out.bullet(), file.display());
if cfg.lobes.is_empty() {
println!(
" {} lobes = [] (default: {})",
out.dim("·"),
paths.claude_home.display()
);
} else {
println!(" {} lobes = {:?}", out.dim("·"), cfg.lobes);
}
println!(
" {} ssh = {} (prefer SSH for melded remotes)",
out.dim("·"),
cfg.ssh
);
if let Some(env) = std::env::var_os("MIND_AGENT_HOMES") {
println!(
"note: MIND_AGENT_HOMES is set and overrides lobes: {}",
env.to_string_lossy()
);
}
Ok(())
}
pub fn lobe_add(paths: &Paths, path: &str) -> Result<()> {
let out = crate::render::ctx();
if let Some(policy) = Policy::load()?
&& policy.lobes_lock()
{
return Err(lobes_locked_error("add"));
}
paths.ensure_config()?;
let mut cfg = Config::load(paths)?;
if cfg.lobes.iter().any(|h| h == path) {
if out.json {
return print_json(&MutationResult::new("lobe-add", path, "no-op"));
}
println!("{} lobe already configured: {path}", out.available());
return Ok(());
}
cfg.lobes.push(path.to_string());
cfg.save(paths)?;
if out.json {
return print_json(&MutationResult::new("lobe-add", path, "added"));
}
println!("{} added lobe {path}", out.ok());
Ok(())
}
pub fn lobe_list(paths: &Paths) -> Result<()> {
let out = crate::render::ctx();
paths.ensure_config()?;
let cfg = Config::load(paths)?;
if out.json {
let lobes = if cfg.lobes.is_empty() {
vec![paths.claude_home.display().to_string()]
} else {
cfg.lobes.clone()
};
return print_json(&serde_json::json!({ "lobes": lobes }));
}
if cfg.lobes.is_empty() {
println!("{} (default)", paths.claude_home.display());
} else {
for h in &cfg.lobes {
println!("{h}");
}
}
let lobes_locked = matches!(Policy::load()?, Some(p) if p.lobes_lock());
if show_override_note(std::env::var_os("MIND_AGENT_HOMES").is_some(), lobes_locked) {
println!("note: MIND_AGENT_HOMES is set and overrides the above");
}
Ok(())
}
fn show_override_note(env_set: bool, lobes_locked: bool) -> bool {
env_set && !lobes_locked
}
pub fn lobe_remove(paths: &Paths, path: &str) -> Result<()> {
let out = crate::render::ctx();
if let Some(policy) = Policy::load()?
&& policy.lobes_lock()
{
return Err(lobes_locked_error("remove"));
}
paths.ensure_config()?;
let mut cfg = Config::load(paths)?;
let before = cfg.lobes.len();
cfg.lobes.retain(|h| h != path);
if cfg.lobes.len() == before {
return Err(MindError::UnknownLobe {
path: path.to_string(),
});
}
cfg.save(paths)?;
if out.json {
return print_json(&MutationResult::new("lobe-remove", path, "removed"));
}
println!("{} removed lobe {path}", out.ok());
Ok(())
}
pub fn completions(shell: clap_complete::Shell) {
use clap::CommandFactory;
let mut cmd = crate::cli::Cli::command();
clap_complete::generate(shell, &mut cmd, "mind", &mut std::io::stdout());
}
pub fn man() -> Result<()> {
use clap::CommandFactory;
let mut out = Vec::new();
clap_mangen::Man::new(crate::cli::Cli::command())
.render(&mut out)
.map_err(|e| MindError::io("<man>", e))?;
std::io::stdout()
.write_all(&out)
.map_err(|e| MindError::io("<stdout>", e))
}
fn lobes_locked_error(action: &str) -> MindError {
MindError::LobesLocked {
action: action.to_string(),
}
}
#[derive(Serialize, Debug, PartialEq, Eq)]
struct SkippedEntry {
source: String,
reason: String,
}
#[derive(Serialize, Debug, PartialEq, Eq)]
struct MutationResult {
action: &'static str,
target: String,
outcome: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
count: Option<usize>,
#[serde(skip_serializing_if = "Vec::is_empty")]
installed: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
removed: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
skipped: Vec<SkippedEntry>,
}
impl MutationResult {
fn new(action: &'static str, target: &str, outcome: &'static str) -> Self {
Self {
action,
target: target.to_string(),
outcome,
count: None,
installed: Vec::new(),
removed: Vec::new(),
skipped: Vec::new(),
}
}
}
fn json_mode() -> bool {
crate::render::ctx().json
}
use crate::render::print_json;
fn single(source: &crate::source::Source) -> Registry {
Registry {
sources: vec![source.clone()],
}
}
fn short(s: &str) -> String {
if s.is_empty() {
"-".to_string()
} else {
s.chars().take(8).collect()
}
}
fn summary(desc: Option<&str>, max: usize) -> String {
let Some(d) = desc else { return String::new() };
let first = d.split(". ").next().unwrap_or(d).trim_end_matches('.');
if first.chars().count() <= max {
return first.to_string();
}
let cut: String = first.chars().take(max.saturating_sub(1)).collect();
format!("{}...", cut.trim_end())
}
fn prompt_line(prompt: &str) -> Result<String> {
print!("{prompt}");
let _ = std::io::stdout().flush();
let mut line = String::new();
if std::io::stdin()
.read_line(&mut line)
.map_err(|e| MindError::io("<stdin>", e))?
== 0
{
return Ok(String::new()); }
Ok(line)
}
fn parse_confirm(input: &str, default_yes: bool) -> bool {
match input.trim().to_ascii_lowercase().as_str() {
"y" | "yes" => true,
"n" | "no" => false,
_ => default_yes,
}
}
fn read_confirm(prompt: &str, hint: &str, default_yes: bool) -> Result<bool> {
print!("\n{prompt} {hint} ");
let _ = std::io::stdout().flush();
let mut line = String::new();
let stdin = std::io::stdin();
if stdin
.read_line(&mut line)
.map_err(|e| MindError::io("<stdin>", e))?
== 0
{
return Ok(false); }
Ok(parse_confirm(&line, default_yes))
}
pub(crate) fn confirm(prompt: &str) -> Result<bool> {
read_confirm(prompt, "[y/N]", false)
}
pub(crate) fn confirm_default_yes(prompt: &str) -> Result<bool> {
read_confirm(prompt, "[Y/n]", true)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::ItemKind;
use crate::manifest::InstalledItem;
use std::path::PathBuf;
use std::sync::Mutex;
use std::sync::atomic::{AtomicU32, Ordering};
static COUNTER: AtomicU32 = AtomicU32::new(0);
#[test]
fn auth_failure_skip_lines_no_message() {
use crate::mindfile::OnAuthFailure;
let cfg = OnAuthFailure {
action: AuthFailureAction::Skip,
message: None,
};
let lines = auth_failure_lines("owner/private-repo", &cfg);
assert_eq!(lines.len(), 1);
assert!(
lines[0].contains("unable to meld source owner/private-repo"),
"line: {}",
lines[0]
);
assert!(
lines[0].contains("(skipping)"),
"must include (skipping) for skip action: {}",
lines[0]
);
}
#[test]
fn auth_failure_skip_lines_with_message() {
use crate::mindfile::OnAuthFailure;
let cfg = OnAuthFailure {
action: AuthFailureAction::Skip,
message: Some("Configure credentials: https://example.com/auth".into()),
};
let lines = auth_failure_lines("owner/private-repo", &cfg);
assert_eq!(lines.len(), 2);
assert!(lines[0].contains("(skipping)"), "first line: {}", lines[0]);
assert_eq!(lines[1], "Configure credentials: https://example.com/auth");
}
#[test]
fn auth_failure_error_lines_no_skipping_suffix() {
use crate::mindfile::OnAuthFailure;
let cfg = OnAuthFailure {
action: AuthFailureAction::Error,
message: None,
};
let lines = auth_failure_lines("owner/private-repo", &cfg);
assert_eq!(lines.len(), 1);
assert!(
lines[0].contains("unable to meld source"),
"line: {}",
lines[0]
);
assert!(
!lines[0].contains("(skipping)"),
"error action must NOT include (skipping): {}",
lines[0]
);
}
#[test]
fn auth_failure_error_lines_with_message_included() {
use crate::mindfile::OnAuthFailure;
let cfg = OnAuthFailure {
action: AuthFailureAction::Error,
message: Some("Contact admin for access.".into()),
};
let lines = auth_failure_lines("owner/private-repo", &cfg);
assert_eq!(lines.len(), 2);
assert!(
!lines[0].contains("(skipping)"),
"error action must NOT include (skipping)"
);
assert_eq!(lines[1], "Contact admin for access.");
}
#[test]
fn auth_failure_lines_strips_ansi_escape() {
use crate::mindfile::OnAuthFailure;
let cfg = OnAuthFailure {
action: AuthFailureAction::Skip,
message: Some("\x1b[2J hello\x1b[0m".into()),
};
let lines = auth_failure_lines("src", &cfg);
assert_eq!(lines.len(), 2);
assert_eq!(
lines[1], " hello",
"expected printable portion only: {:?}",
lines[1]
);
assert!(
!lines[1].contains('\x1b'),
"ANSI escape must be stripped: {:?}",
lines[1]
);
}
#[test]
fn strip_ansi_drops_bidi_and_separator_chars() {
assert_eq!(
strip_ansi("pay \u{202E}oot"),
"pay oot",
"RLO must be dropped"
);
assert_eq!(
strip_ansi("\u{202A}\u{202B}\u{202C}\u{202D}\u{202E}"),
"",
"bidi U+202A-202E must all be dropped"
);
assert_eq!(
strip_ansi("\u{2066}\u{2067}\u{2068}\u{2069}"),
"",
"isolate U+2066-2069 must all be dropped"
);
assert_eq!(
strip_ansi("line\u{2028}break"),
"linebreak",
"U+2028 must be dropped"
);
assert_eq!(
strip_ansi("para\u{2029}sep"),
"parasep",
"U+2029 must be dropped"
);
assert_eq!(strip_ansi("hello\u{00e9}"), "hello\u{00e9}");
}
#[test]
fn strip_ansi_preserves_chars_adjacent_to_blocked_ranges() {
assert_eq!(
strip_ansi("a\u{2027}b"),
"a\u{2027}b",
"U+2027 must pass through (below the separator block)"
);
assert_eq!(
strip_ansi("a\u{202F}b"),
"a\u{202F}b",
"U+202F must pass through (above the bidi-override block)"
);
assert_eq!(
strip_ansi("a\u{2065}b"),
"a\u{2065}b",
"U+2065 must pass through (below the isolate block)"
);
assert_eq!(
strip_ansi("a\u{206A}b"),
"a\u{206A}b",
"U+206A must pass through (above the isolate block)"
);
}
#[test]
fn strip_ansi_separator_at_every_position() {
assert_eq!(strip_ansi("\u{2028}tail"), "tail", "leading U+2028");
assert_eq!(strip_ansi("mid\u{2028}dle"), "middle", "interior U+2028");
assert_eq!(strip_ansi("head\u{2028}"), "head", "trailing U+2028");
assert_eq!(
strip_ansi("\u{2028}\u{2029}\u{2028}"),
"",
"only-separator run must be empty"
);
}
#[test]
fn strip_ansi_alternating_blocked_and_allowed() {
assert_eq!(
strip_ansi("a\u{202E}b\u{2066}c\u{2028}d\u{2069}e"),
"abcde",
"blocked chars removed, allowed text preserved in order"
);
}
#[test]
fn mutation_result_minimal_shape() {
let r = MutationResult::new("forget", "skill:review", "removed");
let v: serde_json::Value = serde_json::to_value(&r).unwrap();
assert_eq!(v["action"], "forget");
assert_eq!(v["target"], "skill:review");
assert_eq!(v["outcome"], "removed");
assert!(v.get("count").is_none(), "count must be omitted: {v}");
assert!(v.get("installed").is_none(), "installed omitted: {v}");
assert!(v.get("removed").is_none(), "removed omitted: {v}");
}
#[test]
fn mutation_result_populated_optional_fields() {
let mut r = MutationResult::new("learn", "skill:review", "installed");
r.installed = vec!["agent:reviewer".to_string(), "skill:review".to_string()];
r.count = Some(2);
let v: serde_json::Value = serde_json::to_value(&r).unwrap();
assert_eq!(v["action"], "learn");
assert_eq!(v["outcome"], "installed");
assert_eq!(v["count"], 2);
assert_eq!(
v["installed"],
serde_json::json!(["agent:reviewer", "skill:review"])
);
assert!(v.get("removed").is_none(), "empty removed omitted: {v}");
}
#[test]
fn parse_confirm_default_no_only_yes_confirms() {
assert!(parse_confirm("y", false));
assert!(parse_confirm("YES", false));
assert!(!parse_confirm("", false));
assert!(!parse_confirm("n", false));
assert!(!parse_confirm("maybe", false));
}
#[test]
fn parse_confirm_default_yes_only_no_declines() {
assert!(parse_confirm("", true));
assert!(parse_confirm("y", true));
assert!(parse_confirm(" Y \n", true));
assert!(parse_confirm("whatever", true));
assert!(!parse_confirm("n", true));
assert!(!parse_confirm("NO", true));
}
static ENV_LOCK: Mutex<()> = Mutex::new(());
fn with_policy(policy_toml: &str) -> (std::sync::MutexGuard<'static, ()>, PathBuf) {
let guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let base = std::env::temp_dir().join(format!("mind-cmd-test-{}-{n}", std::process::id()));
std::fs::create_dir_all(&base).unwrap();
let policy_file = base.join("policy.toml");
std::fs::write(&policy_file, policy_toml).unwrap();
unsafe {
std::env::remove_var("MIND_AGENT_HOMES");
std::env::set_var("MIND_POLICY_FILE", policy_file.to_str().unwrap());
}
(guard, base)
}
fn item(name: &str, source: &str) -> InstalledItem {
InstalledItem {
kind: ItemKind::Skill,
name: name.to_string(),
bare_name: name.to_string(),
source: source.to_string(),
commit: "deadbeef".to_string(),
hash: "h".to_string(),
store: format!("store/skills/{name}"),
links: vec![],
description: None,
}
}
#[test]
fn pol40_override_note_predicate() {
assert!(show_override_note(true, false));
assert!(!show_override_note(true, true));
assert!(!show_override_note(false, false));
assert!(!show_override_note(false, true));
}
#[test]
fn pol40_locked_policy_suppresses_override_note() {
let managed = std::env::temp_dir().join("mind-pol40-managed-target");
let policy_toml = format!(
"[lobes]\nlock = true\ntargets = [\"{}\"]\n",
managed.display()
);
let (_guard, base) = with_policy(&policy_toml);
unsafe {
std::env::set_var("MIND_AGENT_HOMES", base.join("env-lobe").to_str().unwrap());
}
let policy = Policy::load().unwrap().expect("policy should load");
let env_set = std::env::var_os("MIND_AGENT_HOMES").is_some();
let lobes_locked = policy.lobes_lock();
unsafe {
std::env::remove_var("MIND_AGENT_HOMES");
}
assert!(env_set, "test must have the override var set");
assert!(lobes_locked, "policy must report a lobe lock");
assert!(
!show_override_note(env_set, lobes_locked),
"POL-40: a locked policy must suppress the false override note"
);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn pol40_unlocked_keeps_override_note() {
let policy_toml = "[lobes]\nlock = false\n";
let (_guard, base) = with_policy(policy_toml);
unsafe {
std::env::set_var("MIND_AGENT_HOMES", base.join("env-lobe").to_str().unwrap());
}
let lobes_locked = matches!(Policy::load().unwrap(), Some(p) if p.lobes_lock());
let env_set = std::env::var_os("MIND_AGENT_HOMES").is_some();
unsafe {
std::env::remove_var("MIND_AGENT_HOMES");
}
assert!(!lobes_locked, "lock = false means no lobe lock");
assert!(
show_override_note(env_set, lobes_locked),
"unlocked behavior must be unchanged: the note still shows"
);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn pol12_scoped_upgrade_no_skip_for_out_of_scope_source() {
let policy_toml = concat!(
"[sources]\n",
"lock = true\n",
"allow = [\"github.com/me/allowed-src\"]\n",
);
let (_guard, base) = with_policy(policy_toml);
let policy = Policy::load().unwrap().expect("policy should load");
assert!(policy.lock());
let selected = item("wanted", "github.com/me/allowed-src");
let other = item("unwanted", "github.com/them/blocked-src");
let filter = parse_item_ref("wanted").unwrap();
assert_eq!(
upgrade_item_disposition(&other, Some(&filter), Some(&policy)),
UpgradeDisposition::OutOfScope,
"POL-12: an unselected item must not be policy-skipped (no skip line)"
);
assert_eq!(
upgrade_item_disposition(&selected, Some(&filter), Some(&policy)),
UpgradeDisposition::Consider,
);
assert_eq!(
upgrade_item_disposition(&other, None, Some(&policy)),
UpgradeDisposition::PolicyBlocked,
);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn pol12_scoped_upgrade_skips_selected_disallowed_item() {
let policy_toml = concat!(
"[sources]\n",
"lock = true\n",
"allow = [\"github.com/me/allowed-src\"]\n",
);
let (_guard, base) = with_policy(policy_toml);
let policy = Policy::load().unwrap().expect("policy should load");
let selected = item("blocked-item", "github.com/them/blocked-src");
let filter = parse_item_ref("blocked-item").unwrap();
assert_eq!(
upgrade_item_disposition(&selected, Some(&filter), Some(&policy)),
UpgradeDisposition::PolicyBlocked,
"a selected item from a disallowed source is still reported"
);
let _ = std::fs::remove_dir_all(&base);
}
fn hook_source(commit: Option<&str>, hooks: &[(&str, Option<&str>)]) -> crate::source::Source {
use crate::source::RecordedHook;
let mut s = crate::source::parse_spec("acme/tools").expect("spec parses");
s.commit = commit.map(str::to_string);
s.install_hooks = hooks
.iter()
.map(|(cmd, ran_at)| RecordedHook {
command: cmd.to_string(),
ran_at: ran_at.map(str::to_string),
})
.collect();
s
}
#[test]
fn hook_rerun_warranted_truth_table() {
assert!(
!hook_rerun_warranted(&hook_source(Some("abc1234"), &[])),
"no install_hooks means no re-run"
);
assert!(
!hook_rerun_warranted(&hook_source(
Some("abc1234"),
&[("make install", Some("abc1234"))],
)),
"ran_at == commit means the hook already ran here"
);
assert!(
!hook_rerun_warranted(&hook_source(
Some("abc1234"),
&[
("make build", Some("abc1234")),
("make install", Some("abc1234")),
],
)),
"all hooks ran at current commit means no re-run warranted"
);
assert!(
hook_rerun_warranted(&hook_source(Some("abc1234"), &[("make install", None)],)),
"a recorded-but-never-run hook is re-offered"
);
assert!(
hook_rerun_warranted(&hook_source(
Some("def5678"),
&[("make install", Some("abc1234"))],
)),
"an advanced commit warrants a re-run"
);
assert!(
hook_rerun_warranted(&hook_source(
Some("new0000"),
&[
("make build", Some("new0000")),
("make install", Some("old0000")),
],
)),
"at least one stale hook warrants a re-run"
);
}
#[test]
fn init_source_scaffold_includes_hooks_examples() {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let tmp =
std::env::temp_dir().join(format!("mind-cmd-init-hooks-{}-{n}", std::process::id()));
std::fs::create_dir_all(&tmp).unwrap();
struct Rm(std::path::PathBuf);
impl Drop for Rm {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
let _rm = Rm(tmp.clone());
init_source(Some(tmp.to_str().unwrap()), false).expect("init_source should succeed");
let toml_path = tmp.join("mind.toml");
assert!(toml_path.exists(), "mind.toml must be created");
let contents = std::fs::read_to_string(&toml_path).unwrap();
assert!(
contents.contains("[[hooks]]"),
"scaffold must include a commented [[hooks]] example: {contents}"
);
assert!(
contents.contains("install"),
"scaffold must show event = \"install\": {contents}"
);
assert!(
contents.contains("uninstall"),
"scaffold must show event = \"uninstall\": {contents}"
);
assert!(
contents.contains("optional = true"),
"scaffold must have at least one optional = true example: {contents}"
);
let has_uncommented_hooks = contents.lines().any(|l| l.trim() == "[[hooks]]");
assert!(
!has_uncommented_hooks,
"[[hooks]] examples must all be commented out: {contents}"
);
assert!(
contents.contains("[source]"),
"scaffold must still have [source]"
);
assert!(
contents.contains("# prefix = \"prefix\""),
"scaffold must still have commented prefix: {contents}"
);
}
#[test]
fn hook_token_is_count_aware() {
let s0 = hook_source(Some("abc"), &[]);
let token0 = match s0.install_hooks.len() {
0 => String::new(),
1 => " hook".to_string(),
n => format!(" hooks({n})"),
};
assert_eq!(token0, "", "no hooks => empty token");
let s1 = hook_source(Some("abc"), &[("make install", Some("abc"))]);
let token1 = match s1.install_hooks.len() {
0 => String::new(),
1 => " hook".to_string(),
n => format!(" hooks({n})"),
};
assert_eq!(token1, " hook", "1 hook => ' hook'");
let s2 = hook_source(
Some("abc"),
&[("make build", Some("abc")), ("make install", Some("abc"))],
);
let token2 = match s2.install_hooks.len() {
0 => String::new(),
1 => " hook".to_string(),
n => format!(" hooks({n})"),
};
assert_eq!(token2, " hooks(2)", "2 hooks => ' hooks(2)'");
}
#[test]
fn hook_rerun_warranted_multi_hook_model() {
let all_current = hook_source(
Some("fff000"),
&[
("make build", Some("fff000")),
("make install", Some("fff000")),
("make test", Some("fff000")),
],
);
assert!(
!hook_rerun_warranted(&all_current),
"all hooks current => no re-run warranted"
);
let one_never_ran = hook_source(
Some("fff000"),
&[
("make build", Some("fff000")),
("make install", None), ],
);
assert!(
hook_rerun_warranted(&one_never_ran),
"one hook with ran_at=None => re-run warranted"
);
let no_commit = hook_source(None, &[("make install", Some("old"))]);
assert!(
hook_rerun_warranted(&no_commit),
"commit=None and ran_at=Some => they differ => warranted"
);
}
#[test]
fn convention_path_in_root_derives_correct_paths() {
let root = std::path::Path::new("/repo");
assert_eq!(
convention_path_in_root(root, ItemKind::Skill, "review"),
PathBuf::from("/repo/skills/review"),
"skill convention path is skills/<name>/"
);
assert_eq!(
convention_path_in_root(root, ItemKind::Agent, "dev"),
PathBuf::from("/repo/agents/dev.md"),
"agent convention path is agents/<name>.md"
);
assert_eq!(
convention_path_in_root(root, ItemKind::Rule, "style"),
PathBuf::from("/repo/rules/style.md"),
"rule convention path is rules/<name>.md"
);
}
#[test]
fn expand_tilde_handles_home_prefix() {
let home = dirs::home_dir().expect("home dir");
let expanded = expand_tilde("~");
assert_eq!(expanded, home, "bare ~ must expand to home directory");
let expanded2 = expand_tilde("~/foo");
assert_eq!(
expanded2,
home.join("foo"),
"~/foo must expand to <home>/foo"
);
let abs = expand_tilde("/tmp/mydir");
assert_eq!(
abs,
PathBuf::from("/tmp/mydir"),
"absolute path must be unchanged"
);
let rel = expand_tilde("relpath/dir");
assert_eq!(
rel,
PathBuf::from("relpath/dir"),
"relative path must be unchanged"
);
}
#[test]
fn first_scan_root_defaults_to_dest_dir() {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let dir =
std::env::temp_dir().join(format!("mind-abs-scanroot-{}-{n}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let root = first_scan_root(&dir).unwrap();
assert_eq!(
root,
dir.join("."),
"first_scan_root with no mind.toml must be dest/."
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn first_scan_root_uses_minds_toml_roots() {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let dir =
std::env::temp_dir().join(format!("mind-abs-scanroot2-{}-{n}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("mind.toml"),
"[source]\nroots = [\"packages/agents\"]\n",
)
.unwrap();
std::fs::create_dir_all(dir.join("packages/agents")).unwrap();
let root = first_scan_root(&dir).unwrap();
assert_eq!(
root,
dir.join("packages/agents"),
"first_scan_root must use first entry of [source].roots"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn first_scan_root_rejects_escaping_root() {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let dir = std::env::temp_dir().join(format!(
"mind-abs-scanroot-escape-{}-{n}",
std::process::id()
));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("mind.toml"),
"[source]\nroots = [\"../../outside\"]\n",
)
.unwrap();
let err = first_scan_root(&dir).unwrap_err();
assert!(
matches!(err, crate::error::MindError::InvalidRoot { .. }),
"an escaping roots entry must be InvalidRoot: {err}"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn normalize_path_folds_parent_and_current_components() {
use std::path::PathBuf;
assert_eq!(
normalize_path(&PathBuf::from("/repo/./skills")),
PathBuf::from("/repo/skills"),
"a `.` component must be dropped"
);
assert_eq!(
normalize_path(&PathBuf::from("/repo/sub/../skills")),
PathBuf::from("/repo/skills"),
"a `..` must pop the previous component"
);
let escaped = normalize_path(&PathBuf::from("/repo/../../outside"));
assert!(
!escaped.starts_with("/repo"),
"a climbing `..` chain must escape the repo root: {escaped:?}"
);
assert_eq!(
escaped,
PathBuf::from("/outside"),
"folding /repo/../../outside yields /outside"
);
}
#[test]
fn first_scan_root_rejects_existing_escaping_root_via_canonicalize() {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let base = std::env::temp_dir().join(format!(
"mind-abs-scanroot-canon-{}-{n}",
std::process::id()
));
let dest = base.join("repo");
let outside = base.join("outside");
std::fs::create_dir_all(&dest).unwrap();
std::fs::create_dir_all(&outside).unwrap();
std::fs::write(
dest.join("mind.toml"),
"[source]\nroots = [\"../outside\"]\n",
)
.unwrap();
let err = first_scan_root(&dest).unwrap_err();
assert!(
matches!(err, crate::error::MindError::InvalidRoot { .. }),
"an existing escaping root (canonicalize branch) must be InvalidRoot: {err}"
);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn dest_source_prefix_none_when_unset() {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let dir =
std::env::temp_dir().join(format!("mind-abs-pfx-none-{}-{n}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let registry = crate::source::Registry::default();
assert_eq!(
dest_source_prefix(&dir, ®istry),
None,
"no alias and no mind.toml prefix means no effective prefix"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn dest_source_prefix_reads_mindfile_prefix() {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let dir =
std::env::temp_dir().join(format!("mind-abs-pfx-toml-{}-{n}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("mind.toml"), "[source]\nprefix = \"tomlpfx\"\n").unwrap();
let registry = crate::source::Registry::default();
assert_eq!(
dest_source_prefix(&dir, ®istry),
Some("tomlpfx".to_string()),
"an unmelded destination uses its mind.toml [source].prefix"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn dest_source_prefix_alias_beats_mindfile_prefix() {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let dir =
std::env::temp_dir().join(format!("mind-abs-pfx-alias-{}-{n}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("mind.toml"), "[source]\nprefix = \"tomlpfx\"\n").unwrap();
let mut src = crate::source::parse_spec(&dir.to_string_lossy()).unwrap();
src.alias = Some("aliaspfx".to_string());
let registry = crate::source::Registry { sources: vec![src] };
assert_eq!(
dest_source_prefix(&dir, ®istry),
Some("aliaspfx".to_string()),
"the recorded alias must win over the repo's [source].prefix"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn dest_source_prefix_empty_alias_falls_through_to_mindfile() {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let dir =
std::env::temp_dir().join(format!("mind-abs-pfx-empty-{}-{n}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("mind.toml"), "[source]\nprefix = \"tomlpfx\"\n").unwrap();
let mut src = crate::source::parse_spec(&dir.to_string_lossy()).unwrap();
src.alias = Some(String::new()); let registry = crate::source::Registry { sources: vec![src] };
assert_eq!(
dest_source_prefix(&dir, ®istry),
Some("tomlpfx".to_string()),
"an empty alias must not suppress the mind.toml prefix"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn offer_save_absorb_to_yes_writes_config() {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let base = std::env::temp_dir().join(format!("mind-abs-save-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
let paths = Paths {
mind_home: base.join("mind"),
claude_home: base.join("claude"),
};
assert!(
!paths.config_file().exists(),
"sanity: config.toml must not pre-exist"
);
let dest = base.join("personal");
offer_save_absorb_to(&paths, &dest, true).expect("offer_save_absorb_to");
let cfg = Config::load(&paths).expect("load config");
assert_eq!(
cfg.absorb_to.as_deref(),
Some(dest.to_string_lossy().as_ref()),
"the chosen destination must be saved as absorb_to"
);
let _ = std::fs::remove_dir_all(&base);
}
}