use std::path::{Path, PathBuf}; use clap::{CommandFactory, Parser, Subcommand, ValueHint}; use clap_complete::Shell; use crate::dice; use crate::srd::fetch::{self, FetchOutcome, SrdSources}; use crate::srd::verify; #[derive(Parser)] #[command( name = "storied", about = "A terminal storytelling experience, driven by an LLM" )] pub struct Cli { #[command(subcommand)] command: Option, } /// The dice grammar, printed after `storied roll --help`'s usage line. This /// mirrors the grammar in `src/dice.rs`; keep the two in sync. const ROLL_AFTER_HELP: &str = "\ Notation is one or more terms joined by + and -, like `3d6-1 + 4d100+5`. A term is a dice group (XdY) or a flat modifier. A group's count is optional (`d20` is `1d20`); `%` means 100 (`d%` is `d100`). Valid die sizes: 2, 3, 4, 6, 8, 10, 12, 20, 100. Suffixes attach to a group, in any order, case-insensitive. Rerolls and minimums settle before a keep/drop suffix picks which dice count. khN, klN keep the highest/lowest N; bare kh/kl keeps 1 dhN, dlN drop the highest/lowest N roN reroll once when the roll equals N, keeping the new value roN reroll once when the roll is greater than N rN reroll a die until the roll no longer equals N rN reroll until the roll is no longer greater than N miN raise each die's value to at least N Examples: 2d20kh advantage 2d20kl disadvantage 4d6kh3 ability score, keeping the best 3 of 4 4d6dl1 the same roll as 4d6kh3, said the other way 2d6ro<3 Great Weapon Fighting d20ro1 Halfling Luck 8d6mi2 Elemental Adept"; #[derive(Subcommand)] enum Command { /// Manage the SRD rules pipeline. Srd { #[command(subcommand)] command: SrdCommand, }, /// Roll dice notation once and print the result. #[command(after_help = ROLL_AFTER_HELP)] Roll { /// Dice notation to roll, like `d20` or `2d6+3`. notation: String, }, /// Talk to a DM with no world and no character, to try out tools and rules. // Coverage builds leave this command out, along with the terminal it // needs. See `src/play/mod.rs`. #[cfg(not(coverage))] Sandbox { /// A file whose contents go to the DM as the player's first /// line, so a test scenario starts without retyping it. #[arg(value_name = "SCENARIO", value_hint = ValueHint::FilePath)] scenario: Option, /// A directory to keep the world and player knowledge in, instead /// of a temporary one. A world already there resumes; one that /// does not exist yet is created. Nothing under it is deleted /// when the session ends. #[arg(long, value_name = "DIR", value_hint = ValueHint::DirPath)] world: Option, /// The base URL of the OpenAI-compatible API to talk to. #[arg(long, value_name = "URL")] api_base: Option, /// The model to play with. #[arg(long, value_name = "NAME")] model: Option, /// How much the model deliberates before it answers. Valid /// values depend on the provider, for example low, high, max on /// DeepInfra's DeepSeek models. #[arg(long, value_name = "LEVEL")] reasoning_effort: Option, }, /// Play in a named, persistent world, creating it after you confirm /// if it is new. // Coverage builds leave this command out, along with the terminal it // needs. See `src/play/mod.rs`. #[cfg(not(coverage))] Play { /// The world's name, a directory under the worlds root. #[arg(value_name = "NAME")] name: String, /// Where named worlds live. Overrides STORIED_WORLDS, the config /// file's worlds_root key, and the default. #[arg(long, value_name = "DIR", value_hint = ValueHint::DirPath)] worlds_root: Option, /// The base URL of the OpenAI-compatible API to talk to. #[arg(long, value_name = "URL")] api_base: Option, /// The model to play with. #[arg(long, value_name = "NAME")] model: Option, /// How much the model deliberates before it answers. Valid /// values depend on the provider, for example low, high, max on /// DeepInfra's DeepSeek models. #[arg(long, value_name = "LEVEL")] reasoning_effort: Option, }, /// Print a completion script for a shell. #[command(after_help = COMPLETIONS_AFTER_HELP)] Completions { /// The shell to print a script for. #[arg(value_name = "SHELL")] shell: Shell, }, } /// Where to put the script, printed after `storied completions --help`. const COMPLETIONS_AFTER_HELP: &str = "\ For bash, write the script where bash-completion loads it on demand: storied completions bash > ~/.local/share/bash-completion/completions/storied"; #[derive(Subcommand)] enum SrdCommand { /// Download and verify the SRD PDF. Fetch, /// Check the SRD corpus against its vendored source and schema. Verify, } /// Runs the parsed command, using `srd_sources` for `storied srd fetch`, /// `layer_root` for `storied srd verify`, and `session_layers` as the DM's /// ordered knowledge layers in `storied sandbox`. pub fn run( cli: Cli, srd_sources: &SrdSources, layer_root: &Path, // `storied sandbox` is the only reader of the layers, and coverage // builds leave that command out along with the terminal it needs, so // the parameter has no reader there. #[cfg_attr(coverage, allow(unused_variables))] session_layers: &[PathBuf], ) -> Result<(), String> { match cli.command { None => { println!("{}", crate::greeting()); Ok(()) } Some(Command::Srd { command: SrdCommand::Fetch, }) => run_srd_fetch(srd_sources), Some(Command::Srd { command: SrdCommand::Verify, }) => run_srd_verify(layer_root), Some(Command::Roll { notation }) => run_roll(¬ation), #[cfg(not(coverage))] Some(Command::Sandbox { scenario, world, api_base, model, reasoning_effort, }) => run_sandbox( &crate::config::Overrides { api_base, model, reasoning_effort, }, session_layers, scenario.as_deref(), world, ), #[cfg(not(coverage))] Some(Command::Play { name, worlds_root, api_base, model, reasoning_effort, }) => run_play( &crate::config::Overrides { api_base, model, reasoning_effort, }, session_layers, &name, worlds_root, ), Some(Command::Completions { shell }) => run_completions(shell), } } /// Prints the completion script for `shell` on stdout. fn run_completions(shell: Shell) -> Result<(), String> { clap_complete::generate( shell, &mut Cli::command(), "storied", &mut std::io::stdout(), ); Ok(()) } /// Boots a world for a sandbox session and plays until the player quits. /// When `scenario` names a file, its contents open the session as the /// player's first line. When `world` names a directory, the session keeps /// its world and player knowledge there instead of a temporary one deleted /// on exit, so a later run with the same directory resumes it. `/settings` /// shows where the session's world and player directories live. #[cfg(not(coverage))] fn run_sandbox( overrides: &crate::config::Overrides, session_layers: &[PathBuf], scenario: Option<&Path>, world: Option, ) -> Result<(), String> { use crate::play::sandbox::Sandbox; let opening = match scenario { Some(path) => { let text = std::fs::read_to_string(path) .map_err(|error| format!("could not read {}: {error}", path.display()))?; if text.trim().is_empty() { return Err(format!("{} is empty", path.display())); } Some(text) } None => None, }; let sandbox = match world { Some(root) => Sandbox::named(root)?, None => Sandbox::new()?, }; let mut layers = session_layers.to_vec(); layers.push(sandbox.world_dir()); layers.push(sandbox.player_dir()); crate::play::run(overrides, &layers, &sandbox.world_dir(), opening) } /// Opens the named world `name` and plays until the player quits. /// /// The worlds root resolves through `crate::worlds::resolve_root`: /// `worlds_root` first, then `STORIED_WORLDS`, then the config file's own /// `worlds_root` key, then the XDG data home default. When `/` /// does not exist yet, this asks at the prompt before it creates /// anything; an answer other than `y` or `yes` ends the command having /// touched nothing. The world directory is the world layer itself, with /// no `world/` subdirectory inside it, and the player's own knowledge /// lives outside the world at a fixed directory shared across every /// world, with a `worlds/` overlay mounted above this one. /// `/settings` shows the world directory, the player directory, and the /// full mount stack once the session starts. #[cfg(not(coverage))] fn run_play( overrides: &crate::config::Overrides, session_layers: &[PathBuf], name: &str, worlds_root: Option, ) -> Result<(), String> { use std::io::Write; let config = crate::config::load(overrides).map_err(|error| error.to_string())?; let home = std::env::var("HOME").unwrap_or_default(); let xdg_data_home = std::env::var("XDG_DATA_HOME").ok(); let env_worlds_root = std::env::var("STORIED_WORLDS").ok(); let root = crate::worlds::resolve_root( worlds_root.as_deref(), env_worlds_root.as_deref(), config.worlds_root.as_deref(), xdg_data_home.as_deref(), &home, ); let world_dir = root.join(name); let resumed = world_dir.is_dir(); if !resumed { print!( "{} does not exist yet. Create it? [y/N] ", world_dir.display() ); std::io::stdout() .flush() .map_err(|error| error.to_string())?; let mut answer = String::new(); std::io::stdin() .read_line(&mut answer) .map_err(|error| error.to_string())?; if !crate::worlds::wants_to_create(&answer) { println!("{} not created.", world_dir.display()); return Ok(()); } } crate::worlds::create_world_dir(&root, name) .map_err(|error| format!("could not create {}: {error}", world_dir.display()))?; let player_root = crate::worlds::player_root(xdg_data_home.as_deref(), &home); crate::worlds::ensure_player_dirs(&player_root, name) .map_err(|error| format!("could not create {}: {error}", player_root.display()))?; let layers = crate::worlds::mount_stack(session_layers, &world_dir, &player_root, name); crate::play::run(overrides, &layers, &world_dir, None) } fn run_srd_fetch(sources: &SrdSources) -> Result<(), String> { let (pdf_outcome, text_outcome) = fetch::fetch_all(sources).map_err(|error| error.to_string())?; report_fetch(&sources.pdf.destination, pdf_outcome); report_fetch(&sources.text.destination, text_outcome); Ok(()) } /// Runs every check against the corpus layer at `layer_root`, printing one /// failure per line and a summary line on success. fn run_srd_verify(layer_root: &Path) -> Result<(), String> { render_verify_report(&verify::verify(layer_root)) } /// Prints `report`'s failures one per line, then either a summary line (no /// failures) or a terse error naming how many there were. fn render_verify_report(report: &verify::Report) -> Result<(), String> { for failure in &report.failures { println!("{failure}"); } if report.failures.is_empty() { println!("{}", report.summary()); Ok(()) } else { Err(format!("{} failure(s)", report.failures.len())) } } /// Parses `notation`, rolls it once with a real random source, and prints /// its story. fn run_roll(notation: &str) -> Result<(), String> { let parsed = dice::parse(notation).map_err(|error| error.to_string())?; let result = dice::roll(&parsed, &mut rand::rng()); println!("{}", dice::story::tell(&result)); Ok(()) } fn report_fetch(destination: &Path, outcome: FetchOutcome) { match outcome { FetchOutcome::AlreadyPresent => { println!("{} is already present.", destination.display()); } FetchOutcome::Downloaded => { println!("Downloaded {}.", destination.display()); } } } /// Maps a CLI result to a process exit code, printing the error on failure. pub fn exit_code(result: Result<(), String>) -> u8 { match result { Ok(()) => 0, Err(message) => { eprintln!("{message}"); 1 } } } #[cfg(test)] mod tests { use super::*; use crate::srd::fetch::{FetchConfig, SrdMeta, TextFetchConfig}; use sha2::{Digest, Sha256}; use std::fs; use std::io::{Read, Write}; use std::net::TcpListener; use std::path::PathBuf; use std::thread::JoinHandle; use tempfile::TempDir; fn fetch_cli() -> Cli { Cli { command: Some(Command::Srd { command: SrdCommand::Fetch, }), } } fn verify_cli() -> Cli { Cli { command: Some(Command::Srd { command: SrdCommand::Verify, }), } } fn roll_cli(notation: &str) -> Cli { Cli { command: Some(Command::Roll { notation: notation.to_string(), }), } } fn completions_cli(shell: Shell) -> Cli { Cli { command: Some(Command::Completions { shell }), } } fn unused_sources() -> SrdSources { SrdSources { pdf: FetchConfig { url: "http://127.0.0.1:0".to_string(), destination: PathBuf::from("unused"), expected_sha256: "0".repeat(64), }, text: never_dialed_text_config(), meta: fixture_meta(), } } fn sha256_hex(bytes: &[u8]) -> String { let mut hasher = Sha256::new(); hasher.update(bytes); hasher .finalize() .iter() .map(|byte| format!("{byte:02x}")) .collect() } /// Serves `body` once to the first connection it accepts, as a complete /// HTTP/1.1 response, then returns the URL to fetch it from. fn serve_once(body: Vec) -> (String, JoinHandle<()>) { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let addr = listener.local_addr().unwrap(); let handle = std::thread::spawn(move || { let (mut stream, _) = listener.accept().unwrap(); let mut request = [0u8; 1024]; let _ = stream.read(&mut request).unwrap(); let header = format!( "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len() ); stream.write_all(header.as_bytes()).unwrap(); stream.write_all(&body).unwrap(); }); (format!("http://{addr}/fixture.pdf"), handle) } /// Appends a tar entry for `path` to `builder`, as a directory when /// `contents` is `None` or as a file with that content otherwise. fn append_entry(builder: &mut tar::Builder>, path: &str, contents: Option<&[u8]>) { let mut header = tar::Header::new_gnu(); header.set_path(path).unwrap(); header.set_mode(0o644); match contents { Some(bytes) => { header.set_size(bytes.len() as u64); header.set_cksum(); builder.append(&header, bytes).unwrap(); } None => { header.set_size(0); header.set_entry_type(tar::EntryType::Directory); header.set_cksum(); builder.append(&header, std::io::empty()).unwrap(); } } } /// Builds a tiny gzipped tarball with one top-level directory and one /// file inside it, standing in for the real dnd.srd.5.2.1 tarball. fn tiny_tar_gz(top_dir: &str) -> Vec { let mut builder = tar::Builder::new(Vec::new()); append_entry(&mut builder, &format!("{top_dir}/"), None); append_entry( &mut builder, &format!("{top_dir}/file.md"), Some(b"# Fixture\n"), ); let tar_bytes = builder.into_inner().unwrap(); let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast()); encoder.write_all(&tar_bytes).unwrap(); encoder.finish().unwrap() } fn never_dialed_text_config() -> TextFetchConfig { TextFetchConfig { url: "http://127.0.0.1:0".to_string(), destination: PathBuf::from("unused"), expected_tarball_sha256: "0".repeat(64), commit: "deadbeef".to_string(), meta_path: PathBuf::from("unused-meta.yaml"), } } fn fixture_meta() -> SrdMeta { SrdMeta { version: "5.2.1".to_string(), pdf_path: "sources/SRD_CC_v5.2.1.pdf".to_string(), pdf_url: "https://example.test/srd.pdf".to_string(), pdf_sha256: "a".repeat(64), text_path: "sources/dnd.srd.5.2.1/".to_string(), text_url: "https://example.test/dnd.srd.5.2.1".to_string(), text_commit: "deadbeef".to_string(), text_tarball_sha256: "b".repeat(64), } } #[test] fn run_with_no_command_prints_the_greeting() { let sources = SrdSources { pdf: FetchConfig { url: "http://127.0.0.1:0".to_string(), destination: PathBuf::from("unused"), expected_sha256: "0".repeat(64), }, text: never_dialed_text_config(), meta: fixture_meta(), }; let layer_dir = TempDir::new().unwrap(); let result = run(Cli { command: None }, &sources, layer_dir.path(), &[]); assert_eq!(result, Ok(())); } #[test] fn run_srd_fetch_reports_an_already_present_file() { let pdf_dir = TempDir::new().unwrap(); let text_dir = TempDir::new().unwrap(); let meta_dir = TempDir::new().unwrap(); let layer_dir = TempDir::new().unwrap(); let pdf_body = b"already present".to_vec(); let pdf_expected_sha256 = sha256_hex(&pdf_body); let pdf_destination = pdf_dir.path().join("fixture.pdf"); fs::write(&pdf_destination, &pdf_body).unwrap(); let text_destination = text_dir.path().join("dnd.srd.5.2.1"); fs::create_dir_all(&text_destination).unwrap(); let meta_path = meta_dir.path().join("meta.yaml"); fs::write( &meta_path, "sources:\n - path: sources/dnd.srd.5.2.1/\n commit: deadbeef\n", ) .unwrap(); let sources = SrdSources { pdf: FetchConfig { url: "http://127.0.0.1:0".to_string(), destination: pdf_destination, expected_sha256: pdf_expected_sha256, }, text: TextFetchConfig { url: "http://127.0.0.1:0".to_string(), destination: text_destination, expected_tarball_sha256: "0".repeat(64), commit: "deadbeef".to_string(), meta_path, }, meta: fixture_meta(), }; let result = run(fetch_cli(), &sources, layer_dir.path(), &[]); assert_eq!(result, Ok(())); } #[test] fn run_srd_fetch_reports_a_successful_download() { let pdf_body = b"freshly downloaded".to_vec(); let pdf_expected_sha256 = sha256_hex(&pdf_body); let (pdf_url, pdf_server) = serve_once(pdf_body); let text_body = tiny_tar_gz("dnd.srd.5.2.1-deadbeef"); let text_expected_sha256 = sha256_hex(&text_body); let (text_url, text_server) = serve_once(text_body); let pdf_dir = TempDir::new().unwrap(); let text_dir = TempDir::new().unwrap(); let meta_dir = TempDir::new().unwrap(); let layer_dir = TempDir::new().unwrap(); let sources = SrdSources { pdf: FetchConfig { url: pdf_url, destination: pdf_dir.path().join("fixture.pdf"), expected_sha256: pdf_expected_sha256, }, text: TextFetchConfig { url: text_url, destination: text_dir.path().join("dnd.srd.5.2.1"), expected_tarball_sha256: text_expected_sha256, commit: "deadbeef".to_string(), meta_path: meta_dir.path().join("meta.yaml"), }, meta: fixture_meta(), }; let result = run(fetch_cli(), &sources, layer_dir.path(), &[]); assert_eq!(result, Ok(())); pdf_server.join().unwrap(); text_server.join().unwrap(); } #[test] fn run_srd_fetch_reports_a_download_failure_as_an_error() { let pdf_dir = TempDir::new().unwrap(); let layer_dir = TempDir::new().unwrap(); let sources = SrdSources { pdf: FetchConfig { url: "http://127.0.0.1:0/fixture.pdf".to_string(), destination: pdf_dir.path().join("fixture.pdf"), expected_sha256: "0".repeat(64), }, text: never_dialed_text_config(), meta: fixture_meta(), }; let result = run(fetch_cli(), &sources, layer_dir.path(), &[]); assert!(result.is_err()); } #[test] fn run_srd_verify_reports_failures_as_an_error() { let sources = SrdSources { pdf: FetchConfig { url: "http://127.0.0.1:0".to_string(), destination: PathBuf::from("unused"), expected_sha256: "0".repeat(64), }, text: never_dialed_text_config(), meta: fixture_meta(), }; let layer_dir = TempDir::new().unwrap(); let result = run(verify_cli(), &sources, layer_dir.path(), &[]); assert!(result.is_err()); } #[test] fn run_completions_prints_a_script_for_bash() { let layer_dir = TempDir::new().unwrap(); let result = run( completions_cli(Shell::Bash), &unused_sources(), layer_dir.path(), &[], ); assert_eq!(result, Ok(())); } #[test] fn run_roll_prints_the_story_for_valid_notation() { let layer_dir = TempDir::new().unwrap(); let result = run(roll_cli("4d6kh3"), &unused_sources(), layer_dir.path(), &[]); assert_eq!(result, Ok(())); } #[test] fn run_roll_reports_a_parse_error_for_invalid_notation() { let layer_dir = TempDir::new().unwrap(); let result = run(roll_cli("banana"), &unused_sources(), layer_dir.path(), &[]); assert!(result.is_err()); } #[test] fn render_verify_report_succeeds_with_a_summary_when_there_are_no_failures() { let report = verify::Report { failures: vec![], counts: std::collections::HashMap::new(), }; let result = render_verify_report(&report); assert_eq!(result, Ok(())); } #[test] fn render_verify_report_reports_the_failure_count_as_an_error() { let report = verify::Report { failures: vec![ verify::Failure::new("spells/fireball.md", "missing name"), verify::Failure::new("monsters/goblin.md", "missing ac"), ], counts: std::collections::HashMap::new(), }; let result = render_verify_report(&report); assert_eq!(result, Err("2 failure(s)".to_string())); } #[test] fn exit_code_is_zero_on_success() { assert_eq!(exit_code(Ok(())), 0); } #[test] fn exit_code_is_one_on_failure() { assert_eq!(exit_code(Err("something broke".to_string())), 1); } }