snowchains 0.7.0

Tools for online programming contests
Documentation
use crate::config;
use anyhow::bail;
use az::SaturatingAs as _;
use human_size::{Byte, Size};
use indicatif::ProgressDrawTarget;
use itertools::Itertools as _;
use maplit::btreemap;
use snowchains_core::{
    color_spec, judge::CommandExpression, testsuite::TestSuite, web::PlatformKind,
};
use std::{
    collections::HashSet,
    ffi::OsStr,
    io::Write as _,
    iter, mem,
    ops::Deref,
    path::{Path, PathBuf},
    process::Stdio,
};
use termcolor::{Color, WriteColor};

pub(crate) struct Args<W1, W2> {
    pub(crate) stdout: W1,
    pub(crate) stderr: W2,
    pub(crate) stdin_process_redirection: fn() -> Stdio,
    pub(crate) stdout_process_redirection: fn() -> Stdio,
    pub(crate) stderr_process_redirection: fn() -> Stdio,
    pub(crate) progress_draw_target: ProgressDrawTarget,
    pub(crate) base_dir: PathBuf,
    pub(crate) service: PlatformKind,
    pub(crate) contest: Option<String>,
    pub(crate) problem: String,
    pub(crate) src: String,
    pub(crate) transpile: Option<config::Compile>,
    pub(crate) compile: Option<config::Compile>,
    pub(crate) run: config::Command,
    pub(crate) test_case_names: Option<HashSet<String>>,
    pub(crate) display_limit: Size,
}

pub(crate) fn judge(args: Args<impl WriteColor, impl WriteColor>) -> anyhow::Result<()> {
    let Args {
        stdout,
        mut stderr,
        stdin_process_redirection,
        stdout_process_redirection,
        stderr_process_redirection,
        progress_draw_target,
        base_dir,
        service,
        contest,
        problem,
        src,
        transpile,
        compile,
        run,
        test_case_names,
        display_limit,
    } = args;

    let test_suite_dir = base_dir
        .join(".snowchains")
        .join("tests")
        .join(service.to_kebab_case_str())
        .join(contest.as_deref().unwrap_or(""));
    let test_suite_path = test_suite_dir.join(problem).with_extension("yml");

    let test_cases = match crate::fs::read_yaml(&test_suite_path)? {
        TestSuite::Batch(test_sutie) => {
            test_sutie.load_test_cases(&test_suite_dir, test_case_names)?
        }
        _ => todo!("currently only `Batch` is supported"),
    };

    let redirections = (
        stdin_process_redirection,
        stdout_process_redirection,
        stderr_process_redirection,
    );

    let mut newline = false;

    for (action, msg) in &[(transpile, "Transpiling..."), (compile, "Compiling...")] {
        if let Some(action) = action {
            if mem::replace(&mut newline, true) {
                writeln!(stderr)?;
            }

            build(&mut stderr, &base_dir, &src, action, redirections, msg)?;
        }
    }

    if mem::replace(&mut newline, true) {
        writeln!(stderr)?;
    }
    stderr.set_color(color_spec!(Bold))?;
    write!(stderr, "Running the tests...")?;
    stderr.reset()?;
    writeln!(stderr)?;
    stderr.flush()?;

    let (cmd, tempfile) = match run {
        config::Command::Args(args) => {
            let cmd = CommandExpression {
                program: args.get(0).cloned().unwrap_or_default().into(),
                args: args.into_iter().skip(1).map(Into::into).collect(),
                cwd: base_dir,
                env: btreemap!(),
            };

            (cmd, None)
        }
        config::Command::Script(config::Script {
            program,
            extension,
            content,
        }) => {
            let mut tempfile = tempfile::Builder::new()
                .prefix("snowchains-test")
                .suffix(&format!(".{}", extension))
                .tempfile()?;

            tempfile.write_all(content.as_ref())?;

            let cmd = CommandExpression {
                program: program.into(),
                args: vec![tempfile.path().into()],
                cwd: base_dir,
                env: btreemap!(),
            };

            (cmd, Some(tempfile))
        }
    };

    stderr.set_color(color_spec!(Bold, Fg(Color::Magenta)))?;
    write!(stderr, "Test file:")?;
    stderr.reset()?;
    writeln!(stderr, " {}", test_suite_path.display())?;

    stderr.set_color(color_spec!(Bold, Fg(Color::Magenta)))?;
    write!(stderr, "Command:")?;
    stderr.reset()?;
    writeln!(stderr, " {}", shell_escape_args(&cmd.program, &cmd.args))?;

    stderr.set_color(color_spec!(Bold, Fg(Color::Magenta)))?;
    write!(stderr, "Working Directory:")?;
    stderr.reset()?;
    writeln!(stderr, " {}", cmd.cwd.display())?;

    stderr.flush()?;

    let outcome = snowchains_core::judge::judge(
        progress_draw_target,
        tokio::signal::ctrl_c,
        &cmd,
        &test_cases,
    )?;

    if let Some(tempfile) = tempfile {
        tempfile.close()?;
    }

    writeln!(stderr)?;
    stderr.flush()?;
    outcome.print_pretty(
        stdout,
        Some(display_limit.into::<Byte>().value().saturating_as()),
    )?;

    outcome.error_on_fail()
}

