use crate::background::Warmer;
use anyhow::Context;
use crossterm::event::{self, Event};
use mandible_core::audit::{self, Entry};
use mandible_extract::{default_tiers, resolve_tool, Runner};
use mandible_tui::app::{App, RawHelp};
use mandible_tui::app_review::{
handle_review_key, ReviewKeyOutcome, ReviewOverlay, ReviewSubmission,
};
use mandible_tui::{clipboard, event as tui_event, layout, render, terminal, Effect};
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
const MAX_DISCARDED_EVENTS: usize = 1024;
enum LoopExit {
Quit,
ReviewSubmit(ReviewSubmission),
}
pub fn run(mut app: App) -> anyhow::Result<()> {
let mut term = terminal::init().context("failed to initialize the terminal")?;
let result = run_loop(&mut term, &mut app).map(|_| ());
let restore_result = terminal::restore().context("failed to restore the terminal");
result.and(restore_result)
}
pub fn run_review(dir: &Path, seed: u64) -> anyhow::Result<()> {
let path = audit::verdict_path(dir, seed);
let manifest = audit::load(&path)?;
if manifest.needing_attention().next().is_none() {
println!("nothing pending in {}", path.display());
return Ok(());
}
let mut term = terminal::init().context("failed to initialize the terminal")?;
let result = run_review_loop(&mut term, dir, seed);
let restore_result = terminal::restore().context("failed to restore the terminal");
result.and(restore_result)
}
fn run_review_loop(term: &mut terminal::Term, dir: &Path, seed: u64) -> anyhow::Result<()> {
let path = audit::verdict_path(dir, seed);
let mut manifest = audit::load(&path)?;
loop {
let Some(idx) = manifest.needing_attention().next() else {
break;
};
let entry = manifest.entries[idx].clone();
let remaining = manifest.needing_attention().count();
let total = manifest.entries.len();
let resolved = resolve_tool(&entry.tool);
if resolved.path.is_none() {
record_skip(
&path,
&mut manifest,
idx,
"tool not found on PATH during --review",
)?;
continue;
}
let stub = mandible_core::CommandNode::new(
entry.tool.clone(),
mandible_core::Provenance::default(),
);
let mut app = App::new(entry.tool.clone(), stub);
app.review = Some(review_overlay_for(&entry, remaining, total));
match run_loop(term, &mut app)? {
LoopExit::Quit => break,
LoopExit::ReviewSubmit(submission) => {
apply_submission(&path, &mut manifest, idx, submission)?;
}
}
}
Ok(())
}
fn review_overlay_for(entry: &Entry, remaining: usize, total: usize) -> ReviewOverlay {
ReviewOverlay {
tool: entry.tool.clone(),
stratum: entry.stratum.clone(),
k1: entry.k1,
k2: entry.k2,
k3: entry.k3,
include_reason: entry.include_reason.clone(),
remaining,
total,
draft: None,
}
}
fn apply_submission(
path: &Path,
manifest: &mut audit::AuditFile,
idx: usize,
submission: ReviewSubmission,
) -> anyhow::Result<()> {
let mut note = submission.note;
let k1 = audit::extract_tag_override(&mut note, "k1");
let k2 = audit::extract_tag_override(&mut note, "k2");
let k3 = audit::extract_tag_override(&mut note, "k3");
let entry = &mut manifest.entries[idx];
entry.verdict = Some(submission.verdict.to_string());
entry.note = note;
if let Some(v) = k1 {
entry.k1 = Some(v);
}
if let Some(v) = k2 {
entry.k2 = Some(v);
}
if let Some(v) = k3 {
entry.k3 = Some(v);
}
audit::save(path, manifest)
}
fn record_skip(
path: &Path,
manifest: &mut audit::AuditFile,
idx: usize,
reason: &str,
) -> anyhow::Result<()> {
let entry = &mut manifest.entries[idx];
entry.verdict = Some("skip".to_string());
entry.note = reason.to_string();
audit::save(path, manifest)
}
fn run_loop(term: &mut terminal::Term, app: &mut App) -> anyhow::Result<LoopExit> {
let runner = Arc::new(Runner::new(default_tiers()));
let resolved = resolve_tool(&app.tool);
let warmer = Warmer::new();
submit_root_fill(app, &runner, &resolved, &warmer);
loop {
for warmed in warmer.drain() {
let node = warmed.result.node.clone();
let queued = warmer.warm_children(&runner, &resolved, &node, &warmed.path);
app.splice_filled_node(&warmed.path, node);
for path in queued {
app.mark_pending(path);
}
}
app.expire_status(std::time::Instant::now());
app.tick_search(10);
app.ensure_rows_fresh();
{
let size = term.size()?;
let area = ratatui::layout::Rect::new(0, 0, size.width, size.height);
let regions = layout::compute(area, app.focus);
app.tree_viewport = regions
.tree
.map(|rect| {
ratatui::widgets::Block::default()
.borders(ratatui::widgets::Borders::ALL)
.inner(rect)
.height as usize
})
.unwrap_or(0);
app.follow_selection();
}
term.draw(|frame| render::render(frame, app))?;
if !event::poll(Duration::from_millis(100))? {
continue;
}
match event::read()? {
Event::Key(key) => {
let mut claimed = false;
if app.review.is_some() {
if let Some(outcome) = handle_review_key(app, key) {
claimed = true;
if let ReviewKeyOutcome::Submit(submission) = outcome {
warmer.cancel();
return Ok(LoopExit::ReviewSubmit(submission));
}
}
}
if !claimed {
if let Some(effect) = tui_event::handle_key(app, key) {
if !apply_effect(app, effect, &runner, &resolved, &warmer) {
warmer.cancel();
return Ok(LoopExit::Quit);
}
}
}
}
Event::Mouse(mouse) => {
let size = term.size()?;
let area = ratatui::layout::Rect::new(0, 0, size.width, size.height);
let regions = layout::compute(area, app.focus);
if let Some(effect) = tui_event::handle_mouse(app, mouse, ®ions) {
if !apply_effect(app, effect, &runner, &resolved, &warmer) {
warmer.cancel();
return Ok(LoopExit::Quit);
}
}
}
Event::Resize(_, _) => {
}
_ => {}
}
if let Some(effect) = app.raw_fetch_needed() {
if !apply_effect(app, effect, &runner, &resolved, &warmer) {
warmer.cancel();
return Ok(LoopExit::Quit);
}
}
}
}
fn apply_effect(
app: &mut App,
effect: Effect,
runner: &Arc<Runner>,
resolved: &mandible_extract::ResolvedTool,
warmer: &Warmer,
) -> bool {
match effect {
Effect::Quit => return false,
Effect::Copy(text) => {
let status = match clipboard::copy(&text) {
Ok(()) => format!("copied: {text}"),
Err(_) => format!("copy failed (clipboard unavailable): {text}"),
};
app.set_status(status);
}
Effect::Refresh => {
warmer.reset();
let loaded = crate::pipeline::load(&app.tool);
match loaded.root {
Some(root) => {
app.reload(root);
submit_root_fill(app, runner, resolved, warmer);
app.set_status("re-extracted");
}
None => {
app.set_status("re-extraction failed: no tier produced a result");
}
}
discard_input_typed_during_the_block();
}
Effect::Fill(path) => {
if let Some(existing) = mandible_core::resolve(&app.root, &path).cloned() {
app.mark_pending(path.clone());
warmer.submit(Arc::clone(runner), resolved.clone(), path, existing);
}
}
Effect::FetchRaw(path) => {
app.mark_raw_pending(path.clone());
let hints = mandible_extract::NodeHints {
heading_attested: mandible_core::resolve(&app.root, &path)
.is_some_and(|n| n.heading_attested),
};
let result = match mandible_extract::help_text::raw_help(resolved, &path, hints) {
Ok((lines, flag)) => RawHelp::Ready(lines, format!("{} {flag}", path.join(" "))),
Err(e) => RawHelp::Failed(format!("could not fetch raw --help: {e}")),
};
app.set_raw_help(path, result);
}
}
true
}
fn submit_root_fill(
app: &mut App,
runner: &Arc<Runner>,
resolved: &mandible_extract::ResolvedTool,
warmer: &Warmer,
) {
let root_path = vec![app.root.name.clone()];
app.mark_pending(root_path.clone());
warmer.submit(
Arc::clone(runner),
resolved.clone(),
root_path,
app.root.clone(),
);
}
fn discard_input_typed_during_the_block() {
for _ in 0..MAX_DISCARDED_EVENTS {
match event::poll(Duration::from_millis(0)) {
Ok(true) => {
if event::read().is_err() {
return;
}
}
_ => return,
}
}
}