statusline 0.23.0

Simple and fast bash PS1 line with useful features
#![warn(
    clippy::cargo,
    clippy::as_underscore,
    clippy::clone_on_ref_ptr,
    clippy::default_numeric_fallback,
    clippy::format_push_string,
    clippy::host_endian_bytes,
    clippy::if_then_some_else_none,
    clippy::let_underscore_must_use,
    clippy::map_err_ignore,
    clippy::missing_assert_message,
    clippy::multiple_unsafe_ops_per_block,
    clippy::renamed_function_params,
    clippy::shadow_same,
    clippy::shadow_unrelated,
    clippy::string_lit_chars_any,
    clippy::undocumented_unsafe_blocks,
    clippy::unnecessary_safety_comment,
    clippy::unnecessary_safety_doc,
    clippy::unseparated_literal_suffix,
    clippy::unused_result_ok,
    clippy::unused_trait_names,
    clippy::verbose_file_reads
)]
#![allow(clippy::multiple_crate_versions)]

mod block;
mod chassis;
mod ext;
mod file;
mod icon;
mod style;
mod virt;
mod workgroup;

use crate::{
    block::{Block, create_blocks},
    chassis::Chassis,
    icon::{Icon, IconMode, Pretty},
    style::{Color, Style, WithStyle, horizontal_absolute},
};
use argh::FromArgs;
use pwd::Passwd;
use rustix::{
    fd::{FromRawFd as _, OwnedFd},
    fs::{Mode, OFlags},
};
use shell_quote::QuoteInto as _;
use std::{
    fmt::Write as _,
    io::Write as _,
    os::{fd::AsRawFd as _, unix::ffi::OsStrExt as _},
    path::PathBuf,
    time::Duration,
};
use unicode_width::UnicodeWidthStr as _;

fn readline_width(s: &str) -> usize {
    let mut res = s.width();
    for (i, c) in s.bytes().enumerate() {
        match c {
            b'\x01' => res += i,
            b'\x02' => res -= i + 1,
            _ => {}
        }
    }
    res
}

/// statusline
#[derive(FromArgs)]
struct Arguments {
    /// action
    #[argh(subcommand)]
    command: Option<Command>,
}

#[derive(FromArgs)]
#[argh(subcommand)]
enum Command {
    Colorize(Colorize),
    WorkgroupCreate(WorkgroupCreate),
    Chain(Chain),
    Run(Run),
    Env(Env),
}

/// print bash commands
#[derive(FromArgs)]
#[argh(subcommand, name = "env")]
struct Env {}

/// append this host to chain
#[derive(FromArgs)]
#[argh(subcommand, name = "chain")]
struct Chain {}

/// create for this host
#[derive(FromArgs)]
#[argh(subcommand, name = "create")]
struct WorkgroupCreate {}

/// colorize as username
#[derive(FromArgs)]
#[argh(subcommand, name = "colorize")]
struct Colorize {
    /// what to colorize
    #[argh(option)]
    what: String,
}

/// main statusline
#[derive(FromArgs)]
#[argh(subcommand, name = "run")]
struct Run {
    /// return code to show
    #[argh(option)]
    return_code: Option<u8>,

    /// current background jobs count
    #[argh(option)]
    jobs_count: Option<usize>,

    /// elapsed time to show, in seconds
    #[argh(option)]
    elapsed_time: Option<u64>,

    /// control fd for terminating
    #[argh(option)]
    control_fd: Option<i32>,

    /// icon mode. `text` and `minimal` have special meaning
    #[argh(option)]
    mode: Option<String>,

    /// comma-separated list of left blocks
    #[argh(option)]
    left: Option<String>,

    /// comma-separated list of middle blocks
    #[argh(option)]
    middle: Option<String>,

    /// comma-separated list of right blocks
    #[argh(option)]
    right: Option<String>,

    /// comma-separated list of bottom blocks. these blocks are static
    #[argh(option)]
    bottom: Option<String>,
}

