redox/src/bin/repo.rs
2026-05-06 01:06:53 +07:00

1914 lines
68 KiB
Rust

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, SourceRecipe, 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 <command> [flags] <recipe1> <recipe2> ...
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=<cookbook_dir> the "recipes" folder, default to $PWD/recipes
--repo=<repo_dir> the "repo" folder, default to $PWD/repo
--sysroot=<sysroot_dir> 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 <cookbook_dir>
--category=<category> apply to all recipes in <cookbook_dir>/<category>
--filesystem=<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<PathBuf>,
category: Option<PathBuf>,
filesystem: Option<redox_installer::Config>,
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<Self, Self::Err> {
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<Self> {
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<String> = 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<bool> {
Ok(match *command {
CliCommand::Fetch | CliCommand::Cook => {
let repo_inner_fn = move |logger: &PtyOut| -> Result<bool> {
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::<StatusUpdate>();
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<CookRecipe>, 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<String>) -> Result<(CliConfig, CliCommand, Vec<CookRecipe>)> {
let mut config = CliConfig::new()?;
let mut command: Option<String> = None;
let mut recipe_names: Vec<PackageName> = 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<PackageName, CookRecipe> = 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<PackageName> = Vec::new();
let mut binary_names: Vec<PackageName> = Vec::new();
let mut special_rules: HashMap<PackageName, String> = 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<PackageName> = source_names.into_iter().collect();
let binary_names: HashSet<PackageName> = binary_names.into_iter().collect();
// These are list that derived from recipe_names
let mut source_recipe_names: Vec<PackageName> = Vec::new();
let mut binary_recipe_names: Vec<PackageName> = Vec::new();
let mut ignore_recipe_names: Vec<PackageName> = 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 !get_config().recipe_lock.is_empty() {
let lock = &get_config().recipe_lock;
for recipe in recipes.iter_mut() {
if let Some(lock_recipe) = lock.get(recipe.name.as_str()) {
if let Some(rule) = &lock_recipe.fsrule {
recipe.rule = rule.into();
recipe.reload_recipe()?;
}
if let Some(gitrev) = &lock_recipe.gitrev {
if let Some(SourceRecipe::Git { rev, branch, .. }) = &mut recipe.recipe.source {
*rev = Some(gitrev.clone());
*branch = None;
} else {
println!(
"DEBUG: Recipe {:?} contains into git rev but recipe source is not git",
recipe.name.as_str()
);
}
recipe.rule = "source".into();
recipe.reload_recipe()?;
}
}
}
}
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<FetchResult> {
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<bool> {
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<bool> {
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<PathBuf> = OnceLock::new();
fn handle_push(recipes: &Vec<CookRecipe>, 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<PackageName> = 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<bool> {
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<CookRecipe>, 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<PackageName> = 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<u8>),
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<PackageName>,
active_cook: Option<PackageName>,
logs: HashMap<PackageName, Vec<String>>,
log_byte_buffer: HashMap<PackageName, Vec<u8>>,
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<FailurePrompt>,
dump_logs_anyway: bool,
dump_logs_on_exit: Option<(PackageName, String)>,
}
impl TuiApp {
fn new(recipes: Vec<CookRecipe>) -> 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<PackageName> {
if self.log_view_job == JobType::Cook {
self.active_cook.clone()
} else {
self.active_fetch.clone()
}
}
pub fn get_active_log(
&self,
) -> (
Option<PackageName>,
Option<&Vec<String>>,
Option<Cow<'_, str>>,
) {
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<String>>, Option<Cow<'_, str>>) {
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<CookRecipe>) -> Result<TuiApp> {
let (work_tx, work_rx) = mpsc::channel::<(CookRecipe, FetchResult)>();
let (status_tx, status_rx) = mpsc::channel::<StatusUpdate>();
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::<Event>();
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<ListItem> = 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<ListItem> = 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<ListItem> = 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<Line> = 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<String>, line: Option<Cow<'_, str>>) -> 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<R>(
mut reader: R,
package_name: PackageName,
status_tx: mpsc::Sender<StatusUpdate>,
) 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<StatusUpdate>,
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;