use std::collections::{HashMap, HashSet};
use serde::Serialize;
use crate::catalog;
use crate::commands::{self, InstallFlow, PinRequest, SkippedEntry};
use crate::config::Config;
use crate::error::{MindError, Result};
use crate::manifest::Manifest;
use crate::mindfile::{MindToml, NestedSource};
use crate::paths::Paths;
use crate::policy::Policy;
use crate::sanitize::strip_ansi;
use crate::source::{Pin, Registry, Source, parse_spec_quiet};
#[derive(Clone, Default)]
pub struct CurateFlags {
pub check: bool,
pub yes: bool,
pub prune: bool,
pub no_sync: bool,
pub adopt: Option<String>,
pub accept: crate::hook::Accept,
}
#[derive(Serialize)]
struct Change {
kind: &'static str,
curator: String,
source: String,
detail: String,
#[serde(skip)]
action: Action,
}
impl Change {
fn new(
kind: &'static str,
curator: impl Into<String>,
source: impl Into<String>,
detail: impl Into<String>,
action: Action,
) -> Change {
Change {
kind,
curator: strip_ansi(&curator.into()),
source: strip_ansi(&source.into()),
detail: strip_ansi(&detail.into()),
action,
}
}
}
enum Action {
Register {
curator: String,
toml_path: std::path::PathBuf,
entry: Box<NestedSource>,
},
Install {
source: String,
refs: Option<Vec<String>>,
},
InstallProfiles {
source: String,
selection: crate::profile::Selection,
install_items: Option<Vec<String>>,
},
Repin { source: String, pin: Pin },
Upgrade { source: String },
Unlist { source: String },
Advisory,
}
#[derive(Serialize)]
struct CurateResult {
schema: u8,
action: &'static str,
outcome: &'static str,
changes: Vec<Change>,
applied: Vec<String>,
skipped: Vec<SkippedEntry>,
}
pub fn run(paths: &Paths, flags: CurateFlags) -> Result<()> {
paths.ensure_layout()?;
if let Some(identity) = &flags.adopt {
return run_adopt(paths, identity, flags.check);
}
let out = crate::render::ctx();
let mut registry = Registry::load(paths)?;
if !flags.no_sync {
let scope = sync_scope(paths, ®istry);
commands::sync_sources_for_upgrade_scoped(paths, &mut registry, None, Some(&scope), &out)?;
}
let (plan, mut skipped) = build_plan(paths, ®istry)?;
if plan.is_empty() {
if out.json {
return commands::print_json(&CurateResult {
schema: 1,
action: "curate",
outcome: "clean",
changes: Vec::new(),
applied: Vec::new(),
skipped,
});
}
println!("{} curated sources are up to date", out.ok());
report_skipped(&skipped);
return Ok(());
}
report(&plan, flags.prune);
if flags.check {
return finish(&plan, Vec::new(), skipped, flags);
}
if !should_apply(&plan, &flags)? {
return finish(&plan, Vec::new(), skipped, flags);
}
let (applied, apply_skipped) = apply(paths, plan_in_apply_order(&plan), &flags)?;
skipped.extend(apply_skipped);
finish(&plan, applied, skipped, flags)
}
fn run_adopt(paths: &Paths, identity: &str, check: bool) -> Result<()> {
let out = crate::render::ctx();
let mut registry = Registry::load(paths)?;
let Some(registered) = registry.find(identity).cloned() else {
return Err(MindError::NotAnAdoptCandidate {
name: identity.to_string(),
reason: "no melded source has that identity".to_string(),
});
};
if let Some(owner) = ®istered.curated_by {
return Err(MindError::NotAnAdoptCandidate {
name: identity.to_string(),
reason: format!("already curated by {}", strip_ansi(owner)),
});
}
let (curators_list, _) = curators(paths, ®istry)?;
let claims: Vec<Claim> = curators_list
.iter()
.flat_map(|c| entry_claims(c, identity))
.collect();
if claims.is_empty() {
return Err(MindError::NotAnAdoptCandidate {
name: identity.to_string(),
reason: "no registered curator currently lists it".to_string(),
});
}
let mut claimants: Vec<String> = claims
.iter()
.map(|c| strip_ansi(&c.curator))
.collect::<Vec<_>>();
claimants.sort();
claimants.dedup();
if claimants.len() > 1 {
return Err(MindError::NotAnAdoptCandidate {
name: identity.to_string(),
reason: format!(
"{} registered curators claim it ({}); exactly one curator may claim an identity",
claimants.len(),
claimants.join(", ")
),
});
}
if let Some(bad) = claims
.iter()
.find(|c| !same_upstream(&c.source, ®istered))
{
return Err(MindError::NotAnAdoptCandidate {
name: identity.to_string(),
reason: format!(
"{} claims it via {} as {}, but it is registered from {}",
strip_ansi(&bad.curator),
bad.via,
strip_ansi(&bad.source.url),
strip_ansi(®istered.url)
),
});
}
let curator_name = claims[0].curator.clone();
if check {
if out.json {
return commands::print_json(&serde_json::json!({
"schema": 1,
"action": "curate-adopt",
"outcome": "pending",
"source": strip_ansi(identity),
"curator": strip_ansi(&curator_name),
}));
}
println!(
"{} would adopt {} for {} (--check: nothing written)",
out.bullet(),
strip_ansi(identity),
strip_ansi(&curator_name)
);
return Ok(());
}
if let Some(source) = registry.sources.iter_mut().find(|s| s.name == identity) {
source.curated_by = Some(curator_name.clone());
}
registry.save(paths)?;
if out.json {
return commands::print_json(&serde_json::json!({
"schema": 1,
"action": "curate-adopt",
"outcome": "applied",
"source": strip_ansi(identity),
"curator": strip_ansi(&curator_name),
}));
}
println!(
"{} {} is now curated by {}",
out.ok(),
strip_ansi(identity),
strip_ansi(&curator_name)
);
Ok(())
}
struct Claim {
curator: String,
via: &'static str,
source: Source,
}
fn entry_claims<'a>(curator: &'a Curator, target: &'a str) -> impl Iterator<Item = Claim> + 'a {
let from_entries = curator.entries.iter().filter_map(move |entry| {
let mut spec = parse_spec_quiet(&entry.source).ok()?;
spec.apply_alias(entry.effective_alias());
(spec.name == target && spec.name != curator.source.name).then(|| Claim {
curator: curator.source.name.clone(),
via: "a [discover].sources entry",
source: spec,
})
});
let from_market = curator
.market
.iter()
.filter(move |spec| spec.name == target && spec.name != curator.source.name)
.map(|spec| Claim {
curator: curator.source.name.clone(),
via: "its marketplace catalog",
source: spec.clone(),
});
from_entries.chain(from_market)
}
fn same_upstream(claim: &Source, registered: &Source) -> bool {
if claim.base_identity() != registered.base_identity()
|| claim.item_path != registered.item_path
{
return false;
}
if claim.is_local() || registered.is_local() {
return local_path_key(claim) == local_path_key(registered);
}
true
}
fn local_path_key(source: &Source) -> String {
let path = source.url.strip_prefix("file://").unwrap_or(&source.url);
let path = path.trim_end_matches('/');
path.strip_suffix(".git")
.unwrap_or(path)
.trim_end_matches('/')
.to_string()
}
fn sync_scope(paths: &Paths, registry: &Registry) -> HashSet<String> {
let mut scope = HashSet::new();
for source in ®istry.sources {
if let Some(curator) = &source.curated_by {
scope.insert(source.name.clone());
scope.insert(curator.clone());
continue;
}
if source.item_path.is_some() {
continue;
}
let clone = source.clone_dir(paths);
if clone.join("mind.toml").is_file()
|| crate::plugin_manifest::find_marketplace_manifest(&clone).is_some()
{
scope.insert(source.name.clone());
}
}
scope
}
fn report_skipped(skipped: &[SkippedEntry]) {
let out = crate::render::ctx();
if out.json || skipped.is_empty() {
return;
}
for s in skipped {
println!(" {} {}", out.dim("skipped"), s.describe());
}
}
fn finish(
plan: &[Change],
applied: Vec<String>,
skipped: Vec<SkippedEntry>,
flags: CurateFlags,
) -> Result<()> {
let out = crate::render::ctx();
if out.json {
let changes = plan
.iter()
.map(|c| {
Change::new(
c.kind,
c.curator.clone(),
c.source.clone(),
c.detail.clone(),
Action::Advisory,
)
})
.collect();
return commands::print_json(&CurateResult {
schema: 1,
action: "curate",
outcome: if applied.is_empty() {
"pending"
} else {
"applied"
},
changes,
applied,
skipped,
});
}
report_skipped(&skipped);
let applicable = plan.iter().filter(|c| applies(c, flags.prune)).count();
if applied.is_empty() && !flags.check && applicable > 0 {
println!("note: nothing applied; run `mind curate --yes` to apply {applicable} change(s)");
}
Ok(())
}
fn report(plan: &[Change], prune: bool) {
let out = crate::render::ctx();
if out.json {
return;
}
println!("{} curated changes pending:", out.bullet());
for c in plan {
println!(" {:<10} {} {}", out.dim(c.kind), c.source, c.detail);
}
if plan.iter().any(|c| c.kind == "unlist") && !prune {
println!(
"note: `unlist` changes uninstall a source's items; pass --prune to apply them too"
);
}
if plan.iter().any(|c| c.kind == "namespace") {
println!("note: `namespace` changes are advisory; adopt one with the command shown above");
}
if plan.iter().any(|c| c.kind == "adopt") {
println!(
"note: `adopt` changes are advisory; run the `mind curate --adopt` command shown above to apply one"
);
}
}
fn should_apply(plan: &[Change], flags: &CurateFlags) -> Result<bool> {
if flags.yes {
return Ok(true);
}
if crate::render::ctx().json || !crate::hook::is_tty() {
return Ok(false);
}
let applicable = plan.iter().filter(|c| applies(c, flags.prune)).count();
if applicable == 0 {
return Ok(false);
}
let destructive = plan
.iter()
.filter(|c| applies(c, flags.prune) && matches!(c.action, Action::Unlist { .. }))
.count();
commands::confirm_default_yes(&confirm_prompt(applicable, destructive))
}
fn confirm_prompt(applicable: usize, destructive: usize) -> String {
if destructive == 0 {
return format!("apply these {applicable} change(s) now?");
}
format!(
"apply these {applicable} change(s) now, including {destructive} `unlist` \
change(s) that --prune applies by uninstalling that source's items and \
dropping it?"
)
}
fn applies(change: &Change, prune: bool) -> bool {
match change.action {
Action::Advisory => false,
Action::Unlist { .. } => prune,
_ => true,
}
}
fn plan_in_apply_order(plan: &[Change]) -> Vec<&Change> {
let rank = |kind: &str| match kind {
"register" => 0,
"install" => 1,
"repin" => 2,
"upgrade" => 3,
"unlist" => 4,
_ => 5,
};
let mut ordered: Vec<&Change> = plan.iter().collect();
ordered.sort_by_key(|c| rank(c.kind));
ordered
}
fn apply(
paths: &Paths,
ordered: Vec<&Change>,
flags: &CurateFlags,
) -> Result<(Vec<String>, Vec<SkippedEntry>)> {
let out = crate::render::ctx();
let mut applied: Vec<String> = Vec::new();
let mut skipped: Vec<SkippedEntry> = Vec::new();
let flow = InstallFlow {
yes: true,
clobber: commands::Clobber::Prompt,
accept: flags.accept,
};
let mut upgrade_scope: Vec<String> = Vec::new();
for change in ordered {
if !applies(change, flags.prune) {
continue;
}
match &change.action {
Action::Register {
curator,
toml_path,
entry,
} => {
let policy = Policy::load()?;
let prefer_ssh = Config::load(paths)?.ssh;
let mut registry = Registry::load(paths)?;
let added = commands::meld_curated_entry(
paths,
&mut registry,
curator,
toml_path,
entry,
policy.as_ref(),
prefer_ssh,
flags.accept,
&mut skipped,
)?;
registry.save(paths)?;
if added == 0 {
continue;
}
install_declared(paths, &change.source, curator, entry, flow, &mut skipped)?;
applied.push(format!("register:{}", change.source));
}
Action::Install { source, refs } => {
match refs {
Some(list) => commands::install_source_items_subset(paths, source, list, flow)?,
None => commands::install_source_items(paths, source, flow)?,
}
applied.push(format!("install:{source}"));
}
Action::InstallProfiles {
source,
selection,
install_items,
} => {
let registry = Registry::load(paths)?;
if let Some(reg) = registry.find(source)
&& let Err(e) = install_entry_profiles(
paths,
reg,
selection,
install_items.as_ref(),
flow,
true,
)
{
if e.is_hook_stop() {
return Err(e);
}
warn_skip(source, "could not install the entry's profiles", &e);
skipped.push(SkippedEntry::new(
source.clone(),
"profile_selection_invalid",
));
continue;
}
applied.push(format!("install:{source}"));
}
Action::Repin { source, pin } => {
commands::repin_source(paths, source, PinRequest::Follow(pin.clone()))?;
applied.push(format!("repin:{source}"));
}
Action::Upgrade { source } => {
upgrade_scope.push(source.clone());
applied.push(format!("upgrade:{source}"));
}
Action::Unlist { source } => {
commands::unmeld(
paths,
&glob::Pattern::escape(source),
false,
true,
flags.accept,
None,
)?;
applied.push(format!("unlist:{source}"));
}
Action::Advisory => {}
}
}
if !upgrade_scope.is_empty() {
commands::upgrade_sources_no_sync(paths, true, &upgrade_scope, flags.accept)?;
}
if !out.json && !applied.is_empty() {
println!("{} applied {} change(s)", out.ok(), applied.len());
}
Ok((applied, skipped))
}
fn install_declared(
paths: &Paths,
source: &str,
curator: &str,
entry: &NestedSource,
flow: InstallFlow,
skipped: &mut Vec<SkippedEntry>,
) -> Result<()> {
let registry = Registry::load(paths)?;
if let Some(reg) = registry.find(source) {
match commands::curated_profile_route(paths, reg, entry, curator) {
Ok(Some((selection, _))) => {
return match install_entry_profiles(
paths,
reg,
&selection,
entry.install_items.as_ref(),
flow,
false,
) {
Ok(()) => Ok(()),
Err(e) if e.is_hook_stop() => Err(e),
Err(e) => {
warn_skip(source, "could not install the entry's profiles", &e);
skipped.push(SkippedEntry::new(
source.to_string(),
"profile_selection_invalid",
));
Ok(())
}
};
}
Ok(None) => {}
Err(e) => {
warn_skip(source, "could not install the entry's profiles", &e);
skipped.push(SkippedEntry::new(
source.to_string(),
"profile_selection_invalid",
));
return Ok(());
}
}
}
match declared_installs(entry) {
Some(Some(refs)) => commands::install_source_items_subset(paths, source, &refs, flow),
Some(None) => commands::install_source_items(paths, source, flow),
None => Ok(()),
}
}
fn declared_installs(entry: &NestedSource) -> Option<Option<Vec<String>>> {
match &entry.install_items {
Some(refs) if !refs.is_empty() => Some(Some(refs.clone())),
Some(_) => None, None if entry.install => Some(None),
None => None,
}
}
fn pending_profile_installs(
paths: &Paths,
manifest: &Manifest,
source: &Source,
entry: &NestedSource,
curator: &str,
) -> Result<Option<(Vec<String>, Vec<String>)>> {
let Some((_, enabled)) = commands::curated_profile_route(paths, source, entry, curator)? else {
return Ok(None);
};
let items = catalog::scan(paths, &commands::single(source))?;
let table = commands::source_profile_table(paths, source)?;
let expansion = match &table {
Some(t) if !enabled.is_empty() => {
crate::profile::expand(t, &enabled, &items, &source.name)?
}
_ => crate::profile::Expansion::default(),
};
let snapshot = source.profile_snapshot.clone().unwrap_or_default();
let diff = crate::profile::diff(&snapshot, &expansion);
let mut pending: Vec<String> = Vec::new();
for (key, _) in &diff.additions {
if let Some(it) = items
.iter()
.find(|it| format!("{}:{}", it.kind.as_str(), it.name) == *key)
&& !manifest.items.contains_key(it.key().as_str())
{
pending.push(it.display_key());
}
}
if let Some(refs) = entry.install_items.as_ref().filter(|r| !r.is_empty()) {
pending.extend(pending_installs(
paths,
manifest,
source,
&Some(refs.clone()),
)?);
}
pending.sort();
pending.dedup();
Ok(Some((pending, enabled)))
}
fn install_entry_profiles(
paths: &Paths,
source: &Source,
selection: &crate::profile::Selection,
install_items: Option<&Vec<String>>,
flow: InstallFlow,
only_missing: bool,
) -> Result<()> {
let mut patterns: Vec<String> = Vec::new();
if let Some(refs) = install_items.filter(|r| !r.is_empty()) {
let items = catalog::scan(paths, &commands::single(source))?;
patterns = crate::resolve::select_by_bare_refs(&items, refs)
.into_iter()
.map(|it| format!("{}:{}", it.kind.as_str(), it.name))
.collect();
}
let req = commands::ProfileInstall {
source_name: &source.name,
selection,
learn_patterns: &patterns,
only_missing_from_snapshot: only_missing,
};
if crate::render::ctx().json {
commands::install_source_profiles_for_json(paths, &req, flow)?;
} else {
commands::install_source_profiles(paths, &req, flow)?;
}
Ok(())
}
struct Curator {
source: Source,
toml_path: std::path::PathBuf,
entries: Vec<NestedSource>,
market: Vec<Source>,
}
fn curators(paths: &Paths, registry: &Registry) -> Result<(Vec<Curator>, HashSet<String>)> {
let mut out = Vec::new();
let mut unreadable = HashSet::new();
for source in ®istry.sources {
if source.item_path.is_some() {
continue;
}
let clone = source.clone_dir(paths);
if !clone.is_dir() {
unreadable.insert(source.name.clone());
continue;
}
let entries = match MindToml::load(&clone) {
Ok(Some(m)) => m.discover.map(|d| d.sources).unwrap_or_default(),
Ok(None) => Vec::new(),
Err(_) => {
unreadable.insert(source.name.clone());
continue;
}
};
let market: Vec<Source> = match commands::marketplace_subsources(paths, source) {
Ok(list) => list
.into_iter()
.filter(|(_, in_repo)| !in_repo)
.map(|(spec, _)| spec)
.collect(),
Err(_) => {
unreadable.insert(source.name.clone());
continue;
}
};
if entries.is_empty() && market.is_empty() {
continue;
}
out.push(Curator {
source: source.clone(),
toml_path: clone.join("mind.toml"),
entries,
market,
});
}
Ok((out, unreadable))
}
fn build_plan(paths: &Paths, registry: &Registry) -> Result<(Vec<Change>, Vec<SkippedEntry>)> {
let manifest = Manifest::load(paths)?;
let mut plan: Vec<Change> = Vec::new();
let mut skipped: Vec<SkippedEntry> = Vec::new();
let mut listed: HashSet<String> = HashSet::new();
let mut curated: Vec<String> = Vec::new();
let mut proposed_register: HashSet<String> = HashSet::new();
let mut proposed_adopt: HashSet<(String, String)> = HashSet::new();
let (curators_list, unreadable) = curators(paths, registry)?;
for name in &unreadable {
skipped.push(SkippedEntry::new(name.clone(), "curator_unreadable"));
}
let mut incomplete = unreadable;
for Curator {
source: curator,
toml_path,
entries,
market,
} in curators_list
{
for entry in entries {
let mut spec = match parse_spec_quiet(&entry.source) {
Ok(spec) => spec,
Err(e) => {
let shown = strip_ansi(&entry.source);
warn_skip(&shown, "could not read the entry's source", &e);
skipped.push(SkippedEntry::new(shown, "entry_spec_unparseable"));
incomplete.insert(curator.name.clone());
continue;
}
};
spec.apply_alias(entry.effective_alias());
if spec.name == curator.name {
continue;
}
listed.insert(spec.name.clone());
let Some(registered) = registry.find(&spec.name) else {
if let Some(existing) = registry.sources.iter().find(|s| {
s.base_identity() == spec.base_identity()
&& s.item_path == spec.item_path
&& s.curated_by.as_deref() == Some(curator.name.as_str())
}) {
listed.insert(existing.name.clone());
curated.push(existing.name.clone());
plan.push(Change::new(
"namespace",
curator.name.clone(),
existing.name.clone(),
format!(
"{} now declares namespace {}; adopt it with `mind unmeld {} --yes && mind meld {} {}--yes`",
strip_ansi(&curator.name),
entry
.effective_alias()
.map(|a| format!("'{}'", strip_ansi(&a)))
.unwrap_or_else(|| "none".to_string()),
crate::error::shell_quote(&strip_ansi(&existing.name)),
crate::error::shell_quote(&strip_ansi(&existing.url)),
entry
.effective_alias()
.map(|a| format!(
"--namespace {} ",
crate::error::shell_quote(&strip_ansi(&a))
))
.unwrap_or_default(),
),
Action::Advisory,
));
continue;
}
if !proposed_register.insert(spec.name.clone()) {
continue;
}
match entry.pin_directive(&toml_path) {
Ok(Some(pin)) => {
if let Err(e) = curator_pin_allowed(&pin, &toml_path) {
warn_skip(&spec.name, "ignored the pin directive", &e);
skipped
.push(SkippedEntry::new(spec.name.clone(), "pin_ref_not_allowed"));
continue;
}
}
Ok(None) => {}
Err(e) => {
warn_skip(&spec.name, "could not read the pin directive", &e);
skipped.push(SkippedEntry::new(
spec.name.clone(),
"pin_directive_invalid",
));
continue;
}
}
let detail = match declared_installs(&entry) {
_ if entry.profiles.as_ref().is_some_and(|p| !p.is_empty()) => format!(
"listed by {}; register {} and install profiles {}",
strip_ansi(&curator.name),
strip_ansi(&spec.url),
strip_ansi(&entry.profiles.clone().unwrap_or_default().join(", "))
),
Some(Some(refs)) => format!(
"listed by {}; register {} and install {}",
strip_ansi(&curator.name),
strip_ansi(&spec.url),
strip_ansi(&refs.join(", "))
),
Some(None) => format!(
"listed by {}; register {} and install its items",
strip_ansi(&curator.name),
strip_ansi(&spec.url)
),
None => format!(
"listed by {}; register {}",
strip_ansi(&curator.name),
strip_ansi(&spec.url)
),
};
plan.push(Change::new(
"register",
curator.name.clone(),
spec.name.clone(),
detail,
Action::Register {
curator: curator.name.clone(),
toml_path: toml_path.clone(),
entry: Box::new(entry),
},
));
continue;
};
if registered.curated_by.as_deref() != Some(curator.name.as_str()) {
if registered.curated_by.is_none()
&& proposed_adopt.insert((curator.name.clone(), registered.name.clone()))
{
plan.push(Change::new(
"adopt",
curator.name.clone(),
registered.name.clone(),
adopt_detail(&curator.name, ®istered.name),
Action::Advisory,
));
}
continue;
}
curated.push(registered.name.clone());
let profile_result =
pending_profile_installs(paths, &manifest, registered, &entry, &curator.name);
let routed = !matches!(profile_result, Ok(None));
match profile_result {
Ok(Some((pending, enabled))) if !pending.is_empty() => {
plan.push(Change::new(
"install",
curator.name.clone(),
registered.name.clone(),
format!(
"{} declares {} not installed: {} (profiles: {})",
strip_ansi(&curator.name),
pending.len(),
strip_ansi(&pending.join(", ")),
strip_ansi(&enabled.join(", "))
),
Action::InstallProfiles {
source: registered.name.clone(),
selection: crate::profile::Selection::from_nested(&entry),
install_items: entry.install_items.clone(),
},
));
}
Ok(Some((_, enabled)))
if !enabled.is_empty()
&& !enabled.iter().all(|p| {
registered.profiles.as_ref().is_some_and(|r| r.contains(p))
}) =>
{
plan.push(Change::new(
"install",
curator.name.clone(),
registered.name.clone(),
format!(
"{} records profiles {} (members already installed)",
strip_ansi(&curator.name),
strip_ansi(&enabled.join(", "))
),
Action::InstallProfiles {
source: registered.name.clone(),
selection: crate::profile::Selection::from_nested(&entry),
install_items: entry.install_items.clone(),
},
));
}
Ok(_) => {}
Err(e) => {
warn_skip(®istered.name, "could not check the entry's profiles", &e);
skipped.push(SkippedEntry::new(
registered.name.clone(),
"profile_selection_invalid",
));
}
}
let ordinary = if routed {
None
} else {
declared_installs(&entry)
};
if let Some(declared) = ordinary {
match pending_installs(paths, &manifest, registered, &declared) {
Ok(pending) if !pending.is_empty() => {
plan.push(Change::new(
"install",
curator.name.clone(),
registered.name.clone(),
format!(
"{} declares {} not installed: {}",
strip_ansi(&curator.name),
pending.len(),
strip_ansi(&pending.join(", "))
),
Action::Install {
source: registered.name.clone(),
refs: declared,
},
));
}
Ok(_) => {}
Err(e) => {
warn_skip(®istered.name, "could not check declared items", &e);
skipped.push(SkippedEntry::new(
registered.name.clone(),
"declared_items_check_failed",
));
}
}
}
match entry.pin_directive(&toml_path) {
Ok(Some(pin)) if !commands::same_declared_pin(®istered.pin, &pin) => {
if let Err(e) = curator_pin_allowed(&pin, &toml_path) {
warn_skip(®istered.name, "ignored the pin directive", &e);
skipped.push(SkippedEntry::new(
registered.name.clone(),
"pin_ref_not_allowed",
));
} else {
plan.push(Change::new(
"repin",
curator.name.clone(),
registered.name.clone(),
format!(
"{} declares {}; registered as {}",
strip_ansi(&curator.name),
strip_ansi(&commands::pin_description(&pin)),
strip_ansi(&commands::pin_description(®istered.pin))
),
Action::Repin {
source: registered.name.clone(),
pin,
},
));
}
}
Ok(_) => {}
Err(e) => {
warn_skip(®istered.name, "could not read the pin directive", &e);
skipped.push(SkippedEntry::new(
registered.name.clone(),
"pin_directive_invalid",
));
}
}
}
for spec in market {
if spec.name == curator.name {
continue;
}
listed.insert(spec.name.clone());
let Some(registered) = registry.find(&spec.name) else {
continue;
};
if registered.curated_by.as_deref() == Some(curator.name.as_str()) {
curated.push(registered.name.clone());
continue;
}
if registered.curated_by.is_none()
&& proposed_adopt.insert((curator.name.clone(), registered.name.clone()))
{
plan.push(Change::new(
"adopt",
curator.name.clone(),
registered.name.clone(),
adopt_detail(&curator.name, ®istered.name),
Action::Advisory,
));
}
}
}
for source in dedup(curated) {
match outdated_count(paths, registry, &manifest, &source) {
Ok(Some(count)) if count > 0 => {
let curator = registry
.find(&source)
.and_then(|s| s.curated_by.clone())
.unwrap_or_default();
plan.push(Change::new(
"upgrade",
curator.clone(),
source.clone(),
format!(
"{count} installed item(s) out of date ({})",
strip_ansi(&curator)
),
Action::Upgrade { source },
));
}
Ok(_) => {}
Err(e) => {
warn_skip(&source, "could not check for drift", &e);
skipped.push(SkippedEntry::new(source.clone(), "drift_check_failed"));
}
}
}
for source in ®istry.sources {
let Some(curator) = source.curated_by.as_deref() else {
continue; };
if listed.contains(&source.name) {
continue;
}
if incomplete.contains(curator) {
continue;
}
plan.push(Change::new(
"unlist",
curator.to_string(),
source.name.clone(),
format!(
"no longer listed by {}; --prune uninstalls its items and drops it",
strip_ansi(curator)
),
Action::Unlist {
source: source.name.clone(),
},
));
}
Ok((plan, skipped))
}
fn adopt_detail(curator: &str, identity: &str) -> String {
format!(
"{} lists this source but does not own it; run `mind curate --adopt {}` to let it manage it",
strip_ansi(curator),
crate::error::shell_quote(&strip_ansi(identity))
)
}
fn curator_pin_allowed(pin: &Pin, toml_path: &std::path::Path) -> Result<()> {
let value = match pin {
Pin::DefaultBranch | Pin::Version(_) => return Ok(()),
Pin::FollowBranch(v) | Pin::Tag(v) | Pin::Ref(v) => v.as_str(),
};
let Some(rest) = value.strip_prefix("refs/") else {
return Ok(()); };
let named = |ns: &str| {
rest.strip_prefix(ns)
.is_some_and(|name| !name.trim().is_empty())
};
if named("heads/") || named("tags/") {
return Ok(());
}
Err(MindError::MindToml {
path: toml_path.to_path_buf(),
msg: format!(
"curator-declared pin '{}' is not a branch or a tag; a curator may pin only \
refs/heads/<branch>, refs/tags/<tag>, a plain branch/tag name, or a commit",
strip_ansi(value)
),
})
}
fn warn_skip(source: &str, doing: &str, e: &MindError) {
if !crate::render::ctx().json {
eprintln!(" warning: {doing} for {}: {e}", strip_ansi(source));
}
}
fn dedup(mut names: Vec<String>) -> Vec<String> {
names.sort();
names.dedup();
names
}
fn pending_installs(
paths: &Paths,
manifest: &Manifest,
source: &Source,
declared: &Option<Vec<String>>,
) -> Result<Vec<String>> {
let items = catalog::scan(paths, &commands::single(source))?;
let selected: Vec<&catalog::CatalogItem> = match declared {
Some(refs) => crate::resolve::select_by_bare_refs(&items, refs),
None => items.iter().collect(),
};
let mut pending: Vec<String> = selected
.into_iter()
.filter(|it| !manifest.items.contains_key(it.key().as_str()))
.map(|it| it.display_key())
.collect();
pending.sort();
Ok(pending)
}
fn outdated_count(
paths: &Paths,
registry: &Registry,
manifest: &Manifest,
source_name: &str,
) -> Result<Option<usize>> {
let Some(source) = registry.find(source_name) else {
return Ok(None);
};
let items = catalog::scan(paths, &commands::single(source))?;
let by_identity: HashMap<(crate::error::ItemKind, &str), &catalog::CatalogItem> = items
.iter()
.map(|it| ((it.kind, it.name.as_str()), it))
.collect();
let mut count = 0usize;
for installed in manifest.items.values() {
if installed.source != source_name {
continue;
}
let Some(cat) = by_identity.get(&(installed.kind, installed.bare_name.as_str())) else {
continue; };
let hash_lag = cat.content_hash().map_or(true, |h| h != installed.hash);
let rename_lag = cat.effective_name() != installed.name;
if hash_lag || rename_lag {
count += 1;
}
}
Ok(Some(count))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn change_new_sanitizes_curator_source_and_detail() {
let hostile = "evil\u{1b}[31mHIDDEN\u{1b}[0m";
let change = Change::new(
"register",
hostile,
hostile,
hostile.to_string(),
Action::Advisory,
);
assert_eq!(change.curator, strip_ansi(hostile));
assert_eq!(change.source, strip_ansi(hostile));
assert_eq!(change.detail, strip_ansi(hostile));
assert!(!change.curator.contains('\u{1b}'));
assert!(!change.source.contains('\u{1b}'));
assert!(!change.detail.contains('\u{1b}'));
}
#[test]
fn change_new_strips_invisible_unicode_curator_and_source_never_reach_validate_identity_part() {
let bidi = "curator\u{202E}evil";
let zero_width = "lib\u{200B}sneaky";
let change = Change::new(
"register",
bidi.to_string(),
zero_width.to_string(),
"detail".to_string(),
Action::Advisory,
);
assert!(!change.curator.contains('\u{202E}'));
assert!(!change.source.contains('\u{200B}'));
assert!(change.curator.contains("curator"));
assert!(change.source.contains("lib"));
}
#[test]
fn confirm_prompt_names_destructive_unlists_only_when_there_are_some() {
assert_eq!(
confirm_prompt(3, 0),
"apply these 3 change(s) now?",
"with no prune-driven unlist the wording is unchanged"
);
let mixed = confirm_prompt(2, 1);
assert!(
mixed.contains("2 change(s)") && mixed.contains("1 `unlist`"),
"a mixed plan must count both: {mixed}"
);
assert!(
mixed.contains("uninstall") && mixed.contains("--prune"),
"and say what applying them does: {mixed}"
);
}
fn spec(s: &str) -> Source {
parse_spec_quiet(s).expect("fixture spec must parse")
}
#[test]
fn same_upstream_ignores_url_form_ssh_https_dot_git_and_trailing_slash() {
let https = spec("https://github.com/acme/agents");
assert!(same_upstream(
&https,
&spec("https://github.com/acme/agents")
));
assert!(
same_upstream(&spec("git@github.com:acme/agents.git"), &https),
"the ssh form of a repo is the same upstream as its https form"
);
assert!(
same_upstream(&spec("ssh://git@github.com/acme/agents"), &https),
"an ssh:// URL with userinfo is the same upstream too"
);
assert!(
same_upstream(&spec("https://github.com/acme/agents/"), &https),
"a trailing slash does not change the upstream"
);
assert!(
same_upstream(&spec("acme/agents"), &https),
"the bare owner/repo shorthand resolves to the same upstream"
);
let mut ssh_pref = spec("acme/agents");
ssh_pref.prefer_ssh(true);
assert_eq!(ssh_pref.url, "git@github.com:acme/agents");
assert!(
same_upstream(&ssh_pref, &https),
"config `ssh = true` rewrites the URL, never the upstream"
);
}
#[test]
fn same_upstream_rejects_a_different_repo_owner_host_or_item_path() {
let registered = spec("https://github.com/acme/agents");
assert!(!same_upstream(
&spec("https://github.com/acme/other"),
®istered
));
assert!(!same_upstream(
&spec("https://github.com/evil/agents"),
®istered
));
assert!(!same_upstream(
&spec("https://evil.example/acme/agents"),
®istered
));
let link = spec("https://github.com/acme/agents/tree/main/skills/review");
assert!(link.item_path.is_some(), "fixture must be an item link");
assert!(!same_upstream(&link, ®istered));
assert!(!same_upstream(
&spec("https://github.com/acme/agents/tree/main/skills/other"),
&link
));
}
#[test]
fn same_upstream_compares_the_whole_path_for_local_sources() {
let registered = spec("/real/proj/lib");
assert_eq!(registered.name, "local/proj/lib");
let decoy = spec("/decoy/proj/lib");
assert_eq!(
decoy.name, registered.name,
"the fixture is only meaningful if the two derive ONE identity"
);
assert!(
!same_upstream(&decoy, ®istered),
"a different directory that derives the same identity is not the same upstream"
);
assert!(same_upstream(&spec("/real/proj/lib"), ®istered));
assert!(
same_upstream(&spec("file:///real/proj/lib"), ®istered),
"the file:// form of a local path is the same upstream"
);
let mut slashed = registered.clone();
slashed.url = "/real/proj/lib/".to_string();
assert!(same_upstream(&slashed, ®istered));
let mut dotgit = registered.clone();
dotgit.url = "/real/proj/lib.git".to_string();
assert!(same_upstream(&dotgit, ®istered));
}
#[test]
fn same_upstream_never_matches_a_remote_claim_against_a_local_source() {
let local = spec("/real/proj/lib");
assert!(local.is_local());
let remote = spec("https://github.com/proj/lib");
assert!(
!same_upstream(&remote, &local),
"a remote claim is not the same upstream as a local registration"
);
assert!(
!same_upstream(&local, &remote),
"nor the reverse: a local claim on a remote registration"
);
let mut host_local = local.clone();
host_local.url = "https://local/proj/lib".to_string();
assert_eq!(
host_local.base_identity(),
local.base_identity(),
"the fixture is only meaningful if the two derive ONE identity"
);
assert!(
!same_upstream(&host_local, &local),
"a URL that merely derives the `local/...` identity is not the \
directory the source is registered from"
);
assert!(!same_upstream(&local, &host_local));
}
fn pin_check(pin: Pin) -> Result<()> {
curator_pin_allowed(&pin, std::path::Path::new("mind.toml"))
}
#[test]
fn curator_pin_allows_plain_names_shas_and_refs_heads_or_tags() {
assert!(pin_check(Pin::DefaultBranch).is_ok());
assert!(pin_check(Pin::FollowBranch("main".into())).is_ok());
assert!(pin_check(Pin::FollowBranch("release/2.0".into())).is_ok());
assert!(pin_check(Pin::Tag("v1.2.3".into())).is_ok());
assert!(
pin_check(Pin::Ref("0123456789abcdef0123456789abcdef01234567".into())).is_ok(),
"a commit sha names immutable content and is always allowed"
);
assert!(pin_check(Pin::FollowBranch("refs/heads/main".into())).is_ok());
assert!(pin_check(Pin::Tag("refs/tags/v1.2.3".into())).is_ok());
assert!(
pin_check(Pin::Ref("refs/heads/main".into())).is_ok(),
"the namespace is what is checked, not which directive declared it"
);
}
#[test]
fn curator_pin_rejects_pull_remotes_and_other_ref_namespaces() {
for bad in [
"refs/pull/9999/head",
"refs/pull/9999/merge",
"refs/remotes/origin/main",
"refs/changes/12/34/1",
"refs/notes/commits",
"refs/stash",
"refs/heads", "refs/tags/", "refs/headsfoo/x", ] {
assert!(
crate::git::validate_ref_value(bad).is_ok(),
"the fixture must be a ref the SHARED validator accepts, or \
this narrower rule proves nothing: {bad}"
);
assert!(
pin_check(Pin::FollowBranch(bad.to_string())).is_err(),
"a curator must not be able to pin {bad}"
);
assert!(
pin_check(Pin::Ref(bad.to_string())).is_err(),
"nor via pin-ref: {bad}"
);
assert!(
pin_check(Pin::Tag(bad.to_string())).is_err(),
"nor via pin-tag: {bad}"
);
}
}
#[test]
fn curator_pin_refusal_names_the_ref_with_ansi_stripped() {
let err = pin_check(Pin::FollowBranch(
"refs/pull/9999/\u{1b}[31mhead\u{1b}[0m".into(),
))
.expect_err("must be refused");
let text = err.to_string();
assert!(
text.contains("refs/pull/9999/head"),
"the refusal must name the ref: {text}"
);
assert!(
!text.contains('\u{1b}'),
"curator-controlled text must be sanitized: {text:?}"
);
}
}