pub struct Environment {
    pub ret_code: Option<u8>,
    pub jobs_count: Option<usize>,
    pub elapsed_time: Option<Duration>,
    pub work_dir: PathBuf,
    pub git_tree: Option<PathBuf>,
    pub user: String,
    pub host: String,
    pub current_home: Option<(PathBuf, String)>,
    pub left: Vec<String>,
    pub middle: Vec<String>,
    pub right: Vec<String>,
    pub bottom: Vec<String>,
}

fn parse_block_names(s: Option<String>) -> Vec<String> {
    s.unwrap_or_default()
        .split(',')
        .filter(|&s| !s.is_empty())
        .map(|s| s.into())
        .collect()
}

impl From<Run> for Environment {
    fn from(other: Run) -> Environment {
        let ret_code = other.return_code;
        let jobs_count = other.jobs_count;
        let elapsed_time = other.elapsed_time.map(Duration::from_micros);

        let work_dir = std::env::current_dir()
            .unwrap_or_else(|_| PathBuf::from(std::env::var("PWD").unwrap()));

        let git_tree = file::upfind(&work_dir, ".git").map(|dg| dg.parent().unwrap().to_path_buf());

        // XXX: This will correctly work on Android (Termux) after libc 0.2 release after 2026-06-24
        let user = Passwd::current_user()
            .map(|entry| entry.name)
            .or_else(|| std::env::var("USER").ok())
            .unwrap_or_else(|| format!("<user{}>", rustix::process::getuid().as_raw()));
        let host = rustix::system::uname()
            .nodename()
            .to_string_lossy()
            .into_owned();

        let current_home = file::find_current_home(&work_dir, &user);

        Environment {
            ret_code,
            jobs_count,
            elapsed_time,
            work_dir,
            git_tree,
            user,
            host,
            current_home,
            left: parse_block_names(other.left),
            middle: parse_block_names(other.middle),
            right: parse_block_names(other.right),
            bottom: parse_block_names(other.bottom),
        }
    }
}

fn bash_quote(data: &[u8]) -> String {
    let mut out = String::new();
    shell_quote::Bash::quote_into(data, &mut out);
    out
}

fn main() {
    let exec = std::fs::read_link("/proc/self/exe").map_or_else(
        |_| "statusline".into(),
        |pb| bash_quote(pb.into_os_string().as_bytes()),
    );

    let args: Arguments = argh::from_env();

    let Some(command) = args.command else {
        let ver = env!("CARGO_PKG_VERSION");
        let apply_me = format!("source <({exec} env)");
        let apply_me_quot = bash_quote(apply_me.as_bytes());
        println!("[statusline {ver}]: colorful PS1 for Bash");
        println!(">> https://codeberg.org/sylfn/statusline");
        println!("Use `--help` to see advanced usage");
        println!("Simple install:");
        println!("    echo {apply_me_quot} >> ~/.bashrc");
        println!("    source ~/.bashrc");
        println!("Test now:");
        println!("    {apply_me}");
        return;
    };

    match command {
        Command::Colorize(Colorize { what }) => {
            let mut res = String::new();
            res.with_style(Color::of(&what), Style::BOLD, |f| write!(f, "{what}"))
                .unwrap();
            println!("{res}");
        }
        Command::WorkgroupCreate(_) => {
            workgroup::key_create().expect("Could not create workgroup key");
        }
        Command::Env(_) => println!("{}", include_str!("shell.sh").replace("@sl@", &exec)),
        Command::Chain(_) => {
            let Ok(key) = workgroup::key_load() else {
                return;
            };
            let mut ssh_chain = workgroup::chain_open(Some(&key));
            ssh_chain.push(
                rustix::system::uname()
                    .nodename()
                    .to_string_lossy()
                    .into_owned(),
            );
            println!("{}", workgroup::chain_seal(&ssh_chain, &key));
        }
        Command::Run(run) => run_statusline(run),
    }
}

