knott 0.1.8

Fast Rust package manager helper for Arch Linux repos and the AUR
Documentation
mod aur;
mod cli;
mod dep;
mod download;
mod info;
mod install;
mod localdb;
mod menu;
mod output;
mod pacman;
mod resolver;
mod search;
mod transaction;
mod upgrade;
mod version;

use anyhow::{bail, Result};
use std::io;

pub async fn run(args: Vec<String>) -> Result<i32> {
    let original_args = args.clone();
    let parsed = cli::parse(args)?;

    let recovery_flags =
        parsed.options.resume as u8 + parsed.options.abort as u8 + parsed.options.repair as u8;
    if recovery_flags > 1 {
        bail!("choose only one of --resume, --abort, or --repair");
    }

    match &parsed.command {
        cli::Command::Help => {
            cli::print_help();
            return Ok(0);
        }
        cli::Command::Version => {
            println!("knott {}", env!("CARGO_PKG_VERSION"));
            return Ok(0);
        }
        cli::Command::Diagnostics => {
            print_diagnostics();
            return Ok(0);
        }
        _ => {}
    }

    let tools = pacman::Toolchain::from_env();
    let aur = aur::AurClient::from_env_with_progress(
        !parsed.options.quiet && !parsed.options.no_progress,
    )?;

    if parsed.options.abort {
        let _lock = transaction::acquire_lock()?;
        return abort_current();
    }

    if parsed.options.repair {
        let _lock = transaction::acquire_lock()?;
        return repair_current(&parsed.options, &tools, &aur).await;
    }

    if parsed.options.resume {
        let _lock = transaction::acquire_lock()?;
        return resume_current(&parsed.options, &tools, &aur).await;
    }

    match parsed.command {
        cli::Command::DefaultUpgrade => {
            run_mutating(
                original_args,
                &parsed.options,
                &tools,
                &aur,
                &[],
                true,
                true,
            )
            .await
        }
        cli::Command::Search { terms } => {
            search::print_search(&parsed.options, &tools, &aur, &terms).await
        }
        cli::Command::Info { targets } => {
            info::print_info(&parsed.options, &tools, &aur, &targets).await
        }
        cli::Command::Install {
            targets,
            refresh,
            sysupgrade,
        } => {
            run_mutating(
                original_args,
                &parsed.options,
                &tools,
                &aur,
                &targets,
                refresh,
                sysupgrade,
            )
            .await
        }
        cli::Command::Get { targets, print } => {
            download::get_pkgbuilds(&parsed.options, &tools, &aur, &targets, print).await
        }
        cli::Command::QueryForeign => upgrade::print_foreign(&parsed.options, &tools).await,
        cli::Command::QueryAurUpdates => {
            upgrade::print_aur_updates(&parsed.options, &tools, &aur).await
        }
        cli::Command::Forward(args) => tools.forward(&args).await,
        cli::Command::Interactive { terms } => {
            let selected =
                search::interactive_select(&parsed.options, &tools, &aur, &terms).await?;
            if selected.is_empty() {
                return Ok(0);
            }
            let mut command = vec!["-S".to_string()];
            command.extend(selected.iter().cloned());
            run_mutating(
                command,
                &parsed.options,
                &tools,
                &aur,
                &selected,
                false,
                false,
            )
            .await
        }
        cli::Command::Help | cli::Command::Version | cli::Command::Diagnostics => unreachable!(),
    }
}

