use std::collections::{HashMap, HashSet};
use std::io::Read;
use anyhow::Result;
use crate::cli::DoorsCommands;
use crate::doors::{self, FIRED_KEY, FiredDoor, HookInput, Selection};
use crate::kv::{DataValue, EntryAttrs, HistoryEntry, KvStore};
type DoorId = (String, String);
struct FireRow {
session: String,
key: String,
entry: String,
trigger: String,
ts: String,
}
fn field(e: &HistoryEntry, name: &str) -> String {
e.data
.as_ref()
.and_then(|d| d.get(name))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string()
}
fn read_fire_log(store: &KvStore) -> Vec<FireRow> {
let Ok(DataValue::History { entries, .. }) = store.get(FIRED_KEY) else {
return Vec::new();
};
entries
.iter()
.map(|e| FireRow {
session: field(e, "session"),
key: field(e, "key"),
entry: field(e, "entry"),
trigger: field(e, "trigger"),
ts: e.ts.clone(),
})
.collect()
}
fn fire_state(rows: &[FireRow], session: &str) -> (HashSet<DoorId>, HashMap<DoorId, u64>) {
let mut fired: HashSet<DoorId> = HashSet::new();
let mut counts: HashMap<DoorId, u64> = HashMap::new();
for r in rows {
let pair = (r.key.clone(), r.entry.clone());
*counts.entry(pair.clone()).or_insert(0) += 1;
if r.session == session {
fired.insert(pair);
}
}
(fired, counts)
}
fn evaluate(store: &KvStore, session: &str, prompt: &str, budget: usize) -> Selection {
let rows = read_fire_log(store);
let (already, counts) = fire_state(&rows, session);
let candidates = store.iter_triggered();
doors::select(prompt, &candidates, &already, &counts, budget)
}
fn record(store: &mut KvStore, session: &str, fired: &[FiredDoor]) -> Result<()> {
if fired.is_empty() {
return Ok(());
}
for f in fired {
store.push(
FIRED_KEY,
&f.trigger,
EntryAttrs {
data: Some(serde_json::json!({
"session": session,
"key": f.key,
"entry": f.id,
"trigger": f.trigger,
})),
..Default::default()
},
)?;
}
store.save()?;
Ok(())
}
fn print_fired(fired: &[FiredDoor]) {
for f in fired {
println!("{}", f.render());
}
}
fn hook(dry_run: bool, budget: usize) {
let mut raw = String::new();
if let Err(e) = std::io::stdin().read_to_string(&mut raw) {
eprintln!("[mx doors] could not read hook input: {e}");
return;
}
let input: HookInput = match serde_json::from_str(&raw) {
Ok(i) => i,
Err(e) => {
eprintln!("[mx doors] malformed hook JSON: {e}");
return;
}
};
if input.prompt.trim().is_empty() {
return;
}
let mut store = match KvStore::from_env() {
Ok(s) => s,
Err(e) => {
eprintln!("[mx doors] kv unavailable: {e:#}");
return;
}
};
let selection = evaluate(&store, &input.session_id, &input.prompt, budget);
if selection.fired.is_empty() {
return;
}
if !dry_run && let Err(e) = record(&mut store, &input.session_id, &selection.fired) {
eprintln!("[mx doors] could not record fires, no doors opened: {e:#}");
return;
}
print_fired(&selection.fired);
}
fn check(message: &str, session: &str, dry_run: bool, json: bool, budget: usize) -> Result<i32> {
let mut store = KvStore::from_env()?;
let selection = evaluate(&store, session, message, budget);
if !dry_run {
record(&mut store, session, &selection.fired)?;
}
if json {
println!("{}", serde_json::to_string(&selection)?);
} else {
print_fired(&selection.fired);
}
Ok(crate::kv::EXIT_OK)
}
fn stats(since: Option<&str>, json: bool) -> Result<i32> {
let store = KvStore::from_env()?;
let cutoff = match since {
Some(s) => Some(crate::kv::parse_relative_time(s).map_err(|e| {
anyhow::anyhow!(
"{e} -- --since takes a relative window of minutes or longer (e.g. 30m, 24h, 7d, 2w); seconds are not a unit"
)
})?),
None => None,
};
let all_rows = read_fire_log(&store);
let ever_fired: HashSet<DoorId> = all_rows
.iter()
.map(|r| (r.key.clone(), r.entry.clone()))
.collect();
let rows: Vec<&FireRow> = all_rows
.iter()
.filter(|r| match cutoff {
Some(c) => chrono::DateTime::parse_from_rfc3339(&r.ts)
.map(|t| t.with_timezone(&chrono::Utc) >= c)
.unwrap_or(false),
None => true,
})
.collect();
let mut per_entry: HashMap<DoorId, u64> = HashMap::new();
let mut per_trigger: HashMap<String, u64> = HashMap::new();
for r in &rows {
*per_entry
.entry((r.key.clone(), r.entry.clone()))
.or_insert(0) += 1;
*per_trigger.entry(r.trigger.clone()).or_insert(0) += 1;
}
let never: Vec<DoorId> = store
.iter_triggered()
.iter()
.map(|c| (c.key.to_string(), c.id.to_string()))
.filter(|p| !ever_fired.contains(p))
.collect();
if json {
let entries: Vec<serde_json::Value> = per_entry
.iter()
.map(|((k, e), n)| serde_json::json!({"key": k, "entry": e, "fires": n}))
.collect();
let triggers: Vec<serde_json::Value> = per_trigger
.iter()
.map(|(t, n)| serde_json::json!({"trigger": t, "fires": n}))
.collect();
let never_json: Vec<serde_json::Value> = never
.iter()
.map(|(k, e)| serde_json::json!({"key": k, "entry": e}))
.collect();
println!(
"{}",
serde_json::to_string(&serde_json::json!({
"total_fires": rows.len(),
"entries": entries,
"triggers": triggers,
"never_fired": never_json,
}))?
);
return Ok(crate::kv::EXIT_OK);
}
println!("{} fires recorded", rows.len());
let mut entries: Vec<_> = per_entry.into_iter().collect();
entries.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
for ((k, e), n) in &entries {
println!(" {:>4} {}/kv-{}", n, k, e);
}
let mut triggers: Vec<_> = per_trigger.into_iter().collect();
triggers.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
if !triggers.is_empty() {
println!("by trigger:");
for (t, n) in &triggers {
println!(" {:>4} {}", n, t);
}
}
if !never.is_empty() {
println!("never fired:");
for (k, e) in &never {
println!(" {}/kv-{}", k, e);
}
}
Ok(crate::kv::EXIT_OK)
}
fn reset(session: Option<&str>) -> Result<i32> {
let mut store = KvStore::from_env()?;
let removed = match store.data.entries.get_mut(FIRED_KEY) {
Some(DataValue::History { entries, .. }) => {
let before = entries.len();
match session {
Some(s) => entries.retain(|e| field(e, "session") != s),
None => entries.clear(),
}
before - entries.len()
}
_ => 0,
};
if removed > 0 {
store.save()?;
}
eprintln!("[mx doors] cleared {removed} fire rows");
Ok(crate::kv::EXIT_OK)
}
pub(crate) fn handle_doors(command: DoorsCommands) -> Result<i32> {
match command {
DoorsCommands::Hook { dry_run, budget } => {
hook(dry_run, budget);
Ok(crate::kv::EXIT_OK)
}
DoorsCommands::Check {
message,
session,
dry_run,
json,
budget,
} => check(&message, &session, dry_run, json, budget),
DoorsCommands::Stats { since, json } => stats(since.as_deref(), json),
DoorsCommands::Reset { session } => reset(session.as_deref()),
}
}