use std::{
fmt::Debug,
time::{SystemTime, UNIX_EPOCH},
};
use proc_macro::TokenStream;
use quote::quote;
use syn::{spanned::Spanned, Data, DataStruct, DeriveInput, Error, Field, Fields, LitStr};
enum CodexError {
MissingArgument(String),
InvalidFormat(String, String),
CommandNotFound(String),
}
impl CodexError {
pub fn report(&self) -> String {
let label = label();
match self {
Self::MissingArgument(arg) => format!("{label} Missing value for argument '{arg}'"),
Self::InvalidFormat(flag, ty) => format!("{label} Invalid format for '{flag}'. Expected: {ty}"),
Self::CommandNotFound(cmd) => format!("{label} Command not found: '{cmd}'"),
}
}
}
#[derive(Default, Debug)]
struct FieldCmd {
pub aliase: String,
pub command: String,
pub default_value: Option<String>,
}
impl FieldCmd {
fn from_field(field: &Field) -> Result<Self, Error> {
let mut config = Self::default();
for attr in &field.attrs {
if !attr.path().is_ident("codex") {
continue;
}
attr.parse_nested_meta(|meta| {
if meta.path.is_ident("aliase") {
let value = meta.value()?;
let lit: LitStr = value.parse()?;
config.aliase = lit.value();
Ok(())
} else if meta.path.is_ident("command") {
let value = meta.value()?;
let lit: LitStr = value.parse()?;
config.command = lit.value();
Ok(())
} else if meta.path.is_ident("default_value") {
let value = meta.value()?;
let lit: LitStr = value.parse()?;
config.default_value = Some(lit.value());
Ok(())
} else {
Err(meta.error("Unsupported codex attribute. Available options: aliase, command, default_value"))
}
})?;
}
Ok(config)
}
}
fn validate_struct(
ast: &DeriveInput,
) -> Result<&syn::punctuated::Punctuated<syn::Field, syn::token::Comma>, syn::Error> {
match &ast.data {
Data::Struct(DataStruct {
fields: Fields::Named(named_fields),
..
}) => Ok(&named_fields.named),
_ => Err(Error::new(
ast.span(),
"CodexParser only supports structs with named fields.",
)),
}
}
fn build_match_arms(field: &Field, field_cmd: &FieldCmd, match_arms: &mut Vec<proc_macro2::TokenStream>) {
let name = &field.ident;
let ty = &field.ty;
let type_name = quote!(#ty).to_string();
let path_validation = if type_name.contains("Path") || type_name.contains("PathBuf") {
let err_not_exist = label_error();
let err_not_file = label_error();
quote! {
if !std::path::Path::new(&val).exists() {
eprintln!("\n{} The path '{}' does not exist.", #err_not_exist, val);
std::process::exit(1);
} else if std::path::Path::new(&val).is_dir() {
eprintln!("\n{} The path '{}' is not a file.", #err_not_file, val);
std::process::exit(1);
}
}
} else {
quote!()
};
if !field_cmd.command.is_empty() {
if let Some(_n) = name {
let flag = field_cmd.command.clone();
let missing_arg_err = CodexError::MissingArgument(format!("/{flag}")).report();
let invalid_format_err = CodexError::InvalidFormat(format!("/{flag}"), type_name.clone()).report();
match_arms.push(quote! {
#flag => {
let val = args.next().ok_or_else(|| {
eprintln!("{}", #missing_arg_err);
std::process::exit(1);
}).unwrap();
#name = Some(val.parse().map_err(|_| {
eprintln!("{}", #invalid_format_err);
std::process::exit(1);
}).unwrap());
#path_validation
}
});
}
}
if !field_cmd.aliase.is_empty() {
if let Some(_n) = name {
let flag = field_cmd.aliase.clone();
let missing_arg_err = CodexError::MissingArgument(format!("/{flag}")).report();
let invalid_format_err = CodexError::InvalidFormat(format!("/{flag}"), type_name).report();
match_arms.push(quote! {
#flag => {
let val = args.next().ok_or_else(|| {
eprintln!("{}", #missing_arg_err);
std::process::exit(1);
}).unwrap();
#name = Some(val.parse().map_err(|_| {
eprintln!("{}", #invalid_format_err);
std::process::exit(1);
}).unwrap());
#path_validation
}
});
}
}
}
fn build_mapping(field: &Field, command: &FieldCmd, field_mappings: &mut Vec<proc_macro2::TokenStream>) {
let name = &field.ident;
let ty = &field.ty;
let is_optional = quote!(#ty).to_string().contains("Option");
let mapping = command.default_value.as_ref().map_or_else(
|| {
if is_optional {
quote! { #name: #name, }
} else {
let cmd_name = if command.command.is_empty() {
format!("/{}", quote!(#name).to_string().replace('_', "-"))
} else {
format!("/{}", command.command)
};
let err = CodexError::CommandNotFound(cmd_name).report();
let label = label();
quote! {
#name: #name.ok_or_else(|| {
eprintln!("{}", #err);
eprintln!("{} use /help for view commands list", #label);
std::process::exit(1);
}).unwrap(),
}
}
},
|default_val| {
quote! {
#name: #name.or_else(|| Some(#default_val.parse().expect("Invalid default value"))).expect("Internal Error"),
}
},
);
field_mappings.push(mapping);
}
pub fn generate_cli_parser(ast: &DeriveInput) -> Result<TokenStream, Error> {
let struct_name = &ast.ident;
let fields = validate_struct(ast)?;
let mut field_initializers = Vec::new();
let mut field_mappings = Vec::new();
let mut match_arms = Vec::new();
let error = label_error();
for field in fields {
let Some(name) = &field.ident else {
return Err(syn::Error::new_spanned(
field,
"CodexParser only supports structs with named fields.",
));
};
let ty = &field.ty;
if let syn::Type::Path(p) = &field.ty {
if let Some(last_segment) = p.path.segments.last() {
if last_segment.ident == "GlobalArgs" {
continue;
}
}
}
if name == "global" {
continue;
}
let command = FieldCmd::from_field(field)?;
field_initializers.push(quote! {
let mut #name: Option<#ty> = None;
});
build_match_arms(field, &command, &mut match_arms);
build_mapping(field, &command, &mut field_mappings);
}
let label = label();
let expanded = quote! {
impl #struct_name {
pub fn parse_cli(args_opt: Option<&mut std::iter::Skip<std::env::Args>>) -> Self {
let mut owned_args = std::env::args().skip(1);
let args = match args_opt {
Some(a) => a,
None => &mut owned_args,
};
#(#field_initializers)*
let mut global_verbose = false;
let mut global_config: Option<String> = None;
while let Some(arg) = args.next() {
let normalized_arg = arg.trim_start_matches('/');
match normalized_arg {
#(#match_arms)*
"verbose" | "v" => {
global_verbose = true;
println!("{} Verbose set to true", #label);
}
"config_path" => global_config = args.next(),
"help" | "h" => {
println!("\n{} Usage for {}:", #label, stringify!(#struct_name));
println!("{} --long-flags / --short-flags", #label);
std::process::exit(0);
}
_ => {
println!("\n{} Command not found: {}", #error, arg);
std::process::exit(1);
}
}
}
let global = GlobalArgs {
verbose: global_verbose,
config_path: global_config,
};
Self {
global,
#(#field_mappings)*
}
}
}
};
Ok(expanded.into())
}
pub fn generate_subcommand_router(ast: &syn::DeriveInput) -> Result<TokenStream, syn::Error> {
let name = &ast.ident;
let error = label_error();
let variants = match &ast.data {
Data::Enum(e) => &e.variants,
_ => return Err(syn::Error::new_spanned(ast, "CodexSubcommand only supports Enums")),
};
let match_arms = variants.iter().map(|variant| {
let variant_name = &variant.ident;
let command_str = variant_name.to_string().to_lowercase().replace('_', "-");
let ty = match &variant.fields {
syn::Fields::Unnamed(fields) => &fields.unnamed[0].ty,
_ => panic!("Subcommands must be Unnamed fields like Variant(Struct)"),
};
quote! {
#command_str => #name::#variant_name(#ty::parse_cli(Some(&mut args)))
}
});
let expanded = quote! {
impl #name {
pub fn parse() -> Self {
let mut args = std::env::args().skip(1);
let command = args.next().unwrap_or_else(|| {
eprintln!("\n{} No subcommand provided.", #error);
std::process::exit(1);
});
match command.trim_start_matches('/') {
#(#match_arms),*,
_ => {
eprintln!("\n{} Command not found: {}", #error, command);
std::process::exit(1);
}
}
}
}
};
Ok(expanded.into())
}
fn get_formatted_time() -> String {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |time| time.as_secs().cast_signed());
#[allow(unsafe_code)]
unsafe {
let tm = libc::localtime(&raw const now);
tm.as_ref().map_or_else(
|| "00:00:00".to_string(),
|t| format!("{:02}:{:02}:{:02}", t.tm_hour, t.tm_min, t.tm_sec),
)
}
}
fn label() -> String {
let gray = "\x1b[90m";
let green = "\x1b[32m";
let reset = "\x1b[0m";
let time = get_formatted_time();
format!("{gray}[{time}]{green}[Cirious CLI]{reset}")
}
fn label_error() -> String {
let gray = "\x1b[90m";
let green = "\x1b[32m";
let red = "\x1b[31m";
let reset = "\x1b[0m";
let time = get_formatted_time();
let time = format!("{gray}[{time}]{reset}");
let label = format!("{green}Cirious CLI{reset}");
let error = format!("{red}Error{reset}");
format!("{time}[{label} : {error}]")
}