use std::io::{IsTerminal, Write};
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use anyhow::Context;
const VERSION: &str = env!("CARGO_PKG_VERSION");
fn help() -> String {
format!(
"beecast {VERSION} — Browser Cast\n\
Turn an asciinema .cast recording into a single self-contained .html player page:\n\
zero network requests, so a saved copy keeps working fully offline.\n\
\n\
Usage: beecast <command> [options] — a bare `beecast` prints this help at a terminal\n\
(a machine invocation must name a command and gets a usage error otherwise)\n\
\n\
Commands:\n\
\x20 • build <recording.cast> Generate the player page next to the recording.\n\
\x20 --meta <file.json> Metadata sidecar; default: <recording>.meta.json when it exists.\n\
\x20 -o, --output <file> Output path; default: <recording>.html. `-o -` writes to stdout.\n\
\x20 • schema Print the metadata sidecar's JSON Schema to stdout.\n\
\x20 • version Print the version.\n\
\x20 • help [topic] This help, or one of: build, schema, exitcodes.\n\
\n\
Global flags (any command):\n\
\x20 --json Machine output (single-key JSON); the default when stdout is not a TTY.\n\
\x20 --color=<auto|never|no> Color for human output; never/no disable it, as does NO_COLOR.\n\
\n\
The sidecar carries {{ title, summary, chapters }}; see dto/SCHEMA.md. Chapters are keyed\n\
by fractional seconds and the first one must start at 0. The generated page supports\n\
chapter navigation, 0.5×–3× speed, and ?t=<seconds>¬e=<comment> deep links.\n"
)
}
fn help_topic(topic: &str) -> Option<String> {
match topic {
"build" => Some(
"beecast build <recording.cast> [--meta <file.json>] [-o <output.html>]\n\
\n\
Reads the recording (asciicast v1, v2, or v3) and writes one self-contained HTML\n\
page embedding the player, the styles, the recording, and the metadata. --meta\n\
defaults to <recording>.meta.json when that file exists; the page falls back to\n\
the recording's filename when the metadata has no title. `-o -` streams the HTML\n\
to stdout (diagnostics stay on stderr).\n"
.into(),
),
"schema" => Some(
"beecast schema\n\
\n\
Prints the metadata sidecar's formal JSON Schema to stdout, generated from the Rust\n\
types in the beecast-dto crate (the source of truth); dto/SCHEMA.md is the\n\
human-readable rendering.\n"
.into(),
),
"exitcodes" => Some(
"Exit codes:\n\
\x20 0 success\n\
\x20 1 failure (unreadable input, invalid cast or metadata, write error)\n\
\x20 2 usage (unknown command or flag, missing argument, non-canonical machine invocation)\n\
\x20 130 interrupted (Ctrl+C / SIGINT)\n\
A broken pipe (e.g. `beecast schema | head`) ends the program quietly, per SIGPIPE convention.\n\
Machine-mode errors also carry a `stage` field (`usage` or `request`) for scripts to branch on.\n"
.into(),
),
_ => None,
}
}
struct BuildArgs {
cast: PathBuf,
meta: Option<PathBuf>,
output: Option<PathBuf>,
}
struct Usage(String);
fn main() -> ExitCode {
let mut args: Vec<String> = std::env::args().skip(1).collect();
let json_flag = args.iter().any(|a| a == "--json");
let global = match parse_global(&mut args) {
Ok(g) => g,
Err(Usage(msg)) => {
report_error(&msg, json_flag || !std::io::stdout().is_terminal(), "usage", false);
return ExitCode::from(2);
}
};
match dispatch(&args, global.machine, global.color) {
Ok(()) => ExitCode::SUCCESS,
Err(Fail::Usage(msg)) => {
report_error(&msg, global.machine, "usage", global.color);
ExitCode::from(2)
}
Err(Fail::Run(e)) => {
report_error(&format!("{e:#}"), global.machine, "request", global.color);
ExitCode::FAILURE
}
}
}
struct Global {
machine: bool,
color: bool,
}
fn parse_global(args: &mut Vec<String>) -> Result<Global, Usage> {
let mut json = false;
let mut color_off = false;
let mut rest = Vec::with_capacity(args.len());
for a in args.drain(..) {
if a == "--json" {
json = true;
} else if let Some(mode) = a.strip_prefix("--color=") {
match mode {
"auto" => color_off = false,
"never" | "no" => color_off = true,
other => return Err(Usage(format!("unknown --color mode `{other}` (supported: auto, never, no)"))),
}
} else if a == "--color" {
return Err(Usage("--color needs a mode: --color=<auto|never|no>".into()));
} else {
rest.push(a);
}
}
*args = rest;
let machine = json || !std::io::stdout().is_terminal();
let color = !color_off && std::env::var_os("NO_COLOR").is_none() && std::io::stderr().is_terminal();
Ok(Global { machine, color })
}
enum Fail {
Usage(String),
Run(anyhow::Error),
}
impl From<Usage> for Fail {
fn from(u: Usage) -> Self {
Fail::Usage(u.0)
}
}
impl From<anyhow::Error> for Fail {
fn from(e: anyhow::Error) -> Self {
Fail::Run(e)
}
}
fn dispatch(args: &[String], machine: bool, color: bool) -> Result<(), Fail> {
let first = args.first().map(String::as_str);
match first {
None if machine => {
Err(Usage("a command is required: beecast <build|schema|version|help> — run `beecast help`".into()).into())
}
None | Some("help") | Some("--help") | Some("-h") => {
match args.get(1) {
Some(topic) => match help_topic(topic) {
Some(text) => emit(&text),
None => return Err(Usage(format!("unknown help topic `{topic}` (topics: build, schema, exitcodes)")).into()),
},
None => emit(&help()),
}
Ok(())
}
Some("version") | Some("--version") | Some("-V") => {
if machine {
emit(&format!("{}\n", json_doc("Version", serde_json::json!({ "version": VERSION }))));
} else {
emit(&format!("beecast {VERSION}\n"));
}
Ok(())
}
Some("schema") => {
emit(&beecast_dto::generated_schema());
Ok(())
}
Some("build") => Ok(run_build(parse_build_args(&args[1..])?, machine)?),
Some(castish) if castish.ends_with(".cast") => {
if machine {
return Err(Usage(format!("canonical syntax required for machines: beecast build {castish} [...]")).into());
}
if color {
eprintln!("\x1b[2mnote: resolved to the canonical `beecast build {castish}`\x1b[0m");
} else {
eprintln!("note: resolved to the canonical `beecast build {castish}`");
}
Ok(run_build(parse_build_args(args)?, machine)?)
}
Some(other) => Err(Usage(format!("unknown command `{other}` — run `beecast help`")).into()),
}
}
fn parse_build_args(args: &[String]) -> Result<BuildArgs, Usage> {
let mut cast: Option<PathBuf> = None;
let mut meta: Option<PathBuf> = None;
let mut output: Option<PathBuf> = None;
let mut it = args.iter();
while let Some(a) = it.next() {
match a.as_str() {
"--meta" => meta = Some(PathBuf::from(it.next().ok_or(Usage("--meta needs a file argument".into()))?)),
"-o" | "--output" => {
output = Some(PathBuf::from(it.next().ok_or(Usage("-o/--output needs a file argument".into()))?))
}
flag if flag.starts_with('-') && flag != "-" => return Err(Usage(format!("unknown flag `{flag}`"))),
positional => {
if cast.replace(PathBuf::from(positional)).is_some() {
return Err(Usage("build takes exactly one <recording.cast>".into()));
}
}
}
}
Ok(BuildArgs {
cast: cast.ok_or(Usage("usage: beecast build <recording.cast> [--meta <f>] [-o <f>]".into()))?,
meta,
output,
})
}
fn run_build(args: BuildArgs, machine: bool) -> anyhow::Result<()> {
let cast_path = &args.cast;
let ndjson =
std::fs::read_to_string(cast_path).with_context(|| format!("cannot read recording `{}`", cast_path.display()))?;
let info =
beecast_page::inspect(&ndjson).with_context(|| format!("`{}` is not a playable recording", cast_path.display()))?;
let (meta, meta_source) = load_meta(&args)?;
if !machine {
if let Some(src) = &meta_source {
eprintln!("using metadata from `{}`", src.display());
}
}
let mut warnings: Vec<String> = Vec::new();
if let Some(duration) = info.duration {
for c in meta.chapters.iter().filter(|c| c.t > duration) {
warnings.push(format!("chapter `{}` at t={} is past the end of the recording ({duration:.1}s)", c.title, c.t));
}
}
for w in &warnings {
eprintln!("warning: {w}");
}
let fallback_title = cast_path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or("cast".into());
let chapters: Vec<(f64, &str)> = meta.chapters.iter().map(|c| (c.t, c.title.as_str())).collect();
let page_meta =
beecast_page::PageMeta { title: meta.title.as_deref(), summary: meta.summary.as_deref(), chapters: &chapters };
let html = beecast_page::build_page(&ndjson, &page_meta, &fallback_title);
let to_stdout = args.output.as_deref() == Some(Path::new("-"));
if to_stdout {
emit(&html);
if !machine {
eprintln!("asciicast v{}, {} bytes of HTML to stdout", info.version, html.len());
}
return Ok(());
}
let out_path = args.output.unwrap_or_else(|| args.cast.with_extension("html"));
std::fs::write(&out_path, &html).with_context(|| format!("cannot write `{}`", out_path.display()))?;
if machine {
emit(&format!(
"{}\n",
json_doc(
"Built",
serde_json::json!({
"output": out_path.to_string_lossy(),
"bytes": html.len(),
"cast_version": info.version,
"chapters": meta.chapters.len(),
"meta": meta_source.as_ref().map(|p| p.to_string_lossy()),
"warnings": warnings,
})
)
));
} else {
emit(&format!(
"wrote {} ({} KB, asciicast v{}, {} chapters)\n",
out_path.display(),
html.len() / 1024,
info.version,
meta.chapters.len()
));
}
Ok(())
}
fn load_meta(args: &BuildArgs) -> anyhow::Result<(beecast_dto::CastMeta, Option<PathBuf>)> {
let implicit = args.cast.with_extension("meta.json");
let path = match &args.meta {
Some(explicit) => explicit.clone(),
None if implicit.is_file() => implicit,
None => return Ok((beecast_dto::CastMeta::default(), None)),
};
let json = std::fs::read_to_string(&path).with_context(|| format!("cannot read metadata `{}`", path.display()))?;
let parsed = beecast_dto::parse(&json).with_context(|| format!("invalid metadata in `{}`", path.display()))?;
Ok((parsed, Some(path)))
}
fn emit(s: &str) {
let mut out = std::io::stdout();
if let Err(e) = out.write_all(s.as_bytes()).and_then(|()| out.flush()) {
if e.kind() == std::io::ErrorKind::BrokenPipe {
std::process::exit(0);
}
eprintln!("error: writing to stdout: {e}");
std::process::exit(1);
}
}
fn json_doc(variant: &str, payload: serde_json::Value) -> String {
serde_json::to_string_pretty(&serde_json::json!({ variant: payload })).expect("json_doc serializes")
}
fn report_error(message: &str, machine: bool, stage: &str, color: bool) {
if machine {
emit(&format!("{}\n", json_doc("Error", serde_json::json!({ "message": message, "stage": stage }))));
} else {
if color {
eprintln!("\x1b[1;31merror:\x1b[0m {message}");
} else {
eprintln!("error: {message}");
}
}
}