use crate::background::Warmer;
use crate::probing::{self, Ctx};
use crate::queue::{Limit, Urgency};
use crate::raw_view;
use crate::reload::Reloader;
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;
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;
struct Session {
reload: Reloader,
}
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 loaded = mandible_core::config::load();
let mut app = App::new(tool, stub);
app.set_palette(mandible_tui::style::Palette::for_sink(sink));
app.settings.locks = mandible_tui::settings::EnvLocks::from_env();
app.settings.config_path = mandible_core::config::config_path();
app.apply_config(&loaded.config);
app.notice = loaded.notice();
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 let Some(warning) = version_mismatch(&manifest.meta.mandible_version) {
println!("{warning}");
}
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 version_mismatch(manifest_version: &str) -> Option<String> {
let own = env!("CARGO_PKG_VERSION");
if manifest_version.is_empty() || manifest_version == own {
return None;
}
Some(format!(
"this batch is judged with mandible {manifest_version}, but this is mandible {own}"
))
}
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);
let ctx = Ctx {
runner: &runner,
resolved: &resolved,
warmer: &warmer,
};
submit_root_fill(app, &ctx, Limit::Standard);
let mut session = Session {
reload: Reloader::new(),
};
loop {
if let Some(loaded) = session.reload.poll(&app.tool) {
apply_reload(app, loaded.root, &ctx);
}
probing::collect(app, &ctx, &siblings);
app.expire_status(std::time::Instant::now());
app.tick_search();
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))? {
probing::at_rest(app, &ctx);
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, &ctx, &mut session, 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, &ctx, &mut session, 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, &ctx, &mut session, sink) {
warmer.cancel();
return Ok(exit);
}
}
}
}
fn apply_effect(
app: &mut App,
effect: Effect,
ctx: &Ctx,
session: &mut Session,
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 => refresh(app, ctx, session),
Effect::Fill(path) => {
probing::request(app, ctx, path, Urgency::User, false, Limit::Standard);
}
Effect::FetchRaw(path) => {
if !raw_view::serve(app, ctx.resolved, &path) {
app.mark_raw_pending(path);
}
}
}
Control::Continue
}
fn refresh(app: &mut App, ctx: &Ctx, session: &mut Session) {
let Some(path) = app.selected_path() else {
return;
};
if path.len() > 1 {
probing::reread(app, ctx, path);
} else if session
.reload
.start(&app.tool, probing::retry_limit(app, &path).duration())
{
app.set_status("re-extracting");
}
}
fn submit_root_fill(app: &mut App, ctx: &Ctx, limit: Limit) {
let root_path = vec![app.root.name.clone()];
probing::request(app, ctx, root_path, Urgency::Walk, true, limit);
}
fn apply_reload(app: &mut App, root: Option<mandible_core::CommandNode>, ctx: &Ctx) {
let Some(root) = root else {
app.set_status("re-extraction failed: no tier produced a result");
return;
};
let limit = probing::retry_limit(app, std::slice::from_ref(&app.root.name));
ctx.warmer.reset();
app.reload(root);
submit_root_fill(app, ctx, limit);
app.set_status("re-extracted");
}
#[cfg(test)]
mod tests {
use super::*;
use mandible_core::config::CONFIG_DIR_ENV;
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[test]
fn version_mismatch_warns_only_for_a_different_recorded_version() {
let own = env!("CARGO_PKG_VERSION");
assert!(version_mismatch("").is_none());
assert!(version_mismatch(own).is_none());
let warning = version_mismatch("0.0.1").unwrap();
assert!(warning.contains("0.0.1") && warning.contains(own));
}
fn stub() -> mandible_core::CommandNode {
mandible_core::CommandNode::new("git", mandible_core::Provenance::default())
}
#[test]
fn new_app_applies_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 = true\nraw_view = true\ndescription_preview = false\n\
[sort]\nflags = \"az\"\ncommands = \"az\"\n[colors]\ntheme = \"none\"\n",
)
.unwrap();
std::env::set_var(CONFIG_DIR_ENV, dir.path());
let pure = App::new("git".to_string(), stub());
assert!(!pure.horizontal_scroll_enabled && pure.parsed_wrap && !pure.raw_mode);
assert!(pure.settings.description_preview);
let wired = new_app("git".to_string(), stub(), terminal::Sink::Stdout);
assert!(wired.horizontal_scroll_enabled && !wired.parsed_wrap);
assert!(wired.raw_mode);
assert!(!wired.settings.description_preview);
assert_eq!(wired.sorts.detail, mandible_tui::sort::DetailSort::Overall);
assert!(wired.sorts.tree);
assert!(!wired.palette.color && !wired.color_enabled);
assert_eq!(
wired.settings.config_path.as_deref(),
Some(dir.path().join("config.toml").as_path())
);
assert_eq!(wired.notice, None);
std::env::remove_var(CONFIG_DIR_ENV);
}
#[test]
fn new_app_with_no_config_file_uses_the_defaults_and_writes_nothing() {
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 && app.parsed_wrap && !app.raw_mode);
assert!(app.settings.description_preview);
assert_eq!(app.notice, None);
assert!(!dir.path().join("config.toml").exists());
std::env::remove_var(CONFIG_DIR_ENV);
}
#[test]
fn new_app_reports_problems_in_one_footer_line() {
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"),
"[sort]\nsrot = 1\nflags = \"sideways\"\ncommands = \"az\"\n",
)
.unwrap();
std::env::set_var(CONFIG_DIR_ENV, dir.path());
let app = new_app("git".to_string(), stub(), terminal::Sink::Stdout);
assert!(app.sorts.tree, "the good key still applies");
assert_eq!(
app.notice.as_deref(),
Some("2 problems in config.toml: line 2: unknown key \"srot\", ignored")
);
std::env::remove_var(CONFIG_DIR_ENV);
}
#[test]
fn new_app_applies_the_custom_colours_of_the_file() {
use mandible_core::config::{ColorIndex, Roles};
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"),
"[colors]\ntheme = \"custom\"\naccent = \"bright-red\"\nwarning = \"#ff0000\"\nmuted = 244\n",
)
.unwrap();
std::env::set_var(CONFIG_DIR_ENV, dir.path());
let app = new_app("git".to_string(), stub(), terminal::Sink::Stdout);
let want = Roles {
accent: ColorIndex(9),
muted: ColorIndex(244),
..Roles::DEFAULT
};
assert_eq!(app.settings.custom, want);
assert_eq!(
app.notice.as_deref(),
Some("config.toml line 4: bad value for warning, ignored")
);
assert_eq!(app.settings.theme, mandible_core::config::Theme::Custom);
std::env::remove_var(CONFIG_DIR_ENV);
}
fn rig() -> (std::sync::Arc<crate::doubles::Spy>, App) {
let spy = crate::doubles::Spy::new();
spy.children("t", &["a", "b"]);
let root = mandible_core::CommandNode::new("t", mandible_core::Provenance::default());
(spy, App::new("no-such-tool-xyz-123".to_string(), root))
}
fn walked(app: &mut App, ctx: &Ctx) {
submit_root_fill(app, ctx, Limit::Standard);
let end = std::time::Instant::now() + Duration::from_secs(10);
while !app.fill_finished(&["t".to_string(), "b".to_string()])
&& std::time::Instant::now() < end
{
probing::collect(app, ctx, &[]);
std::thread::sleep(Duration::from_millis(5));
}
app.ensure_rows_fresh();
}
#[test]
fn r_on_a_command_reads_that_command_and_on_the_root_reloads_the_tool() {
let (spy, mut app) = rig();
let runner = spy.runner();
let resolved = crate::doubles::Spy::tool();
let warmer = Warmer::with_threads(3);
let ctx = Ctx {
runner: &runner,
resolved: &resolved,
warmer: &warmer,
};
let mut session = Session {
reload: Reloader::new(),
};
walked(&mut app, &ctx);
spy.forget();
app.select_index(2);
assert_eq!(app.selected_path().unwrap()[1], "b");
refresh(&mut app, &ctx, &mut session);
assert!(spy.wait_for(&["t".to_string(), "b".to_string()], 1));
assert!(!session.reload.is_running(), "a command is not the tool");
app.select_index(0);
refresh(&mut app, &ctx, &mut session);
assert!(session.reload.is_running(), "the root reloads the tool");
std::thread::sleep(Duration::from_millis(100));
assert_eq!(spy.calls().len(), 1, "the root's r read no command");
}
}