monitr 0.1.4

A lightweight macOS activity monitor TUI built with Rust and Ratatui
mod app;
mod error;
mod format;
mod sampler;
mod terminal_backend;
mod ui;

use std::{
    env,
    io::{self, Stdout},
    time::Duration,
};

use crossterm::{
    event::{DisableMouseCapture, EnableMouseCapture},
    execute,
    terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use ratatui_core::terminal::Terminal;

use crate::app::App;
use crate::error::Result;
use crate::terminal_backend::CrosstermBackend;

const DEFAULT_INTERVAL_MS: u64 = 1_000;
const MIN_INTERVAL_MS: u64 = 250;
const MAX_INTERVAL_MS: u64 = 10_000;

#[derive(Debug, PartialEq, Eq)]
struct Args {
    interval: u64,
    filter: Option<String>,
}

fn main() -> Result<()> {
    let Some(args) = parse_args()? else {
        return Ok(());
    };
    let interval = Duration::from_millis(args.interval);

    let mut session = TerminalSession::enter()?;
    let result = match App::new(interval, args.filter) {
        Ok(mut app) => app.run(session.terminal_mut()),
        Err(error) => Err(error),
    };
    let restore_result = session.restore();
    restore_result?;
    result
}

fn parse_args() -> Result<Option<Args>> {
    parse_args_from(env::args().skip(1))
}

fn parse_args_from<I, S>(args: I) -> Result<Option<Args>>
where
    I: IntoIterator<Item = S>,
    S: Into<String>,
{
    let mut args = args.into_iter().map(Into::into);
    let mut interval = DEFAULT_INTERVAL_MS;
    let mut filter = None;

    while let Some(arg) = args.next() {
        match arg.as_str() {
            "-h" | "--help" => {
                print_help();
                return Ok(None);
            }
            "-V" | "--version" => {
                println!("monitr {}", env!("CARGO_PKG_VERSION"));
                return Ok(None);
            }
            "-i" | "--interval" => {
                let Some(value) = args.next() else {
                    return Err(error::message(format!(
                        "{arg} requires a millisecond value"
                    )));
                };
                interval = parse_interval(&value)?;
            }
            "-f" | "--filter" => {
                let Some(value) = args.next() else {
                    return Err(error::message(format!("{arg} requires a filter value")));
                };
                filter = Some(value);
            }
            _ if arg.starts_with("--interval=") => {
                interval = parse_interval(&arg["--interval=".len()..])?;
            }
            _ if arg.starts_with("--filter=") => {
                filter = Some(arg["--filter=".len()..].to_string());
            }
            _ => {
                return Err(error::message(format!(
                    "unknown option: {arg}. Run monitr --help for usage."
                )));
            }
        }
    }

    Ok(Some(Args { interval, filter }))
}

fn parse_interval(value: &str) -> Result<u64> {
    let interval = value.parse().map_err(|_| {
        error::message(format!("invalid interval `{value}`; expected milliseconds"))
    })?;
    if !(MIN_INTERVAL_MS..=MAX_INTERVAL_MS).contains(&interval) {
        return Err(error::message(format!(
            "invalid interval `{value}`; expected {MIN_INTERVAL_MS}-{MAX_INTERVAL_MS} milliseconds"
        )));
    }
    Ok(interval)
}

fn print_help() {
    println!(
        "\
A lightweight macOS activity monitor TUI

Usage: monitr [OPTIONS]

Options:
  -i, --interval <MS>    Refresh interval in milliseconds ({MIN_INTERVAL_MS}-{MAX_INTERVAL_MS}) [default: {DEFAULT_INTERVAL_MS}]
  -f, --filter <FILTER>  Start with a process filter
  -h, --help             Print help
  -V, --version          Print version"
    );
}

type CrosstermTerminal = Terminal<CrosstermBackend<Stdout>>;

struct TerminalSession {
    terminal: CrosstermTerminal,
    active: bool,
}

impl TerminalSession {
    fn enter() -> Result<Self> {
        let terminal = enter_terminal()?;
        Ok(Self {
            terminal,
            active: true,
        })
    }

    fn terminal_mut(&mut self) -> &mut CrosstermTerminal {
        &mut self.terminal
    }

    fn restore(&mut self) -> Result<()> {
        restore_terminal(&mut self.terminal)?;
        self.active = false;
        Ok(())
    }
}

impl Drop for TerminalSession {
    fn drop(&mut self) {
        if self.active {
            let _ = restore_terminal(&mut self.terminal);
        }
    }
}

fn enter_terminal() -> Result<CrosstermTerminal> {
    enable_raw_mode()?;
    if let Err(error) = execute!(io::stdout(), EnterAlternateScreen, EnableMouseCapture) {
        let _ = disable_raw_mode();
        return Err(error.into());
    }
    let backend = CrosstermBackend::new(io::stdout());
    let mut terminal = match Terminal::new(backend) {
        Ok(terminal) => terminal,
        Err(error) => {
            let _ = disable_raw_mode();
            let _ = execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture);
            return Err(error.into());
        }
    };
    if let Err(error) = terminal.clear() {
        let _ = restore_terminal(&mut terminal);
        return Err(error.into());
    }
    Ok(terminal)
}

fn restore_terminal(terminal: &mut CrosstermTerminal) -> Result<()> {
    let raw_result = disable_raw_mode();
    let screen_result = execute!(
        terminal.backend_mut(),
        LeaveAlternateScreen,
        DisableMouseCapture
    );
    let cursor_result = terminal.show_cursor();

    raw_result?;
    screen_result?;
    cursor_result?;

    Ok(())
}

#[cfg(test)]
mod tests {
    use super::{Args, parse_args_from, parse_interval};

    #[test]
    fn parses_default_args() {
        assert_eq!(
            parse_args_from(Vec::<String>::new()).unwrap(),
            Some(Args {
                interval: 1000,
                filter: None,
            })
        );
    }

    #[test]
    fn parses_interval_and_filter_args() {
        assert_eq!(
            parse_args_from(["--interval=750", "--filter", "codex"]).unwrap(),
            Some(Args {
                interval: 750,
                filter: Some("codex".to_string()),
            })
        );
    }

    #[test]
    fn rejects_invalid_interval() {
        let error = parse_interval("fast").unwrap_err().to_string();
        assert!(error.contains("invalid interval"));
    }

    #[test]
    fn rejects_out_of_range_interval() {
        let error = parse_interval("100").unwrap_err().to_string();
        assert!(error.contains("250-10000"));
    }
}