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, ThemeResolveError, ValidationError, compile_html_resolved, compile_text_resolved,
compile_theme_resolved, resolve_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,
#[cfg(feature = "install")]
Install {
spec: String,
},
}
#[derive(Debug, Clone, Copy, clap::ValueEnum)]
enum Format {
Pdf,
Text,
Html,
}
fn resolve_theme_name(format: Format, requested: Option<&str>) -> &str {
match requested {
Some(name) => name,
None => match format {
Format::Html => "html-minimal",
Format::Pdf | Format::Text => "text-minimal",
},
}
}
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(),
#[cfg(feature = "install")]
ThemesCommands::Install { spec } => run_themes_install(&spec),
},
}
}
#[cfg(feature = "install")]
fn run_themes_install(spec: &str) -> Result<ExitCode> {
use crate::install::{self, InstallError, pipeline::InstallOutcome};
let parsed = match install::spec::parse_spec(spec) {
Ok(s) => s,
Err(err) => {
eprintln!("error: {err}");
return Ok(ExitCode::from(2));
}
};
match install::install_with_transitive(&parsed) {
Ok(summary) => {
let primary_path = summary.primary.path().clone();
{
let stdout = io::stdout();
let mut stdout = stdout.lock();
if let Err(err) = writeln!(stdout, "{}", primary_path.display()) {
eprintln!("error: failed to write install path to stdout: {err}");
return Ok(ExitCode::from(2));
}
}
match &summary.primary {
InstallOutcome::Installed { .. } => {
eprintln!(
"installed @preview/{}:{} into {}",
parsed.name,
parsed.version,
primary_path.display(),
);
}
InstallOutcome::AlreadyCached { .. } => {
eprintln!(
"@preview/{}:{} already cached at {}",
parsed.name,
parsed.version,
primary_path.display(),
);
}
}
if !summary.transitive.is_empty() {
eprintln!(
"also resolved {} transitive dep(s):",
summary.transitive.len(),
);
for (dep_spec, outcome) in &summary.transitive {
let tag = match outcome {
InstallOutcome::Installed { .. } => "installed",
InstallOutcome::AlreadyCached { .. } => "cached",
};
eprintln!(
" @preview/{}:{} -> {} [{}]",
dep_spec.name,
dep_spec.version,
outcome.path().display(),
tag,
);
}
}
Ok(ExitCode::SUCCESS)
}
Err(InstallError::TransitiveDepFailed {
parent,
child,
source,
}) => {
eprintln!(
"error: failed to install transitive dep {child} required by {parent}: {source}",
);
if let Ok(p) = install::cache::package_cache_dir(&parsed.name, &parsed.version)
&& p.is_dir()
{
eprintln!(
"note: primary @preview/{}:{} remains cached at {}; \
rerun after fixing the transitive",
parsed.name,
parsed.version,
p.display(),
);
}
Ok(ExitCode::from(2))
}
Err(err) => {
eprintln!("error: {err}");
match &err {
InstallError::Extract { .. } | InstallError::Io { .. } => {
if let Ok(root) = install::cache::preview_cache_root() {
eprintln!("cache root: {}", root.display());
}
}
InstallError::CacheDirUnresolved => {
eprintln!("hint: set FERROCV_CACHE_DIR to override the cache location");
}
_ => {}
}
Ok(ExitCode::from(2))
}
}
}
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 = resolve_theme_name(format, theme_name);
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 resolve_theme(theme_name) {
Ok(t) => t,
Err(err) => {
match &err {
ThemeResolveError::NotFound { available, .. } => {
eprintln!("error: {err}");
let mut names: Vec<&'static str> = available.clone();
names.sort_unstable();
eprintln!("available themes: {}", names.join(", "));
}
_ => {
eprintln!("error: {err}");
}
}
return Ok(ExitCode::from(2));
}
};
let bytes: Vec<u8> = match format {
Format::Pdf => match compile_theme_resolved(&theme, &value) {
Ok(bytes) => bytes,
Err(err) => {
eprintln!("{err}");
return Ok(ExitCode::from(2));
}
},
Format::Text => match compile_text_resolved(&theme, &value) {
Ok(text) => text.into_bytes(),
Err(err) => {
eprintln!("{err}");
return Ok(ExitCode::from(2));
}
},
Format::Html => match compile_html_resolved(&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"),
}
}