prismtty 0.2.1

Fast terminal output highlighter focused on network devices and Unix systems
Documentation
mod args;
mod profile_selection;
mod pty;
mod runtime;
mod stream;
mod trace;

use std::ffi::OsString;
use std::fs;
use std::io::{self, Write};
use std::process::ExitCode;

use is_terminal::IsTerminal;
use thiserror::Error;

use crate::config::{PrismConfig, load_profile_file};
use crate::highlight::Highlighter;

use args::{Action, Options, parse_args, print_help};
use profile_selection::profile_store;
use pty::run_command;
use runtime::{ReloadWatcher, RuntimeRegistration, request_reload};
use stream::highlight_stream;
use trace::IoTrace;

#[cfg(feature = "completion-generation")]
#[doc(hidden)]
pub use args::completion_command;

#[derive(Debug, Error)]
pub enum CliError {
    #[error("{0}")]
    Usage(String),
    #[error(transparent)]
    Config(#[from] crate::config::ConfigError),
    #[error(transparent)]
    Highlight(#[from] crate::highlight::HighlightError),
    #[error("I/O error: {0}")]
    Io(#[from] io::Error),
    #[error("PTY error: {0}")]
    Pty(#[from] anyhow::Error),
    #[error("terminal mode error: {0}")]
    Terminal(#[from] nix::errno::Errno),
}

pub fn run() -> ExitCode {
    match run_inner(std::env::args_os().skip(1).collect()) {
        Ok(code) => code,
        Err(error) => {
            let _ = writeln!(io::stderr(), "prismtty: {error}");
            ExitCode::from(1)
        }
    }
}

fn run_inner(args: Vec<OsString>) -> Result<ExitCode, CliError> {
    let (options, action) = parse_args(args)?;
    match action {
        Action::Help => {
            print_help();
            Ok(ExitCode::SUCCESS)
        }
        Action::Version => {
            println!("prismtty {}", env!("CARGO_PKG_VERSION"));
            Ok(ExitCode::SUCCESS)
        }
        Action::Reload => {
            let count = request_reload()?;
            println!("Processes reloaded: {count}");
            Ok(ExitCode::SUCCESS)
        }
        Action::ProfilesList => {
            let store = profile_store()?;
            for name in store.names() {
                println!("{name}");
            }
            Ok(ExitCode::SUCCESS)
        }
        Action::ProfilesShow(profile_name) => {
            let store = profile_store()?;
            let profile = store
                .profile(&profile_name)
                .ok_or_else(|| CliError::Usage(format!("unknown profile '{profile_name}'")))?;
            println!("profile: {}", profile.name);
            if profile.inherits.is_empty() {
                println!("inherits: none");
            } else {
                println!("inherits: {}", profile.inherits.join(", "));
            }
            if profile.detection.is_empty() {
                println!("detection: none");
            } else {
                println!("detection: {}", profile.detection.join(", "));
            }
            println!("rules:");
            for rule in &profile.rules {
                println!("  - {}", rule.description);
            }
            Ok(ExitCode::SUCCESS)
        }
        Action::ProfilesValidate(path) => {
            let loaded = load_profile_file(&path)?;
            let mut store = profile_store()?;
            store.insert_profile(
                loaded.meta.name.clone(),
                loaded.meta.inherits.clone(),
                loaded.meta.detection.clone(),
                loaded.rules.clone(),
            );
            let config = PrismConfig {
                rules: PrismConfig::from_profiles(&store, &[loaded.meta.name.as_str()])?.rules,
                enabled_profiles: vec![loaded.meta.name.clone()],
            };
            let _ = Highlighter::from_config(config)?;
            println!("profile {} valid", loaded.meta.name);
            Ok(ExitCode::SUCCESS)
        }
        Action::ProfilesTest { profile, fixture } => {
            let input = fs::read(&fixture)?;
            let store = profile_store()?;
            let config = PrismConfig::from_profiles(&store, &[profile.as_str()])?;
            let highlighter = Highlighter::from_config(config)?;
            io::stdout().write_all(&highlighter.highlight_bytes(&input))?;
            Ok(ExitCode::SUCCESS)
        }
        Action::Stdin => run_stdin(options),
        Action::Run(command) if command.is_empty() => run_stdin(options),
        Action::Run(command) => run_command(options, command),
    }
}

fn run_stdin(options: Options) -> Result<ExitCode, CliError> {
    let _registration = RuntimeRegistration::register()?;
    let reload_watcher = Some(ReloadWatcher::new());
    let trace = IoTrace::open(options.trace_io.as_deref())?;
    let stdin = io::stdin();
    let mut stdout = io::stdout();
    let interactive = stdin_mode_interactive_highlighting(stdout.is_terminal());
    highlight_stream(
        stdin.lock(),
        &mut stdout,
        &options,
        interactive,
        reload_watcher,
        trace,
        None,
    )?;
    Ok(ExitCode::SUCCESS)
}

fn stdin_mode_interactive_highlighting(stdout_is_terminal: bool) -> bool {
    stdout_is_terminal
}

#[cfg(test)]
mod tests {
    #[test]
    fn stdin_mode_uses_interactive_highlighting_when_output_is_terminal() {
        assert!(super::stdin_mode_interactive_highlighting(true));
        assert!(!super::stdin_mode_interactive_highlighting(false));
    }

    #[test]
    fn pty_module_only_imports_highlight_stream_from_stream_module() {
        let source = include_str!("cli/pty.rs");

        assert!(
            !source.contains("run_stdin"),
            "stdin orchestration should stay outside pty.rs"
        );
    }
}