cnf 0.6.0

Distribution-agnostic 'command not found'-handler
Documentation
// SPDX-License-Identifier: GPL-3.0-or-later
// SPDX-FileCopyrightText: (C) 2023 Andreas Hartmann <hartan@7x.de>
// This file is part of cnf, available at <https://gitlab.com/hartang/rust/cnf>

//! # cnf - A distribution-agnostic "command not found"-handler
//!
//! This is the binary interface to the [`cnf_lib`] crate, meant for direct user interaction. If
//! you're here, you'll probably want to know more about one of the following things:
//!
//! - [Application configuration (config file)][config]
//! - [Supported environment variables][env]
//! - [Command aliases][alias]
//!
//! For additional information (like usage, installation, ...), please refer to [the project
//! repository website][0].
//!
//! [0]: https://gitlab.com/hartang/rust/cnf
pub mod alias;
mod cli;
pub mod config;
mod directories;
pub mod env;
mod trace;
pub mod ui;

use anyhow::{Context, Result};
use cnf_lib::{
    prelude::*,
    provider::{apt, cargo, cwd, dnf, flatpak, pacman, path},
};
use logerr::LoggableError;
use std::{str::FromStr, sync::Arc};
use tracing::{debug, info};
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;

pub use cli::Args;
pub use env::Env;

/// Take a provider base type, wrap it into a [`cnf_lib::Provider`] enum and wrap that into an
/// [`Arc`].
macro_rules! arc_provider {
    ($provider:ty) => {
        Arc::new(<$provider>::new().into())
    };
}

