//! Subplot top level binary //! use anyhow::Result; use env_logger::fmt::Color; use log::{debug, error, info, trace, warn}; use subplot::{ codegen, load_document, resource, Document, EmbeddedFile, MarkupOpts, Style, SubplotError, }; use time::{format_description::FormatItem, macros::format_description, OffsetDateTime}; use clap::{CommandFactory, FromArgMatches, Parser}; use std::convert::TryFrom; use std::ffi::OsString; use std::fs::{self, write}; use std::io::Write; use std::path::{Path, PathBuf}; use std::process::{self, Command}; use std::time::UNIX_EPOCH; mod cli; use git_testament::*; git_testament!(VERSION); #[derive(Debug, Parser)] struct Toplevel { #[clap(flatten)] resources: resource::ResourceOpts, #[clap(flatten)] markup: MarkupOpts, #[clap(subcommand)] command: Cmd, } impl Toplevel { fn run(&self) -> Result<()> { trace!("Toplevel: {:?}", self); self.command.run() } fn handle_special_args(&self) { let doc_path = self.command.doc_path(); self.resources.handle(doc_path); self.markup.handle(); } } #[derive(Debug, Parser)] enum Cmd { Extract(Extract), Metadata(Metadata), Docgen(Docgen), Codegen(Codegen), #[clap(hide = true)] Resources(Resources), } impl Cmd { fn run(&self) -> Result<()> { match self { Cmd::Extract(e) => e.run(), Cmd::Metadata(m) => m.run(), Cmd::Docgen(d) => d.run(), Cmd::Codegen(c) => c.run(), Cmd::Resources(r) => r.run(), } } fn doc_path(&self) -> Option<&Path> { match self { Cmd::Extract(e) => e.doc_path(), Cmd::Metadata(m) => m.doc_path(), Cmd::Docgen(d) => d.doc_path(), Cmd::Codegen(c) => c.doc_path(), Cmd::Resources(r) => r.doc_path(), } } } fn long_version() -> Result { use std::fmt::Write as _; let mut ret = String::new(); writeln!(ret, "{}", render_testament!(VERSION))?; writeln!(ret, "Crate version: {}", env!("CARGO_PKG_VERSION"))?; if let Some(branch) = VERSION.branch_name { writeln!(ret, "Built from branch: {}", branch)?; } else { writeln!(ret, "Branch information is missing.")?; } writeln!(ret, "Commit info: {}", VERSION.commit)?; if VERSION.modifications.is_empty() { writeln!(ret, "Working tree is clean")?; } else { use GitModification::*; for fmod in VERSION.modifications { match fmod { Added(f) => writeln!(ret, "Added: {}", String::from_utf8_lossy(f))?, Removed(f) => writeln!(ret, "Removed: {}", String::from_utf8_lossy(f))?, Modified(f) => writeln!(ret, "Modified: {}", String::from_utf8_lossy(f))?, Untracked(f) => writeln!(ret, "Untracked: {}", String::from_utf8_lossy(f))?, } } } Ok(ret) } #[derive(Debug, Parser)] /// Examine embedded resources built into Subplot struct Resources {} impl Resources { fn doc_path(&self) -> Option<&Path> { None } fn run(&self) -> Result<()> { for (name, bytes) in subplot::resource::embedded_files() { println!("{} {} bytes", name, bytes.len()); } Ok(()) } } #[derive(Debug, Parser)] /// Extract embedded files from a subplot document /// /// If no embedded filenames are provided, this will /// extract all embedded files. if the output directory /// is not specified then this will extract to the current directory. struct Extract { /// Allow warnings in document? #[clap(long)] merciful: bool, /// Directory to write extracted files to #[clap( name = "DIR", long = "directory", short = 'd', parse(from_os_str), default_value = "." )] directory: PathBuf, /// Don't actually write the files out #[clap(long)] dry_run: bool, /// Input subplot document filename #[clap(parse(from_os_str))] filename: PathBuf, /// Names of embedded files to be extracted. embedded: Vec, } impl Extract { fn doc_path(&self) -> Option<&Path> { self.filename.parent() } fn run(&self) -> Result<()> { let doc = load_linted_doc(&self.filename, Style::default(), None, self.merciful)?; let files: Vec<&EmbeddedFile> = if self.embedded.is_empty() { doc.files() .iter() .map(Result::Ok) .collect::>>() } else { self.embedded .iter() .map(|filename| cli::extract_file(&doc, filename)) .collect::>>() }?; for f in files { let outfile = self.directory.join(f.filename()); if self.dry_run { println!("Would write out {}", outfile.display()); } else { write(outfile, f.contents())? } } Ok(()) } } #[derive(Debug, Parser)] /// Extract metadata about a document /// /// Load and process a subplot document, extracting various metadata about the /// document. This can then render that either as a plain text report for humans, /// or as a JSON object for further scripted processing. struct Metadata { /// Allow warnings in document? #[clap(long)] merciful: bool, /// Form that you want the output to take #[clap(short = 'o', default_value = "plain", possible_values=&["plain", "json"])] output_format: cli::OutputFormat, /// Input subplot document filename #[clap(parse(from_os_str))] filename: PathBuf, } impl Metadata { fn doc_path(&self) -> Option<&Path> { self.filename.parent() } fn run(&self) -> Result<()> { let mut doc = load_linted_doc(&self.filename, Style::default(), None, self.merciful)?; let meta = cli::Metadata::try_from(&mut doc)?; match self.output_format { cli::OutputFormat::Plain => meta.write_out(), cli::OutputFormat::Json => println!("{}", serde_json::to_string_pretty(&meta)?), } Ok(()) } } #[derive(Debug, Parser)] /// Typeset subplot document /// /// Process a subplot document and typeset it using Pandoc. struct Docgen { /// Allow warnings in document? #[clap(long)] merciful: bool, /// The template to use from the document. /// /// If not specified, subplot will try and find a unique template name from the document #[clap(name = "TEMPLATE", long = "template", short = 't')] template: Option, // Input Subplot document #[clap(parse(from_os_str))] input: PathBuf, // Output document filename #[clap(name = "FILE", long = "output", short = 'o', parse(from_os_str))] output: PathBuf, // Set date. #[clap(name = "DATE", long = "date")] date: Option, } impl Docgen { fn doc_path(&self) -> Option<&Path> { self.input.parent() } fn run(&self) -> Result<()> { let mut style = Style::default(); if self.output.extension() == Some(&OsString::from("pdf")) { trace!("PDF output chosen"); style.typeset_links_as_notes(); } let mut doc = load_linted_doc(&self.input, style, self.template.as_deref(), self.merciful)?; let mut pandoc = pandoc::new(); // Metadata date from command line or file mtime. However, we // can't set it directly, since we don't want to override the date // in the actual document, if given, so we only set // user-provided-date. Our parsing code will use that if date is // not document metadata. let date = if let Some(date) = self.date.clone() { date } else if let Some(date) = doc.meta().date() { date.to_string() } else { Self::mtime_formatted(Self::mtime(doc.meta().markdown_filename())?) }; pandoc.add_option(pandoc::PandocOption::Meta("date".to_string(), Some(date))); pandoc.add_option(pandoc::PandocOption::TableOfContents); pandoc.add_option(pandoc::PandocOption::Standalone); pandoc.add_option(pandoc::PandocOption::NumberSections); if Self::need_output(&mut doc, self.template.as_deref(), &self.output) { doc.typeset(); pandoc.set_input_format(pandoc::InputFormat::Json, vec![]); pandoc.set_input(pandoc::InputKind::Pipe(doc.ast()?)); pandoc.set_output(pandoc::OutputKind::File(self.output.clone())); debug!("Executing pandoc to produce {}", self.output.display()); let r = pandoc.execute(); if let Err(pandoc::PandocError::Err(output)) = r { let code = output.status.code().unwrap_or(127); let stderr = String::from_utf8_lossy(&output.stderr); error!("Failed to execute Pandoc: exit code {}", code); error!("{}", stderr.strip_suffix('\n').unwrap()); return Err(anyhow::Error::msg("Pandoc failed")); } r?; } Ok(()) } fn mtime(filename: &Path) -> Result<(u64, u32)> { let mtime = fs::metadata(filename)?.modified()?; let mtime = mtime.duration_since(UNIX_EPOCH)?; Ok((mtime.as_secs(), mtime.subsec_nanos())) } fn mtime_formatted(mtime: (u64, u32)) -> String { const DATE_FORMAT: &[FormatItem<'_>] = format_description!("[year]-[month]-[day] [hour]:[minute]"); let secs: i64 = format!("{}", mtime.0).parse().unwrap_or(0); let time = OffsetDateTime::from_unix_timestamp(secs).unwrap(); time.format(DATE_FORMAT).unwrap() } fn need_output(doc: &mut subplot::Document, template: Option<&str>, output: &Path) -> bool { let output = match Self::mtime(output) { Err(_) => return true, Ok(ts) => ts, }; for filename in doc.sources(template) { let source = match Self::mtime(&filename) { Err(_) => return true, Ok(ts) => ts, }; if source >= output { return true; } } false } } #[derive(Debug, Parser)] /// Generate test suites from Subplot documents /// /// This reads a subplot document, extracts the scenarios, and writes out a test /// program capable of running the scenarios in the subplot document. struct Codegen { /// The template to use from the document. /// /// If not specified, subplot will try and find a unique template name from the document #[clap(name = "TEMPLATE", long = "template", short = 't')] template: Option, /// Input filename. #[clap(parse(from_os_str))] filename: PathBuf, /// Write generated test program to this file. #[clap( long, short, parse(from_os_str), help = "Writes generated test program to FILE" )] output: PathBuf, /// Run the generated test program after writing it? #[clap(long, short, help = "Runs generated test program")] run: bool, } impl Codegen { fn doc_path(&self) -> Option<&Path> { self.filename.parent() } fn run(&self) -> Result<()> { debug!("codegen starts"); let output = codegen(&self.filename, &self.output, self.template.as_deref())?; if self.run { let spec = output .doc .meta() .document_impl(&output.template) .unwrap() .spec(); let run = match spec.run() { None => { error!( "Template {} does not specify how to run suites", spec.template_filename().display() ); std::process::exit(1); } Some(x) => x, }; let status = Command::new(run).arg(&self.output).status()?; if !status.success() { error!("Test suite failed!"); std::process::exit(2); } } debug!("codegen ends successfully"); Ok(()) } } fn load_linted_doc( filename: &Path, style: Style, template: Option<&str>, merciful: bool, ) -> Result { let mut doc = load_document(filename, style, None)?; trace!("Got doc, now linting it"); doc.lint()?; trace!("Doc linted ok"); let meta = doc.meta(); trace!("Looking for template, meta={:#?}", meta); // Get template as given to use (from command line), or from // document, or else default to the empty string. let template = if let Some(t) = template { t } else if let Ok(t) = doc.template() { t } else { "" }; let template = template.to_string(); trace!("Template: {:#?}", template); doc.check_named_files_exist(&template)?; doc.check_matched_steps_have_impl(&template); doc.check_embedded_files_are_used(&template)?; for w in doc.warnings() { warn!("{}", w); } if !doc.warnings().is_empty() && !merciful { return Err(SubplotError::Warnings(doc.warnings().len())); } Ok(doc) } fn real_main() { info!("Starting Subplot"); let argparser = Toplevel::command(); let version = long_version().unwrap(); let argparser = argparser.long_version(version.as_str()); let args = argparser.get_matches(); let args = Toplevel::from_arg_matches(&args).unwrap(); args.handle_special_args(); match args.run() { Ok(_) => { info!("Subplot finished successfully"); } Err(e) => { error!("{}", e); let mut e = e.source(); while let Some(source) = e { error!("caused by: {}", source); e = source.source(); } process::exit(1); } } } fn main() { let env = env_logger::Env::new() .filter_or("SUBPLOT_LOG", "info") .write_style("SUBPLOT_LOG_STYLE"); env_logger::Builder::from_env(env) .format_timestamp(None) .format(|buf, record| { let mut level_style = buf.style(); level_style.set_color(Color::Red).set_bold(true); writeln!( buf, "{}: {}", level_style.value(record.level()), record.args() ) }) .init(); real_main(); }