use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use serde_json::Value;
use crate::{
THEMES, ValidationError, compile_html, compile_text, compile_theme, find_theme, validate_value,
};
#[derive(Debug, Parser)]
#[command(name = "ferrocv", version, about, long_about = None)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Debug, Subcommand)]
enum Commands {
Validate {
path: Option<PathBuf>,
},
Render {
path: Option<PathBuf>,
#[arg(long)]
theme: Option<String>,
#[arg(long, default_value = "pdf")]
format: Format,
#[arg(short = 'o', long)]
output: Option<PathBuf>,
},
Themes {
#[command(subcommand)]
command: ThemesCommands,
},
}
#[derive(Debug, Subcommand)]
enum ThemesCommands {
List,
}
#[derive(Debug, Clone, Copy, clap::ValueEnum)]
enum Format {
Pdf,
Text,
Html,
}
fn resolve_theme_name(format: Format, requested: Option<&str>) -> Result<&str, &'static str> {
match (format, requested) {
(_, Some(name)) => Ok(name),
(Format::Text, None) | (Format::Html, None) => Ok("text-minimal"),
(Format::Pdf, None) => Err("error: --theme is required for --format pdf"),
}
}
fn default_output_path(format: Format) -> PathBuf {
match format {
Format::Pdf => PathBuf::from("dist/resume.pdf"),
Format::Text => PathBuf::from("dist/resume.txt"),
Format::Html => PathBuf::from("dist/resume.html"),
}
}
pub fn run() -> Result<ExitCode> {
let cli = Cli::parse();
match cli.command {
Commands::Validate { path } => run_validate(path.as_deref()),
Commands::Render {
path,
theme,
format,
output,
} => run_render(path.as_deref(), theme.as_deref(), format, output.as_deref()),
Commands::Themes { command } => match command {
ThemesCommands::List => run_themes_list(),
},
}
}
fn run_themes_list() -> Result<ExitCode> {
let mut names: Vec<&'static str> = THEMES.iter().map(|t| t.name).collect();
names.sort_unstable();
let stdout = io::stdout();
let mut stdout = stdout.lock();
for name in names {
if let Err(err) = writeln!(stdout, "{name}") {
eprintln!("error: failed to write theme list to stdout: {err}");
return Ok(ExitCode::from(2));
}
}
Ok(ExitCode::SUCCESS)
}
fn run_validate(path: Option<&Path>) -> Result<ExitCode> {
let input = read_input(path)?;
let value: Value = match serde_json::from_str(&input) {
Ok(v) => v,
Err(err) => {
eprintln!("error: {err}");
return Ok(ExitCode::from(2));
}
};
match validate_value(&value) {
Ok(()) => Ok(ExitCode::SUCCESS),
Err(errors) => {
report_validation_errors(&errors, "");
Ok(ExitCode::from(1))
}
}
}
fn report_validation_errors(errors: &[ValidationError], suffix: &str) {
let n = errors.len();
let plural = if n == 1 { "" } else { "s" };
eprintln!("error: schema validation failed ({n} error{plural}){suffix}");
for err in errors {
eprintln!(" {err}");
}
}
fn run_render(
path: Option<&Path>,
theme_name: Option<&str>,
format: Format,
output: Option<&Path>,
) -> Result<ExitCode> {
let theme_name = match resolve_theme_name(format, theme_name) {
Ok(name) => name,
Err(msg) => {
eprintln!("{msg}");
return Ok(ExitCode::from(2));
}
};
let input = read_input(path)?;
let value: Value = match serde_json::from_str(&input) {
Ok(v) => v,
Err(err) => {
eprintln!("error: {err}");
return Ok(ExitCode::from(2));
}
};
if let Err(errors) = validate_value(&value) {
report_validation_errors(&errors, "; no output written");
return Ok(ExitCode::from(1));
}
let theme = match find_theme(theme_name) {
Some(t) => t,
None => {
eprintln!("error: unknown theme `{theme_name}`");
let names: Vec<&'static str> = THEMES.iter().map(|t| t.name).collect();
eprintln!("available themes: {}", names.join(", "));
return Ok(ExitCode::from(2));
}
};
let bytes: Vec<u8> = match format {
Format::Pdf => match compile_theme(theme, &value) {
Ok(bytes) => bytes,
Err(err) => {
eprintln!("{err}");
return Ok(ExitCode::from(2));
}
},
Format::Text => match compile_text(theme, &value) {
Ok(text) => text.into_bytes(),
Err(err) => {
eprintln!("{err}");
return Ok(ExitCode::from(2));
}
},
Format::Html => match compile_html(theme, &value) {
Ok(html) => html.into_bytes(),
Err(err) => {
eprintln!("{err}");
return Ok(ExitCode::from(2));
}
},
};
let out_path: PathBuf = output
.map(PathBuf::from)
.unwrap_or_else(|| default_output_path(format));
if let Some(parent) = out_path.parent()
&& !parent.as_os_str().is_empty()
&& let Err(err) = std::fs::create_dir_all(parent)
{
eprintln!(
"error: failed to create output directory {}: {err}",
parent.display()
);
return Ok(ExitCode::from(2));
}
if let Err(err) = std::fs::write(&out_path, &bytes) {
eprintln!(
"error: failed to write output file {}: {err}",
out_path.display()
);
return Ok(ExitCode::from(2));
}
Ok(ExitCode::SUCCESS)
}
fn read_input(path: Option<&Path>) -> Result<String> {
match path {
Some(p) => {
std::fs::read_to_string(p).with_context(|| format!("failed to read {}", p.display()))
}
None => std::io::read_to_string(std::io::stdin()).context("failed to read JSON from stdin"),
}
}