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 BACKSTOP_TAIL: usize = 500;
29const TIMELINE_TAIL: usize = 5000;
33const POLL: Duration = Duration::from_millis(250);
35const SPLASH_TICK: Duration = Duration::from_millis(60);
37
38pub fn run(db_path: &Path, snapshot_dir: &Path) -> Result<()> {
41 let color = std::env::var_os("NO_COLOR").is_none();
42 let mut app = App::new(color);
43 app.set_local_offset(time::UtcOffset::current_local_offset().unwrap_or(time::UtcOffset::UTC));
48 if let kintsugi_core::admin::VaultState::Locked(v) =
50 kintsugi_core::admin::load_vault(&kintsugi_core::admin::default_vault_path())
51 {
52 app.set_vault(Some(*v));
53 }
54 app.start_on_splash();
55
56 let mut terminal = ratatui::init(); let result = event_loop(&mut terminal, &mut app, db_path, snapshot_dir);
58 ratatui::restore();
59 result
60}
61
62fn event_loop(
63 terminal: &mut ratatui::DefaultTerminal,
64 app: &mut App,
65 db_path: &Path,
66 snapshot_dir: &Path,
67) -> Result<()> {
68 let mut log: Option<EventLog> = None;
73 reload(app, db_path, &mut log);
74 loop {
75 app.page_rows = (terminal.size()?.height as usize).saturating_sub(6).max(1);
78 terminal.draw(|f| ui::render(f, app))?;
79
80 let tick = if app.screen == Screen::Splash {
83 SPLASH_TICK
84 } else {
85 POLL
86 };
87
88 if event::poll(tick)? {
89 match event::read()? {
90 Event::Key(key) if key.kind == KeyEventKind::Press => match app.on_key(key.code) {
91 Action::Quit => break,
92 Action::Undo => undo(app, db_path, snapshot_dir, &mut log),
93 Action::Approve(id) => resolve(app, &id, true),
94 Action::Deny(id) => resolve(app, &id, false),
95 Action::None => {}
96 },
97 Event::Resize(_, _) => { }
98 _ => {}
99 }
100 } else if app.screen == Screen::Splash {
101 app.tick_splash();
102 } else {
103 reload(app, db_path, &mut log);
104 }
105 }
106 Ok(())
107}
108
109fn resolve(app: &mut App, id: &str, approve: bool) {
111 let res = if approve {
112 kintsugi_daemon::Client::approve(id)
113 } else {
114 kintsugi_daemon::Client::deny(id)
115 };
116 app.status = Some(match res {
117 Ok(()) if approve => "approved — the requesting agent may proceed".to_string(),
118 Ok(()) => "denied".to_string(),
119 Err(e) => format!("could not resolve (is the daemon running?): {e}"),
120 });
121}
122
123fn reload(app: &mut App, db_path: &Path, log: &mut Option<EventLog>) {
128 app.daemon_up = kintsugi_daemon::Client::is_daemon_running();
130 app.scorer = if app.daemon_up {
131 kintsugi_daemon::Client::status_scorer().ok()
132 } else {
133 None
134 };
135 if log.is_none() && db_path.exists() {
136 *log = EventLog::open(db_path).ok();
137 }
138 if let Some(l) = log.as_ref() {
139 let seq = l.latest_seq().unwrap_or(0);
140 if seq == app.last_seq && app.filter == app.last_filter {
143 return;
144 }
145 let filtering = !app.filter.trim().is_empty();
146 let backstop_limit = if filtering {
150 TIMELINE_TAIL
151 } else {
152 BACKSTOP_TAIL
153 };
154
155 use kintsugi_core::log::Filter;
159 let mut events = l
160 .query(&Filter {
161 agent_not: Some("fs-watch".to_string()),
162 limit: Some(TIMELINE_TAIL),
163 ..Filter::default()
164 })
165 .unwrap_or_default();
166 if let Ok(mut backstop) = l.query(&Filter {
167 agent: Some("fs-watch".to_string()),
168 limit: Some(backstop_limit),
169 ..Filter::default()
170 }) {
171 events.append(&mut backstop);
172 }
173 events.sort_by_key(|e| std::cmp::Reverse(e.seq));
175 app.set_events(events);
176 app.last_seq = seq;
177 app.last_filter = app.filter.clone();
178 }
179}
180
181fn undo(app: &mut App, db_path: &Path, snapshot_dir: &Path, log: &mut Option<EventLog>) {
184 app.status = Some(match try_undo(db_path, snapshot_dir) {
185 Ok(Some(cmd)) => format!("undid `{cmd}`"),
186 Ok(None) => "nothing to undo".to_string(),
187 Err(e) => format!("undo failed: {e}"),
188 });
189 app.last_seq = -1; reload(app, db_path, log);
191}
192
193fn try_undo(db_path: &Path, snapshot_dir: &Path) -> Result<Option<String>> {
194 if !db_path.exists() {
195 return Ok(None);
196 }
197 let log = EventLog::open(db_path)?;
198 let Some(manifest) = log.latest_unreverted_snapshot()? else {
199 return Ok(None);
200 };
201 kintsugi_core::restore_snapshot(snapshot_dir, &manifest)?;
202 log.mark_reverted(&manifest.id)?;
203 Ok(Some(manifest.command))
204}
205
206#[cfg(test)]
207mod tests {
208 use super::*;
209 use kintsugi_core::{Class, Decision, ProposedCommand, Verdict};
210
211 #[test]
212 fn reload_reads_live_events() {
213 let tmp = tempfile::tempdir().unwrap();
214 let db = tmp.path().join("e.db");
215 {
216 let log = EventLog::open(&db).unwrap();
217 let cmd = ProposedCommand::new("shim", "/tmp", vec!["ls".into()], "ls");
218 log.log_event(
219 &cmd,
220 &Verdict::rules(Class::Safe, Decision::Allow, "r"),
221 None,
222 )
223 .unwrap();
224 }
225 let mut app = App::new(false);
226 let mut log = None;
227 reload(&mut app, &db, &mut log);
228 assert_eq!(app.visible().len(), 1);
229 assert!(log.is_some(), "the connection is opened once and reused");
230 }
231
232 #[test]
233 fn undo_with_nothing_reports_so() {
234 let tmp = tempfile::tempdir().unwrap();
235 let db = tmp.path().join("e.db");
236 EventLog::open(&db).unwrap();
237 let mut app = App::new(false);
238 undo(&mut app, &db, &tmp.path().join("snapshots"), &mut None);
239 assert_eq!(app.status.as_deref(), Some("nothing to undo"));
240 }
241
242 #[test]
243 fn undo_restores_via_snapshot() {
244 let tmp = tempfile::tempdir().unwrap();
245 let db = tmp.path().join("e.db");
246 let snaps = tmp.path().join("snapshots");
247 let work = tmp.path().join("work");
248 std::fs::create_dir_all(&work).unwrap();
249 let file = work.join("f.txt");
250 std::fs::write(&file, b"orig").unwrap();
251
252 {
253 let log = EventLog::open(&db).unwrap();
254 let cmd =
255 ProposedCommand::new("shim", &work, vec!["rm".into(), "f.txt".into()], "rm f.txt");
256 let m = kintsugi_core::capture_snapshot(&snaps, &cmd)
257 .unwrap()
258 .unwrap();
259 log.record_snapshot(&m).unwrap();
260 }
261 std::fs::write(&file, b"changed").unwrap();
262
263 let mut app = App::new(false);
264 undo(&mut app, &db, &snaps, &mut None);
265 assert!(app.status.as_deref().unwrap().contains("undid"));
266 assert_eq!(std::fs::read(&file).unwrap(), b"orig");
267 }
268}