From 7f7e1865637da086ff7883586767b5f4794901d7 Mon Sep 17 00:00:00 2001 From: konojunya Date: Thu, 3 Sep 2026 17:15:56 +0900 Subject: [PATCH] Add atomic stack render command --- .github/workflows/ci.yaml | 6 + README.md | 12 +- scripts/validate-render.py | 29 ++++ src/lib.rs | 305 +++++++++++++++++++++++++++++++++++- tests/fixtures/render.stack | 5 + tests/render.rs | 217 +++++++++++++++++++++++++ 6 files changed, 564 insertions(+), 10 deletions(-) create mode 100644 scripts/validate-render.py create mode 100644 tests/fixtures/render.stack create mode 100644 tests/render.rs diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 34239b9..3897178 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -57,10 +57,15 @@ jobs: 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: Validate rendered SVG + run: python3 scripts/validate-render.py - name: Smoke test command metadata run: | ./target/release/stack --help ./target/release/stack --version + ./target/release/stack check --help + ./target/release/stack fmt --help + ./target/release/stack render --help - name: Verify repository files run: | test -s README.md @@ -70,6 +75,7 @@ jobs: test -s Cargo.lock test -s src/main.rs test -s tests/specification-revision + test -s tests/fixtures/render.stack msrv: name: Minimum supported Rust diff --git a/README.md b/README.md index cf167ef..19b8bf2 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ `stack-sh/cli` is the private source repository for the native Rust `stack` command. -The repository contains native validation and formatting commands. The CLI is not yet distributed as a supported external binary and its interface remains pre-release. +The repository contains native validation, formatting, and rendering commands. The CLI is not yet distributed as a supported external binary and its interface remains pre-release. ## Commands @@ -11,24 +11,22 @@ stack check arch.stack stack fmt arch.stack stack fmt --check arch.stack stack fmt - +stack render arch.stack +stack render arch.stack -o arch.svg ``` `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. `stack fmt` uses the engine formatter and preserves comments. File mode replaces changed source atomically through a temporary file in the same directory; unchanged files are not replaced. Syntax, encoding, and host I/O failures leave the original file untouched. `stack fmt -` reads bytes from standard input and writes only canonical source to standard output. `--check` never writes source and exits with status `1` when formatting is required. +`stack render` uses the same engine pipeline to produce deterministic standalone SVG. Without `-o`, standard output contains only SVG. With `-o`, the output is written atomically in the destination directory. Diagnostics remain on standard error, warnings preserve SVG, and Stack errors never create or replace output. + | Result | Exit status | | --- | ---: | | No error diagnostics, including warning-only input | `0` | | One or more Stack error diagnostics, or `fmt --check` finds a difference | `1` | | Invalid arguments, host I/O failure, or engine operational failure | `2` | -The remaining planned command is: - -```text -stack render arch.stack -o arch.svg -``` - The CLI will link `stack-engine` as a native Rust dependency. It owns filesystem and standard-stream behavior, process exit codes, configuration discovery, and command presentation. It must not duplicate compiler, formatter, layout, or SVG-rendering logic. Future authenticated theme delivery may add a client for short-lived, scope-limited Stack tokens and entitlement-aware theme downloads. Credentials and downloaded paid-theme contents must never be committed to this repository. diff --git a/scripts/validate-render.py b/scripts/validate-render.py new file mode 100644 index 0000000..2f2e301 --- /dev/null +++ b/scripts/validate-render.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 +from pathlib import Path +import subprocess +import xml.etree.ElementTree as ET + + +ROOT = Path(__file__).resolve().parent.parent +BINARY = ROOT / "target" / "release" / "stack" +FIXTURE = ROOT / "tests" / "fixtures" / "render.stack" + + +completed = subprocess.run( + [BINARY, "render", FIXTURE], + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, +) +if completed.returncode != 0: + raise SystemExit(completed.stderr.decode("utf-8", errors="replace")) +if completed.stderr: + raise SystemExit("render smoke emitted unexpected diagnostics") + +root = ET.fromstring(completed.stdout) +if root.tag != "{http://www.w3.org/2000/svg}svg": + raise SystemExit("render output is not an SVG root element") +if not root.attrib.get("viewBox"): + raise SystemExit("render output has no viewBox") + +print("validated CLI standalone SVG") diff --git a/src/lib.rs b/src/lib.rs index 7dc9a01..7d4aee1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,7 +8,9 @@ use std::fs::{self, File, OpenOptions}; use std::io::{self, Read, Write}; use std::path::{Path, PathBuf}; -use stack_engine::{CheckOutput, Diagnostic, Engine, FormatOutput, OperationalError, Severity}; +use stack_engine::{ + CheckOutput, Diagnostic, Engine, FormatOutput, OperationalError, RenderOutput, Severity, +}; /// Exit status used when a command completes without Stack error diagnostics. pub const EXIT_SUCCESS: u8 = 0; @@ -17,10 +19,11 @@ 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 fmt [--check] \n stack --help\n stack --version\n\nCommands:\n check Validate a Stack source file without modifying it\n fmt Format a file in place or read from standard input\n"; +const GENERAL_HELP: &str = "Stack diagram toolchain\n\nUsage:\n stack check \n stack fmt [--check] \n stack render [-o ]\n stack --help\n stack --version\n\nCommands:\n check Validate a Stack source file without modifying it\n fmt Format a file in place or read from standard input\n render Render standalone SVG to standard output or a file\n"; const CHECK_HELP: &str = "Validate a Stack source file without modifying it\n\nUsage:\n stack check \n"; const FORMAT_HELP: &str = "Format Stack source canonically\n\nUsage:\n stack fmt \n stack fmt --check \n stack fmt -\n\nArguments:\n Format the file atomically in place\n - Read from standard input and write to standard output\n\nOptions:\n --check Report whether formatting is required without writing output\n"; +const RENDER_HELP: &str = "Render Stack source as standalone SVG\n\nUsage:\n stack render \n stack render -o \n\nArguments:\n Read Stack source bytes from this file\n\nOptions:\n -o Write SVG atomically instead of using standard output\n"; #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum FormatMode { @@ -28,6 +31,12 @@ enum FormatMode { Check, } +#[derive(Debug, PartialEq, Eq)] +enum RenderDestination { + Stdout, + File(PathBuf), +} + /// Runs the CLI with explicit streams and returns its process exit status. pub fn run( arguments: impl IntoIterator, @@ -68,6 +77,9 @@ pub fn run( if command == OsStr::new("fmt") { return run_format(arguments, stdin, stdout, stderr); } + if command == OsStr::new("render") { + return run_render(arguments, stdout, stderr); + } argument_error( &format!("unknown command '{}'", command.to_string_lossy()), @@ -75,6 +87,58 @@ pub fn run( ) } +fn run_render( + mut arguments: impl Iterator, + stdout: &mut dyn Write, + stderr: &mut dyn Write, +) -> u8 { + let Some(source) = arguments.next() else { + return argument_error("missing file for 'stack render'", stderr); + }; + if source == OsStr::new("--help") || source == OsStr::new("-h") { + if let Some(extra) = arguments.next() { + return argument_error( + &format!("unexpected argument '{}'", extra.to_string_lossy()), + stderr, + ); + } + return write_stdout(RENDER_HELP, stdout, stderr); + } + if source.to_string_lossy().starts_with('-') { + return argument_error( + &format!("unknown option '{}'", source.to_string_lossy()), + stderr, + ); + } + + let destination = match arguments.next() { + None => RenderDestination::Stdout, + Some(option) if option == OsStr::new("-o") => { + let Some(output) = arguments.next() else { + return argument_error("missing output file after '-o'", stderr); + }; + if let Some(extra) = arguments.next() { + return argument_error( + &format!("unexpected argument '{}'", extra.to_string_lossy()), + stderr, + ); + } + if output == source { + return argument_error("input and output files must be different", stderr); + } + RenderDestination::File(PathBuf::from(output)) + } + Some(extra) => { + return argument_error( + &format!("unexpected argument '{}'", extra.to_string_lossy()), + stderr, + ); + } + }; + + render_file(Path::new(&source), destination, stdout, stderr) +} + fn run_format( mut arguments: impl Iterator, stdin: &mut dyn Read, @@ -195,6 +259,85 @@ fn check_file_with( } } +fn render_file( + path: &Path, + destination: RenderDestination, + stdout: &mut dyn Write, + stderr: &mut dyn Write, +) -> u8 { + render_file_with( + path, + destination, + stdout, + stderr, + |source| Engine::bundled().render(source), + atomic_write_output, + ) +} + +fn render_file_with( + path: &Path, + destination: RenderDestination, + stdout: &mut dyn Write, + stderr: &mut dyn Write, + render: impl FnOnce(&[u8]) -> Result, + write_output: impl FnOnce(&Path, &[u8]) -> io::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 render(&source) { + Ok(output) => output, + Err(error) => { + return write_stderr_error( + &format!("cannot render '{}': {error}", path.display()), + stderr, + ); + } + }; + let has_errors = match write_diagnostics(path, &output.diagnostics, stderr) { + Ok(has_errors) => has_errors, + Err(()) => return EXIT_USAGE_OR_IO, + }; + if has_errors { + return EXIT_STACK_ERROR; + } + let Some(svg) = output.svg else { + return write_stderr_error("renderer produced no SVG or error diagnostic", stderr); + }; + + match destination { + RenderDestination::Stdout => { + if stdout.write_all(svg.as_bytes()).is_err() { + return write_stderr_error("cannot write rendered SVG", stderr); + } + } + RenderDestination::File(output_path) => { + if let Err(error) = write_output(&output_path, svg.as_bytes()) { + return write_stderr_error( + &format!( + "cannot write '{}': {}", + output_path.display(), + stable_io_error(error.kind()) + ), + stderr, + ); + } + } + } + EXIT_SUCCESS +} + fn format_file(mode: FormatMode, path: &Path, stderr: &mut dyn Write) -> u8 { format_file_with( mode, @@ -332,6 +475,23 @@ fn format_stdin_with( fn atomic_replace(path: &Path, contents: &[u8]) -> io::Result<()> { let permissions = fs::metadata(path)?.permissions(); + atomic_write(path, contents, Some(permissions)) +} + +fn atomic_write_output(path: &Path, contents: &[u8]) -> io::Result<()> { + let permissions = match fs::metadata(path) { + Ok(metadata) => Some(metadata.permissions()), + Err(error) if error.kind() == io::ErrorKind::NotFound => None, + Err(error) => return Err(error), + }; + atomic_write(path, contents, permissions) +} + +fn atomic_write( + path: &Path, + contents: &[u8], + permissions: Option, +) -> io::Result<()> { let parent = path .parent() .filter(|parent| !parent.as_os_str().is_empty()) @@ -340,7 +500,10 @@ fn atomic_replace(path: &Path, contents: &[u8]) -> io::Result<()> { let prepared = temporary_file .write_all(contents) - .and_then(|()| temporary_file.set_permissions(permissions)) + .and_then(|()| match permissions { + Some(permissions) => temporary_file.set_permissions(permissions), + None => Ok(()), + }) .and_then(|()| temporary_file.sync_all()); drop(temporary_file); if let Err(error) = prepared { @@ -539,6 +702,36 @@ mod tests { OsString::from("file.stack"), OsString::from("extra"), ], + vec![OsString::from("render")], + vec![ + OsString::from("render"), + OsString::from("--help"), + OsString::from("extra"), + ], + vec![OsString::from("render"), OsString::from("--unknown")], + vec![ + OsString::from("render"), + OsString::from("file.stack"), + OsString::from("-o"), + ], + vec![ + OsString::from("render"), + OsString::from("file.stack"), + OsString::from("unexpected"), + ], + vec![ + OsString::from("render"), + OsString::from("file.stack"), + OsString::from("-o"), + OsString::from("out.svg"), + OsString::from("extra"), + ], + vec![ + OsString::from("render"), + OsString::from("file.stack"), + OsString::from("-o"), + OsString::from("file.stack"), + ], vec![ OsString::from("check"), OsString::from("file.stack"), @@ -578,6 +771,17 @@ mod tests { EXIT_SUCCESS ); assert_eq!(stdout, FORMAT_HELP.as_bytes()); + + stdout.clear(); + assert_eq!( + run_without_input( + [OsString::from("render"), OsString::from("--help")], + &mut stdout, + &mut stderr, + ), + EXIT_SUCCESS + ); + assert_eq!(stdout, RENDER_HELP.as_bytes()); } #[test] @@ -884,4 +1088,99 @@ mod tests { Some(io::ErrorKind::NotFound) ); } + + #[test] + fn render_failures_do_not_emit_partial_artifacts() { + let path = std::env::temp_dir().join(format!( + "stack-cli-render-failures-{}.stack", + std::process::id() + )); + let source = b"stack 1.0 diagram \"Valid\" { node api \"API\" }"; + assert!(fs::write(&path, source).is_ok()); + + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + assert_eq!( + render_file_with( + &path, + RenderDestination::Stdout, + &mut stdout, + &mut stderr, + |_| { + Err(OperationalError::InvalidIntermediateRepresentation { + reason: "test failure", + }) + }, + atomic_write_output, + ), + EXIT_USAGE_OR_IO + ); + assert!(stdout.is_empty()); + + let output = Engine::bundled().render(source); + assert!(output.is_ok()); + let Ok(mut empty_output) = output else { + return; + }; + empty_output.svg = None; + empty_output.diagnostics.clear(); + stderr.clear(); + assert_eq!( + render_file_with( + &path, + RenderDestination::Stdout, + &mut stdout, + &mut stderr, + |_| Ok(empty_output), + atomic_write_output, + ), + EXIT_USAGE_OR_IO + ); + + let mut failed_stdout = FailingWriter; + stderr.clear(); + assert_eq!( + render_file_with( + &path, + RenderDestination::Stdout, + &mut failed_stdout, + &mut stderr, + |source| Engine::bundled().render(source), + atomic_write_output, + ), + EXIT_USAGE_OR_IO + ); + + let output_path = path.with_extension("svg"); + stderr.clear(); + assert_eq!( + render_file_with( + &path, + RenderDestination::File(output_path.clone()), + &mut stdout, + &mut stderr, + |source| Engine::bundled().render(source), + |_, _| Err(io::Error::from(io::ErrorKind::PermissionDenied)), + ), + EXIT_USAGE_OR_IO + ); + assert!(!output_path.exists()); + + let syntax = b"stack 1.0 diagram \"Incomplete\" {"; + assert!(fs::write(&path, syntax).is_ok()); + let mut failed_stderr = FailingWriter; + assert_eq!( + render_file_with( + &path, + RenderDestination::Stdout, + &mut stdout, + &mut failed_stderr, + |source| Engine::bundled().render(source), + atomic_write_output, + ), + EXIT_USAGE_OR_IO + ); + assert!(stdout.is_empty()); + assert!(fs::remove_file(path).is_ok()); + } } diff --git a/tests/fixtures/render.stack b/tests/fixtures/render.stack new file mode 100644 index 0000000..dbe4279 --- /dev/null +++ b/tests/fixtures/render.stack @@ -0,0 +1,5 @@ +stack 1.0 diagram "CLI render" { + node web "Web" + edge web -> api "HTTPS" + node api "API" +} diff --git a/tests/render.rs b/tests/render.rs new file mode 100644 index 0000000..7f54efa --- /dev/null +++ b/tests/render.rs @@ -0,0 +1,217 @@ +use std::env; +use std::error::Error; +use std::ffi::OsStr; +use std::fs; +use std::path::PathBuf; +use std::process::{Command, Output}; +use std::sync::atomic::{AtomicU64, Ordering}; + +use stack_engine::Engine; + +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-render-{}-{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 engine_svg(source: &[u8]) -> Result> { + let output = Engine::bundled().render(source)?; + output + .svg + .ok_or_else(|| format!("engine returned no SVG: {:?}", output.diagnostics).into()) +} + +#[test] +fn stdout_is_exactly_the_engine_svg() -> Result<(), Box> { + let directory = TestDirectory::new("stdout")?; + let source = b"stack 1.0 diagram \"API\" { node web \"Web\" edge web -> api \"HTTPS\" node api \"API\" }"; + let path = directory.file("arch.stack", source)?; + let expected = engine_svg(source)?; + + let output = stack([OsStr::new("render"), path.as_os_str()])?; + + assert_eq!(output.status.code(), Some(0)); + assert_eq!(output.stdout, expected.as_bytes()); + assert!(output.stderr.is_empty()); + assert_eq!(fs::read(&path)?, source); + Ok(()) +} + +#[test] +fn warnings_stay_on_stderr_without_suppressing_svg() -> Result<(), Box> { + let directory = TestDirectory::new("warning")?; + let source = + b"stack 1.0 diagram \"Fallback\" { theme neon node api \"API\" { icon \"missing\" } }"; + let path = directory.file("warning.stack", source)?; + let expected = engine_svg(source)?; + + let output = stack([OsStr::new("render"), path.as_os_str()])?; + let stderr = String::from_utf8(output.stderr)?; + + assert_eq!(output.status.code(), Some(0)); + assert_eq!(output.stdout, expected.as_bytes()); + assert!(stderr.contains("warning[STK6001]")); + assert!(stderr.contains("warning[STK5001]")); + Ok(()) +} + +#[test] +fn output_file_is_replaced_atomically_with_exact_engine_bytes() -> Result<(), Box> { + let directory = TestDirectory::new("output")?; + let source = b"stack 1.0 diagram \"Output\" { node api \"API\" }"; + let input = directory.file("arch.stack", source)?; + let output_path = directory.file("arch.svg", b"sentinel")?; + let expected = engine_svg(source)?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&output_path, fs::Permissions::from_mode(0o640))?; + } + + let output = stack([ + OsStr::new("render"), + input.as_os_str(), + OsStr::new("-o"), + output_path.as_os_str(), + ])?; + + assert_eq!(output.status.code(), Some(0)); + assert!(output.stdout.is_empty()); + assert!(output.stderr.is_empty()); + assert_eq!(fs::read(&output_path)?, expected.as_bytes()); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + fs::metadata(&output_path)?.permissions().mode() & 0o777, + 0o640 + ); + } + assert_eq!(fs::read_dir(&directory.path)?.count(), 2); + + fs::remove_file(&output_path)?; + let created = stack([ + OsStr::new("render"), + input.as_os_str(), + OsStr::new("-o"), + output_path.as_os_str(), + ])?; + assert_eq!(created.status.code(), Some(0)); + assert!(created.stdout.is_empty()); + assert!(created.stderr.is_empty()); + assert_eq!(fs::read(&output_path)?, expected.as_bytes()); + assert_eq!(fs::read_dir(&directory.path)?.count(), 2); + Ok(()) +} + +#[test] +fn compiler_errors_never_create_or_replace_output() -> Result<(), Box> { + let directory = TestDirectory::new("source-error")?; + let source = b"stack 1.0 diagram \"Incomplete\" {"; + let input = directory.file("invalid.stack", source)?; + let output_path = directory.file("existing.svg", b"sentinel")?; + + let stdout = stack([OsStr::new("render"), input.as_os_str()])?; + assert_eq!(stdout.status.code(), Some(1)); + assert!(stdout.stdout.is_empty()); + assert!(String::from_utf8(stdout.stderr)?.contains("error[STK2003]")); + + let file = stack([ + OsStr::new("render"), + input.as_os_str(), + OsStr::new("-o"), + output_path.as_os_str(), + ])?; + assert_eq!(file.status.code(), Some(1)); + assert!(file.stdout.is_empty()); + assert!(String::from_utf8(file.stderr)?.contains("error[STK2003]")); + assert_eq!(fs::read(&output_path)?, b"sentinel"); + assert_eq!(fs::read_dir(&directory.path)?.count(), 2); + Ok(()) +} + +#[test] +fn missing_input_and_output_parent_are_host_failures() -> Result<(), Box> { + let directory = TestDirectory::new("host-failure")?; + let missing = directory.path.join("missing.stack"); + let missing_output = stack([OsStr::new("render"), missing.as_os_str()])?; + assert_eq!(missing_output.status.code(), Some(2)); + assert!(missing_output.stdout.is_empty()); + assert!(String::from_utf8(missing_output.stderr)?.contains("file not found")); + + let source = b"stack 1.0 diagram \"Output\" { node api \"API\" }"; + let input = directory.file("arch.stack", source)?; + let output_path = directory.path.join("missing").join("arch.svg"); + let output = stack([ + OsStr::new("render"), + input.as_os_str(), + OsStr::new("-o"), + output_path.as_os_str(), + ])?; + assert_eq!(output.status.code(), Some(2)); + assert!(output.stdout.is_empty()); + assert!(String::from_utf8(output.stderr)?.contains("cannot write")); + assert!(!output_path.exists()); + Ok(()) +} + +#[cfg(unix)] +#[test] +fn atomic_output_failure_leaves_no_partial_file() -> Result<(), Box> { + use std::os::unix::fs::PermissionsExt; + + let directory = TestDirectory::new("atomic-failure")?; + let source = b"stack 1.0 diagram \"Output\" { node api \"API\" }"; + let input = directory.file("arch.stack", source)?; + let output_path = directory.path.join("arch.svg"); + let original_permissions = fs::metadata(&directory.path)?.permissions(); + fs::set_permissions(&directory.path, fs::Permissions::from_mode(0o555))?; + + let output = stack([ + OsStr::new("render"), + input.as_os_str(), + OsStr::new("-o"), + output_path.as_os_str(), + ]); + let restored = fs::set_permissions(&directory.path, original_permissions); + restored?; + let output = output?; + + assert_eq!(output.status.code(), Some(2)); + assert!(output.stdout.is_empty()); + assert!(String::from_utf8(output.stderr)?.contains("cannot write")); + assert!(!output_path.exists()); + assert_eq!(fs::read_dir(&directory.path)?.count(), 1); + Ok(()) +}