1#![forbid(unsafe_code)]
10
11pub mod app;
12pub mod splash;
13pub mod ui;
14
15use std::path::Path;
16use std::time::Duration;
17
18use anyhow::Result;
19use crossterm::event::{self, Event, KeyEventKind};
20use kintsugi_core::EventLog;
21
22pub use app::{Action, App, Mode, Screen};
23
24pub const VERSION: &str = env!("CARGO_PKG_VERSION");
25
26const TAIL: usize = 500;
28const POLL: Duration = Duration::from_millis(250);
29const SPLASH_TICK: Duration = Duration::from_millis(60);
31
32pub fn run(db_path: &Path, snapshot_dir: &Path) -> Result<()> {
35 let color = std::env::var_os("NO_COLOR").is_none();
36 let mut app = App::new(color);
37 if let kintsugi_core::admin::VaultState::Locked(v) =
39 kintsugi_core::admin::load_vault(&kintsugi_core::admin::default_vault_path())
40 {
41 app.set_vault(Some(*v));
42 }
43 app.start_on_splash();
44
45 let mut terminal = ratatui::init(); let result = event_loop(&mut terminal, &mut app, db_path, snapshot_dir);
47 ratatui::restore();
48 result
49}
50
51fn event_loop(
52 terminal: &mut ratatui::DefaultTerminal,
53 app: &mut App,
54 db_path: &Path,
55 snapshot_dir: &Path,
56) -> Result<()> {
57 let mut log: Option<EventLog> = None;
62 reload(app, db_path, &mut log);
63 loop {
64 app.page_rows = (terminal.size()?.height as usize).saturating_sub(6).max(1);
67 terminal.draw(|f| ui::render(f, app))?;
68
69 let tick = if app.screen == Screen::Splash {
72 SPLASH_TICK
73 } else {
74 POLL
75 };
76
77 if event::poll(tick)? {
78 match event::read()? {
79 Event::Key(key) if key.kind == KeyEventKind::Press => match app.on_key(key.code) {
80 Action::Quit => break,
81 Action::Undo => undo(app, db_path, snapshot_dir, &mut log),
82 Action::Approve(id) => resolve(app, &id, true),
83 Action::Deny(id) => resolve(app, &id, false),
84 Action::None => {}
85 },
86 Event::Resize(_, _) => { }
87 _ => {}
88 }
89 } else if app.screen == Screen::Splash {
90 app.tick_splash();
91 } else {
92 reload(app, db_path, &mut log);
93 }
94 }
95 Ok(())
96}
97
98fn resolve(app: &mut App, id: &str, approve: bool) {
100 let res = if approve {
101 kintsugi_daemon::Client::approve(id)
102 } else {
103 kintsugi_daemon::Client::deny(id)
104 };
105 app.status = Some(match res {
106 Ok(()) if approve => "approved — the requesting agent may proceed".to_string(),
107 Ok(()) => "denied".to_string(),
108 Err(e) => format!("could not resolve (is the daemon running?): {e}"),
109 });
110}
111
112fn reload(app: &mut App, db_path: &Path, log: &mut Option<EventLog>) {
117 app.daemon_up = kintsugi_daemon::Client::is_daemon_running();
119 app.scorer = if app.daemon_up {
120 kintsugi_daemon::Client::status_scorer().ok()
121 } else {
122 None
123 };
124 if log.is_none() && db_path.exists() {
125 *log = EventLog::open(db_path).ok();
126 }
127 if let Some(l) = log.as_ref() {
128 if let Ok(mut events) = l.tail(TAIL) {
129 events.reverse();
131 app.set_events(events);
132 }
133 }
134}
135
136fn undo(app: &mut App, db_path: &Path, snapshot_dir: &Path, log: &mut Option<EventLog>) {
139 app.status = Some(match try_undo(db_path, snapshot_dir) {
140 Ok(Some(cmd)) => format!("undid `{cmd}`"),
141 Ok(None) => "nothing to undo".to_string(),
142 Err(e) => format!("undo failed: {e}"),
143 });
144 reload(app, db_path, log);
145}
146
147fn try_undo(db_path: &Path, snapshot_dir: &Path) -> Result<Option<String>> {
148 if !db_path.exists() {
149 return Ok(None);
150 }
151 let log = EventLog::open(db_path)?;
152 let Some(manifest) = log.latest_unreverted_snapshot()? else {
153 return Ok(None);
154 };
155 kintsugi_core::restore_snapshot(snapshot_dir, &manifest)?;
156 log.mark_reverted(&manifest.id)?;
157 Ok(Some(manifest.command))
158}
159
160#[cfg(test)]
161mod tests {
162 use super::*;
163 use kintsugi_core::{Class, Decision, ProposedCommand, Verdict};
164
165 #[test]
166 fn reload_reads_live_events() {
167 let tmp = tempfile::tempdir().unwrap();
168 let db = tmp.path().join("e.db");
169 {
170 let log = EventLog::open(&db).unwrap();
171 let cmd = ProposedCommand::new("shim", "/tmp", vec!["ls".into()], "ls");
172 log.log_event(
173 &cmd,
174 &Verdict::rules(Class::Safe, Decision::Allow, "r"),
175 None,
176 )
177 .unwrap();
178 }
179 let mut app = App::new(false);
180 let mut log = None;
181 reload(&mut app, &db, &mut log);
182 assert_eq!(app.visible().len(), 1);
183 assert!(log.is_some(), "the connection is opened once and reused");
184 }
185
186 #[test]
187 fn undo_with_nothing_reports_so() {
188 let tmp = tempfile::tempdir().unwrap();
189 let db = tmp.path().join("e.db");
190 EventLog::open(&db).unwrap();
191 let mut app = App::new(false);
192 undo(&mut app, &db, &tmp.path().join("snapshots"), &mut None);
193 assert_eq!(app.status.as_deref(), Some("nothing to undo"));
194 }
195
196 #[test]
197 fn undo_restores_via_snapshot() {
198 let tmp = tempfile::tempdir().unwrap();
199 let db = tmp.path().join("e.db");
200 let snaps = tmp.path().join("snapshots");
201 let work = tmp.path().join("work");
202 std::fs::create_dir_all(&work).unwrap();
203 let file = work.join("f.txt");
204 std::fs::write(&file, b"orig").unwrap();
205
206 {
207 let log = EventLog::open(&db).unwrap();
208 let cmd =
209 ProposedCommand::new("shim", &work, vec!["rm".into(), "f.txt".into()], "rm f.txt");
210 let m = kintsugi_core::capture_snapshot(&snaps, &cmd)
211 .unwrap()
212 .unwrap();
213 log.record_snapshot(&m).unwrap();
214 }
215 std::fs::write(&file, b"changed").unwrap();
216
217 let mut app = App::new(false);
218 undo(&mut app, &db, &snaps, &mut None);
219 assert!(app.status.as_deref().unwrap().contains("undid"));
220 assert_eq!(std::fs::read(&file).unwrap(), b"orig");
221 }
222}