use std::path::{Path, PathBuf};
use anyhow::{bail, Context as _, Result};
use camino::{Utf8Path, Utf8PathBuf};
use clap::{Args, Parser, Subcommand};
use config::Config;
use cooklang::{
convert::{builder::ConverterBuilder, units_file::UnitsFile},
CooklangParser,
};
use cooklang_fs::FsIndex;
use once_cell::sync::OnceCell;
use tracing::{debug, warn};
mod config;
mod convert;
mod recipe;
#[cfg(feature = "serve")]
mod serve;
mod shopping_list;
mod units;
#[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)]
#[clap(color = concolor_clap::color_choice())]
struct CliArgs {
#[command(subcommand)]
command: Command,
#[command(flatten)]
global_args: GlobalArgs,
}
#[derive(Debug, Subcommand, strum::Display)]
enum Command {
#[command(alias = "r")]
Recipe(Box<recipe::RecipeArgs>),
#[cfg(feature = "serve")]
Serve(serve::ServeArgs),
#[command(alias = "list")]
ShoppingList(shopping_list::ShoppingListArgs),
#[command(alias = "u")]
Units(units::UnitsArgs),
#[command(alias = "c")]
Convert(convert::ConvertArgs),
Config,
}
#[derive(Debug, Args)]
pub struct GlobalArgs {
#[arg(long, action = clap::ArgAction::Append, global = true)]
units: Vec<Utf8PathBuf>,
#[arg(long, hide_short_help = true, global = true)]
no_default_units: bool,
#[arg(long, global = true)]
no_extensions: bool,
#[arg(long, global = true)]
warnings_as_errors: bool,
#[arg(long, conflicts_with = "warnings_as_errors", global = true)]
ignore_warnings: bool,
#[command(flatten)]
color: concolor_clap::Color,
#[arg(long, value_name = "PATH", global = true)]
path: Option<Utf8PathBuf>,
#[arg(long, hide_short_help = true, global = true)]
no_recipe_ref_check: bool,
#[arg(long, global = true)]
max_depth: Option<usize>,
#[arg(long, hide_short_help = true, global = true)]
debug_trace: bool,
}
pub fn main() -> Result<()> {
let args = CliArgs::parse();
init_color(args.global_args.color);
if args.global_args.debug_trace {
tracing_subscriber::FmtSubscriber::builder()
.compact()
.with_max_level(tracing::Level::TRACE)
.with_span_events(tracing_subscriber::fmt::format::FmtSpan::CLOSE)
.with_ansi(concolor::get(concolor::Stream::Stderr).ansi_color())
.init();
} else {
tracing_subscriber::FmtSubscriber::builder()
.compact()
.with_target(false)
.with_ansi(concolor::get(concolor::Stream::Stderr).ansi_color())
.init();
}
let ctx = configure_context(args.global_args)?;
let _enter = tracing::info_span!("run", cmd = %args.command).entered();
match args.command {
Command::Recipe(args) => recipe::run(&ctx, *args),
#[cfg(feature = "serve")]
Command::Serve(args) => serve::run(ctx, args),
Command::ShoppingList(args) => shopping_list::run(&ctx, args),
Command::Units(args) => units::run(ctx.parser()?.converter(), args),
Command::Convert(args) => convert::run(ctx.parser()?.converter(), args),
Command::Config => config::run(&ctx),
}
}
fn init_color(color: concolor_clap::Color) {
color.apply();
let stdout_support = concolor::get(concolor::Stream::Stdout);
if stdout_support.ansi_color() {
yansi::Paint::enable();
} else if stdout_support.color() {
if cfg!(windows) && !yansi::Paint::enable_windows_ascii() {
yansi::Paint::disable();
}
} else {
yansi::Paint::disable();
}
}
pub struct Context {
parser: OnceCell<CooklangParser>,
recipe_index: FsIndex,
global_args: GlobalArgs,
base_dir: Utf8PathBuf,
config: config::Config,
config_path: PathBuf,
}
const COOK_DIR: &str = ".cooklang";
const APP_NAME: &str = "cooklang-chef";
#[tracing::instrument(level = "debug", skip_all)]
fn configure_context(args: GlobalArgs) -> Result<Context> {
let base_dir = args
.path
.as_deref()
.unwrap_or(Utf8Path::new("."))
.to_path_buf();
let (mut config, config_path) = Config::read(&base_dir)?;
config.override_with_args(&args);
if !base_dir.is_dir() {
bail!("Base path '{base_dir}' is not a directory");
}
let index = FsIndex::new(&base_dir, config.max_depth)?;
Ok(Context {
parser: OnceCell::new(),
recipe_index: index,
config,
config_path,
global_args: args,
base_dir,
})
}
impl Context {
fn parser(&self) -> Result<&CooklangParser> {
self.parser.get_or_try_init(|| {
configure_parser(&self.config, self.base_dir.as_std_path(), &self.config_path)
})
}
}
#[tracing::instrument(level = "debug", skip_all)]
fn configure_parser(
config: &Config,
base_path: &Path,
config_path: &Path,
) -> Result<CooklangParser> {
let mut parser = CooklangParser::builder().with_extensions(config.extensions);
let units = config.units(config_path, base_path);
if config.default_units || !units.is_empty() {
let mut builder = ConverterBuilder::new();
if config.default_units {
builder
.add_units_file(UnitsFile::bundled())
.expect("Failed to add bundled units");
}
for file in units {
debug!("Loading units {}", file.display());
let text = std::fs::read_to_string(file)?;
let units = toml::from_str(&text)?;
builder.add_units_file(units)?;
}
parser.set_converter(builder.finish()?);
}
Ok(parser.finish())
}
fn write_to_output<F>(output: Option<&Utf8Path>, f: F) -> Result<()>
where
F: FnOnce(Box<dyn std::io::Write>) -> Result<()>,
{
if let Some(path) = output {
let file = std::fs::File::create(path).context("Failed to create output file")?;
let colors = yansi::Paint::is_enabled();
yansi::Paint::disable();
f(Box::new(file))?;
if colors {
yansi::Paint::enable();
}
Ok(())
} else {
f(Box::new(std::io::stdout()))?;
Ok(())
}
}
struct Input {
text: String,
recipe_name: String,
file_name: String,
path: Option<Utf8PathBuf>,
}
impl Input {
fn parse(&self, ctx: &Context) -> Result<cooklang::Recipe> {
let checker = if ctx.global_args.no_recipe_ref_check {
None
} else {
Some(Box::new(|name: &str| ctx.recipe_index.contains(name))
as cooklang::RecipeRefChecker)
};
let r = ctx
.parser()?
.parse_with_recipe_ref_checker(&self.text, &self.recipe_name, checker);
unwrap_recipe(r, &self.file_name, &self.text, ctx)
}
}
fn unwrap_recipe(
r: cooklang::RecipeResult,
file_name: &str,
text: &str,
ctx: &Context,
) -> Result<cooklang::Recipe> {
if r.invalid() || ctx.global_args.warnings_as_errors && r.has_warnings() {
r.into_report()
.eprint(file_name, text, ctx.global_args.ignore_warnings)?;
bail!("Error parsing recipe");
} else {
let (recipe, warnings) = r.into_result().unwrap();
if !ctx.global_args.ignore_warnings && warnings.has_warnings() {
warnings.eprint(file_name, text, false)?;
}
Ok(recipe)
}
}