async fn run_mutating(
    command: Vec<String>,
    options: &cli::Options,
    tools: &pacman::Toolchain,
    aur: &aur::AurClient,
    targets: &[String],
    refresh: bool,
    sysupgrade: bool,
) -> Result<i32> {
    if options.dry_run {
        return install::sync(options, tools, aur, targets, refresh, sysupgrade, None).await;
    }

    let _lock = transaction::acquire_lock()?;
    if let Some(mut tx) = transaction::load_current()? {
        print_interrupted(&tx);
        if options.no_confirm {
            output::error("--noconfirm will not choose a recovery path; run knott --resume, --abort, or --repair");
            return Ok(1);
        }

        match prompt_recovery()? {
            RecoveryChoice::Resume => return resume_loaded(options, tools, aur, &mut tx).await,
            RecoveryChoice::Abort => {
                transaction::mark_aborted(&mut tx)?;
                output::warning("interrupted transaction marked aborted; rerun the requested command to start over");
                return Ok(1);
            }
            RecoveryChoice::IgnoreOnce => {
                output::warning("not starting a second mutating transaction while one is active");
                return Ok(1);
            }
        }
    }

    let mut tx = transaction::create_current(command, refresh, sysupgrade, options)?;
    install::sync(
        options,
        tools,
        aur,
        targets,
        refresh,
        sysupgrade,
        Some(&mut tx),
    )
    .await
}

fn abort_current() -> Result<i32> {
    let Some(mut tx) = transaction::load_current()? else {
        output::line("knott: no active Knott transaction");
        return Ok(0);
    };
    transaction::mark_aborted(&mut tx)?;
    output::line(format!(
        "knott: transaction {} marked aborted; installed packages were not rolled back",
        output::inline(&tx.id)
    ));
    Ok(0)
}

async fn resume_current(
    options: &cli::Options,
    tools: &pacman::Toolchain,
    aur: &aur::AurClient,
) -> Result<i32> {
    let Some(mut tx) = transaction::load_current()? else {
        output::line("knott: no active Knott transaction");
        return Ok(0);
    };
    resume_loaded(options, tools, aur, &mut tx).await
}

async fn repair_current(
    options: &cli::Options,
    tools: &pacman::Toolchain,
    aur: &aur::AurClient,
) -> Result<i32> {
    let Some(mut tx) = transaction::load_current()? else {
        let code = tools.pacman_database_check(options.dry_run).await?;
        if code == 0 {
            output::line("knott: no active Knott transaction");
        }
        return Ok(code);
    };
    let code = install::repair_transaction(options, tools, Some(aur), &mut tx).await?;
    if code != 0 || transaction::load_current()?.is_none() {
        return Ok(code);
    }
    resume_loaded(options, tools, aur, &mut tx).await
}

async fn resume_loaded(
    options: &cli::Options,
    tools: &pacman::Toolchain,
    aur: &aur::AurClient,
    tx: &mut transaction::Transaction,
) -> Result<i32> {
    if tx.phase.blocks_aur_resume() || failed_during_repo_upgrade(tx) {
        install::print_repo_interrupted(tx);
        return Ok(1);
    }

    if tx.aur_items.is_empty()
        && tx.repo_deps.is_empty()
        && matches!(
            tx.phase,
            transaction::TransactionPhase::Planned
                | transaction::TransactionPhase::RepoUpgradeDone
                | transaction::TransactionPhase::AurPlanWritten
        )
    {
        return resume_saved_command(options, tools, aur, tx).await;
    }

    install::resume_transaction(options, tools, Some(aur), tx).await
}

async fn resume_saved_command(
    options: &cli::Options,
    tools: &pacman::Toolchain,
    aur: &aur::AurClient,
    tx: &mut transaction::Transaction,
) -> Result<i32> {
    let parsed = cli::parse(tx.command.clone())?;
    let mut tx_options = tx.options.to_options();
    tx_options.no_confirm |= options.no_confirm;
    tx_options.no_progress |= options.no_progress;

    match parsed.command {
        cli::Command::DefaultUpgrade => {
            install::sync(
                &tx_options,
                tools,
                aur,
                &[],
                tx.refresh,
                tx.sysupgrade,
                Some(tx),
            )
            .await
        }
        cli::Command::Install {
            targets,
            refresh,
            sysupgrade,
        } => {
            install::sync(
                &tx_options,
                tools,
                aur,
                &targets,
                refresh || tx.refresh,
                sysupgrade || tx.sysupgrade,
                Some(tx),
            )
            .await
        }
        _ => {
            output::error("saved transaction command cannot be resumed automatically");
            Ok(1)
        }
    }
}

