asciinama-scenario 0.1.0

Create asciinema videos from a text file.
use asciicast::{Entry, EventType, Header};
use console::style;
use failure::Error;
use serde::Deserialize;
use serde_json::{from_str, to_string};
use simplelog::{Config, TermLogger, TerminalMode};
use std::fs::File;
use std::io::{BufRead, BufReader};
use std::path::Path;
use std::process::exit;
use structopt::StructOpt;
use structopt_flags::{LogLevel, Verbose};

#[derive(Deserialize, Debug)]
struct ScenarioHeader {
    #[serde(default = "default_step")]
    step: f64,

    #[serde(default = "default_weight")]
    width: u32,

    #[serde(default = "default_height")]
    height: u32,
}

fn default_step() -> f64 {
    0.10
}

fn default_weight() -> u32 {
    77
}

fn default_height() -> u32 {
    20
}

fn print_entry(entry: Entry) -> Result<(), Error> {
    println!("{}", to_string(&entry)?);
    Ok(())
}

fn clear_terminal(time: &mut f64, step: &f64) -> Result<(), Error> {
    *time += 3.0 * step;
    print_entry(Entry {
        time: *time,
        event_type: EventType::Output,
        event_data: "\r\x1b[2J\r\x1b[H".to_string(),
    })?;
    Ok(())
}

fn echo_typing(time: &mut f64, step: &f64, line_raw: &str, nl: bool) -> Result<(), Error> {
    let mut bright_applied = false;
    for char in line_raw.to_string().chars() {
        *time += step;
        if char == '#' {
            print_entry(Entry {
                time: *time,
                event_type: EventType::Output,
                event_data: "\x1b[1m".to_string(),
            })?;
            bright_applied = true;
        }
        print_entry(Entry {
            time: *time,
            event_type: EventType::Output,
            event_data: char.to_string(),
        })?;
    }
    // clear
    if bright_applied {
        print_entry(Entry {
            time: *time,
            event_type: EventType::Output,
            event_data: "\x1b[0m".to_string(),
        })?;
    }
    if nl {
        *time += 3.0 * step;
        print_entry(Entry {
            time: *time,
            event_type: EventType::Output,
            event_data: "\r\n".to_string(),
        })?;
    }
    Ok(())
}

fn echo_console_line(time: &mut f64, step: &f64, prompt: &str, line: &str) -> Result<(), Error> {
    *time += step;
    let prompt_line = if prompt != "" {
        format!("\x1b[32m{}\x1b[0m$ ", prompt)
    } else {
        "$ ".to_string()
    };
    print_entry(Entry {
        time: *time,
        event_type: EventType::Output,
        event_data: prompt_line,
    })?;
    *time += 3.0 * step;
    echo_typing(time, step, line, true)?;
    Ok(())
}

#[derive(Debug, StructOpt)]
#[structopt(name = "verbose", about = "Show logs in stderr.")]
struct Cli {
    #[structopt(flatten)]
    verbose: Verbose,

    scenario_file: String,
}

fn main() -> Result<(), Error> {
    let cli = Cli::from_args();

    // Initialize logging
    let log_level = cli.verbose.get_level_filter();

    // stdout/stderr based logger
    TermLogger::init(
        log_level,            // set log level via "-vvv" flags
        Config::default(),    // how to format logs
        TerminalMode::Stderr, // log to stderr
    )?;

    // check if scenario_file exists
    if !Path::new(&cli.scenario_file).exists() {
        println!(
            "{} scenario file `{}` does not exist!",
            style("ERROR:").red(),
            cli.scenario_file
        );
        exit(1);
    }

    // Read lines from scenario_file
    let f = File::open(cli.scenario_file)?;
    let reader = BufReader::new(f);
    let mut time = 0.0;
    let mut step = 0.10;
    for (index, maybe_line) in reader.lines().enumerate() {
        let line = maybe_line?;
        if index == 0 && line.starts_with("#! ") {
            let header: ScenarioHeader = from_str(&line[3..])?;
            step = header.step;
            let asciicast_header = Header {
                version: 2,
                width: header.height,
                height: header.width,
                timestamp: None,
                duration: None,
                idle_time_limit: None,
                command: None,
                title: None,
                env: None,
            };
            println!("{}", to_string(&asciicast_header)?);

        // skip lines starting with "#"
        } else if line.starts_with("#") {
            continue;

        // lines starting with "$ " display as console lines
        } else if line.starts_with("$ ") {
            echo_console_line(&mut time, &step, "", &line[2..])?;

        // lines starting with "(nix-shell) $ " display as console lines
        } else if line.starts_with("(nix-shell) $ ") {
            echo_console_line(&mut time, &step, "(nix-shell) ", &line[14..])?;

        // lines starting with "--" will clear display
        } else if line.starts_with("--") {
            clear_terminal(&mut time, &step)?;

        // timeout
        } else if line.trim() == "" {
            time += 3.0 * step;

        // everything else print immediately
        } else {
            // apply brightness after #
            if line.contains("#") {
                let mut parts: Vec<&str> = line.splitn(2, '#').collect();
                parts.insert(1, "\x1b[1m#");
                parts.push("\x1b[0m");
                let styled = parts.join("");
                print_entry(Entry {
                    time: time,
                    event_type: EventType::Output,
                    event_data: format!("{}\r\n", styled),
                })?;
            } else {
                print_entry(Entry {
                    time: time,
                    event_type: EventType::Output,
                    event_data: format!("{}\r\n", line),
                })?;
            }
        }
    }

    Ok(())
}