fn run_statusline(run: Run) {
    if let Some(fd) = run.control_fd {
        // SAFETY: This file descriptor is already open
        let fd = unsafe { OwnedFd::from_raw_fd(fd) };
        // SAFETY: This file descriptor is not reused for concurrently running invocations.
        unsafe { libc::fcntl(fd.as_raw_fd(), libc::F_SETOWN, rustix::process::getpid()) };
        rustix::fs::fcntl_setfl(fd, OFlags::ASYNC).unwrap();
    }

    let mode = match run.mode.as_deref() {
        Some("text") => IconMode::Text,
        Some("minimal") => IconMode::MinimalIcons,
        _ => IconMode::Icons,
    };

    let environ: Environment = run.into();

    let mut left = create_blocks(&environ.left, &environ);
    let middle = create_blocks(&environ.middle, &environ);
    let right = create_blocks(&environ.right, &environ);
    let bottom = create_blocks(&environ.bottom, &environ);

    print_statusline(mode, &environ, &mut left, &middle, &right, &bottom);
}

fn print_statusline(
    mode: IconMode,
    environ: &Environment,
    left: &mut [Box<dyn Block>],
    middle: &[Box<dyn Block>],
    right: &[Box<dyn Block>],
    bottom: &[Box<dyn Block>],
) {
    let middle = pretty(middle, mode);
    let right = pretty(right, mode);
    let bottom = pretty(bottom, mode);

    let cont = if let IconMode::Text = mode {
        ">"
    } else {
        "\u{f105}"
    };

    let terminal_width: usize = terminal_size::terminal_size()
        .map_or(80, |(w, _h)| w.0)
        .into();

    let right_length = readline_width(&right);
    let right_formatted = format!(
        "{}{right}",
        // XXX: This may not be the right way to set right prompt...
        horizontal_absolute(terminal_width.saturating_sub(right_length))
    );

    let left_formatted = pretty(left, mode);

    let three_line_mode =
        readline_width(&left_formatted) + readline_width(&middle) + right_length + 16
            >= terminal_width;

    let prologue = crate::style::prologue(three_line_mode);
    let epilogue = crate::style::epilogue();

    let eprint_top_part = |top| {
        if three_line_mode {
            eprint!("{prologue}{top}{right_formatted}\n{cont} {middle}{epilogue}");
        } else {
            eprint!("{prologue}{top} {middle}{right_formatted}{epilogue}");
        }
    };

    let title = make_title(environ);
    eprint!("{title}");

    if three_line_mode {
        eprint!("\n\n\n");
    } else {
        eprint!("\n\n");
    }
    eprint_top_part(left_formatted);

    print!("{bottom} ");
    std::io::stdout().flush().unwrap();
    rustix::stdio::dup2_stdout(rustix::fs::open("/dev/null", OFlags::RDWR, Mode::empty()).unwrap())
        .unwrap();

    for block in &mut *left {
        block.extend();
    }
    eprint_top_part(pretty(left, mode));
}

fn make_title(env: &Environment) -> String {
    let pwd = if let Some((home, user)) = &env.current_home {
        let wd = env.work_dir.strip_prefix(home).unwrap_or(&env.work_dir);
        if wd.as_os_str().is_empty() {
            format_args!("~{}", *user)
        } else {
            format_args!("~{}/{}", *user, wd.display())
        }
    } else {
        format_args!("{}", env.work_dir.display())
    };
    crate::style::title(&format!("{}@{}: {}", env.user, env.host, pwd))
}

fn pretty(line: &[Box<dyn Block>], mode: IconMode) -> String {
    let mut res = String::new();
    for block in line {
        let prev_len = res.len();
        write!(res, "{}", crate::icon::display(block.as_ref(), mode)).unwrap();
        if prev_len != res.len() {
            res.push(' ');
        }
    }
    res.pop();
    res
}