fn print_interrupted(tx: &transaction::Transaction) {
    if tx.phase == transaction::TransactionPhase::RepoUpgradeStarted
        || failed_during_repo_upgrade(tx)
    {
        install::print_repo_interrupted(tx);
        return;
    }

    output::error("knott: interrupted transaction detected");
    output::line(format!("id: {}", output::inline(&tx.id)));
    output::line(format!("phase: {}", tx.phase));
    if let Some(item) = tx
        .aur_items
        .iter()
        .find(|item| item.status != transaction::AurItemStatus::Installed)
    {
        output::line(format!("package: {}", output::inline(&item.name)));
    }
    output::line("resume: knott --resume");
    output::line("abort:  knott --abort");
    output::line("repair: knott --repair");
}

fn failed_during_repo_upgrade(tx: &transaction::Transaction) -> bool {
    tx.phase == transaction::TransactionPhase::Failed
        && tx
            .last_error
            .as_deref()
            .is_some_and(|err| err.contains("pacman repo upgrade"))
}

enum RecoveryChoice {
    Resume,
    Abort,
    IgnoreOnce,
}

fn prompt_recovery() -> Result<RecoveryChoice> {
    output::prompt("Interrupted transaction: [R]esume, [A]bort, [I]gnore once? ")?;
    let mut input = String::new();
    io::stdin().read_line(&mut input)?;
    let input = input.trim();
    if input.is_empty() || input.eq_ignore_ascii_case("r") || input.eq_ignore_ascii_case("resume") {
        return Ok(RecoveryChoice::Resume);
    }
    if input.eq_ignore_ascii_case("a") || input.eq_ignore_ascii_case("abort") {
        return Ok(RecoveryChoice::Abort);
    }
    if input.eq_ignore_ascii_case("i") || input.eq_ignore_ascii_case("ignore") {
        return Ok(RecoveryChoice::IgnoreOnce);
    }
    Ok(RecoveryChoice::IgnoreOnce)
}

pub fn sanitize_for_terminal(input: &str) -> String {
    output::text(input)
}

fn print_diagnostics() {
    println!("knott diagnostics");
    println!("version: {}", env!("CARGO_PKG_VERSION"));
    println!("aur_info_batch_size: {}", aur::INFO_CHUNK);
    println!("aur_info_cache_limit: {}", aur::INFO_CACHE_LIMIT);
    println!("aur_search_cache_limit: {}", aur::SEARCH_CACHE_LIMIT);
    println!(
        "aur_search_cache_result_limit: {}",
        aur::SEARCH_CACHE_RESULT_LIMIT
    );
    println!("pkgbuild_byte_limit: {}", aur::MAX_PKG_BUILD_BYTES);
    println!("resolver_node_limit: {}", resolver::MAX_RESOLVE_NODES);
    println!("local_desc_byte_limit: {}", localdb::MAX_DESC_BYTES);
    println!("spawned_tokio_tasks: 0");
    println!("channels: 0");
    println!("state_root: {}", transaction::state_root_path().display());
    println!(
        "transaction_dir: {}",
        transaction::transactions_dir_path().display()
    );
    println!(
        "transaction_current: {}",
        transaction::current_path().display()
    );
    println!("transaction_lock: {}", transaction::lock_path().display());
    println!("output_sanitization: ansi-and-control-stripped");
    println!("progress_loader: aisling:tqdm");
    match current_rss_kib() {
        Some(rss) => println!("rss_kib: {rss}"),
        None => println!("rss_kib: unavailable"),
    }
}

fn current_rss_kib() -> Option<u64> {
    let status = std::fs::read_to_string("/proc/self/status").ok()?;
    status.lines().find_map(|line| {
        line.strip_prefix("VmRSS:")?
            .split_whitespace()
            .next()?
            .parse()
            .ok()
    })
}