diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 49a17b1..e5fadd3 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -14,11 +14,52 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out repository - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Check whitespace run: git diff --check "$(git hash-object -t tree /dev/null)" HEAD - - name: Verify foundation files + - name: Install latest stable Rust toolchain + run: rustup toolchain install stable --profile minimal --component clippy,rustfmt,llvm-tools-preview + - name: Check formatting + run: cargo +stable fmt --check + - name: Run tests + run: cargo +stable test --locked + - name: Run Clippy + run: cargo +stable clippy --all-targets --locked -- -D warnings + - name: Build documentation + env: + RUSTDOCFLAGS: -D warnings + run: cargo +stable doc --no-deps --locked + - name: Install cargo-llvm-cov + uses: taiki-e/install-action@e67fa11c4b9316fa714ddf0abed07a0c3143b95b # v2.87.4 + with: + tool: cargo-llvm-cov@0.9.0 + fallback: none + - name: Enforce test coverage + run: cargo +stable llvm-cov --all-features --locked --fail-under-lines 90 --fail-under-functions 95 --fail-under-regions 90 + - name: Build release binary + run: cargo +stable build --release --locked + - name: Smoke test command metadata + run: | + ./target/release/stack --help + ./target/release/stack --version + - name: Verify repository files run: | test -s README.md test -s AGENTS.md test -s LICENSING.md + test -s Cargo.toml + test -s Cargo.lock + test -s src/main.rs + + msrv: + name: Minimum supported Rust + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v7 + - name: Install Rust 1.85 + run: rustup toolchain install 1.85.0 --profile minimal --component clippy + - name: Run tests + run: cargo +1.85.0 test --locked + - name: Run Clippy + run: cargo +1.85.0 clippy --all-targets --locked -- -D warnings diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b83d222 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target/ diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..a953a67 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,138 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "stack-cli" +version = "0.1.0" +dependencies = [ + "stack-engine", +] + +[[package]] +name = "stack-compiler" +version = "0.1.0" +source = "git+https://github.com/stack-sh/compiler.git?rev=17a0abe9c35e641761ff08fdf59b29a42828d9fd#17a0abe9c35e641761ff08fdf59b29a42828d9fd" + +[[package]] +name = "stack-engine" +version = "0.1.0" +source = "git+https://github.com/stack-sh/engine.git?rev=07b71c783a3c1f9037d865b19672a9522eb0b24d#07b71c783a3c1f9037d865b19672a9522eb0b24d" +dependencies = [ + "stack-compiler", + "stack-formatter", + "stack-theme", +] + +[[package]] +name = "stack-formatter" +version = "0.1.0" +source = "git+https://github.com/stack-sh/engine.git?rev=07b71c783a3c1f9037d865b19672a9522eb0b24d#07b71c783a3c1f9037d865b19672a9522eb0b24d" +dependencies = [ + "stack-compiler", +] + +[[package]] +name = "stack-theme" +version = "0.1.0" +source = "git+https://github.com/stack-sh/theme.git?rev=ed6c500762fc9ccffc8777172ac672a716dcd916#ed6c500762fc9ccffc8777172ac672a716dcd916" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..e5186f5 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "stack-cli" +version = "0.1.0" +edition = "2024" +rust-version = "1.85" +publish = false +description = "Native command-line interface for Stack diagrams" +repository = "https://github.com/stack-sh/cli" + +[[bin]] +name = "stack" +path = "src/main.rs" + +[dependencies] +stack-engine = { git = "https://github.com/stack-sh/engine.git", rev = "07b71c783a3c1f9037d865b19672a9522eb0b24d" } + +[lints.clippy] +expect_used = "deny" +panic = "deny" +todo = "deny" +unimplemented = "deny" +unreachable = "deny" +unwrap_used = "deny" diff --git a/README.md b/README.md index 5ee8597..bf963db 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,26 @@ `stack-sh/cli` is the private source repository for the native Rust `stack` command. -This repository currently contains only its repository foundation. It does not yet provide a distributable binary or a stable command-line interface. +The repository now contains the first native command: `stack check`. The CLI is not yet distributed as a supported external binary and its interface remains pre-release. -## Planned commands +## Commands ```text -stack render arch.stack -o arch.svg stack check arch.stack +``` + +`stack check` reads the file as bytes and runs the full compiler, theme, layout, and routing validation pipeline without changing the source. Diagnostics are written to standard error in source order. Standard output remains empty. + +| Result | Exit status | +| --- | ---: | +| No error diagnostics, including warning-only input | `0` | +| One or more Stack error diagnostics | `1` | +| Invalid arguments, host I/O failure, or engine operational failure | `2` | + +The remaining planned commands are: + +```text +stack render arch.stack -o arch.svg stack fmt arch.stack stack fmt --check arch.stack ``` @@ -19,7 +32,15 @@ Future authenticated theme delivery may add a client for short-lived, scope-limi ## Development -Repository checks currently validate the foundation files on every push and pull request. Rust formatting, linting, tests, and release builds will be added with the first CLI implementation. +The CLI requires Rust 1.85 or newer. + +```sh +cargo run -- check arch.stack +cargo test --locked +cargo clippy --all-targets --locked -- -D warnings +``` + +CI validates formatting, unit and process-level integration tests, at least 90% line/region coverage and 95% function coverage, Clippy, documentation, a release build, `--help`, and `--version` on stable Rust. Tests and Clippy also run on Rust 1.85. ## Licensing diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..157e3d9 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,378 @@ +//! Host boundary and command contract for the native Stack CLI. + +#![forbid(unsafe_code)] + +use std::ffi::{OsStr, OsString}; +use std::fmt::Write as _; +use std::fs; +use std::io::{self, Write}; +use std::path::Path; + +use stack_engine::{CheckOutput, Diagnostic, Engine, OperationalError, Severity}; + +/// Exit status used when a command completes without Stack error diagnostics. +pub const EXIT_SUCCESS: u8 = 0; +/// Exit status used when Stack source contains at least one error diagnostic. +pub const EXIT_STACK_ERROR: u8 = 1; +/// Exit status used for argument, host I/O, or engine operational failures. +pub const EXIT_USAGE_OR_IO: u8 = 2; + +const GENERAL_HELP: &str = "Stack diagram toolchain\n\nUsage:\n stack check \n stack --help\n stack --version\n\nCommands:\n check Validate a Stack source file without modifying it\n"; +const CHECK_HELP: &str = + "Validate a Stack source file without modifying it\n\nUsage:\n stack check \n"; + +/// Runs the CLI with explicit streams and returns its process exit status. +pub fn run( + arguments: impl IntoIterator, + stdout: &mut dyn Write, + stderr: &mut dyn Write, +) -> u8 { + let mut arguments = arguments.into_iter(); + let Some(command) = arguments.next() else { + return argument_error("missing command", stderr); + }; + + if command == OsStr::new("--help") || command == OsStr::new("-h") { + if let Some(extra) = arguments.next() { + return argument_error( + &format!("unexpected argument '{}'", extra.to_string_lossy()), + stderr, + ); + } + return write_stdout(GENERAL_HELP, stdout, stderr); + } + if command == OsStr::new("--version") || command == OsStr::new("-V") { + if let Some(extra) = arguments.next() { + return argument_error( + &format!("unexpected argument '{}'", extra.to_string_lossy()), + stderr, + ); + } + return write_stdout( + concat!("stack ", env!("CARGO_PKG_VERSION"), "\n"), + stdout, + stderr, + ); + } + if command == OsStr::new("check") { + return run_check(arguments, stdout, stderr); + } + + argument_error( + &format!("unknown command '{}'", command.to_string_lossy()), + stderr, + ) +} + +fn run_check( + mut arguments: impl Iterator, + stdout: &mut dyn Write, + stderr: &mut dyn Write, +) -> u8 { + let Some(path) = arguments.next() else { + return argument_error("missing file for 'stack check'", stderr); + }; + + if path == OsStr::new("--help") || path == OsStr::new("-h") { + if let Some(extra) = arguments.next() { + return argument_error( + &format!("unexpected argument '{}'", extra.to_string_lossy()), + stderr, + ); + } + return write_stdout(CHECK_HELP, stdout, stderr); + } + + if let Some(extra) = arguments.next() { + return argument_error( + &format!("unexpected argument '{}'", extra.to_string_lossy()), + stderr, + ); + } + + check_file(Path::new(&path), stderr) +} + +fn check_file(path: &Path, stderr: &mut dyn Write) -> u8 { + check_file_with(path, stderr, |source| Engine::bundled().check(source)) +} + +fn check_file_with( + path: &Path, + stderr: &mut dyn Write, + check: impl FnOnce(&[u8]) -> Result, +) -> u8 { + let source = match fs::read(path) { + Ok(source) => source, + Err(error) => { + return write_stderr_error( + &format!( + "cannot read '{}': {}", + path.display(), + stable_io_error(error.kind()) + ), + stderr, + ); + } + }; + + let output = match check(&source) { + Ok(output) => output, + Err(error) => { + return write_stderr_error( + &format!("cannot check '{}': {error}", path.display()), + stderr, + ); + } + }; + let has_errors = output + .diagnostics + .iter() + .any(|diagnostic| diagnostic.severity == Severity::Error); + let rendered = render_diagnostics(path, &output.diagnostics); + if !rendered.is_empty() && stderr.write_all(rendered.as_bytes()).is_err() { + return EXIT_USAGE_OR_IO; + } + + if has_errors { + EXIT_STACK_ERROR + } else { + EXIT_SUCCESS + } +} + +fn render_diagnostics(path: &Path, diagnostics: &[Diagnostic]) -> String { + let mut rendered = String::new(); + for diagnostic in diagnostics { + let severity = match diagnostic.severity { + Severity::Error => "error", + Severity::Warning => "warning", + }; + let _ = writeln!( + rendered, + "{}:{}:{}: {severity}[{}]: {}", + path.display(), + diagnostic.range.start.line, + diagnostic.range.start.column, + diagnostic.code, + diagnostic.message + ); + if let Some(help) = &diagnostic.help { + let _ = writeln!(rendered, " help: {help}"); + } + for related in &diagnostic.related { + let _ = writeln!( + rendered, + "{}:{}:{}: note: {}", + path.display(), + related.range.start.line, + related.range.start.column, + related.message + ); + } + } + rendered +} + +fn stable_io_error(kind: io::ErrorKind) -> &'static str { + match kind { + io::ErrorKind::NotFound => "file not found", + io::ErrorKind::PermissionDenied => "permission denied", + io::ErrorKind::InvalidData => "invalid data", + _ => "I/O error", + } +} + +fn argument_error(message: &str, stderr: &mut dyn Write) -> u8 { + let _ = writeln!(stderr, "error: {message}\n\n{GENERAL_HELP}"); + EXIT_USAGE_OR_IO +} + +fn write_stderr_error(message: &str, stderr: &mut dyn Write) -> u8 { + let _ = writeln!(stderr, "error: {message}"); + EXIT_USAGE_OR_IO +} + +fn write_stdout(message: &str, stdout: &mut dyn Write, stderr: &mut dyn Write) -> u8 { + if stdout.write_all(message.as_bytes()).is_ok() { + EXIT_SUCCESS + } else { + write_stderr_error("cannot write command output", stderr) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + struct FailingWriter; + + impl Write for FailingWriter { + fn write(&mut self, _buffer: &[u8]) -> io::Result { + Err(io::Error::other("test writer failure")) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } + } + + #[test] + fn help_version_and_argument_errors_have_stable_streams() { + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + assert_eq!( + run([OsString::from("--version")], &mut stdout, &mut stderr), + EXIT_SUCCESS + ); + assert_eq!(stdout, b"stack 0.1.0\n"); + assert!(stderr.is_empty()); + + stdout.clear(); + assert_eq!( + run([OsString::from("--help")], &mut stdout, &mut stderr), + EXIT_SUCCESS + ); + assert_eq!(stdout, GENERAL_HELP.as_bytes()); + + stdout.clear(); + assert_eq!(run([], &mut stdout, &mut stderr), EXIT_USAGE_OR_IO); + assert!(stdout.is_empty()); + assert!(String::from_utf8_lossy(&stderr).contains("error: missing command")); + } + + #[test] + fn command_aliases_and_invalid_arguments_are_stable() { + for alias in ["-h", "-V"] { + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + assert_eq!( + run([OsString::from(alias)], &mut stdout, &mut stderr), + EXIT_SUCCESS + ); + assert!(!stdout.is_empty()); + assert!(stderr.is_empty()); + } + + for arguments in [ + vec![OsString::from("unknown")], + vec![OsString::from("--help"), OsString::from("extra")], + vec![OsString::from("--version"), OsString::from("extra")], + vec![OsString::from("check")], + vec![ + OsString::from("check"), + OsString::from("--help"), + OsString::from("extra"), + ], + vec![ + OsString::from("check"), + OsString::from("file.stack"), + OsString::from("extra"), + ], + ] { + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + assert_eq!(run(arguments, &mut stdout, &mut stderr), EXIT_USAGE_OR_IO); + assert!(stdout.is_empty()); + assert!(String::from_utf8_lossy(&stderr).starts_with("error:")); + } + + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + assert_eq!( + run( + [OsString::from("check"), OsString::from("-h")], + &mut stdout, + &mut stderr, + ), + EXIT_SUCCESS + ); + assert_eq!(stdout, CHECK_HELP.as_bytes()); + assert!(stderr.is_empty()); + } + + #[test] + fn diagnostic_rendering_includes_related_locations() { + let diagnostic = Diagnostic { + code: "STKTEST".to_owned(), + severity: Severity::Error, + message: "primary".to_owned(), + range: stack_engine::SourceRange { + start: stack_engine::SourcePosition { + byte_offset: 0, + line: 2, + column: 3, + }, + end: stack_engine::SourcePosition { + byte_offset: 1, + line: 2, + column: 4, + }, + }, + help: None, + related: vec![stack_engine::RelatedInformation { + message: "related".to_owned(), + range: stack_engine::SourceRange { + start: stack_engine::SourcePosition { + byte_offset: 2, + line: 4, + column: 5, + }, + end: stack_engine::SourcePosition { + byte_offset: 3, + line: 4, + column: 6, + }, + }, + }], + }; + + assert_eq!( + render_diagnostics(Path::new("arch.stack"), &[diagnostic]), + "arch.stack:2:3: error[STKTEST]: primary\narch.stack:4:5: note: related\n" + ); + } + + #[test] + fn host_error_labels_and_stream_failures_are_stable() { + assert_eq!( + stable_io_error(io::ErrorKind::PermissionDenied), + "permission denied" + ); + assert_eq!(stable_io_error(io::ErrorKind::InvalidData), "invalid data"); + assert_eq!(stable_io_error(io::ErrorKind::Other), "I/O error"); + + let mut failed_stdout = FailingWriter; + let mut stderr = Vec::new(); + assert_eq!( + write_stdout("help", &mut failed_stdout, &mut stderr), + EXIT_USAGE_OR_IO + ); + assert_eq!(stderr, b"error: cannot write command output\n"); + assert!(failed_stdout.flush().is_ok()); + + let path = std::env::temp_dir().join(format!( + "stack-cli-writer-failure-{}.stack", + std::process::id() + )); + assert!( + fs::write( + &path, + b"stack 1.0 diagram \"Fallback\" { theme neon node api \"API\" }", + ) + .is_ok() + ); + let mut failed_stderr = FailingWriter; + let status = check_file(&path, &mut failed_stderr); + let mut operational_stderr = Vec::new(); + let operational_status = check_file_with(&path, &mut operational_stderr, |_| { + Err(OperationalError::InvalidIntermediateRepresentation { + reason: "test failure", + }) + }); + assert!(fs::remove_file(path).is_ok()); + assert_eq!(status, EXIT_USAGE_OR_IO); + assert_eq!(operational_status, EXIT_USAGE_OR_IO); + assert!(String::from_utf8_lossy(&operational_stderr).contains("error: cannot check")); + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..071cd0e --- /dev/null +++ b/src/main.rs @@ -0,0 +1,15 @@ +#![forbid(unsafe_code)] + +use std::env; +use std::io; +use std::process::ExitCode; + +fn main() -> ExitCode { + let mut stdout = io::stdout().lock(); + let mut stderr = io::stderr().lock(); + ExitCode::from(stack_cli::run( + env::args_os().skip(1), + &mut stdout, + &mut stderr, + )) +} diff --git a/tests/cli.rs b/tests/cli.rs new file mode 100644 index 0000000..4f11e25 --- /dev/null +++ b/tests/cli.rs @@ -0,0 +1,132 @@ +use std::env; +use std::error::Error; +use std::ffi::OsStr; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; +use std::sync::atomic::{AtomicU64, Ordering}; + +static CASE_ID: AtomicU64 = AtomicU64::new(0); + +struct TestDirectory { + path: PathBuf, +} + +impl TestDirectory { + fn new(label: &str) -> Result> { + let case_id = CASE_ID.fetch_add(1, Ordering::Relaxed); + let path = env::temp_dir().join(format!( + "stack-cli-{}-{label}-{case_id}", + std::process::id() + )); + fs::create_dir(&path)?; + Ok(Self { path }) + } + + fn file(&self, name: &str, bytes: &[u8]) -> Result> { + let path = self.path.join(name); + fs::write(&path, bytes)?; + Ok(path) + } +} + +impl Drop for TestDirectory { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } +} + +fn stack(arguments: impl IntoIterator>) -> Result> { + Ok(Command::new(env!("CARGO_BIN_EXE_stack")) + .args(arguments) + .output()?) +} + +fn assert_unchanged(path: &Path, expected: &[u8]) -> Result<(), Box> { + assert_eq!(fs::read(path)?, expected); + Ok(()) +} + +#[test] +fn valid_source_is_silent_and_unchanged() -> Result<(), Box> { + let directory = TestDirectory::new("valid")?; + let source = b"stack 1.0 diagram \"Valid\" { node api \"API\" }"; + let path = directory.file("valid.stack", source)?; + + let output = stack([OsStr::new("check"), path.as_os_str()])?; + + assert_eq!(output.status.code(), Some(0)); + assert!(output.stdout.is_empty()); + assert!(output.stderr.is_empty()); + assert_unchanged(&path, source) +} + +#[test] +fn warning_source_exits_zero_and_writes_only_stderr() -> Result<(), Box> { + let directory = TestDirectory::new("warning")?; + let source = b"stack 1.0 diagram \"Fallback\" { theme neon node api \"API\" }"; + let path = directory.file("warning.stack", source)?; + let warning_column = source + .windows(b"neon".len()) + .position(|window| window == b"neon") + .ok_or("missing warning position")? + + 1; + + let output = stack([OsStr::new("check"), path.as_os_str()])?; + let expected = format!( + "{}:1:{warning_column}: warning[STK6001]: theme 'neon' is unavailable; default theme was used\n help: Install the requested theme or select an available theme.\n", + path.display() + ); + + assert_eq!(output.status.code(), Some(0)); + assert!(output.stdout.is_empty()); + assert_eq!(String::from_utf8(output.stderr)?, expected); + assert_unchanged(&path, source) +} + +#[test] +fn invalid_utf8_exits_one_and_writes_only_stderr() -> Result<(), Box> { + let directory = TestDirectory::new("invalid-utf8")?; + let source = [0xff]; + let path = directory.file("invalid.stack", &source)?; + + let output = stack([OsStr::new("check"), path.as_os_str()])?; + let expected = format!( + "{}:1:1: error[STK1001]: Input is not valid UTF-8.\n", + path.display() + ); + + assert_eq!(output.status.code(), Some(1)); + assert!(output.stdout.is_empty()); + assert_eq!(String::from_utf8(output.stderr)?, expected); + assert_unchanged(&path, &source) +} + +#[test] +fn missing_file_exits_two_with_a_stable_host_error() -> Result<(), Box> { + let directory = TestDirectory::new("missing")?; + let path = directory.path.join("missing.stack"); + + let output = stack([OsStr::new("check"), path.as_os_str()])?; + let expected = format!("error: cannot read '{}': file not found\n", path.display()); + + assert_eq!(output.status.code(), Some(2)); + assert!(output.stdout.is_empty()); + assert_eq!(String::from_utf8(output.stderr)?, expected); + assert!(!path.exists()); + Ok(()) +} + +#[test] +fn help_and_version_are_stdout_only() -> Result<(), Box> { + let help = stack(["--help"])?; + assert_eq!(help.status.code(), Some(0)); + assert!(help.stderr.is_empty()); + assert!(String::from_utf8(help.stdout)?.contains("stack check ")); + + let version = stack(["--version"])?; + assert_eq!(version.status.code(), Some(0)); + assert_eq!(version.stdout, b"stack 0.1.0\n"); + assert!(version.stderr.is_empty()); + Ok(()) +}