pub(crate) fn transpile(
    stderr: impl WriteColor,
    base_dir: &Path,
    src: &str,
    transpile: &config::Compile,
    stdin_process_redirection: fn() -> Stdio,
    stdout_process_redirection: fn() -> Stdio,
    stderr_process_redirection: fn() -> Stdio,
) -> anyhow::Result<()> {
    build(
        stderr,
        base_dir,
        src,
        transpile,
        (
            stdin_process_redirection,
            stdout_process_redirection,
            stderr_process_redirection,
        ),
        "Transpiling...",
    )
}

#[allow(clippy::type_complexity)]
fn build(
    mut stderr: impl WriteColor,
    base_dir: &Path,
    src: &str,
    build_action: &config::Compile,
    redirections: (fn() -> Stdio, fn() -> Stdio, fn() -> Stdio),
    msg: &'static str,
) -> anyhow::Result<()> {
    let src_modified = {
        let src = Path::new(&src);
        let src = base_dir.join(src.strip_prefix(".").unwrap_or(src));
        crate::fs::metadata(src)?.modified()?
    };

    let config::Compile { command, output } = build_action;

    let output = Path::new(&output);
    let output = base_dir.join(output.strip_prefix(".").unwrap_or(output));

    let (stdin_process_redirection, stdout_process_redirection, stderr_process_redirection) =
        redirections;

    if output.exists() && crate::fs::metadata(&output)?.modified()? > src_modified {
        writeln!(stderr, "{} is up to date.", output.display())?;
        stderr.flush()?;
    } else {
        stderr.set_color(color_spec!(Bold))?;
        write!(stderr, "{}", msg)?;
        stderr.reset()?;
        writeln!(stderr)?;
        stderr.flush()?;

        if let Some(parent) = output.parent() {
            if !parent.exists() {
                crate::fs::create_dir_all(parent)?;

                write!(stderr, "Created ")?;
                stderr.set_color(color_spec!(Fg(Color::Cyan)))?;
                write!(stderr, "{}", parent.display())?;
                stderr.reset()?;
                writeln!(stderr)?;
                stderr.flush()?;
            }
        }

        match command {
            config::Command::Args(args) => run_command(
                args.get(0).map(Deref::deref).unwrap_or(""),
                args.iter().skip(1),
                &base_dir,
                stdin_process_redirection(),
                stdout_process_redirection(),
                stderr_process_redirection(),
                &mut stderr,
            )?,
            config::Command::Script(config::Script {
                program,
                extension,
                content,
            }) => {
                let mut tempfile = tempfile::Builder::new()
                    .prefix("snowchains-test")
                    .suffix(&format!(".{}", extension))
                    .tempfile()?;

                tempfile.write_all(content.as_ref())?;

                run_command(
                    program,
                    &[tempfile.path()],
                    &base_dir,
                    stdin_process_redirection(),
                    stdout_process_redirection(),
                    stderr_process_redirection(),
                    &mut stderr,
                )?;

                tempfile.close()?;
            }
        }
    }

    Ok(())
}

fn run_command<S1: AsRef<OsStr>, S2: AsRef<OsStr>, I: IntoIterator<Item = S2>, W: WriteColor>(
    program: S1,
    args: I,
    base_dir: &Path,
    stdin_process_redirection: Stdio,
    stdout_process_redirection: Stdio,
    stderr_process_redirection: Stdio,
    mut stderr: W,
) -> anyhow::Result<()> {
    let program = program.as_ref();

    let args = args
        .into_iter()
        .map(|s| s.as_ref().to_owned())
        .collect::<Vec<_>>();

    let shell_escaped = shell_escape_args(program, &args);

    stderr.set_color(color_spec!(Bold, Fg(Color::Magenta)))?;
    write!(stderr, "Command:")?;
    stderr.reset()?;
    writeln!(stderr, " {}", shell_escaped)?;

    stderr.set_color(color_spec!(Bold, Fg(Color::Magenta)))?;
    write!(stderr, "Working Directory:")?;
    stderr.reset()?;
    writeln!(stderr, " {}", base_dir.display())?;

    stderr.flush()?;

    let status = std::process::Command::new(program)
        .args(&args)
        .current_dir(base_dir)
        .stdin(stdin_process_redirection)
        .stdout(stdout_process_redirection)
        .stderr(stderr_process_redirection)
        .status()?;

    if !status.success() {
        bail!(
            "{} {}",
            shell_escaped,
            if let Some(code) = status.code() {
                format!("exited with code {}", code)
            } else {
                "was terminated by signal".to_owned()
            },
        );
    }

    Ok(())
}

fn shell_escape_args(program: impl AsRef<OsStr>, args: &[impl AsRef<OsStr>]) -> String {
    format!(
        "`{}`",
        iter::once(program.as_ref())
            .chain(args.iter().map(AsRef::as_ref))
            .map(|s| shell_escape::unix::escape(s.to_string_lossy()))
            .format(" "),
    )
}