/// Application entrypoint.
#[doc(hidden)]
pub async fn main(args: cli::Args) -> Result<()> {
    // Load application config
    config::load();

    // Handle shell hooks
    if let Some(hook) = args.hooks {
        cli::install_hook(hook);
        return Ok(());
    }

    // holds guards which ensure that all captured trace data is flushed on program exit
    let mut guards: Vec<Box<dyn trace::Guard>> = vec![];
    // the registry holds all the layers we want to add for tracing purposes
    let registry = tracing_subscriber::registry();
    // logfile
    let log_layer = trace::logfile().context("failed to enable application logging")?;
    let registry = registry.with(log_layer.layer);
    guards.push(log_layer.guard);
    // flame graphs
    #[cfg(feature = "debug-flame")]
    let registry = {
        let flame_file = trace::flame_file()?;
        let (writer, guard) = tracing_appender::non_blocking(flame_file);
        guards.push(Box::new(guard));
        registry.with(
            tracing_flame::FlameLayer::new(writer)
                .with_empty_samples(true)
                .with_threads_collapsed(true)
                .with_file_and_line(true),
        )
    };
    // register all tracing components
    registry.init();

    info!("Launching application");
    let cur_env = Arc::new(cnf_lib::environment::current());
    info!("Running in environment '{}'", cur_env);

    // Check and limit recursion depth
    let recursion_depth = if let Some(recursion_depth) = Env::RecursionDepth.get::<usize>() {
        debug!("cnf execution at recursion depth {}", recursion_depth);
        let max_recursion = config::get().max_recursion_depth;
        if recursion_depth >= max_recursion {
            anyhow::bail!(
                "current recursion depth {} exceeds configured maximum recursion depth {}",
                recursion_depth,
                max_recursion
            );
        }

        recursion_depth + 1
    } else {
        0
    };
    // Unsafe behavior is only present in multi-threaded code, but here we haven't forked any
    // subprocess yet.
    #[allow(unsafe_code)]
    unsafe {
        Env::RecursionDepth.set(recursion_depth + 1);
    }

    // The base command being searched for
    let Some(command) = args.command.first() else {
        anyhow::bail!("no command to find provided, please check CLI usage");
    };

    // Execute command aliases
    let alias = args.alias_target_env.and_then(|target_env| {
        let mut cmd = CommandLine::new(&args.command);
        cmd.needs_privileges(args.alias_privileged);
        cmd.is_interactive(args.alias_interactive);
        let alias = alias::Alias {
            source_env: "".to_string(),
            target_env,
            command: command.clone(),
            alias: cmd,
        };

        if args
            .alias_source_env
            .is_some_and(|env| env != cur_env.to_json())
        {
            // source environment doesn't match, skip it
            None
        } else {
            Some(alias)
        }
    });

    if let Some(alias) = alias {
        info!(command, "executing command alias");
        let target_env = Environment::from_str(&alias.target_env)
            .with_context(|| format!("failed to resolve alias '{:?}'", alias))?;
        // the environment may not be started yet, so we enforce this here
        target_env.start()?;

        let mut cmd = alias.alias.clone();
        if Env::AliasPrivileged.is_set() {
            debug!("privileged alias execution requested by env variable");
            cmd.needs_privileges(true);
        }

        let status = target_env
            .execute(cmd)
            .await
            .with_context(|| format!("failed to prepare alias execution in env '{}'", target_env))?
            .status()
            .await
            .map_err(anyhow::Error::new)?;
        match status.code() {
            Some(code) if code != 0 => std::process::exit(code),
            None => std::process::exit(255),
            _ => return Ok(()),
        }
    }

    // There's no point in spinning up a full-blown TUI when run non-interactively.
    if !std::io::IsTerminal::is_terminal(&std::io::stdout()) {
        anyhow::bail!("command not found: {}", command);
    }

    // No alias, fire up the regular application flow
    let (tx, rx) = tokio::sync::mpsc::unbounded_channel();

    // Collect environments
    let mut envs = vec![
        cur_env,
        Arc::new(cnf_lib::Environment::Host(cnf_lib::env::host::Host::new())),
    ];

    // Add toolboxes from config
    let mut toolbx_names = config::get()
        .toolbx_names
        .clone()
        .into_iter()
        .map(|n| if n.is_empty() { None } else { Some(n) })
        .collect::<Vec<_>>();
    if toolbx_names.is_empty() {
        toolbx_names.push(None);
    }
    for toolbx_name in toolbx_names {
        if let Ok(toolbx) = cnf_lib::env::toolbx::Toolbx::new(toolbx_name)
            .context("cannot search in 'toolbx' environment")
            .to_log()
            && toolbx.exists().await
        {
            envs.push(Arc::new(toolbx.into()));
        }
    }

    // Add distroboxes from config
    let mut distrobox_names = config::get()
        .distrobox_names
        .clone()
        .into_iter()
        .map(|n| if n.is_empty() { None } else { Some(n) })
        .collect::<Vec<_>>();
    if distrobox_names.is_empty() {
        distrobox_names.push(None)
    }
    for distrobox_name in distrobox_names {
        if let Ok(distrobox) = cnf_lib::env::distrobox::Distrobox::new(distrobox_name)
            .context("cannot search in 'distrobox' environment")
            .to_log()
            && distrobox.exists().await
        {
            envs.push(Arc::new(distrobox.into()));
        }
    }

    // Deduplicate environments
    envs.sort();
    envs.dedup();

    // Check for invalid envs in config
    for origin in &config::get().query_origins {
        drop(origin.check_env_exists(&envs).to_log());
    }

    // Collect providers
    let mut providers: Vec<Arc<Provider>> = vec![
        arc_provider!(path::Path),
        arc_provider!(dnf::Dnf),
        arc_provider!(cargo::Cargo),
        arc_provider!(pacman::Pacman),
        arc_provider!(apt::Apt),
        arc_provider!(flatpak::Flatpak),
    ];
    if let Ok(val) = cwd::Cwd::new()
        .context("cannot search in provider 'cwd'")
        .to_log()
    {
        providers.push(Arc::new(val.into()));
    }
    // Custom providers
    config::get()
        .custom_providers
        .iter()
        .for_each(|provider| providers.push(Arc::new(Provider::from(provider.clone()))));

    // Check for invalid providers in config
    for origin in &config::get().query_origins {
        drop(origin.check_providers_exist(&providers).to_log());
    }

    for env in envs {
        // Configuration entries for this environment
        let env_config = config::get()
            .query_origins
            .iter()
            .find(|origin| origin.environment == env.to_string());

        let empty: Vec<String> = vec![];
        let disabled_providers = env_config
            .map(|config| &config.disabled_providers)
            .unwrap_or(&empty);
        let enabled_providers = env_config
            .map(|config| &config.enabled_providers)
            .unwrap_or(&empty);

        // By default (no config), enable all environments
        if env_config.map(|config| config.enabled).unwrap_or(true) {
            for prov in &providers {
                let prov_name = prov.to_string();
                if disabled_providers.is_empty() && enabled_providers.is_empty() {
                    // By default (no config), enable all providers
                } else if disabled_providers.contains(&prov_name) {
                    debug!(
                        "provider '{}' for env '{}' set to inactive in config",
                        prov.to_string(),
                        env.to_string()
                    );
                    continue;
                } else if disabled_providers.is_empty() && !enabled_providers.contains(&prov_name) {
                    debug!(
                        "provider '{}' for env '{}' not set to active in config",
                        prov.to_string(),
                        env.to_string()
                    );
                    continue;
                }

                let cloned_env = env.clone();
                let env_name = env.to_string();
                let cloned_sender = tx.clone();
                let cloned_provider = prov.clone();
                let provider_name = prov.to_string();
                let cloned_cmd = command.clone();
                tokio::task::spawn(async move {
                    let result = search_in(cloned_provider, &cloned_cmd, cloned_env).await;
                    drop(
                        cloned_sender
                            .send(result)
                            .with_context(|| {
                                format!(
                                    "failed to report results from '{}' in '{}'",
                                    &provider_name, &env_name
                                )
                            })
                            .to_log(),
                    );
                });
            }
        } else {
            debug!("env '{}' is ignored according to config", env.to_string());
        }
    }

    // Manually force the drop so the channel is closed when the last async task exits.
    drop(tx);

    ui::tui(rx, &args.command).await
}