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),
PrintSelection(String),
}
enum Control {
Continue,
Exit(LoopExit),
}
pub fn new_app(tool: String, stub: mandible_core::CommandNode, sink: terminal::Sink) -> App {
let mut app = App::new(tool, stub);
app.horizontal_scroll_enabled = mandible_core::config::load().ui.horizontal_scroll;
app.set_palette(mandible_tui::style::Palette::for_sink(sink));
app
}
pub fn run(mut app: App, sink: terminal::Sink) -> anyhow::Result<()> {
let mut term = terminal::init(sink).context("failed to initialize the terminal")?;
let result = run_loop(&mut term, &mut app, sink);
let restore_result = terminal::restore(sink).context("failed to restore the terminal");
let exit = result?;
restore_result?;
if let LoopExit::PrintSelection(line) = exit {
println!("{line}");
}
Ok(())
}
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(terminal::Sink::Stdout).context("failed to initialize the terminal")?;
let result = run_review_loop(&mut term, dir, seed);
let restore_result =
terminal::restore(terminal::Sink::Stdout).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 = new_app(entry.tool.clone(), stub, terminal::Sink::Stdout);
app.review = Some(review_overlay_for(&entry, remaining, total));
match run_loop(term, &mut app, terminal::Sink::Stdout)? {
LoopExit::Quit => break,
LoopExit::PrintSelection(_) => 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,
sink: terminal::Sink,
) -> anyhow::Result<LoopExit> {
let runner = Arc::new(Runner::new(default_tiers()));
let resolved = resolve_tool(&app.tool);
let warmer = Warmer::new();
let siblings = mandible_extract::discover_path_siblings(&resolved.name);
submit_root_fill(app, &runner, &resolved, &warmer);
loop {
for warmed in warmer.drain() {
let mut node = warmed.result.node;
if warmed.path.len() == 1 {
crate::discovery::attach_path_siblings(&mut node, &siblings);
}
app.splice_filled_node(&warmed.path, node);
let queued = warmer.warm_children(&runner, &resolved, &app.root, &warmed.path);
for path in queued {
app.mark_pending(path);
}
app.settle_requested_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 let Control::Exit(exit) =
apply_effect(app, effect, &runner, &resolved, &warmer, sink)
{
warmer.cancel();
return Ok(exit);
}
}
}
}
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 let Control::Exit(exit) =
apply_effect(app, effect, &runner, &resolved, &warmer, sink)
{
warmer.cancel();
return Ok(exit);
}
}
}
Event::Resize(_, _) => {
}
_ => {}
}
if let Some(effect) = app.raw_fetch_needed() {
if let Control::Exit(exit) =
apply_effect(app, effect, &runner, &resolved, &warmer, sink)
{
warmer.cancel();
return Ok(exit);
}
}
}
}
fn apply_effect(
app: &mut App,
effect: Effect,
runner: &Arc<Runner>,
resolved: &mandible_extract::ResolvedTool,
warmer: &Warmer,
sink: terminal::Sink,
) -> Control {
match effect {
Effect::Quit => return Control::Exit(LoopExit::Quit),
Effect::PrintSelection(line) => return Control::Exit(LoopExit::PrintSelection(line)),
Effect::Copy(text) => {
let status = match clipboard::copy(&text, sink) {
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() {
let (tool, probe_path) = crate::discovery::probe_target(&app.root, resolved, &path);
app.mark_pending(path.clone());
warmer.submit(Arc::clone(runner), tool, probe_path, 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),
abbrev_probe_attested: mandible_core::resolve(&app.root, &path)
.is_some_and(|n| n.abbrev_probe_attested),
help_word_attested: mandible_core::resolve(&app.root, &path)
.is_some_and(|n| n.help_word_attested),
};
let (raw_tool, raw_path) = crate::discovery::probe_target(&app.root, resolved, &path);
let result = match mandible_extract::help_text::raw_help(&raw_tool, &raw_path, hints) {
Ok((lines, flag)) => {
let words: Vec<String> = raw_path.iter().skip(1).cloned().collect();
RawHelp::Ready(
lines,
mandible_extract::help_text::render_argv(&raw_tool.name, &words, &flag),
)
}
Err(e) => RawHelp::Failed(format!("could not fetch raw --help: {e}")),
};
app.set_raw_help(path, result);
}
}
Control::Continue
}
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.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,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use mandible_core::config::CONFIG_DIR_ENV;
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn stub() -> mandible_core::CommandNode {
mandible_core::CommandNode::new("git", mandible_core::Provenance::default())
}
#[test]
fn new_app_honors_config_toml_while_app_new_stays_pure() {
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("config.toml"),
"[ui]\nhorizontal_scroll = false\n",
)
.unwrap();
std::env::set_var(CONFIG_DIR_ENV, dir.path());
let pure = App::new("git".to_string(), stub());
assert!(
pure.horizontal_scroll_enabled,
"App::new must never read config.toml itself"
);
let wired = new_app("git".to_string(), stub(), terminal::Sink::Stdout);
assert!(
!wired.horizontal_scroll_enabled,
"new_app must apply the real config.toml"
);
std::env::remove_var(CONFIG_DIR_ENV);
}
#[test]
fn new_app_defaults_to_horizontal_scroll_on_with_no_config_file() {
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let dir = tempfile::tempdir().unwrap();
std::env::set_var(CONFIG_DIR_ENV, dir.path());
let app = new_app("git".to_string(), stub(), terminal::Sink::Stdout);
assert!(app.horizontal_scroll_enabled);
std::env::remove_var(CONFIG_DIR_ENV);
}
}