wsb 0.253.6349

Workspace tools for every project under $PRJ: git across submodules, template stamping, GitHub, and the workbench
Documentation
use super::*;

/// The crate's own source, so the discipline tests read what ships.
fn source_files() -> Vec<std::path::PathBuf> {
    fn walk(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
        let entries = std::fs::read_dir(dir)
            .unwrap_or_else(|e| panic!("cannot read {}: {e}", dir.display()));
        for entry in entries {
            let path = entry.expect("a directory entry").path();
            if path.is_dir() {
                walk(&path, out);
            } else if path.extension().is_some_and(|e| e == "rs") {
                out.push(path);
            }
        }
    }
    let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
    let mut files = Vec::new();
    walk(&root, &mut files);
    files.sort();
    assert!(
        files.len() > 50,
        "the source tree should have been walked, found {} files",
        files.len()
    );
    files
}

/// Named the way the allow-lists below are written — `/` on every platform, or
/// on Windows no file matches its own entry and the allowed ones are flagged.
fn relative(path: &std::path::Path) -> String {
    let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
    crate::platform::slash_path(path.strip_prefix(root).unwrap_or(path))
}

/// TEST9660: nothing writes to a terminal except this module.
///
/// The promise `--jsonl` makes is that EVERY byte the run emits is
/// a record. A single `println!` anywhere else breaks it — not loudly, but by
/// dropping one unpackaged line into the middle of a stream a consumer is
/// parsing. The promise therefore has to be a property of the source, checked
/// here, rather than a rule people remember.
#[test]
fn test9660_only_the_sink_writes_to_the_terminal() {
    // The macros themselves live here, and the logging layer writes the log
    // file's own diagnostics before the sink exists to route them.
    let allowed = ["src/output/sink.rs", "src/output/sink_tests.rs"];
    let banned = ["println!", "eprintln!", "print!", "eprint!"];

    let mut offences = Vec::new();
    for file in source_files() {
        let name = relative(&file);
        if allowed.contains(&name.as_str()) {
            continue;
        }
        let text = std::fs::read_to_string(&file).expect("a source file must be readable");
        for (number, line) in text.lines().enumerate() {
            let code = code_of(line);
            for macro_name in banned {
                if contains_call(&code, macro_name) {
                    offences.push(format!("{name}:{}: {}", number + 1, line.trim()));
                }
            }
        }
    }

    assert!(
        offences.is_empty(),
        "output must go through outln!/errln! so --jsonl can package it:\n{}",
        offences.join("\n")
    );
}

/// TEST9661: only this module builds a process.
///
/// A `std::process::Command` gives its child this process's stdout by default,
/// which writes past the sink entirely: under `--jsonl` the child's
/// output would arrive as raw bytes in the middle of a JSONL stream.
/// [`crate::output::Command`] is the one process type in this crate, and it
/// captures exactly the streams the caller left to inherit.
///
/// The server's own runner is the single documented exception: it drives a
/// tokio child, reads both pipes itself, and turns them into the operation's
/// output — which is the same packaging, done where the operation lives.
#[test]
fn test9661_only_the_output_module_builds_a_process() {
    let allowed = ["src/output/child.rs", "src/output/sink_tests.rs"];
    let runner = "src/web/runner.rs";

    let mut offences = Vec::new();
    for file in source_files() {
        let name = relative(&file);
        if allowed.contains(&name.as_str()) {
            continue;
        }
        let text = std::fs::read_to_string(&file).expect("a source file must be readable");
        for (number, line) in text.lines().enumerate() {
            let code = code_of(line);
            let builds_std = code.contains("std::process::Command");
            let builds_tokio = code.contains("tokio::process::Command");
            if builds_std || (builds_tokio && name != runner) {
                offences.push(format!("{name}:{}: {}", number + 1, line.trim()));
            }
        }
    }

    assert!(
        offences.is_empty(),
        "a child process must be built with output::Command so its output can be packaged:\n{}",
        offences.join("\n")
    );
}

