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<String> {
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)]
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)]
struct Extract {
#[clap(long)]
merciful: bool,
#[clap(
name = "DIR",
long = "directory",
short = 'd',
parse(from_os_str),
default_value = "."
)]
directory: PathBuf,
#[clap(long)]
dry_run: bool,
#[clap(parse(from_os_str))]
filename: PathBuf,
embedded: Vec<String>,
}
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::<Result<Vec<_>>>()
} else {
self.embedded
.iter()
.map(|filename| cli::extract_file(&doc, filename))
.collect::<Result<Vec<_>>>()
}?;
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)]
struct Metadata {
#[clap(long)]
merciful: bool,
#[clap(short = 'o', default_value = "plain", possible_values=&["plain", "json"])]
output_format: cli::OutputFormat,
#[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)]
struct Docgen {
#[clap(long)]
merciful: bool,
#[clap(name = "TEMPLATE", long = "template", short = 't')]
template: Option<String>,
#[clap(parse(from_os_str))]
input: PathBuf,
#[clap(name = "FILE", long = "output", short = 'o', parse(from_os_str))]
output: PathBuf,
#[clap(name = "DATE", long = "date")]
date: Option<String>,
}
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();
let date = if let Some(date) = self.date.clone() {
date
} else if let Some(date) = doc.meta().date() {
date.to_string()
} else {
let filename = doc.meta().basedir().join(doc.meta().markdown_filename());
Self::mtime_formatted(Self::mtime(&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)
.map_err(|e| SubplotError::InputFileUnreadable(filename.into(), e))?
.modified()
.map_err(|e| SubplotError::InputFileMtime(filename.into(), e))?;
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)]
struct Codegen {
#[clap(name = "TEMPLATE", long = "template", short = 't')]
template: Option<String>,
#[clap(parse(from_os_str))]
filename: PathBuf,
#[clap(
long,
short,
parse(from_os_str),
help = "Writes generated test program to FILE"
)]
output: PathBuf,
#[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<Document, SubplotError> {
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);
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();
}