From 011eae0e842282cec4829b83bd96f3b0466fc420 Mon Sep 17 00:00:00 2001 From: manabeai <100462113+manabeai@users.noreply.github.com> Date: Thu, 30 Apr 2026 09:02:14 +0900 Subject: [PATCH] Update CLI to open/help behavior and URL-encoded state support --- README.md | 8 +++---- src/lib.rs | 23 +++++++++++++++++++- src/link_state.rs | 55 ++++++++--------------------------------------- src/main.rs | 7 +++--- tests/cli.rs | 32 +++++++++++++++++++++------ 5 files changed, 64 insertions(+), 61 deletions(-) diff --git a/README.md b/README.md index 44eaa17..ac6fa90 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,9 @@ from cp-ast-ecosystems share links. ```sh rt 'https://manabeai.github.io/cp-ast-ecosystems/?state=...' -rt 'v2....' --seed 42 -rt browse +rt '%7B%22schema_version%22%3A1%2C...%7D' --seed 42 +rt open +rt state.txt ``` -The generator accepts both the current compressed `v2.` share state and legacy -base64 state values. +The generator accepts URL-encoded JSON `state` values. diff --git a/src/lib.rs b/src/lib.rs index 7b740ad..5b49704 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,6 +3,7 @@ pub mod browse; pub mod link_state; use cp_ast_core::sample::{generate, sample_to_text}; +use std::path::Path; use thiserror::Error; pub const EDITOR_URL: &str = "https://manabeai.github.io/cp-ast-ecosystems/"; @@ -15,12 +16,32 @@ pub enum RtError { Ast(#[from] ast_dto::AstDtoError), #[error("failed to generate sample: {0}")] Sample(#[from] cp_ast_core::sample::GenerationError), + #[error("failed to read input file {path}: {source}")] + ReadInputFile { path: String, source: std::io::Error }, } pub fn generate_sample_text(input: &str, seed: Option) -> Result<(u64, String), RtError> { - let json = link_state::decode_input(input)?; + let resolved_input = resolve_input(input)?; + let json = link_state::decode_input(&resolved_input)?; let engine = ast_dto::engine_from_json(&json)?; let seed = seed.unwrap_or_else(rand::random::); let sample = generate(&engine, seed)?; Ok((seed, sample_to_text(&engine, &sample))) } + +fn resolve_input(input: &str) -> Result { + let candidate = input.trim(); + if candidate.is_empty() { + return Ok(String::new()); + } + let path = Path::new(candidate); + if path.is_file() { + let contents = std::fs::read_to_string(path).map_err(|source| RtError::ReadInputFile { + path: candidate.to_owned(), + source, + })?; + Ok(contents.trim().to_owned()) + } else { + Ok(candidate.to_owned()) + } +} diff --git a/src/link_state.rs b/src/link_state.rs index b19b729..5d5494f 100644 --- a/src/link_state.rs +++ b/src/link_state.rs @@ -1,24 +1,12 @@ -use std::io::Read; - -use base64::Engine; -use flate2::read::GzDecoder; use thiserror::Error; use url::Url; -const SHARE_STATE_PREFIX: &str = "v2."; - #[derive(Debug, Error)] pub enum LinkStateError { #[error("input does not contain a state value")] MissingState, #[error("invalid URL encoding: {0}")] UrlDecode(String), - #[error("invalid base64 state: {0}")] - Base64(#[from] base64::DecodeError), - #[error("failed to decompress state: {0}")] - Gzip(#[from] std::io::Error), - #[error("state is not valid UTF-8: {0}")] - Utf8(#[from] std::string::FromUtf8Error), } pub fn decode_input(input: &str) -> Result { @@ -55,58 +43,33 @@ pub fn extract_state(input: &str) -> Result { } pub fn decode_state(state: &str) -> Result { - if let Some(encoded) = state.strip_prefix(SHARE_STATE_PREFIX) { - let compressed = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(encoded)?; - let mut decoder = GzDecoder::new(compressed.as_slice()); - let mut json = String::new(); - decoder.read_to_string(&mut json)?; - return Ok(json); - } - let decoded = urlencoding::decode(state).map_err(|err| LinkStateError::UrlDecode(err.to_string()))?; - let bytes = base64::engine::general_purpose::STANDARD.decode(decoded.as_bytes())?; - Ok(String::from_utf8(bytes)?) + Ok(decoded.into_owned()) } #[cfg(test)] mod tests { use super::*; - use flate2::{Compression, write::GzEncoder}; - use std::io::Write; - - fn encode_v2(json: &str) -> String { - let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); - encoder.write_all(json.as_bytes()).unwrap(); - let compressed = encoder.finish().unwrap(); - format!( - "{SHARE_STATE_PREFIX}{}", - base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(compressed) - ) - } #[test] fn extracts_state_from_full_url() { - let input = "https://manabeai.github.io/cp-ast-ecosystems/?state=v2.abc"; - assert_eq!(extract_state(input).unwrap(), "v2.abc"); + let input = "https://manabeai.github.io/cp-ast-ecosystems/?state=%7B%22schema_version%22%3A1%7D"; + assert_eq!( + extract_state(input).unwrap(), + r#"{"schema_version":1}"# + ); } #[test] fn accepts_state_value_directly() { - assert_eq!(extract_state("v2.abc").unwrap(), "v2.abc"); - } - - #[test] - fn decodes_v2_gzip_base64url_state() { - let state = encode_v2(r#"{"schema_version":1}"#); - assert_eq!(decode_state(&state).unwrap(), r#"{"schema_version":1}"#); + assert_eq!(extract_state("abc").unwrap(), "abc"); } #[test] - fn decodes_legacy_url_encoded_base64_state() { + fn decodes_url_encoded_json_state() { let json = r#"{"schema_version":1}"#; - let encoded = base64::engine::general_purpose::STANDARD.encode(json); - let state = urlencoding::encode(&encoded); + let state = urlencoding::encode(json); assert_eq!(decode_state(&state).unwrap(), json); } } diff --git a/src/main.rs b/src/main.rs index 2a428f8..b87137d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,7 +5,8 @@ use random_test_cli::{EDITOR_URL, browse, generate_sample_text}; #[command( name = "rt", version, - about = "Generate random tests from cp-ast share links" + about = "Generate random tests from cp-ast share links", + arg_required_else_help = true )] struct Cli { #[command(subcommand)] @@ -22,7 +23,7 @@ struct Cli { #[derive(Debug, Subcommand)] enum Command { /// Open the cp-ast editor in the default browser. - Browse, + Open, } fn main() { @@ -34,7 +35,7 @@ fn main() { fn run(cli: Cli) -> Result<(), Box> { match cli.command { - Some(Command::Browse) => { + Some(Command::Open) => { browse::open_url(EDITOR_URL)?; } None => { diff --git a/tests/cli.rs b/tests/cli.rs index c9cd9c2..6a2b9ab 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -1,5 +1,4 @@ -use base64::Engine; -use std::process::Command; +use std::{fs, process::Command}; fn minimal_scalar_json() -> &'static str { r#"{ @@ -26,15 +25,14 @@ fn minimal_scalar_json() -> &'static str { }"# } -fn legacy_state() -> String { - urlencoding::encode(&base64::engine::general_purpose::STANDARD.encode(minimal_scalar_json())) - .into_owned() +fn encoded_state() -> String { + urlencoding::encode(minimal_scalar_json()).into_owned() } #[test] fn same_seed_generates_same_output() { let bin = env!("CARGO_BIN_EXE_rt"); - let state = legacy_state(); + let state = encoded_state(); let first = Command::new(bin) .arg(&state) .arg("--seed") @@ -67,7 +65,7 @@ fn full_url_input_is_accepted() { let bin = env!("CARGO_BIN_EXE_rt"); let url = format!( "https://manabeai.github.io/cp-ast-ecosystems/?state={}", - legacy_state() + encoded_state() ); let output = Command::new(bin) .arg(url) @@ -83,3 +81,23 @@ fn full_url_input_is_accepted() { ); assert!(!output.stdout.is_empty()); } + +#[test] +fn input_file_is_accepted() { + let bin = env!("CARGO_BIN_EXE_rt"); + let path = std::env::temp_dir().join("rt_state.txt"); + fs::write(&path, encoded_state()).expect("state file should be written"); + let output = Command::new(bin) + .arg(&path) + .arg("--seed") + .arg("1") + .output() + .expect("rt should run"); + + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(!output.stdout.is_empty()); +}