cirious_codex_derive 0.2.0

Centralized Procedural Macros for the Cirious Codex Ecosystem
Documentation
//! Core parsing and code generation logic for the command-line interface.
//!
//! This module is responsible for analyzing the Abstract Syntax Tree (AST) of structs
//! deriving `CodexParser` and generating the strict runtime argument parsing logic.

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}'"),
    }
  }
}

/// Internal configuration extracted from the `#[codex(...)]` attribute on a specific field.
#[derive(Default, Debug)]
struct FieldCmd {
  /// Whether the field should be parsed as a short flag (e.g., `/p`).
  pub aliase: String,
  /// Whether the field should be parsed as a long flag (e.g., `/port`).
  pub command: String,
  /// An optional default value provided by the user (e.g., `default = "8080"`).
  pub default_value: Option<String>,
}

impl FieldCmd {
  /// Parses the `#[codex(...)]` attributes on a given AST field.
  fn from_field(field: &Field) -> Result<Self, Error> {
    let mut config = Self::default();

    for attr in &field.attrs {
      // Ignore any attribute that is not `#[codex(...)]`
      if !attr.path().is_ident("codex") {
        continue;
      }

      // Parse the internal metadata safely using syn 2.0 paradigm
      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 {
          // Strict validation: Reject unknown attributes to prevent typos silently failing
          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);
}

/// Generates the implementation block required for CLI argument parsing.
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 {
      /// Parses arguments directly from the environment.
      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);

              // O SubcommandRouter consome o PRIMEIRO argumento, que é o comando principal
              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}]")
}