/// TEST9676: nothing asks a person for anything except through the one gate.
///
/// A prompt is output with a question attached, and it has the same problem:
/// a command that calls `rpassword` directly asks wherever it happens to be
/// running. For an operation the workbench started, that is the terminal the
/// SERVER was started from — where the prompt waited, unseen, while the page
/// showed a run that never finished. `secret::session::ask_passphrase` is the
/// one call that asks, and it refuses when nobody can answer.
#[test]
fn test9676_only_the_session_asks_a_person_for_anything() {
    let allowed = ["src/secret/session.rs", "src/output/sink_tests.rs"];

    let mut offences = Vec::new();
    for file in source_files() {
        let name = relative(&file);
        if allowed.contains(&name.as_str()) {
            continue;
        }
        let text = std::fs::read_to_string(&file).expect("a source file must be readable");
        for (number, line) in text.lines().enumerate() {
            if code_of(line).contains("rpassword") {
                offences.push(format!("{name}:{}: {}", number + 1, line.trim()));
            }
        }
    }

    assert!(
        offences.is_empty(),
        "a passphrase must be asked for through secret::session::ask_passphrase:\n{}",
        offences.join("\n")
    );
}

/// TEST9662: the flag is taken out of the argument vector, wherever it sits.
///
/// It has to be removed rather than parsed: wsb hands whole argument vectors
/// to subcommands that read their own arguments, and a flag only the top-level
/// parser knew about would reach them as an argument they do not have.
#[test]
fn test9662_the_flag_is_removed_from_any_position() {
    let mut argv: Vec<String> = ["wsb", "test", "--jsonl", "--filter", "wsb"]
        .iter()
        .map(|s| s.to_string())
        .collect();
    assert!(take_jsonl_flag(&mut argv));
    assert_eq!(argv, vec!["wsb", "test", "--filter", "wsb"]);

    let mut trailing: Vec<String> = ["wsb", "publish", "--jsonl"]
        .iter()
        .map(|s| s.to_string())
        .collect();
    assert!(take_jsonl_flag(&mut trailing));
    assert_eq!(trailing, vec!["wsb", "publish"]);

    let mut absent: Vec<String> = ["wsb", "status"].iter().map(|s| s.to_string()).collect();
    assert!(!take_jsonl_flag(&mut absent));
    assert_eq!(absent, vec!["wsb", "status"]);

    // A value that merely contains the flag's text is an argument, not a flag.
    let mut lookalike: Vec<String> = ["wsb", "note", "--jsonl-ish"]
        .iter()
        .map(|s| s.to_string())
        .collect();
    assert!(!take_jsonl_flag(&mut lookalike));
    assert_eq!(lookalike.len(), 3);
}

/// TEST9663: a write becomes one record per line, and a line keeps its
/// newlines out of the text.
///
/// A consumer that has to split strings is parsing prose again, which is the
/// thing this format exists to end.
#[test]
fn test9663_a_write_becomes_one_record_per_line() {
    assert_eq!(super::split_lines("one\n"), vec!["one".to_string()]);
    assert_eq!(
        super::split_lines("one\ntwo\n"),
        vec!["one".to_string(), "two".to_string()]
    );
    // No trailing newline: still said, still one line.
    assert_eq!(super::split_lines("partial"), vec!["partial".to_string()]);
    // A deliberate blank line is a line.
    assert_eq!(super::split_lines("\n"), vec![String::new()]);
    // Nothing written is nothing said.
    assert!(super::split_lines("").is_empty());
}

