bmux_cli 0.0.1-alpha.1

Command-line interface for bmux terminal multiplexer
use anyhow::{Context, Result};
use bmux_config::ConfigPaths;
use std::io::{self, Read, Seek, Write};
use std::time::Duration;
use time::{Duration as TimeDuration, OffsetDateTime, format_description::well_known::Rfc3339};
use tracing::Level;

use super::{EFFECTIVE_LOG_LEVEL, active_log_file_path};

pub(super) fn run_logs_path(as_json: bool) -> Result<u8> {
    let path = active_log_file_path();
    if as_json {
        println!(
            "{}",
            serde_json::to_string_pretty(&serde_json::json!({ "path": path }))
                .context("failed to encode log path json")?
        );
        return Ok(0);
    }
    println!("{}", path.display());
    Ok(0)
}

pub(super) fn run_logs_level(as_json: bool) -> Result<u8> {
    let level = EFFECTIVE_LOG_LEVEL.get().copied().unwrap_or(Level::INFO);
    let value = match level {
        Level::ERROR => "error",
        Level::WARN => "warn",
        Level::INFO => "info",
        Level::DEBUG => "debug",
        Level::TRACE => "trace",
    };
    if as_json {
        println!(
            "{}",
            serde_json::to_string_pretty(&serde_json::json!({ "level": value }))
                .context("failed to encode log level json")?
        );
        return Ok(0);
    }
    println!("{value}");
    Ok(0)
}

pub(super) fn run_logs_tail(lines: usize, since: Option<&str>, follow: bool) -> Result<u8> {
    let path = active_log_file_path();
    if !path.exists() {
        println!(
            "no log file in {} (expected prefix: bmux.log)",
            ConfigPaths::default().logs_dir().display()
        );
        return Ok(0);
    }

    let since_cutoff = match since {
        Some(value) => Some(parse_since_cutoff(value)?),
        None => None,
    };

    let content = std::fs::read_to_string(&path)
        .with_context(|| format!("failed reading log file {}", path.display()))?;
    let all_lines = content
        .lines()
        .filter(|line| line_matches_since(line, since_cutoff))
        .collect::<Vec<_>>();
    let start = all_lines.len().saturating_sub(lines.max(1));
    for line in &all_lines[start..] {
        println!("{line}");
    }

    if !follow {
        return Ok(0);
    }

    let mut file = std::fs::OpenOptions::new()
        .read(true)
        .open(&path)
        .with_context(|| format!("failed opening log file {}", path.display()))?;
    let mut read_offset = file
        .metadata()
        .with_context(|| format!("failed reading metadata for {}", path.display()))?
        .len();

    loop {
        let metadata = file
            .metadata()
            .with_context(|| format!("failed reading metadata for {}", path.display()))?;
        let file_len = metadata.len();
        if file_len < read_offset {
            read_offset = 0;
        }
        if file_len > read_offset {
            file.seek(std::io::SeekFrom::Start(read_offset))
                .with_context(|| format!("failed seeking {}", path.display()))?;
            let mut chunk = String::new();
            file.read_to_string(&mut chunk)
                .with_context(|| format!("failed reading appended logs from {}", path.display()))?;
            if !chunk.is_empty() {
                print!("{chunk}");
                io::stdout().flush().context("failed flushing log output")?;
            }
            read_offset = file_len;
        }
        std::thread::sleep(Duration::from_millis(250));
    }
}

pub(super) fn parse_since_cutoff(raw: &str) -> Result<OffsetDateTime> {
    let duration = parse_since_duration(raw)?;
    let now = OffsetDateTime::now_utc();
    Ok(now - duration)
}

pub(super) fn parse_since_duration(raw: &str) -> Result<TimeDuration> {
    let trimmed = raw.trim();
    if trimmed.is_empty() {
        anyhow::bail!("--since must be a non-empty duration like 30s, 10m, 2h, or 1d");
    }

    let split_at = trimmed
        .find(|char: char| !char.is_ascii_digit())
        .unwrap_or(trimmed.len());
    let (value_part, unit_part) = trimmed.split_at(split_at);
    if value_part.is_empty() {
        anyhow::bail!("--since must start with a number");
    }

    let amount = value_part
        .parse::<i64>()
        .with_context(|| format!("invalid --since value '{raw}'"))?;
    if amount < 0 {
        anyhow::bail!("--since must be non-negative");
    }

    let duration = match unit_part {
        "" | "s" => TimeDuration::seconds(amount),
        "m" => TimeDuration::minutes(amount),
        "h" => TimeDuration::hours(amount),
        "d" => TimeDuration::days(amount),
        _ => {
            anyhow::bail!(
                "invalid --since unit '{unit_part}' (use s, m, h, d; example: 30s, 10m, 2h, 1d)"
            )
        }
    };
    Ok(duration)
}

