use ansi_to_tui::IntoText; use cookbook::config::{CookConfig, get_config, init_config}; use cookbook::cook::cook_build::{build, get_stage_dirs, remove_stage_dir}; use cookbook::cook::fetch::{FetchResult, fetch, fetch_offline}; use cookbook::cook::fs::{create_dir, create_target_dir, remove_all, run_command}; use cookbook::cook::package::{package, package_handle_push}; use cookbook::cook::pty::{PtyOut, UnixSlavePty, flush_pty, setup_pty, write_to_pty}; use cookbook::cook::script::KILL_ALL_PID; use cookbook::cook::tree::{self, WalkTreeEntry}; use cookbook::cook::{fetch_repo, ident}; use cookbook::recipe::{CookRecipe, recipes_flatten_package_names, recipes_mark_as_deps}; use cookbook::{Error, Result, staged_pkg}; use pkg::{PackageName, PackageState}; use ratatui::Terminal; use ratatui::layout::{Constraint, Direction, Layout, Rect}; use ratatui::prelude::TermionBackend; use ratatui::style::{Color, Style}; use ratatui::text::{Line, Span, Text}; use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap}; use redox_installer::PackageConfig; use std::borrow::Cow; use std::collections::{BTreeMap, HashMap, HashSet}; use std::io::{Read, Write, stderr, stdin, stdout}; use std::path::PathBuf; use std::process::Command; use std::str::FromStr; use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; use std::sync::{Arc, OnceLock, mpsc}; use std::time::{Duration, Instant}; use std::{cmp, env, fs}; use std::{process, thread}; use termion::event::{Event, Key}; use termion::input::TermRead; use termion::raw::IntoRawMode; use termion::screen::IntoAlternateScreen; use termion::{color, style}; // A repo manager, to replace repo.sh const REPO_HELP_STR: &str = r#" Usage: repo [flags] ... command list: fetch download recipe sources cook build recipe packages unfetch delete recipe sources clean delete recipe artifacts clean-target delete recipe artifacts for one target push extract package into sysroot find find path of recipe packages cook-tree show tree of recipe build push-tree show tree of recipe packages common flags: --cookbook= the "recipes" folder, default to $PWD/recipes --repo= the "repo" folder, default to $PWD/repo --sysroot= the "root" folder used for "push" command For Redox, defaults to "/", else default to $PWD/sysroot --with-package-deps include package deps (always implied in push command) --all apply to all recipes in --category= apply to all recipes in / --filesystem= override recipes config using installer file --repo-binary override recipes config to use repo_binary cook env and their defaults: CI= set to any value to disable TUI COOKBOOK_LOGS= whether to capture build logs (default is !CI) COOKBOOK_OFFLINE=false prevent internet access if possible ignored when command "fetch" is used COOKBOOK_NONSTOP=false keep running even a recipe build failed COOKBOOK_COMPRESSED=false build packages in compressed format COOKBOOK_VERBOSE=true print success/error on each recipe COOKBOOK_CLEAN_BUILD=false remove build directory before building COOKBOOK_CLEAN_TARGET=false remove target directory after building COOKBOOK_WRITE_FILETREE=false whether to write stage files tree COOKBOOK_MAKE_JOBS= override build jobs count from nproc COOKBOOK_WEB=false whether to generate package web files "#; #[derive(Clone)] struct CliConfig { cookbook_dir: PathBuf, repo_dir: PathBuf, sysroot_dir: PathBuf, logs_dir: Option, category: Option, filesystem: Option, with_package_deps: bool, all: bool, cook: CookConfig, } #[derive(PartialEq)] enum CliCommand { Fetch, Cook, CookTree, Unfetch, Clean, CleanTarget, Push, PushTree, Find, } impl CliCommand { pub fn is_informational(&self) -> bool { *self == CliCommand::PushTree || *self == CliCommand::CookTree || *self == CliCommand::Find } pub fn is_building(&self) -> bool { *self == CliCommand::Fetch || *self == CliCommand::Cook || *self == CliCommand::CookTree } pub fn is_pushing(&self) -> bool { *self == CliCommand::Push || *self == CliCommand::PushTree } pub fn is_cleaning(&self) -> bool { *self == CliCommand::Clean || *self == CliCommand::CleanTarget || *self == CliCommand::Unfetch } } impl FromStr for CliCommand { type Err = Error; fn from_str(s: &str) -> std::result::Result { match s { "fetch" => Ok(CliCommand::Fetch), "cook" => Ok(CliCommand::Cook), "unfetch" => Ok(CliCommand::Unfetch), "clean" => Ok(CliCommand::Clean), "clean-target" => Ok(CliCommand::CleanTarget), "push" => Ok(CliCommand::Push), "push-tree" => Ok(CliCommand::PushTree), "cook-tree" => Ok(CliCommand::CookTree), "find" => Ok(CliCommand::Find), _ => bail_options_err!("Unknown command {:?}", s), } } } impl ToString for CliCommand { fn to_string(&self) -> String { match self { CliCommand::Fetch => "fetch".to_string(), CliCommand::Cook => "cook".to_string(), CliCommand::Unfetch => "unfetch".to_string(), CliCommand::Clean => "clean".to_string(), CliCommand::CleanTarget => "clean-target".to_string(), CliCommand::Push => "push".to_string(), CliCommand::PushTree => "push-tree".to_string(), CliCommand::CookTree => "cook-tree".to_string(), CliCommand::Find => "find".to_string(), } } } impl CliConfig { fn new() -> Result { let current_dir = env::current_dir().map_err(|e| Error::from_io_error(e, "Getting cwd"))?; Ok(CliConfig { //FIXME: This config is unused as redox-pkg harcoded this to $PWD/recipes cookbook_dir: current_dir.join("recipes"), repo_dir: current_dir.join("repo"), // build dir here is hardcoded in repo_builder as well logs_dir: if get_config().cook.logs { Some(current_dir.join("build/logs")) } else { None }, category: None, sysroot_dir: if cfg!(target_os = "redox") { PathBuf::from("/") } else { current_dir.join("sysroot") }, with_package_deps: false, cook: get_config().cook.clone(), all: false, filesystem: None, }) } } fn main() { init_config(); if let Err(e) = main_inner() { match e { Error::Options(e) => eprintln!("{}\n{}", e, REPO_HELP_STR), e => eprintln!("{}", e), } process::exit(1); }; } fn main_inner() -> Result<()> { let args: Vec = env::args().skip(1).collect(); if args.is_empty() || args.contains(&"--help".to_string()) || args.contains(&"-h".to_string()) { bail_options_err!(""); } let (config, command, recipes) = parse_args(args)?; if command.is_building() { ident::init_ident(); } if command == CliCommand::Cook && config.cook.tui { match run_tui_cook(config.clone(), recipes.clone()) { Ok(TuiApp { dump_logs_on_exit: Some((name, err)), .. }) => { let _ = stderr().write(err.as_bytes()); let _ = stderr().write(b"\n\n"); print_failed(&command, &name); return Err(Error::from(format!("Execution has failed"))); } Ok(app) => { for (recipe, status) in app.recipes { match status { RecipeStatus::Cached => print_cached(&command, &recipe.name), RecipeStatus::Done => print_success(&command, &recipe.name), RecipeStatus::Failed(err) => { let _ = stderr().write(err.as_bytes()); let _ = stderr().write(b"\n\n"); print_failed(&command, &recipe.name) } _ => unreachable!(), } } } Err(e) => return Err(e), } return publish_packages(&recipes, &config.repo_dir); } if command == CliCommand::PushTree { return handle_tree(&recipes, false, &config); } if command == CliCommand::CookTree { return handle_tree(&recipes, true, &config); } if command == CliCommand::Push { return handle_push(&recipes, &config); } let verbose = config.cook.verbose; for recipe in &recipes { match repo_inner(&config, &command, recipe) { Ok(cached) => { if !command.is_informational() { if cached { print_cached(&command, &recipe.name); } else { print_success(&command, &recipe.name); } } } Err(e) => { if config.cook.nonstop { if verbose { eprintln!("{}", e); } if let Err(e) = handle_nonstop_fail(recipe) { eprintln!("{}", e) }; } print_failed(&command, &recipe.name); if !config.cook.nonstop { return Err(e); } } } } if command == CliCommand::Cook { return publish_packages(&recipes, &config.repo_dir); } if verbose && recipes.len() > 1 { println!( "\nCommand '{}' completed for {} recipes.", command.to_string(), recipes.len() ); } Ok(()) } fn print_failed(command: &CliCommand, recipe: &PackageName) { eprintln!( "{}{}{} {} - failed {}{}", style::Bold, color::Fg(color::AnsiValue(196)), command.to_string(), recipe.as_str(), color::Fg(color::Reset), style::Reset, ); } fn print_success(command: &CliCommand, recipe: &PackageName) { eprintln!( "{}{}{} {} - successful{}{}", style::Bold, color::Fg(color::AnsiValue(46)), command.to_string(), recipe.as_str(), color::Fg(color::Reset), style::Reset, ); } fn print_cached(command: &CliCommand, recipe: &PackageName) { eprintln!( "{}{}{} {} - cached{}{}", style::Bold, color::Fg(color::AnsiValue(45)), command.to_string(), recipe.as_str(), color::Fg(color::Reset), style::Reset, ); } fn repo_inner(config: &CliConfig, command: &CliCommand, recipe: &CookRecipe) -> Result { Ok(match *command { CliCommand::Fetch | CliCommand::Cook => { let repo_inner_fn = move |logger: &PtyOut| -> Result { let is_cook = *command == CliCommand::Cook; let fetch_result = handle_fetch(recipe, config, is_cook, logger)?; let cached = if is_cook { handle_cook(recipe, config, fetch_result.source_dir, logger)? } else { fetch_result.cached }; Ok(cached) }; let Some(log_path) = &config.logs_dir else { return repo_inner_fn(&None); }; let (status_tx, status_rx) = mpsc::channel::(); let (mut stdout_writer, mut stderr_writer) = setup_logger(&status_tx, &recipe.name); let mut app = TuiApp::new(vec![recipe.clone()]); app.dump_logs_anyway = config.cook.verbose; let dump_fail_logs = !app.dump_logs_anyway; let th = thread::spawn(move || { while let Ok(update) = status_rx.recv() { match &update { StatusUpdate::CookThreadFinished => break, StatusUpdate::FailCook(r, _) => { let (logs, line) = app.get_recipe_log(&r.name); if let Some(logs) = logs { println!("{}", join_logs(logs, line)); } } _ => app.update_status(update), } } }); let mut logger = Some((&mut stdout_writer, &mut stderr_writer)); let result = repo_inner_fn(&logger); if let Err(err_ctx) = &result { write_to_pty(&logger, &format!("\n{err_ctx}")); } // successful cached build is not that useful to log if !matches!(result, Ok(true)) { flush_pty(&mut logger); let log_path = log_path.join(format!("{}/{}.log", recipe.target, recipe.name.name())); status_tx .send(StatusUpdate::FlushLog(recipe.name.clone(), log_path)) .unwrap_or_default(); if dump_fail_logs && result.is_err() { status_tx .send(StatusUpdate::FailCook(recipe.clone(), "".into())) .unwrap_or_default(); } } status_tx .send(StatusUpdate::CookThreadFinished) .unwrap_or_default(); let _ = th.join(); result? } CliCommand::Unfetch | CliCommand::Clean | CliCommand::CleanTarget => { handle_clean(recipe, config, command)? } CliCommand::Push => unreachable!(), CliCommand::PushTree => unreachable!(), CliCommand::CookTree => unreachable!(), CliCommand::Find => { println!("{}", recipe.dir.display()); false } }) } fn publish_packages(recipe_names: &Vec, repo_path: &PathBuf) -> Result<()> { let repo_bin = env::current_exe() .map_err(|e| Error::from_io_error(e, "Getting exe path"))? .parent() .unwrap() .join("repo_builder"); let mut command = Command::new(repo_bin); command .arg(repo_path) .args(recipe_names.iter().filter_map(|n| { if !n.is_deps { Some(n.name.as_str()) } else { None } })); run_command(command, &None) } fn parse_args(args: Vec) -> Result<(CliConfig, CliCommand, Vec)> { let mut config = CliConfig::new()?; let mut command: Option = None; let mut recipe_names: Vec = Vec::new(); let mut override_filesystem_repo_binary = false; for arg in args { if arg.starts_with("--") { if let Some((key, value)) = arg.split_once('=') { match key { "--cookbook" => config.cookbook_dir = PathBuf::from(value), "--repo" => config.repo_dir = PathBuf::from(value), "--sysroot" => config.sysroot_dir = PathBuf::from(value), "--category" => config.category = Some(PathBuf::from(value)), "--filesystem" => { config.filesystem = Some({ let r = redox_installer::Config::from_file(&PathBuf::from(value)); r.map_err(|e| Error::Other(format!("{:?}", e)))? }) } _ => bail_options_err!("Error: Unknown flag with value: {}", arg), } } else if arg.starts_with("--category-") { // to workaround make command limit we provide this option config.category = Some(PathBuf::from(arg[("--category-").len()..].to_owned())); } else { match arg.as_str() { "--repo-binary" => override_filesystem_repo_binary = true, "--with-package-deps" => config.with_package_deps = true, "--all" => config.all = true, _ => bail_options_err!("Error: Unknown flag: {}", arg), } } } else if arg.starts_with('-') { match arg.as_str() { _ => bail_options_err!("Error: Unknown flag: {}", arg), } } else if command.is_none() { // The first non-flag argument is the command command = Some(arg); } else { // Subsequent non-flag arguments are recipe names recipe_names.push(arg.try_into().map_err(Error::from)?); } } if let Some(c) = config.category { // need to prefix by cookbook dir config.category = Some(PathBuf::from("recipes").join(c)); } if let Some(c) = config.logs_dir.as_mut() { create_dir(&c.join(redoxer::target()))?; create_dir(&c.join(redoxer::host_target()))?; } let Some(command) = command else { bail_options_err!("Error: No command specified"); }; let command: CliCommand = str::parse(&command)?; if command.is_informational() { // avoid extra data that clobber stdout config.cook.verbose = false; } let mut preloaded_recipes: BTreeMap = BTreeMap::new(); if recipe_names.is_empty() { if config.all || config.category.is_some() { if !recipe_names.is_empty() { bail_options_err!( "Do not specify recipe names when using the --all or --category flag" ); } if config.all && config.category.is_some() { bail_options_err!("Do not specify both --all and --category flag."); } if config.all && !command.is_cleaning() { // because read_recipe is false by logic below // some recipes on wip folders are invalid anyway bail_options_err!( "Refusing to run an unrealistic command to {} all recipes", command.to_string() ); } let all_recipes_path = match &config.category { None => staged_pkg::list(""), Some(prefix) => staged_pkg::list("") .into_iter() .filter(|p| p.starts_with(prefix)) .collect(), }; for path in all_recipes_path { // TODO: Allow selecting recipes from category as host? let recipe = CookRecipe::from_path(&path, !command.is_cleaning(), false)?; let recipe_name = recipe.name.clone(); preloaded_recipes.insert(recipe_name.clone(), recipe); recipe_names.push(recipe_name); } } else { if let Some(conf) = config.filesystem.as_ref() { recipe_names = conf .packages .keys() .filter_map(|k| PackageName::new(k.to_string()).ok()) .collect(); } else { bail_options_err!( "Error: No recipe names or filesystem config provided and --all flag was not used." ); } } } if command.is_cleaning() { let recipes = if preloaded_recipes.is_empty() { CookRecipe::from_list(recipe_names)? } else { preloaded_recipes.into_values().collect() }; return Ok((config, command, recipes)); } let mut recipes = if let Some(conf) = config.filesystem.as_ref() { let repo_binary = override_filesystem_repo_binary; // Expand deps for "source" + "local" and "binary" // This is the complete map from filesystem config let mut source_names: Vec = Vec::new(); let mut binary_names: Vec = Vec::new(); let mut special_rules: HashMap = HashMap::new(); let default_rule = if repo_binary { "binary" } else { "source" }; for (recipe_name_str, recipe_config) in conf.packages.iter() { let Ok(recipe_name) = PackageName::new(recipe_name_str) else { continue; }; let rule = match recipe_config { PackageConfig::Build(rule) => { special_rules.insert(recipe_name.clone(), rule.to_string()); rule } _ => default_rule, }; if rule == "source" || rule == "local" { source_names.push(recipe_name); } else if rule == "binary" { binary_names.push(recipe_name); } } source_names = CookRecipe::get_all_deps_names_recursive(&source_names, true)?; binary_names = CookRecipe::get_all_deps_names_recursive(&binary_names, false)?; let source_names: HashSet = source_names.into_iter().collect(); let binary_names: HashSet = binary_names.into_iter().collect(); // These are list that derived from recipe_names let mut source_recipe_names: Vec = Vec::new(); let mut binary_recipe_names: Vec = Vec::new(); let mut ignore_recipe_names: Vec = Vec::new(); for recipe_name in recipe_names.iter() { if source_names.contains(recipe_name) { source_recipe_names.push(recipe_name.clone()); } else if binary_names.contains(recipe_name) { binary_recipe_names.push(recipe_name.clone()); } else { if special_rules .get(recipe_name) .is_some_and(|s| s == "ignore") { ignore_recipe_names.push(recipe_name.clone()); } else if repo_binary { binary_recipe_names.push(recipe_name.clone()); } else { source_recipe_names.push(recipe_name.clone()); } } } if config.with_package_deps || command.is_pushing() { source_recipe_names = CookRecipe::get_package_deps_recursive(&source_recipe_names, true)?; binary_recipe_names = CookRecipe::get_package_deps_recursive(&binary_recipe_names, true)?; } let mut recipes = if command.is_building() || command.is_pushing() { // Pushing do not need dev deps, so does binary recipes at building let include_dev = command.is_building(); if include_dev && default_rule == "source" { // let's cover a very specific case, binary -> source -> binary -> dev // in this case, we need to move that "source" to "binary", because // that would include dev from its binary child, which is unnecessary let mut i = 0; while i < source_recipe_names.len() { let name = &source_recipe_names[i]; match special_rules.get(name) { Some(s) if s.as_str() == "source" => { if binary_names.contains(name) { let bin = source_recipe_names.remove(i); binary_recipe_names.push(bin); continue; } } _ => {} } i += 1; } } CookRecipe::get_build_deps_recursive(&source_recipe_names, include_dev)? } else { CookRecipe::from_list(source_recipe_names.clone())? }; let binary_recipes = if command.is_building() || command.is_pushing() { CookRecipe::get_build_deps_recursive(&binary_recipe_names, false)? } else { CookRecipe::from_list(binary_recipe_names.clone())? }; let ignore_recipes = CookRecipe::from_list(ignore_recipe_names.clone())?; recipes.extend(binary_recipes); recipes.extend(ignore_recipes); recipes = recipes_flatten_package_names(recipes); for recipe in recipes.iter_mut() { if let Some(special_rule) = special_rules.get(&recipe.name) { recipe.apply_filesystem_config(&special_rule)?; continue; } let rule = match ( source_names.contains(&recipe.name), binary_names.contains(&recipe.name), ) { (true, true) => { // both lists: flip logic if repo_binary { "source" } else { "binary" } } (true, false) => "source", (false, true) => "binary", (false, false) => default_rule, }; if recipe.name.is_host() && rule == "binary" { // host recipe binaries is currently not supported continue; } recipe.apply_filesystem_config(rule)?; } recipes } else { if config.with_package_deps || command.is_pushing() { recipe_names = CookRecipe::get_package_deps_recursive(&recipe_names, true)?; } if command.is_building() || command.is_pushing() { let include_dev = command.is_building(); CookRecipe::get_build_deps_recursive(&recipe_names, include_dev)? } else { CookRecipe::from_list(recipe_names.clone())? } }; if command.is_building() && recipes.iter().any(|r| r.rule == "binary") { let (_, repository) = fetch_repo::get_binary_repo(); for recipe in recipes.iter_mut() { if recipe.rule == "binary" && !repository.packages.contains_key(recipe.name.as_str()) { if config.cook.verbose && !(config.cook.tui && command == CliCommand::Cook) { // TODO: this should be printed at fetch log, not here println!( "DEBUG: Recipe {:?} has no binary package", recipe.name.as_str() ); } recipe.rule = "source".into(); recipe.reload_recipe()?; } } } if !config.with_package_deps || command.is_informational() { // In CliCommand::Cook, is_deps==true will make it skip checking source recipes_mark_as_deps(&recipe_names, &mut recipes); } Ok((config, command, recipes)) } fn handle_fetch( recipe: &CookRecipe, config: &CliConfig, allow_offline: bool, logger: &PtyOut, ) -> Result { match config.cook.offline && allow_offline { true => fetch_offline(&recipe, logger), false => fetch(&recipe, !recipe.is_deps, logger), } } fn handle_cook( recipe: &CookRecipe, config: &CliConfig, source_dir: PathBuf, logger: &PtyOut, ) -> Result { let recipe_dir = &recipe.dir; let target_dir = create_target_dir(recipe_dir, recipe.target)?; let build_result = build( recipe_dir, &source_dir, &target_dir, &recipe, &config.cook, logger, )?; package(&recipe, &build_result, &config.cook, logger)?; if config.cook.clean_target || config.cook.write_filetree { for stage_dir in &build_result.stage_dirs { if stage_dir.is_dir() { if config.cook.write_filetree { let mut stage_files_buf = String::new(); tree::walk_file_tree(&stage_dir, "", &mut stage_files_buf) .map_err(|e| Error::from_io_error(e, "Walking files tree"))?; fs::write(stage_dir.with_added_extension("files"), stage_files_buf) .map_err(|e| Error::from_io_error(e, "Writing files tree"))?; } if config.cook.clean_target { remove_all(&stage_dir)?; } } } } Ok(build_result.cached) } /// delete stage artifacts upon nonstop failure to let repo_builder know fn handle_nonstop_fail(recipe: &CookRecipe) -> cookbook::Result<()> { let target_dir = recipe.target_dir(); let stage_dirs = get_stage_dirs(&recipe.recipe.optional_packages, &target_dir); for stage_dir in stage_dirs { remove_stage_dir(&stage_dir)?; } Ok(()) } fn handle_clean(recipe: &CookRecipe, _config: &CliConfig, command: &CliCommand) -> Result { let mut dir = recipe.dir.join("target"); let mut cached = true; if matches!(*command, CliCommand::CleanTarget) { dir = dir.join(redoxer::target()) } if dir.exists() { remove_all(&dir)?; cached = false; } let dir = recipe.dir.join("source"); if dir.exists() && matches!(*command, CliCommand::Unfetch) { remove_all(&dir)?; cached = false; } Ok(cached) } static PUSH_SYSROOT_DIR: OnceLock = OnceLock::new(); fn handle_push(recipes: &Vec, config: &CliConfig) -> Result<()> { let recipe_map: HashMap<&PackageName, &CookRecipe> = recipes.iter().map(|r| (&r.name, r)).collect(); let mut total_size: u64 = 0; let mut total_count: u64 = 0; let mut visited: HashSet = HashSet::new(); let num_recipes = recipes.len(); PUSH_SYSROOT_DIR.set(config.sysroot_dir.clone()).unwrap(); let handle_push_inner = move |package_name: &PackageName, _prefix: &str, _is_last: bool, entry: &WalkTreeEntry| -> Result { let r = match entry { WalkTreeEntry::Built(archive_path, _) => { let install_path = PUSH_SYSROOT_DIR.get().unwrap(); let mut state = PackageState::from_sysroot(install_path).map_err(Error::from)?; let r = package_handle_push(&mut state, archive_path, &install_path, false); if matches!(r, Ok(false)) { state .to_sysroot(install_path) .map_err(|e| Error::from_io_error(e, "Extracting package"))?; } r } WalkTreeEntry::NotBuilt => Err(Error::Other(format!( "Package {} has not been built", package_name.name() ))), WalkTreeEntry::Deduped | WalkTreeEntry::Missing => { // does not matter return Ok(false); } }; match r { Ok(true) => { print_cached(&CliCommand::Push, package_name); Ok(true) } Ok(false) => { print_success(&CliCommand::Push, package_name); Ok(false) } Err(e) => { print_failed(&CliCommand::Push, package_name); if get_config().cook.nonstop { Ok(true) } else { Err(e) } } } }; for (i, recipe) in recipes.iter().enumerate() { tree::walk_tree_entry( &recipe.name, &recipe_map, "", i == num_recipes - 1, false, &mut visited, &mut total_size, &mut total_count, handle_push_inner, )?; } if config.cook.verbose { println!(""); println!( "Pushed {} of {} {}", tree::format_size(total_size), total_count, if total_count == 1 { "package" } else { "packages" }, ); } Ok(()) } fn handle_tree(recipes: &Vec, is_build_tree: bool, _config: &CliConfig) -> Result<()> { let recipe_map: HashMap<&PackageName, &CookRecipe> = recipes.iter().map(|r| (&r.name, r)).collect(); let mut total_size: u64 = 0; let mut total_count: u64 = 0; let mut visited: HashSet = HashSet::new(); let roots: Vec<&CookRecipe> = recipes.iter().filter(|r| !r.is_deps).collect(); let num_roots = roots.len(); for (i, root) in roots.iter().enumerate() { tree::display_tree_entry( &root.name, &recipe_map, "", i == num_roots - 1, is_build_tree, &mut visited, &mut total_size, &mut total_count, )?; } println!(""); if is_build_tree { println!( "Build summary: {} need build, {} may rebuild, with total of {} {}", total_size, roots.len(), visited.len(), if visited.len() == 1 { "recipe" } else { "recipes" }, ); } else { println!( "Estimated image size: {} of {} {}", tree::format_size(total_size), visited.len(), if visited.len() == 1 { "package" } else { "packages" }, ); } Ok(()) } // // ------------- TUI SPECIFIC CODE ------------------- // #[derive(Debug, Clone, PartialEq)] enum RecipeStatus { Pending, Fetching, Fetched, Cooking, Cached, Done, Failed(String), } impl RecipeStatus { pub fn fetch_is_part_of(&self) -> bool { matches!(*self, RecipeStatus::Pending | RecipeStatus::Fetching) } pub fn fetch_style(&self) -> Style { match *self { RecipeStatus::Fetching => Style::default().fg(Color::Yellow), _ => Style::default(), } } pub fn fetch_icon(&self, spin: char) -> char { match *self { RecipeStatus::Pending => ' ', RecipeStatus::Fetching => spin, _ => '?', } } pub fn cook_is_part_of(&self) -> bool { matches!( *self, RecipeStatus::Fetched | RecipeStatus::Cooking | RecipeStatus::Done | RecipeStatus::Cached | RecipeStatus::Failed(_) ) } pub fn cook_style(&self) -> Style { match *self { RecipeStatus::Fetched => Style::default(), RecipeStatus::Cooking => Style::default().fg(Color::Yellow), RecipeStatus::Done => Style::default().fg(Color::Green), RecipeStatus::Cached => Style::default().fg(Color::Cyan), RecipeStatus::Failed(_) => Style::default().fg(Color::Red), _ => Style::default(), } } pub fn cook_icon(&self, spin: char) -> char { match *self { RecipeStatus::Fetched => ' ', RecipeStatus::Cooking => spin, RecipeStatus::Done => '+', RecipeStatus::Cached => ' ', RecipeStatus::Failed(_) => 'X', _ => '?', } } } #[derive(Debug, Clone, PartialEq)] enum StatusUpdate { StartFetch(PackageName), Fetched(CookRecipe), FailFetch(CookRecipe, String), StartCook(PackageName), Cooked(CookRecipe, bool), FailCook(CookRecipe, String), PushLog(PackageName, Vec), FlushLog(PackageName, PathBuf), FetchThreadFinished, CookThreadFinished, } #[derive(PartialEq)] enum JobType { Fetch, Cook, } impl ToString for JobType { fn to_string(&self) -> String { match self { JobType::Fetch => "Fetch", JobType::Cook => "Cook", } .to_string() } } const PROMPT_WAIT: Duration = Duration::from_millis(101); struct TuiApp { recipes: Vec<(CookRecipe, RecipeStatus)>, active_fetch: Option, active_cook: Option, logs: HashMap>, log_byte_buffer: HashMap>, log_scroll: usize, log_view_job: JobType, auto_scroll: bool, cook_scroll: usize, cook_list_state: ListState, fetch_complete: bool, cook_complete: bool, prompt: Option, dump_logs_anyway: bool, dump_logs_on_exit: Option<(PackageName, String)>, } impl TuiApp { fn new(recipes: Vec) -> Self { Self { recipes: recipes .iter() .cloned() .map(|r| (r, RecipeStatus::Pending)) .collect(), active_fetch: None, active_cook: None, logs: HashMap::new(), log_byte_buffer: HashMap::new(), log_scroll: 0, auto_scroll: true, log_view_job: JobType::Fetch, cook_scroll: 0, cook_list_state: ListState::default(), fetch_complete: false, cook_complete: false, prompt: None, dump_logs_anyway: false, dump_logs_on_exit: None, } } pub fn get_active_name(&self) -> Option { if self.log_view_job == JobType::Cook { self.active_cook.clone() } else { self.active_fetch.clone() } } pub fn get_active_log( &self, ) -> ( Option, Option<&Vec>, Option>, ) { let active_name = self.get_active_name(); let (log_text, log_line) = if let Some(active_name) = active_name.as_ref() { self.get_recipe_log(active_name) } else { (None, None) }; (active_name, log_text, log_line) } pub fn get_recipe_log( &self, recipe_name: &PackageName, ) -> (Option<&Vec>, Option>) { let log_text = self.logs.get(recipe_name); let log_line = if let Some(b) = self.log_byte_buffer.get(recipe_name) { Some(String::from_utf8_lossy(b)) } else { None }; (log_text, log_line) } pub fn write_log(&self, recipe_name: &PackageName, log_path: &PathBuf) -> Result<()> { let (Some(logs), line) = self.get_recipe_log(recipe_name) else { return Ok(()); }; let str = strip_ansi_escapes::strip_str(join_logs(logs, line)); if !str.trim_end().is_empty() { fs::write(log_path, str).map_err(|e| Error::from_io_error(e, "Writing log"))?; } return Ok(()); } // Update the state based on a message from a worker thread fn update_status(&mut self, update: StatusUpdate) { let (name, new_status) = match update { StatusUpdate::StartFetch(name) => { self.active_fetch = Some(name.clone()); self.logs.insert(name.clone(), Vec::new()); self.log_byte_buffer.insert(name.clone(), Vec::new()); self.log_scroll = 0; self.auto_scroll = true; (name.clone(), RecipeStatus::Fetching) } StatusUpdate::Fetched(recipe) => (recipe.name.clone(), RecipeStatus::Fetched), StatusUpdate::FailFetch(recipe, err) => { self.prompt = Some(FailurePrompt::new(recipe.clone(), err.clone())); (recipe.name.clone(), RecipeStatus::Failed(err)) } StatusUpdate::StartCook(name) => { self.active_cook = Some(name.clone()); self.logs.insert(name.clone(), Vec::new()); self.log_byte_buffer.insert(name.clone(), Vec::new()); (name.clone(), RecipeStatus::Cooking) } StatusUpdate::PushLog(name, chunk) => { let buffer = self.log_byte_buffer.entry(name.clone()).or_default(); buffer.extend_from_slice(&chunk); if self.dump_logs_anyway { let _ = std::io::stdout().write_all(&chunk); } let log_list = self.logs.entry(name.clone()).or_default(); // TODO: multibyte-aware line split? while let Some(newline_pos) = buffer.iter().position(|&b| b == b'\n') { let line_bytes = buffer.drain(..=newline_pos); let line_str = String::from_utf8_lossy(&line_bytes.as_slice()); let line_str_pos = line_str.trim_end(); let line_str = line_str_pos.rsplit('\r').next().unwrap_or(&line_str_pos); log_list.push(line_str.to_owned()); } return; } StatusUpdate::FlushLog(name, path) => { // TODO: This blocks the TUI, maybe open separate thread? // FIXME: handle error here? let _ = self.write_log(&name, &path); return; } StatusUpdate::Cooked(recipe, cached) => { if self.active_cook.as_ref() == Some(&recipe.name) { self.active_cook = None; } self.auto_scroll = true; ( recipe.name.clone(), if cached { RecipeStatus::Cached } else { RecipeStatus::Done }, ) } StatusUpdate::FailCook(recipe, err) => { self.prompt = Some(FailurePrompt::new(recipe.clone(), err.clone())); (recipe.name.clone(), RecipeStatus::Failed(err)) } StatusUpdate::FetchThreadFinished => { self.fetch_complete = true; self.log_view_job = JobType::Cook; return; } StatusUpdate::CookThreadFinished => { self.cook_complete = true; return; } }; if let Some((_, status)) = self.recipes.iter_mut().find(|(r, _)| r.name == name) { *status = new_status; } } } fn run_tui_cook(config: CliConfig, recipes: Vec) -> Result { let (work_tx, work_rx) = mpsc::channel::<(CookRecipe, FetchResult)>(); let (status_tx, status_rx) = mpsc::channel::(); let running = Arc::new(AtomicBool::new(true)); let prompting = Arc::new(AtomicU32::new(0)); const TICK_RATE: Duration = Duration::from_millis(100); // ---- Cooker Thread ---- let cooker_config = config.clone(); let cooker_status_tx = status_tx.clone(); let cooker_prompting = prompting.clone(); let cooker_handle = thread::spawn(move || { 'done: for (mut recipe, fetch_result) in work_rx { let name = recipe.name.clone(); let (mut stdout_writer, mut stderr_writer) = setup_logger(&cooker_status_tx, &name); let mut logger = Some((&mut stdout_writer, &mut stderr_writer)); 'again: loop { cooker_status_tx .send(StatusUpdate::StartCook(name.clone())) .unwrap(); let _ = recipe.reload_recipe(); // reread recipe.toml in case we're retrying let handler = handle_cook( &recipe, &cooker_config, fetch_result.source_dir.clone(), &logger, ); if let Some(log_path) = cooker_config.logs_dir.as_ref() // prefer to retain full build logs && !matches!(handler, Ok(true)) { if let Err(err_ctx) = &handler { write_to_pty(&logger, &format!("\n{err_ctx}")); } flush_pty(&mut logger); let log_path = log_path.join(format!("{}/{}.log", recipe.target, name.name())); cooker_status_tx .send(StatusUpdate::FlushLog(name.clone(), log_path)) .unwrap_or_default(); } match handler { Ok(cached) => { cooker_status_tx .send(StatusUpdate::Cooked(recipe, cached)) .unwrap_or_default(); if cooker_config.cook.nonstop && cooker_prompting.load(Ordering::SeqCst) == 4 { break 'done; } break; } Err(e) => { cooker_status_tx .send(StatusUpdate::FailCook(recipe.clone(), e.to_string())) .unwrap_or_default(); if cooker_config.cook.nonstop { if cooker_prompting.load(Ordering::SeqCst) == 4 { break 'done; } // TODO: where to report error? let _ = handle_nonstop_fail(&recipe); break; } while cooker_prompting.load(Ordering::SeqCst) != 0 { thread::sleep(PROMPT_WAIT); // wait other prompt } cooker_prompting.swap(1, Ordering::SeqCst); 'wait: loop { match cooker_prompting.load(Ordering::SeqCst) { 0 => break 'again, 1 => thread::sleep(PROMPT_WAIT), 2 => { cooker_prompting.swap(0, Ordering::SeqCst); break 'wait; } // retry 3 => { cooker_prompting.swap(0, Ordering::SeqCst); let _ = handle_nonstop_fail(&recipe); break 'again; } // skip 4 => { cooker_prompting.swap(0, Ordering::SeqCst); break 'done; } // done _ => unreachable!(), } } } } } } cooker_status_tx .send(StatusUpdate::CookThreadFinished) .unwrap_or_default(); }); let mstdin = stdin(); let mstdout = stdout() .into_raw_mode() .unwrap() .into_alternate_screen() .unwrap(); // ----- Input Thread ----- let (input_tx, input_rx) = mpsc::channel::(); let _input_handle = thread::spawn(move || { for evt in mstdin.events() { if let Ok(evt) = evt { if input_tx.send(evt).is_err() { return; } } } }); // ---- Fetcher Thread ---- let fetcher_recipes = recipes.clone(); let fetcher_status_tx = status_tx.clone(); let fetcher_config = config.clone(); let fetcher_prompting = prompting.clone(); let fetcher_handle = thread::spawn(move || { 'done: for mut recipe in fetcher_recipes { let name = recipe.name.clone(); let (mut stdout_writer, mut stderr_writer) = setup_logger(&fetcher_status_tx, &name); let mut logger = Some((&mut stdout_writer, &mut stderr_writer)); 'again: loop { fetcher_status_tx .send(StatusUpdate::StartFetch(name.clone())) .unwrap(); let _ = recipe.reload_recipe(); // reread recipe.toml in case we're retrying let handler = handle_fetch(&recipe, &fetcher_config, true, &logger); if let Some(log_path) = fetcher_config.logs_dir.as_ref() // prefer to retain full build logs && !matches!(handler, Ok(FetchResult { cached: true, .. })) { if let Err(err_ctx) = &handler { write_to_pty(&logger, &format!("\n{err_ctx}")); } flush_pty(&mut logger); let log_path = log_path.join(format!("{}/{}.log", recipe.target, name.name())); fetcher_status_tx .send(StatusUpdate::FlushLog(name.clone(), log_path)) .unwrap_or_default(); } match handler { Ok(fetch) => { fetcher_status_tx .send(StatusUpdate::Fetched(recipe.clone())) .unwrap(); if work_tx.send((recipe.clone(), fetch)).is_err() { // Cooker thread died break 'done; } if fetcher_config.cook.nonstop && fetcher_prompting.load(Ordering::SeqCst) == 4 { break 'done; } break; } Err(e) => { fetcher_status_tx .send(StatusUpdate::FailFetch(recipe.clone(), e.to_string())) .unwrap_or_default(); if fetcher_config.cook.nonstop { if fetcher_prompting.load(Ordering::SeqCst) == 4 { break 'done; } let _ = handle_nonstop_fail(&recipe); break; } while fetcher_prompting.load(Ordering::SeqCst) != 0 { thread::sleep(PROMPT_WAIT); // wait other prompt } fetcher_prompting.swap(1, Ordering::SeqCst); 'wait: loop { match fetcher_prompting.load(Ordering::SeqCst) { 0 => break 'again, 1 => thread::sleep(PROMPT_WAIT), 2 => { fetcher_prompting.swap(0, Ordering::SeqCst); break 'wait; } // retry 3 => { fetcher_prompting.swap(0, Ordering::SeqCst); let _ = handle_nonstop_fail(&recipe); break 'again; } // skip 4 => { fetcher_prompting.swap(0, Ordering::SeqCst); break 'done; } // done _ => unreachable!(), } } } } } } status_tx .send(StatusUpdate::FetchThreadFinished) .unwrap_or_default(); }); let mut terminal = Terminal::new(TermionBackend::new(stdout())) .map_err(|e| Error::from_io_error(e, "Reading terminal pty"))?; terminal .clear() .map_err(|e| Error::from_io_error(e, "Clearing terminal pty"))?; let mut app = TuiApp::new(recipes); let spinner = ['-', '\\', '|', '/']; let mut spinner_i = 0; while running.load(Ordering::SeqCst) { let frame_start = Instant::now(); let r = terminal.draw(|f| { spinner_i = (spinner_i + 1) % spinner.len(); let spin = spinner[spinner_i]; let mut constraints = Vec::new(); if !app.fetch_complete { constraints.push(Constraint::Length(22)); } constraints.push(Constraint::Length(22)); constraints.push(Constraint::Min(20)); let chunks = Layout::default() .direction(Direction::Horizontal) .constraints(constraints) .split(f.area()); let panel_height = chunks[0].height.saturating_sub(2) as usize; // Left Pane let fetch_items: Vec = app .recipes .iter() .filter(|(_, s)| s.fetch_is_part_of()) .map(|(r, s)| { let icon = s.fetch_icon(spin); ListItem::new(format!("{icon} {}", r.name)).style(s.fetch_style()) }) .collect(); let fetch_list = List::new(fetch_items).block( Block::default() .title("Fetch Queue [1]") .borders(Borders::ALL), ); f.render_widget(fetch_list, chunks[0]); // Right Pane let cook_items: Vec = app .recipes .iter() .filter(|(_, s)| s.cook_is_part_of()) .map(|(r, s)| { let icon = s.cook_icon(spin); ListItem::new(format!("{icon} {}", r.name)).style(s.cook_style()) }) .collect(); { let cooking_index = app .recipes .iter() .filter(|(_, s)| s.cook_is_part_of()) .position(|(_r, s)| *s == RecipeStatus::Cooking); if let Some(index) = cooking_index { app.cook_list_state.select(Some(index)); let index_u16 = index; let center_offset = panel_height / 2; let new_offset = index_u16.saturating_sub(center_offset) as usize; *app.cook_list_state.offset_mut() = new_offset; } } let cook_items: Vec = cook_items[app.cook_scroll..].into(); let cook_chunk = chunks[if app.fetch_complete { 0 } else { 1 }]; let cook_list = List::new(cook_items).block( Block::default() .title("Cook Queue [2]") .borders(Borders::ALL), ); f.render_stateful_widget(cook_list, cook_chunk, &mut app.cook_list_state); let (active_name, log_text, log_line) = app.get_active_log(); let log_title = if let Some(active_name) = active_name { format!( " {} Log: {} ", app.log_view_job.to_string(), active_name.as_str() ) } else { format!(" {} Log ", app.log_view_job.to_string()) }; let mut enable_auto_scroll = false; let mut intended_scroll_pos = 0usize; let mut log_lines: Vec = if let Some(log_text) = log_text && !log_text.is_empty() { let total_log_lines = log_text.len() as usize; let start = if app.auto_scroll { if total_log_lines > panel_height { intended_scroll_pos = total_log_lines - panel_height; total_log_lines - panel_height } else { 0 } } else { if total_log_lines > panel_height { let limit = 2; // arbitrary number if app.log_scroll >= total_log_lines - limit { if app.prompt.is_none() || config.cook.nonstop { enable_auto_scroll = true; } intended_scroll_pos = total_log_lines - limit; total_log_lines - limit } else { app.log_scroll } } else { 0 } }; let end = cmp::min(panel_height + start, total_log_lines - 1); log_text[start..end] .iter() .map(|s| { let text_with_colors = s .into_text() .unwrap_or_else(|_| Text::raw("--unrenderable line--")); text_with_colors .lines .into_iter() .next() .unwrap_or_else(|| Line::raw("--unrenderable line--")) }) .collect() } else { vec![Line::from("No logs yet")] }; if let Some(buffer) = log_line && !buffer.is_empty() { let text_with_colors = handle_cr(&buffer) .into_text() .unwrap_or_else(|_| Text::raw("--unrenderable line--")); if let Some(line) = text_with_colors.lines.into_iter().next() { log_lines.push(line); } } let instruct = format!( " Keys: [c] Stop [PageUp/Down] Scroll{}{} ", match app.auto_scroll { true => "", false => " [End] Follow log trails", }, match (&app.log_view_job, app.fetch_complete) { (JobType::Fetch, _) => " [2] View Cook Log", (JobType::Cook, false) => " [1] View Fetch Log", (JobType::Cook, true) => "", } ); let mut log_paragraph = Paragraph::new(log_lines).block( Block::default() .title(log_title) .title_bottom(instruct) .borders(Borders::ALL), ); if !app.auto_scroll { log_paragraph = log_paragraph.wrap(Wrap { trim: false }); } f.render_widget( log_paragraph, chunks[if app.fetch_complete { 1 } else { 2 }], ); if let Some(prompt) = &mut app.prompt { if config.cook.nonstop && prompt.selected == PromptOption::Retry { prompt.selected = PromptOption::Skip; } draw_prompt(f, prompt, config.cook.nonstop); } if enable_auto_scroll { app.auto_scroll = true; } if intended_scroll_pos > 0 { app.log_scroll = intended_scroll_pos; } while let Ok(event) = input_rx.try_recv() { if let Some((app, res)) = handle_prompt_input(&event, &mut app) { prompting.swap(res as u32, Ordering::SeqCst); if res == PromptOption::Exit { // TODO: This can be a different log with what prompted on nonstop mode let (name, log, line) = app.get_active_log(); if let Some(name) = name && let Some(log) = log { app.dump_logs_on_exit = Some((name.to_owned(), join_logs(log, line))); } running.store(false, Ordering::SeqCst); } app.prompt = None; } else { handle_main_event(&mut app, &event); } } }); r.map_err(|e| Error::from_io_error(e, "Drawing to terminal pty"))?; while let Ok(update) = status_rx.try_recv() { app.update_status(update); } if app.cook_complete { running.swap(false, Ordering::SeqCst); } if let Some(sleep_duration) = TICK_RATE.checked_sub(frame_start.elapsed()) { thread::sleep(sleep_duration); } } drop(mstdout); let _ = stdout().flush(); if config.cook.nonstop && app.dump_logs_on_exit.is_some() { kill_everything(); } let _ = fetcher_handle.join(); let _ = cooker_handle.join(); Ok(app) } fn join_logs(log: &Vec, line: Option>) -> String { let mut logs = log.join("\n"); if let Some(line) = line { logs.push_str("\n"); logs.push_str(handle_cr(&line)); } logs } fn handle_cr<'a>(buffer: &'a Cow<'_, str>) -> &'a str { let st = buffer.trim_end(); st.rsplit('\r').next().unwrap_or(&st) } fn handle_main_event(app: &mut TuiApp, event: &Event) { match event { Event::Key(key) => match key { Key::Char('1') => { app.log_view_job = JobType::Fetch; } Key::Char('2') => { app.log_view_job = JobType::Cook; } Key::Char('c') => { // as compilers still running, we use this way to stop it kill_everything(); } Key::Up => { app.auto_scroll = false; app.log_scroll = app.log_scroll.saturating_sub(1); } Key::Down => { app.auto_scroll = false; app.log_scroll = app.log_scroll.saturating_add(1); } Key::PageUp => { app.auto_scroll = false; app.log_scroll = app.log_scroll.saturating_sub(20); } Key::PageDown => { app.auto_scroll = false; app.log_scroll = app.log_scroll.saturating_add(20); } Key::End => { app.auto_scroll = true; } Key::Home => { app.auto_scroll = false; app.log_scroll = 0; } _ => {} }, _ => {} } } fn kill_everything() { let pid = std::process::id(); Command::new("bash") .arg("-c") .arg(KILL_ALL_PID.replace("$PID", &pid.to_string())) .stdout(process::Stdio::null()) .stderr(process::Stdio::null()) .spawn() .expect("unable to spawn kill"); } fn handle_prompt_input<'a>( event: &Event, app: &'a mut TuiApp, ) -> Option<(&'a mut TuiApp, PromptOption)> { if let Some(prompt) = &mut app.prompt { match event { Event::Key(key) => match key { Key::Char('q') | Key::Ctrl('c') | Key::Esc => { // Treat as "Exit" return Some((app, PromptOption::Exit)); } Key::Left | Key::BackTab => prompt.prev(), Key::Right | Key::Char('\t') => prompt.next(), Key::Char('\n') => { let prompt = app.prompt.take().unwrap(); return Some((app, prompt.selected)); } _ => {} }, _ => {} // Ignore mouse events } } None } fn draw_prompt(f: &mut ratatui::Frame, prompt: &FailurePrompt, is_nonstop: bool) { let title = format!( " FAILURE in {} {}", prompt.recipe.name, if is_nonstop { "(skipped) " } else { "" } ); let mut error_text = prompt.error.clone(); if error_text.len() > 200 { error_text = error_text[0..100].to_string() + ".." + &error_text[(error_text.len() - 100)..(error_text.len() - 1)]; } else if error_text.len() > 100 { error_text = error_text[0..100].to_string() + ".."; } // Style for options let retry_style = if prompt.selected == PromptOption::Retry { Style::default().bg(Color::White).fg(Color::Black) } else { Style::default() }; let skip_style = if prompt.selected == PromptOption::Skip { Style::default().bg(Color::White).fg(Color::Black) } else { Style::default() }; let exit_style = if prompt.selected == PromptOption::Exit { Style::default().bg(Color::White).fg(Color::Black) } else { Style::default() }; let mut buttons = vec![ Span::styled(" [Skip] ", skip_style), Span::raw(" "), Span::styled(" [Exit] ", exit_style), ]; if !is_nonstop { buttons.push(Span::raw(" ")); buttons.push(Span::styled(" [Retry] ", retry_style)); } let text = vec![ Line::from(error_text).style(Style::default().fg(Color::Yellow)), Line::from(""), Line::from(buttons), ]; let block = Block::default() .title(Span::styled( title, Style::default().fg(Color::White).bg(Color::Red), )) .borders(Borders::ALL) .border_style(Style::default().fg(Color::Red)); let paragraph = Paragraph::new(text) .block(block) .alignment(ratatui::layout::Alignment::Center) .wrap(Wrap { trim: true }); let area = f.area(); let popup_area = Rect { x: area.width / 4, y: area.height / 3, width: area.width / 2, height: 10, }; f.render_widget(Clear, popup_area); // Clear the background f.render_widget(paragraph, popup_area); } fn spawn_log_reader( mut reader: R, package_name: PackageName, status_tx: mpsc::Sender, ) where R: Read + Send + 'static, { thread::spawn(move || { let mut buffer = [0; 1024]; loop { let buf = match reader.read(&mut buffer) { Ok(0) => break, Ok(n) => buffer[..n].to_vec(), Err(e) => format!("[IO Error] {}", e).into_bytes(), }; if status_tx .send(StatusUpdate::PushLog(package_name.clone(), buf)) .is_err() { // TUI thread hung up break; } } }); } fn setup_logger( status_tx: &mpsc::Sender, name: &PackageName, ) -> (UnixSlavePty, std::io::PipeWriter) { let (pty_reader, log_reader, pipes) = setup_pty(); spawn_log_reader(pty_reader, name.clone(), status_tx.clone()); spawn_log_reader(log_reader, name.clone(), status_tx.clone()); pipes } #[derive(PartialEq, Clone, Copy)] #[repr(u32)] enum PromptOption { Retry = 2, Skip, Exit, } struct FailurePrompt { recipe: CookRecipe, error: String, selected: PromptOption, } impl FailurePrompt { fn new(recipe: CookRecipe, error: String) -> Self { Self { recipe, error, selected: PromptOption::Exit, } } fn next(&mut self) { self.selected = match self.selected { PromptOption::Retry => PromptOption::Skip, PromptOption::Skip => PromptOption::Exit, PromptOption::Exit => PromptOption::Retry, } } fn prev(&mut self) { self.selected = match self.selected { PromptOption::Retry => PromptOption::Exit, PromptOption::Skip => PromptOption::Retry, PromptOption::Exit => PromptOption::Skip, } } } macro_rules! bail_options_err { ($($arg:tt)*) => { return Err(cookbook::Error::Options(format!($($arg)*))) }; } use bail_options_err;