/// TEST9681: a record's text is text, not a picture of text.
///
/// Colour reaches this format three ways — the colour crate, escape sequences
/// written into a format string, and children that colour their own output.
/// Only the first can be told not to, so all three are stripped here. A
/// consumer that had to do it would be parsing terminal control codes out of
/// JSON, which is the opposite of what this format is for.
#[test]
fn test9681_records_carry_no_terminal_control_sequences() {
    // What the colour crate emits.
    assert_eq!(super::strip_ansi("\x1b[31mred\x1b[0m"), "red");
    // What this crate writes by hand for the test matrix.
    assert_eq!(
        super::strip_ansi("\n\x1b[1m\x1b[0;36m--- MATRIX\x1b[0m"),
        "\n--- MATRIX"
    );
    // Cursor movement, which a live display writes between redraws.
    assert_eq!(super::strip_ansi("row\x1b[K"), "row");
    assert_eq!(super::strip_ansi("\x1b[2Aup"), "up");
    // A window title, which ends with BEL rather than a letter.
    assert_eq!(super::strip_ansi("\x1b]0;a title\x07after"), "after");
    // Text that merely mentions a bracket is untouched.
    assert_eq!(super::strip_ansi("[not an escape]"), "[not an escape]");
    // And a line becomes a record with none of it left.
    assert_eq!(
        super::split_lines("\x1b[32mgreen\x1b[0m\n"),
        vec!["green".to_string()]
    );
}

/// A line with its comments and string literals removed — what the compiler
/// will actually see. Sample source inside a fixture string is not a call, and
/// neither is a macro named in a doc comment.
fn code_of(line: &str) -> String {
    let trimmed = line.trim_start();
    if trimmed.starts_with("//") || trimmed.starts_with('*') {
        return String::new();
    }
    let mut code = String::with_capacity(line.len());
    let mut in_string = false;
    let mut escaped = false;
    let mut chars = line.chars().peekable();
    while let Some(c) = chars.next() {
        if in_string {
            if escaped {
                escaped = false;
            } else if c == '\\' {
                escaped = true;
            } else if c == '"' {
                in_string = false;
            }
            continue;
        }
        if c == '"' {
            in_string = true;
            continue;
        }
        if c == '/' && chars.peek() == Some(&'/') {
            break;
        }
        code.push(c);
    }
    code
}

/// Whether a line really calls `name!`, rather than mentioning it.
fn contains_call(code: &str, name: &str) -> bool {
    let mut from = 0;
    while let Some(at) = code[from..].find(name) {
        let start = from + at;
        let before = code[..start].chars().next_back();
        // `eprintln!` contains `println!`; `crate::output::print!` does not
        // exist, but a qualified path would not be this macro either.
        let is_boundary = before.is_none_or(|c| !c.is_alphanumeric() && c != '_' && c != ':');
        if is_boundary {
            return true;
        }
        from = start + name.len();
    }
    false
}


/// TEST12374: a live display passes through byte for byte, and the lines it
/// settles on are what is kept.
///
/// A release's build on another machine was refused before it started. The
/// reason was drawn on the terminal by the program that refused, which had
/// been handed the terminal; the recording of the release said "exited with
/// status 2". The display is now read and passed on: every byte reaches the
/// terminal as it was written — redraws, partial lines and all — and each line
/// is kept as it was LEFT, not once per redraw. A last line with no newline is
/// kept too: that is how a refusal that ends the program can arrive.
#[test]
fn test12374_a_live_display_is_passed_on_and_its_settled_lines_kept() {
    let drawn: &[u8] = b"mg-test: copying 10%\rmg-test: copying 90%\r                  \rmg-test: copying \x1b[32m5s\x1b[0m\n\n\rError: the source changed: audit/shots/a.png";
    let mut lines = SettledLines::default();
    // In pieces that split a line and a redraw, as a pipe delivers them.
    let mut settled = Vec::new();
    for piece in drawn.chunks(7) {
        settled.extend(lines.feed(piece));
    }
    assert_eq!(settled, vec!["mg-test: copying 5s".to_string()], "one line per settled line, without redraws or colour");
    assert_eq!(lines.finish().as_deref(), Some("Error: the source changed: audit/shots/a.png"));

    let mut terminal = Vec::new();
    pass_live_display(drawn, &mut terminal);
    assert_eq!(terminal, drawn, "the terminal did not get what was drawn");
}