#![forbid(unsafe_code)]
pub mod app;
pub mod splash;
pub mod ui;
use std::path::Path;
use std::time::Duration;
use anyhow::Result;
use crossterm::event::{self, Event, KeyEventKind};
use kintsugi_core::EventLog;
pub use app::{Action, App, Mode, Screen};
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
const TAIL: usize = 500;
const POLL: Duration = Duration::from_millis(250);
const SPLASH_TICK: Duration = Duration::from_millis(60);
pub fn run(db_path: &Path, snapshot_dir: &Path) -> Result<()> {
let color = std::env::var_os("NO_COLOR").is_none();
let mut app = App::new(color);
if let kintsugi_core::admin::VaultState::Locked(v) =
kintsugi_core::admin::load_vault(&kintsugi_core::admin::default_vault_path())
{
app.set_vault(Some(*v));
}
app.start_on_splash();
let mut terminal = ratatui::init(); let result = event_loop(&mut terminal, &mut app, db_path, snapshot_dir);
ratatui::restore();
result
}
fn event_loop(
terminal: &mut ratatui::DefaultTerminal,
app: &mut App,
db_path: &Path,
snapshot_dir: &Path,
) -> Result<()> {
let mut log: Option<EventLog> = None;
reload(app, db_path, &mut log);
loop {
app.page_rows = (terminal.size()?.height as usize).saturating_sub(6).max(1);
terminal.draw(|f| ui::render(f, app))?;
let tick = if app.screen == Screen::Splash {
SPLASH_TICK
} else {
POLL
};
if event::poll(tick)? {
match event::read()? {
Event::Key(key) if key.kind == KeyEventKind::Press => match app.on_key(key.code) {
Action::Quit => break,
Action::Undo => undo(app, db_path, snapshot_dir, &mut log),
Action::Approve(id) => resolve(app, &id, true),
Action::Deny(id) => resolve(app, &id, false),
Action::None => {}
},
Event::Resize(_, _) => { }
_ => {}
}
} else if app.screen == Screen::Splash {
app.tick_splash();
} else {
reload(app, db_path, &mut log);
}
}
Ok(())
}
fn resolve(app: &mut App, id: &str, approve: bool) {
let res = if approve {
kintsugi_daemon::Client::approve(id)
} else {
kintsugi_daemon::Client::deny(id)
};
app.status = Some(match res {
Ok(()) if approve => "approved — the requesting agent may proceed".to_string(),
Ok(()) => "denied".to_string(),
Err(e) => format!("could not resolve (is the daemon running?): {e}"),
});
}
fn reload(app: &mut App, db_path: &Path, log: &mut Option<EventLog>) {
app.daemon_up = kintsugi_daemon::Client::is_daemon_running();
app.scorer = if app.daemon_up {
kintsugi_daemon::Client::status_scorer().ok()
} else {
None
};
if log.is_none() && db_path.exists() {
*log = EventLog::open(db_path).ok();
}
if let Some(l) = log.as_ref() {
if let Ok(mut events) = l.tail(TAIL) {
events.reverse();
app.set_events(events);
}
}
}
fn undo(app: &mut App, db_path: &Path, snapshot_dir: &Path, log: &mut Option<EventLog>) {
app.status = Some(match try_undo(db_path, snapshot_dir) {
Ok(Some(cmd)) => format!("undid `{cmd}`"),
Ok(None) => "nothing to undo".to_string(),
Err(e) => format!("undo failed: {e}"),
});
reload(app, db_path, log);
}
fn try_undo(db_path: &Path, snapshot_dir: &Path) -> Result<Option<String>> {
if !db_path.exists() {
return Ok(None);
}
let log = EventLog::open(db_path)?;
let Some(manifest) = log.latest_unreverted_snapshot()? else {
return Ok(None);
};
kintsugi_core::restore_snapshot(snapshot_dir, &manifest)?;
log.mark_reverted(&manifest.id)?;
Ok(Some(manifest.command))
}
#[cfg(test)]
mod tests {
use super::*;
use kintsugi_core::{Class, Decision, ProposedCommand, Verdict};
#[test]
fn reload_reads_live_events() {
let tmp = tempfile::tempdir().unwrap();
let db = tmp.path().join("e.db");
{
let log = EventLog::open(&db).unwrap();
let cmd = ProposedCommand::new("shim", "/tmp", vec!["ls".into()], "ls");
log.log_event(
&cmd,
&Verdict::rules(Class::Safe, Decision::Allow, "r"),
None,
)
.unwrap();
}
let mut app = App::new(false);
let mut log = None;
reload(&mut app, &db, &mut log);
assert_eq!(app.visible().len(), 1);
assert!(log.is_some(), "the connection is opened once and reused");
}
#[test]
fn undo_with_nothing_reports_so() {
let tmp = tempfile::tempdir().unwrap();
let db = tmp.path().join("e.db");
EventLog::open(&db).unwrap();
let mut app = App::new(false);
undo(&mut app, &db, &tmp.path().join("snapshots"), &mut None);
assert_eq!(app.status.as_deref(), Some("nothing to undo"));
}
#[test]
fn undo_restores_via_snapshot() {
let tmp = tempfile::tempdir().unwrap();
let db = tmp.path().join("e.db");
let snaps = tmp.path().join("snapshots");
let work = tmp.path().join("work");
std::fs::create_dir_all(&work).unwrap();
let file = work.join("f.txt");
std::fs::write(&file, b"orig").unwrap();
{
let log = EventLog::open(&db).unwrap();
let cmd =
ProposedCommand::new("shim", &work, vec!["rm".into(), "f.txt".into()], "rm f.txt");
let m = kintsugi_core::capture_snapshot(&snaps, &cmd)
.unwrap()
.unwrap();
log.record_snapshot(&m).unwrap();
}
std::fs::write(&file, b"changed").unwrap();
let mut app = App::new(false);
undo(&mut app, &db, &snaps, &mut None);
assert!(app.status.as_deref().unwrap().contains("undid"));
assert_eq!(std::fs::read(&file).unwrap(), b"orig");
}
}