termcinema-engine 0.1.0

🧠 Core typewriter-style terminal animation engine (SVG renderer) for termcinema
Documentation
use once_cell::sync::Lazy;
use regex::Regex;

/// Attempts to match a REPL or shell prompt line.
///
/// This function iterates through a list of known prompt patterns,
/// trying to match the provided `line`. If a match is found, it returns:
///
/// - `Some((prompt, command))` if the pattern includes a command part,
/// - `Some((prompt, ""))` if the pattern is prompt-only,
/// - `None` if the line doesn't match any known prompt format.
///
/// All patterns are compiled into a [`PROMPT_PATTERNS`] table on first use.
pub(crate) fn match_prompt_line(line: &str) -> Option<(&str, &str)> {
    for (re, has_cmd) in PROMPT_PATTERNS.iter() {
        if let Some(cap) = re.captures(line) {
            let prompt = cap.name("prompt").map(|m| m.as_str()).unwrap_or_default();
            return if *has_cmd {
                let cmd = cap.name("cmd").map(|m| m.as_str()).unwrap_or_default();
                Some((prompt, cmd))
            } else {
                Some((prompt, ""))
            };
        }
    }
    None
}

/// A list of known prompt patterns across various platforms and tools,
/// along with a flag indicating whether they include a command.
///
/// This is a comprehensive list designed to support parsing terminal
/// transcripts from:
///
/// - Unix shells (bash, zsh, root user)
/// - Oh My Zsh themes
/// - PowerShell and CMD
/// - Language REPLs (Python, JS, MySQL, psql, etc.)
/// - Network devices (Cisco, Huawei, H3C)
/// - Mini-systems / management consoles (iLO, UCSM, etc.)
/// - Storage systems (EMC, NetApp, HNAS, etc.)
///
/// Each entry is a tuple:
/// - `(Regex, bool)` where `bool = true` if pattern includes a command
#[rustfmt::skip]
pub(crate) static PROMPT_PATTERNS: Lazy<Vec<(Regex, bool)>> = Lazy::new(|| vec![
    // ─── Generic Shell Prompts ─────────────────────────────
    (Regex::new(r"^(?P<prompt>.+?[#\$]) (?P<cmd>.+)$").unwrap(), true),               // Default shell match (e.g., bash, root)
    (Regex::new(r"^(?P<prompt>.+?[#\$])$").unwrap(), false),                          // Prompt only

    // ─── Oh My Zsh ────────────────────────────────────────
    (Regex::new(r"^(?P<prompt>➜ ~) (?P<cmd>.+)$").unwrap(), true),                    // Fixed ➜ ~ prompt
    (Regex::new(r"^(?P<prompt>➜ ~)$").unwrap(), false),
    (Regex::new(r"^(?P<prompt>git:\(.+\)(?: ✗)?)\s+(?P<cmd>.+)$").unwrap(), true),    // Git status prompt
    (Regex::new(r"^(?P<prompt>git:\(.+\)(?: ✗)?)\s*$").unwrap(), false),

    // ─── PowerShell / CMD ───────────────────────────────────────
    (Regex::new(r"^(?P<prompt>PS [A-Z]:\\[^>]+>) (?P<cmd>.+)$").unwrap(), true),      // PowerShell
    (Regex::new(r"^(?P<prompt>PS [A-Z]:\\[^>]+>)$").unwrap(), false),
    (Regex::new(r"^(?P<prompt>[A-Z]:\\>) (?P<cmd>.+)$").unwrap(), true),              // CMD
    (Regex::new(r"^(?P<prompt>[A-Z]:\\>)$").unwrap(), false),

    // ─── REPL Prompts (Python, JS, SQL, etc.) ─────────────
    (Regex::new(r"^(?P<prompt>>>>) (?P<cmd>.+)$").unwrap(), true),                    // Python REPL
    (Regex::new(r"^(?P<prompt>>>>)$").unwrap(), false),
    (Regex::new(r"^(?P<prompt>>)(?P<cmd>.+)$").unwrap(), true),                       // JavaScript / Node / MongoDB REPL
    (Regex::new(r"^(?P<prompt>>)$").unwrap(), false),
    (Regex::new(r"^(?P<prompt>mysql>) (?P<cmd>.+)$").unwrap(), true),                 // MySQL
    (Regex::new(r"^(?P<prompt>mysql>)$").unwrap(), false),
    (Regex::new(r"^(?P<prompt>sqlite>) (?P<cmd>.+)$").unwrap(), true),                // SQLite
    (Regex::new(r"^(?P<prompt>sqlite>)$").unwrap(), false),
    (Regex::new(r"^(?P<prompt>psql=#) (?P<cmd>.+)$").unwrap(), true),                 // PostgreSQL
    (Regex::new(r"^(?P<prompt>psql=#)$").unwrap(), false),
    (Regex::new(r"^(?P<prompt>SQL>|[A-Z]+@[A-Z0-9]+>) (?P<cmd>.+)$").unwrap(), true), // Oracle / generic SQL
    (Regex::new(r"^(?P<prompt>SQL>|[A-Z]+@[A-Z0-9]+>)$").unwrap(), false),

    // ─── Network Devices ──────────────────────────────────
    (Regex::new(r"^(?P<prompt>\S+[>#]) (?P<cmd>.+)$").unwrap(), true),                // Cisco, Juniper, etc.
    (Regex::new(r"^(?P<prompt>\S+[>#])$").unwrap(), false),
    (Regex::new(r"^(?P<prompt><(Huawei|H3C)>) (?P<cmd>.+)$").unwrap(), true),         // Huawei, H3C devices
    (Regex::new(r"^(?P<prompt><(Huawei|H3C)>)$").unwrap(), false),
    (Regex::new(r"^(?P<prompt>\[.*\]) (?P<cmd>.+)$").unwrap(), true),                 // H3C in interface/exec mode
    (Regex::new(r"^(?P<prompt>\[.*\])$").unwrap(), false),

    // ─── Mini-systems / Console Managers ──────────────────
    (Regex::new(r"^(?P<prompt>HP-iLO>) (?P<cmd>.+)$").unwrap(), true),                // HPE iLO console
    (Regex::new(r"^(?P<prompt>HP-iLO>)$").unwrap(), false),
    (Regex::new(r"^(?P<prompt>AS400>) (?P<cmd>.+)$").unwrap(), true),                 // IBM AS/400 or VIO console
    (Regex::new(r"^(?P<prompt>AS400>)$").unwrap(), false),
    (Regex::new(r"^(?P<prompt>UCSM#) (?P<cmd>.+)$").unwrap(), true),                  // Cisco UCS / Dell DRAC
    (Regex::new(r"^(?P<prompt>UCSM#)$").unwrap(), false),

    // ─── Storage CLI ──────────────────────────────────────
    (Regex::new(r"^(?P<prompt>uemcli>) (?P<cmd>.+)$").unwrap(), true),                // EMC Unity
    (Regex::new(r"^(?P<prompt>uemcli>)$").unwrap(), false),
    (Regex::new(r"^(?P<prompt>symcli>) (?P<cmd>.+)$").unwrap(), true),                // EMC VMAX
    (Regex::new(r"^(?P<prompt>symcli>)$").unwrap(), false),
    (Regex::new(r"^(?P<prompt>CLI>|3PAR>) (?P<cmd>.+)$").unwrap(), true),             // 3PAR storage
    (Regex::new(r"^(?P<prompt>CLI>|3PAR>)$").unwrap(), false),
    (Regex::new(r"^(?P<prompt>HNAS>) (?P<cmd>.+)$").unwrap(), true),                  // Hitachi NAS
    (Regex::new(r"^(?P<prompt>HNAS>)$").unwrap(), false),
    (Regex::new(r"^(?P<prompt>cluster::>|.*\*>) (?P<cmd>.+)$").unwrap(), true),       // NetApp ONTAP cluster shell
    (Regex::new(r"^(?P<prompt>cluster::>|.*\*>)$").unwrap(), false),
]);

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

    #[test]
    fn test_bash_prompt() {
        let line = "user@localhost:~$ echo hello";
        let matched = match_prompt_line(line);
        assert_eq!(matched, Some(("user@localhost:~$", "echo hello")));
    }

    #[test]
    fn test_root_prompt() {
        let line = "root@localhost:/etc# cat hosts";
        let matched = match_prompt_line(line);
        assert_eq!(matched, Some(("root@localhost:/etc#", "cat hosts")));
    }

    #[test]
    fn test_oh_my_zsh() {
        let line = "➜ ~ cargo run";
        let matched = match_prompt_line(line);
        assert_eq!(matched, Some(("➜ ~", "cargo run")));
    }

    #[test]
    fn test_git_branch_zsh() {
        let line = "git:(main) ✗ git commit -m 'fix'";
        let matched = match_prompt_line(line);
        assert_eq!(matched, Some(("git:(main) ✗", "git commit -m 'fix'")));
    }

    #[test]
    fn test_powershell() {
        let line = "PS C:\\Users\\Admin> Get-Process";
        let matched = match_prompt_line(line);
        assert_eq!(matched, Some(("PS C:\\Users\\Admin>", "Get-Process")));
    }

    #[test]
    fn test_cmd() {
        let line = "C:\\> dir";
        let matched = match_prompt_line(line);
        assert_eq!(matched, Some(("C:\\>", "dir")));
    }

    #[test]
    fn test_python_prompt() {
        let line = ">>> print(\"hello\")";
        let matched = match_prompt_line(line);
        assert_eq!(matched, Some((">>>", "print(\"hello\")")));
    }

    #[test]
    fn test_mysql_prompt() {
        let line = "mysql> SELECT * FROM users;";
        let matched = match_prompt_line(line);
        assert_eq!(matched, Some(("mysql>", "SELECT * FROM users;")));
    }

    #[test]
    fn test_postgres_prompt() {
        let line = "psql=# SELECT now();";
        let matched = match_prompt_line(line);
        assert_eq!(matched, Some(("psql=#", "SELECT now();")));
    }

    #[test]
    fn test_cisco_prompt() {
        let line = "Router# show ip interface brief";
        let matched = match_prompt_line(line);
        assert_eq!(matched, Some(("Router#", "show ip interface brief")));
    }

    #[test]
    fn test_3par_prompt() {
        let line = "3PAR> showvv";
        let matched = match_prompt_line(line);
        assert_eq!(matched, Some(("3PAR>", "showvv")));
    }

    #[test]
    fn test_netapp_prompt() {
        let line = "cluster::> volume show";
        let matched = match_prompt_line(line);
        assert_eq!(matched, Some(("cluster::>", "volume show")));
    }

    #[test]
    fn test_prompt_only_lines() {
        let cases = [
            ("root@localhost:~#", "root@localhost:~#"),
            ("user@host:~$", "user@host:~$"),
            ("[root@host:/etc]#", "[root@host:/etc]#"),
            ("➜ ~", "➜ ~"),
            ("git:(main)", "git:(main)"),
            ("git:(dev ✗)", "git:(dev ✗)"),
            ("PS C:\\Users\\admin>", "PS C:\\Users\\admin>"),
            ("C:\\>", "C:\\>"),
            (">>>", ">>>"),
            (">", ">"),
            ("mysql>", "mysql>"),
            ("sqlite>", "sqlite>"),
            ("psql=#", "psql=#"),
            ("SQL>", "SQL>"),
            ("SYS@XE>", "SYS@XE>"),
            ("Router#", "Router#"),
            ("Switch>", "Switch>"),
            ("<Huawei>", "<Huawei>"),
            ("<H3C>", "<H3C>"),
            ("[Huawei]", "[Huawei]"),
            ("[GigabitEthernet0/0]", "[GigabitEthernet0/0]"),
            ("HP-iLO>", "HP-iLO>"),
            ("AS400>", "AS400>"),
            ("UCSM#", "UCSM#"),
            ("uemcli>", "uemcli>"),
            ("symcli>", "symcli>"),
            ("CLI>", "CLI>"),
            ("3PAR>", "3PAR>"),
            ("HNAS>", "HNAS>"),
            ("cluster::*>", "cluster::*>"),
        ];

        for (input, expected_prompt) in cases {
            let matched = match_prompt_line(input);
            assert_eq!(
                matched,
                Some((expected_prompt, "")),
                "Failed to match prompt-only line: {}",
                input
            );
        }
    }
}