twrap 0.1.0

A TUI wrapper for applying rules to live terminal screens.
use crate::model::{
    BindingAction, Cli, HighlightRule, KeyBinding, KeyChord, OutputTransformKind,
    OutputTransformRule, Rgb, Runtime, ScreenshotKey,
};
use anyhow::{Context, Result};
use regex::Regex;
use std::{fs, path::Path};

pub(crate) fn build_runtime(cli: Cli) -> Result<Runtime> {
    let highlight_color = parse_rgb(&cli.highlight_color).context("invalid --highlight-color")?;
    let highlight_rules = cli
        .highlight
        .iter()
        .map(|pattern| {
            Ok(HighlightRule {
                key: pattern.clone(),
                pattern: pattern.clone(),
                regex: Regex::new(pattern)
                    .with_context(|| format!("invalid highlight regex: {pattern}"))?,
                color: highlight_color,
                command: parse_highlight_command(cli.highlight_command.as_deref())
                    .context("invalid --highlight-command")?,
                capture_tui_screenshot: cli.highlight_capture_tui_screenshot,
            })
        })
        .collect::<Result<Vec<_>>>()?;

    if !cli.screenshot_dir.exists() {
        fs::create_dir_all(&cli.screenshot_dir).with_context(|| {
            format!(
                "failed to create screenshot dir: {}",
                cli.screenshot_dir.display()
            )
        })?;
    } else if !Path::new(&cli.screenshot_dir).is_dir() {
        anyhow::bail!(
            "--screenshot-dir is not a directory: {}",
            cli.screenshot_dir.display()
        );
    }

    let key_bindings =
        compile_key_bindings(&cli.bind, cli.screenshot_key).context("failed to compile --bind")?;
    let output_transforms = compile_output_transforms(&cli.replace, &cli.mask, &cli.mask_char)
        .context("failed to compile replace/mask rules")?;

    Ok(Runtime {
        command: cli.command,
        startup_capture_ms: cli.startup_capture_ms,
        screenshot_dir: cli.screenshot_dir,
        screenshot_prefix: cli.screenshot_prefix,
        highlight_rules,
        key_bindings,
        output_transforms,
    })
}

fn parse_highlight_command(value: Option<&str>) -> Result<Option<Vec<String>>> {
    let Some(value) = value else {
        return Ok(None);
    };
    let parts = shell_words::split(value)?;
    if parts.is_empty() {
        anyhow::bail!("command must not be empty");
    }
    Ok(Some(parts))
}

fn parse_rgb(value: &str) -> Result<Rgb> {
    let hex = value.trim().trim_start_matches('#');
    if hex.len() != 6 {
        anyhow::bail!("expected 6-digit hex color");
    }

    let r = u8::from_str_radix(&hex[0..2], 16)?;
    let g = u8::from_str_radix(&hex[2..4], 16)?;
    let b = u8::from_str_radix(&hex[4..6], 16)?;
    Ok(Rgb(r, g, b))
}

fn compile_key_bindings(
    bind_specs: &[String],
    screenshot_key: ScreenshotKey,
) -> Result<Vec<KeyBinding>> {
    let mut bindings = Vec::new();
    for spec in bind_specs {
        let (left, right) = spec
            .split_once('=')
            .with_context(|| format!("binding must be FROM=TO: {spec}"))?;
        let trigger = parse_key_chord(left.trim())?;
        let action = parse_binding_action(right.trim())?;
        bindings.push(KeyBinding { trigger, action });
    }

    let default_trigger = screenshot_key_to_chord(screenshot_key);
    let has_override = bindings
        .iter()
        .any(|binding| binding.trigger.bytes == default_trigger.bytes);
    if !has_override {
        bindings.push(KeyBinding {
            trigger: default_trigger,
            action: BindingAction::Screenshot,
        });
    }

    Ok(bindings)
}

fn compile_output_transforms(
    replace_specs: &[String],
    mask_specs: &[String],
    mask_char: &str,
) -> Result<Vec<OutputTransformRule>> {
    let mut rules = Vec::new();
    let mask_char = parse_mask_char(mask_char)?;

    if !replace_specs.len().is_multiple_of(2) {
        anyhow::bail!("--replace expects PATTERN TEXT pairs");
    }

    for pair in replace_specs.chunks_exact(2) {
        let pattern = pair[0].clone();
        let replacement = pair[1].clone();
        rules.push(OutputTransformRule {
            regex: Regex::new(&pattern)
                .with_context(|| format!("invalid replace regex: {pattern}"))?,
            kind: OutputTransformKind::Replace(replacement),
        });
    }

    for pattern in mask_specs {
        rules.push(OutputTransformRule {
            regex: Regex::new(pattern).with_context(|| format!("invalid mask regex: {pattern}"))?,
            kind: OutputTransformKind::Mask(mask_char),
        });
    }

    Ok(rules)
}

fn parse_binding_action(value: &str) -> Result<BindingAction> {
    if value.eq_ignore_ascii_case("screenshot") {
        return Ok(BindingAction::Screenshot);
    }

    if let Some(text) = value.strip_prefix("text:") {
        return Ok(BindingAction::Send(text.as_bytes().to_vec()));
    }

    let key_list = value.strip_prefix("send:").unwrap_or(value);
    let mut bytes = Vec::new();
    for item in key_list.split(',') {
        let chord = parse_key_chord(item.trim())?;
        bytes.extend_from_slice(&chord.bytes);
    }
    Ok(BindingAction::Send(bytes))
}

