use std::collections::BTreeMap;
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use crate::error::Result;
use crate::manifest::Manifest;
use crate::mindfile::{HookEvent, MindToml};
use crate::paths::Paths;
use crate::sanitize::strip_ansi;
use crate::source::{HookOrigin, RecordedItemsHook, Registry, Source};
const EVENT: &str = "items";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ItemEntry {
pub kind: String,
pub name: String,
pub effective_name: String,
pub store: String,
pub profiles: Vec<String>,
}
impl ItemEntry {
fn key(&self) -> (String, String) {
(self.kind.clone(), self.effective_name.clone())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AppliedItem {
#[serde(flatten)]
pub entry: ItemEntry,
pub hash: String,
}
#[derive(Debug, Serialize)]
struct ItemsDoc<'a> {
version: u8,
source: &'a str,
commit: &'a str,
command: &'a str,
added: Vec<&'a ItemEntry>,
updated: Vec<&'a ItemEntry>,
removed: Vec<&'a ItemEntry>,
installed: Vec<&'a ItemEntry>,
}
#[derive(Debug, Default, PartialEq, Eq)]
struct Delta<'a> {
added: Vec<&'a ItemEntry>,
updated: Vec<&'a ItemEntry>,
removed: Vec<&'a ItemEntry>,
}
impl Delta<'_> {
fn is_empty(&self) -> bool {
self.added.is_empty() && self.updated.is_empty() && self.removed.is_empty()
}
fn summary(&self) -> String {
format!(
"{} added, {} updated, {} removed",
self.added.len(),
self.updated.len(),
self.removed.len()
)
}
}
fn delta<'a>(applied: Option<&'a [AppliedItem]>, current: &'a [AppliedItem]) -> Delta<'a> {
let applied = applied.unwrap_or_default();
let before: BTreeMap<(String, String), &AppliedItem> =
applied.iter().map(|a| (a.entry.key(), a)).collect();
let after: BTreeMap<(String, String), &AppliedItem> =
current.iter().map(|a| (a.entry.key(), a)).collect();
let mut d = Delta::default();
for item in current {
match before.get(&item.entry.key()) {
None => d.added.push(&item.entry),
Some(prev) if prev.hash != item.hash => d.updated.push(&item.entry),
Some(_) => {}
}
}
for item in applied {
if !after.contains_key(&item.entry.key()) {
d.removed.push(&item.entry);
}
}
d
}
fn installed_set(paths: &Paths, manifest: &Manifest, source: &str) -> Vec<AppliedItem> {
let mut set: Vec<AppliedItem> = manifest
.items
.values()
.filter(|it| it.source == source)
.map(|it| AppliedItem {
entry: ItemEntry {
kind: it.kind.as_str().to_string(),
name: it.bare_name.clone(),
effective_name: it.name.clone(),
store: paths.mind_home.join(&it.store).display().to_string(),
profiles: it.profiles.clone(),
},
hash: it.hash.clone(),
})
.collect();
set.sort_by_key(|a| a.entry.key());
set
}
#[derive(Debug, Default, PartialEq, Eq)]
pub struct Snapshot(BTreeMap<String, BTreeMap<String, String>>);
pub fn snapshot(paths: &Paths) -> Snapshot {
let mut out: BTreeMap<String, BTreeMap<String, String>> = BTreeMap::new();
if let Ok(manifest) = Manifest::load(paths) {
for it in manifest.items.values() {
out.entry(it.source.clone())
.or_default()
.insert(it.key().into(), it.hash.clone());
}
}
Snapshot(out)
}
impl Snapshot {
fn source(&self, name: &str) -> Option<&BTreeMap<String, String>> {
self.0.get(name)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ItemsHook {
pub run: String,
pub label: String,
pub optional: bool,
}
pub fn effective_hooks(paths: &Paths, source: &Source) -> Vec<ItemsHook> {
let dir = source.clone_dir(paths);
match MindToml::load(&dir) {
Ok(Some(mf)) => mf
.resolved_hooks(&dir.join("mind.toml"))
.unwrap_or_default()
.into_iter()
.filter(|h| h.event == HookEvent::Items)
.map(|h| ItemsHook {
label: h.label().to_string(),
run: h.run,
optional: h.optional,
})
.collect(),
Ok(None) => source
.items_hooks
.iter()
.filter(|r| r.origin == Some(HookOrigin::Curated))
.map(|r| ItemsHook {
run: r.command.clone(),
label: r
.name
.as_deref()
.filter(|n| !n.trim().is_empty())
.unwrap_or(&r.command)
.to_string(),
optional: r.optional,
})
.collect(),
Err(_) => Vec::new(),
}
}
pub fn record_curated(source: &mut Source, hooks: &[crate::mindfile::ResolvedHook]) {
for h in hooks.iter().filter(|h| h.event == HookEvent::Items) {
let rec = record_mut(source, &h.run);
rec.name = h.name.clone();
rec.optional = h.optional;
rec.origin = Some(HookOrigin::Curated);
}
}
fn record_mut<'a>(source: &'a mut Source, command: &str) -> &'a mut RecordedItemsHook {
let idx = match source.items_hooks.iter().position(|r| r.command == command) {
Some(i) => i,
None => {
source.items_hooks.push(RecordedItemsHook {
command: command.to_string(),
applied: None,
name: None,
optional: false,
origin: None,
});
source.items_hooks.len() - 1
}
};
&mut source.items_hooks[idx]
}
pub fn status(paths: &Paths, manifest: &Manifest, source: &Source, command: &str) -> String {
let current = installed_set(paths, manifest, &source.name);
let applied = source
.items_hooks
.iter()
.find(|r| r.command == command)
.and_then(|r| r.applied.as_deref());
let d = delta(applied, ¤t);
let recorded = applied.map(<[AppliedItem]>::len).unwrap_or(0);
if d.is_empty() {
format!("applied ({recorded} items)")
} else {
format!("pending ({}; {recorded} items applied)", d.summary())
}
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct Tally {
pub existed: usize,
pub ran: usize,
pub skipped_for_consent: usize,
pub settled: usize,
}
pub fn offer(
paths: &Paths,
source_name: &str,
command: &str,
accept: crate::hook::Accept,
force: bool,
) -> Result<Tally> {
let mut tally = Tally::default();
let mut registry = Registry::load(paths)?;
let Some(idx) = registry.sources.iter().position(|s| s.name == source_name) else {
return Ok(tally);
};
let hooks = effective_hooks(paths, ®istry.sources[idx]);
if hooks.is_empty() {
return Ok(tally);
}
let manifest = Manifest::load(paths)?;
let current = installed_set(paths, &manifest, source_name);
let mut dirty = false;
for hook in &hooks {
let source = ®istry.sources[idx];
let applied = source
.items_hooks
.iter()
.find(|r| r.command == hook.run)
.and_then(|r| r.applied.as_deref());
let d = delta(applied, ¤t);
if d.is_empty() && !force {
tally.settled += 1;
continue;
}
tally.existed += 1;
let dir = source.clone_dir(paths);
let commit = source.commit.clone().unwrap_or_default();
let clone_path = dir.display().to_string();
let browse_url = source.browse_url(&commit);
let disclosure = crate::hook::hook_disclosure_text(
&hook.label,
EVENT,
hook.optional,
&source.name,
&crate::commands::pin_description(&source.pin),
&commit,
&clone_path,
&hook.run,
None,
browse_url.as_deref(),
);
let site = crate::hook_log::Site {
item: None,
commit: source.commit.as_deref(),
replaced: &[],
};
let key = crate::consent::HookKey::source_hook(&source.name, EVENT, &hook.run);
let planned = matches!(
crate::consent::answer_for(&key),
Some(crate::consent::Answer::RunAll | crate::consent::Answer::SkipAll)
);
let unasked = crate::prompt::captured() && !accept.items && !planned;
let act = if unasked {
crate::hook::HookAct::Skip
} else {
crate::hook::decide(&key, &disclosure, hook.optional, accept.items)?
};
if act != crate::hook::HookAct::Run {
if act == crate::hook::HookAct::Abort {
crate::hook_log::aborted(&source.name, site, EVENT, &hook.label, &hook.run);
} else if unasked {
crate::hook_log::file(crate::hook_log::base(
&source.name,
site,
EVENT,
&hook.label,
&hook.run,
crate::hook_log::Outcome::Skipped,
));
} else {
crate::hook_log::not_run(&source.name, site, EVENT, &hook.label, &hook.run);
}
if crate::hook::is_tty() && !unasked {
crate::render::note(format!(
"note: skipped items hook '{}' for {}; it stays pending",
strip_ansi(&hook.label),
strip_ansi(&source.name)
));
} else {
tally.skipped_for_consent += 1;
let why = if unasked {
"probe cannot prompt"
} else {
"not a terminal"
};
crate::render::note(skip_note(&hook.label, &source.name, why));
}
let run = hook.run.clone();
record_mut(&mut registry.sources[idx], &run);
dirty = true;
continue;
}
tally.ran += 1;
println!(
"running items hook '{}' for {} ({})",
strip_ansi(&hook.label),
strip_ansi(&source.name),
d.summary()
);
let doc = ItemsDoc {
version: 1,
source: &source.name,
commit: &commit,
command,
added: d.added.clone(),
updated: d.updated.clone(),
removed: d.removed.clone(),
installed: current.iter().map(|a| &a.entry).collect(),
};
let result = write_doc(paths, &doc).and_then(|file| {
let mut env = crate::commands::source_hook_env(source);
env.items_file = Some(file.clone());
let result = crate::hook::run_hook(
&hook.run,
&dir,
&source.name,
EVENT,
&hook.label,
&env,
site,
);
let _ = std::fs::remove_file(&file);
result
});
if let Err(e) = result {
if dirty {
registry.save(paths)?;
}
return Err(e);
}
record_mut(&mut registry.sources[idx], &hook.run).applied = Some(current.clone());
dirty = true;
}
if dirty {
registry.save(paths)?;
}
Ok(tally)
}
fn write_doc(paths: &Paths, doc: &ItemsDoc) -> Result<PathBuf> {
use std::sync::atomic::{AtomicU32, Ordering};
static SEQ: AtomicU32 = AtomicU32::new(0);
let dir = paths.tmp_dir().join("items-hooks");
std::fs::create_dir_all(&dir).map_err(|e| crate::error::MindError::io(&dir, e))?;
let file = dir.join(format!(
"{}-{}.json",
std::process::id(),
SEQ.fetch_add(1, Ordering::SeqCst)
));
let body = serde_json::to_vec_pretty(doc).expect("the items document serializes");
std::fs::write(&file, body).map_err(|e| crate::error::MindError::io(&file, e))?;
Ok(file)
}
fn skip_note(label: &str, source_name: &str, why: &str) -> String {
let label = strip_ansi(label);
let source_name = strip_ansi(source_name);
let quoted = crate::error::shell_quote(&source_name);
format!(
"note: skipped items hook '{label}' for {source_name} ({why}); it stays \
pending. Run it unattended with:\n mind hooks run {quoted} --event items \
--accept-hooks=items"
)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PlanWhen {
Touched,
Pending,
Emptied,
}
pub fn planned<S: AsRef<str>>(
paths: &Paths,
registry: &Registry,
sources: impl IntoIterator<Item = S>,
when: PlanWhen,
accept: crate::hook::Accept,
) -> Vec<crate::consent::PlannedHook> {
let manifest = match when {
PlanWhen::Pending => Manifest::load(paths).ok(),
PlanWhen::Touched | PlanWhen::Emptied => None,
};
let policy = crate::policy::Policy::load().ok().flatten();
let wanted: std::collections::BTreeSet<String> = sources
.into_iter()
.map(|s| s.as_ref().to_string())
.collect();
let mut plan = Vec::new();
for source in registry.sources.iter().filter(|s| wanted.contains(&s.name)) {
let name = source.name.as_str();
if !policy_permits(policy.as_ref(), source) {
continue;
}
let current = match (when, &manifest) {
(PlanWhen::Pending, Some(m)) => Some(installed_set(paths, m, name)),
(PlanWhen::Pending, None) => continue,
(PlanWhen::Emptied, _) => Some(Vec::new()),
(PlanWhen::Touched, _) => None,
};
let commit = source.commit.clone().unwrap_or_default();
let dir = source.clone_dir(paths);
for hook in effective_hooks(paths, source) {
if let Some(current) = ¤t {
let applied = source
.items_hooks
.iter()
.find(|r| r.command == hook.run)
.and_then(|r| r.applied.as_deref());
if delta(applied, current).is_empty() {
continue;
}
}
plan.push(crate::consent::PlannedHook {
key: crate::consent::HookKey::source_hook(name, EVENT, &hook.run),
label: hook.label,
optional: hook.optional,
commit: commit.clone(),
run_dir: dir.display().to_string(),
inspect: None,
browse_url: source.browse_url(&commit),
replaced: None,
accepted: accept.items,
});
}
}
plan
}
fn policy_permits(policy: Option<&crate::policy::Policy>, source: &Source) -> bool {
!policy.is_some_and(|p| p.lock() && !p.allow_matches(&source.base_identity()))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Scope {
Touched,
Pending(Option<String>),
}
pub fn settle(
paths: &Paths,
before: &Snapshot,
command: &str,
scope: &Scope,
accept: crate::hook::Accept,
) -> Result<()> {
let after = snapshot(paths);
let registry = Registry::load(paths)?;
let policy = crate::policy::Policy::load().ok().flatten();
for source in ®istry.sources {
let touched = before.source(&source.name) != after.source(&source.name);
let in_scope = match scope {
Scope::Touched => false,
Scope::Pending(filter) => filter
.as_deref()
.is_none_or(|f| crate::resolve::source_matches_glob(&source.name, f)),
};
let listed = || {
effective_hooks(paths, source).iter().any(|h| {
let key = crate::consent::HookKey::source_hook(&source.name, EVENT, &h.run);
crate::consent::answer_for(&key).is_some()
})
};
if !touched && !in_scope && !listed() {
continue;
}
if !policy_permits(policy.as_ref(), source) {
continue;
}
if effective_hooks(paths, source).is_empty() {
continue;
}
offer(paths, &source.name, command, accept, false)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn item(kind: &str, name: &str, hash: &str) -> AppliedItem {
AppliedItem {
entry: ItemEntry {
kind: kind.to_string(),
name: name.to_string(),
effective_name: name.to_string(),
store: format!("/store/{kind}/{name}"),
profiles: Vec::new(),
},
hash: hash.to_string(),
}
}
#[test]
fn delta_is_taken_against_the_applied_set() {
let applied = vec![item("skill", "a", "1"), item("skill", "b", "1")];
let current = vec![item("skill", "a", "2"), item("tool", "c", "1")];
let d = delta(Some(&applied), ¤t);
let names = |v: &[&ItemEntry]| v.iter().map(|e| e.name.clone()).collect::<Vec<_>>();
assert_eq!(names(&d.added), ["c"]);
assert_eq!(names(&d.updated), ["a"]);
assert_eq!(names(&d.removed), ["b"]);
assert_eq!(d.summary(), "1 added, 1 updated, 1 removed");
}
#[test]
fn a_hook_never_applied_sees_every_item_as_added_and_an_empty_set_as_settled() {
let current = vec![item("skill", "a", "1")];
let d = delta(None, ¤t);
assert_eq!(d.added.len(), 1);
assert!(d.updated.is_empty() && d.removed.is_empty());
assert!(delta(None, &[]).is_empty());
assert!(delta(Some(¤t), ¤t).is_empty());
}
#[test]
fn items_are_compared_by_kind_and_effective_name() {
let applied = vec![item("skill", "x", "1")];
let current = vec![item("agent", "x", "1")];
let d = delta(Some(&applied), ¤t);
assert_eq!(d.added.len(), 1);
assert_eq!(d.removed.len(), 1);
}
#[test]
fn the_skip_note_quotes_the_identity_in_its_remedy() {
let note = skip_note("regen", "local/a b/c", "not a terminal");
assert!(note.contains("(not a terminal)"));
assert!(note.contains("mind hooks run 'local/a b/c' --event items"));
assert!(note.contains("stays pending"));
}
}