pub(super) fn line_matches_since(line: &str, cutoff: Option<OffsetDateTime>) -> bool {
    let Some(cutoff) = cutoff else {
        return true;
    };
    let Some(timestamp) = line.split_whitespace().next() else {
        return false;
    };
    let Ok(parsed) = OffsetDateTime::parse(timestamp, &Rfc3339) else {
        return false;
    };
    parsed >= cutoff
}
#[cfg(test)]
mod tests {
    use super::*;
    use crate::runtime::logs_watch::{
        LogFilterCaseMode, LogFilterKind, LogFilterRule, compile_filter_regex,
        line_visible_in_watch, logs_watch_filter_rule_to_state, logs_watch_filter_state_to_rule,
        normalize_logs_watch_profile,
    };

    #[test]
    fn parse_since_duration_accepts_supported_units() {
        assert_eq!(
            parse_since_duration("45s").expect("seconds should parse"),
            time::Duration::seconds(45)
        );
        assert_eq!(
            parse_since_duration("10m").expect("minutes should parse"),
            time::Duration::minutes(10)
        );
        assert_eq!(
            parse_since_duration("2h").expect("hours should parse"),
            time::Duration::hours(2)
        );
        assert_eq!(
            parse_since_duration("1d").expect("days should parse"),
            time::Duration::days(1)
        );
        assert_eq!(
            parse_since_duration("30").expect("plain values should default to seconds"),
            time::Duration::seconds(30)
        );
    }

    #[test]
    fn parse_since_duration_rejects_invalid_values() {
        assert!(parse_since_duration("").is_err());
        assert!(parse_since_duration("abc").is_err());
        assert!(parse_since_duration("5w").is_err());
        assert!(parse_since_duration("-1m").is_err());
    }

    #[test]
    fn line_matches_since_uses_rfc3339_prefix() {
        let cutoff = time::OffsetDateTime::parse(
            "2026-03-15T10:00:00Z",
            &time::format_description::well_known::Rfc3339,
        )
        .expect("cutoff should parse");
        assert!(line_matches_since(
            "2026-03-15T10:30:00Z INFO bmux started",
            Some(cutoff)
        ));
        assert!(!line_matches_since(
            "2026-03-15T09:30:00Z INFO bmux started",
            Some(cutoff)
        ));
        assert!(!line_matches_since("INFO missing timestamp", Some(cutoff)));
    }

    #[test]
    fn compile_filter_regex_supports_case_modes() {
        let sensitive = compile_filter_regex("error", LogFilterCaseMode::Sensitive)
            .expect("sensitive regex should compile");
        let insensitive = compile_filter_regex("error", LogFilterCaseMode::Insensitive)
            .expect("insensitive regex should compile");

        assert!(sensitive.is_match("error line"));
        assert!(!sensitive.is_match("ERROR line"));
        assert!(insensitive.is_match("ERROR line"));
    }

    #[test]
    fn line_visible_in_watch_respects_include_and_exclude_rules() {
        let filters = vec![
            LogFilterRule::new(
                LogFilterKind::Include,
                "server".to_string(),
                LogFilterCaseMode::Sensitive,
            ),
            LogFilterRule::new(
                LogFilterKind::Exclude,
                "listening".to_string(),
                LogFilterCaseMode::Sensitive,
            ),
        ];

        assert!(!line_visible_in_watch(
            "INFO bmux server listening",
            &filters,
            None
        ));
        assert!(line_visible_in_watch(
            "INFO bmux server started",
            &filters,
            None
        ));
        assert!(!line_visible_in_watch("INFO unrelated", &filters, None));
    }

    #[test]
    fn line_visible_in_watch_supports_quick_filter() {
        assert!(line_visible_in_watch(
            "INFO subsystem ready",
            &[],
            Some("subsystem")
        ));
        assert!(!line_visible_in_watch(
            "INFO subsystem ready",
            &[],
            Some("error")
        ));
    }

    #[test]
    fn normalize_logs_watch_profile_defaults_and_validates() {
        assert_eq!(
            normalize_logs_watch_profile(None).expect("default profile should resolve"),
            "default"
        );
        assert_eq!(
            normalize_logs_watch_profile(Some("incident_db"))
                .expect("valid profile should resolve"),
            "incident_db"
        );
        assert!(normalize_logs_watch_profile(Some("bad name")).is_err());
        assert!(normalize_logs_watch_profile(Some("")).is_err());
    }

    #[test]
    fn logs_watch_filter_state_roundtrip_preserves_case_and_enabled() {
        let mut rule = LogFilterRule::new(
            LogFilterKind::Exclude,
            "server listening".to_string(),
            LogFilterCaseMode::Insensitive,
        );
        rule.enabled = false;
        let state = logs_watch_filter_rule_to_state(&rule);
        let roundtrip = logs_watch_filter_state_to_rule(state);
        assert!(matches!(roundtrip.kind, LogFilterKind::Exclude));
        assert!(matches!(
            roundtrip.case_mode,
            LogFilterCaseMode::Insensitive
        ));
        assert!(!roundtrip.enabled);
        assert_eq!(roundtrip.pattern, "server listening");
    }
}