fn screenshot_key_to_chord(key: ScreenshotKey) -> KeyChord {
    match key {
        ScreenshotKey::CtrlG => KeyChord {
            label: "ctrl-g".to_string(),
            bytes: vec![0x07],
        },
        ScreenshotKey::CtrlT => KeyChord {
            label: "ctrl-t".to_string(),
            bytes: vec![0x14],
        },
        ScreenshotKey::CtrlBackslash => KeyChord {
            label: "ctrl-\\".to_string(),
            bytes: vec![0x1c],
        },
    }
}

fn parse_key_chord(value: &str) -> Result<KeyChord> {
    let normalized = value.trim().to_ascii_lowercase();
    if normalized.is_empty() {
        anyhow::bail!("key binding token must not be empty");
    }

    let bytes = match normalized.as_str() {
        "up" => b"\x1b[A".to_vec(),
        "down" => b"\x1b[B".to_vec(),
        "right" => b"\x1b[C".to_vec(),
        "left" => b"\x1b[D".to_vec(),
        "home" => b"\x1b[H".to_vec(),
        "end" => b"\x1b[F".to_vec(),
        "pageup" => b"\x1b[5~".to_vec(),
        "pagedown" => b"\x1b[6~".to_vec(),
        "insert" => b"\x1b[2~".to_vec(),
        "delete" => b"\x1b[3~".to_vec(),
        "enter" => vec![b'\r'],
        "tab" => vec![b'\t'],
        "esc" => vec![0x1b],
        "space" => vec![b' '],
        "backspace" => vec![0x7f],
        "f1" => b"\x1bOP".to_vec(),
        "f2" => b"\x1bOQ".to_vec(),
        "f3" => b"\x1bOR".to_vec(),
        "f4" => b"\x1bOS".to_vec(),
        "f5" => b"\x1b[15~".to_vec(),
        "f6" => b"\x1b[17~".to_vec(),
        "f7" => b"\x1b[18~".to_vec(),
        "f8" => b"\x1b[19~".to_vec(),
        "f9" => b"\x1b[20~".to_vec(),
        "f10" => b"\x1b[21~".to_vec(),
        "f11" => b"\x1b[23~".to_vec(),
        "f12" => b"\x1b[24~".to_vec(),
        token if token.starts_with("ctrl-") => parse_ctrl_key(token)?,
        token => parse_literal_key(token)?,
    };

    Ok(KeyChord {
        label: normalized,
        bytes,
    })
}

fn parse_ctrl_key(value: &str) -> Result<Vec<u8>> {
    let suffix = &value[5..];
    if suffix.len() != 1 {
        anyhow::bail!("ctrl binding must have one character: {value}");
    }
    let ch = suffix.as_bytes()[0];
    if !ch.is_ascii_alphabetic() && ch != b'\\' && ch != b'[' && ch != b']' {
        anyhow::bail!("unsupported ctrl binding: {value}");
    }
    Ok(match ch {
        b'\\' => vec![0x1c],
        b'[' => vec![0x1b],
        b']' => vec![0x1d],
        _ => vec![ch.to_ascii_uppercase() - b'@'],
    })
}

fn parse_literal_key(value: &str) -> Result<Vec<u8>> {
    if value.chars().count() != 1 {
        anyhow::bail!("unsupported key binding token: {value}");
    }
    Ok(value.as_bytes().to_vec())
}

fn parse_mask_char(value: &str) -> Result<char> {
    let mut chars = value.chars();
    let ch = chars
        .next()
        .with_context(|| "mask char must not be empty".to_string())?;
    if chars.next().is_some() {
        anyhow::bail!("mask char must be a single character");
    }
    Ok(ch)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parse_rgb_accepts_hash_prefix() {
        let rgb = parse_rgb("#12abef").expect("color should parse");
        assert_eq!(rgb, Rgb(0x12, 0xab, 0xef));
    }

    #[test]
    fn compile_key_bindings_adds_default_screenshot_binding() {
        let bindings =
            compile_key_bindings(&[], ScreenshotKey::CtrlG).expect("bindings should compile");

        assert_eq!(bindings.len(), 1);
        assert_eq!(bindings[0].action, BindingAction::Screenshot);
        assert_eq!(bindings[0].trigger.bytes, vec![0x07]);
    }

    #[test]
    fn compile_key_bindings_parses_send_and_screenshot_actions() {
        let bindings = compile_key_bindings(
            &[
                "j=down".to_string(),
                "ctrl-t=screenshot".to_string(),
                "g=text:gg".to_string(),
            ],
            ScreenshotKey::CtrlG,
        )
        .expect("bindings should compile");

        assert!(
            bindings
                .iter()
                .any(|binding| binding.action == BindingAction::Screenshot)
        );
        assert!(
            bindings
                .iter()
                .any(|binding| binding.action == BindingAction::Send(b"\x1b[B".to_vec()))
        );
        assert!(
            bindings
                .iter()
                .any(|binding| binding.action == BindingAction::Send(b"gg".to_vec()))
        );
    }

    #[test]
    fn compile_output_transforms_parses_replace_and_mask() {
        let rules = compile_output_transforms(
            &["secret".to_string(), "xxxxx".to_string()],
            &["token=[^ ]+".to_string()],
            "#",
        )
        .expect("rules should compile");

        assert_eq!(rules.len(), 2);
        assert_eq!(
            rules[0].kind,
            OutputTransformKind::Replace("xxxxx".to_string())
        );
        assert_eq!(rules[1].kind, OutputTransformKind::Mask('#'));
    }
}