oas3-gen 0.23.1

A rust type generator for OpenAPI v3.1.x specification.
use std::{collections::HashSet, path::PathBuf};

use chrono::{Local, Timelike};
use crossterm::style::Stylize;

use crate::{
  generator::{
    codegen::Visibility,
    orchestrator::{ClientModOutput, GenerationStats, Orchestrator},
  },
  ui::{Colors, EnumCaseMode, GenerateCommand, GenerateMode},
  utils::spec::SpecLoader,
};

fn format_timestamp() -> String {
  let now = Local::now();
  format!("[{:02}:{:02}:{:02}]", now.hour(), now.minute(), now.second())
}

#[derive(Debug, Clone)]
#[allow(clippy::struct_excessive_bools)]
pub struct GenerateConfig {
  pub mode: GenerateMode,
  pub input: PathBuf,
  pub output: PathBuf,
  pub visibility: Visibility,
  pub verbose: bool,
  pub quiet: bool,
  pub all_schemas: bool,
  pub odata_support: bool,
  pub preserve_case_variants: bool,
  pub case_insensitive_enums: bool,
  pub only_operations: Option<HashSet<String>>,
  pub excluded_operations: Option<HashSet<String>>,
  pub no_helpers: bool,
}

#[derive(Debug, Clone, Copy)]
struct EnumPolicies {
  preserve_case_variants: bool,
  case_insensitive_enums: bool,
}

impl GenerateConfig {
  async fn load_spec(&self) -> anyhow::Result<oas3::Spec> {
    SpecLoader::open(&self.input).await?.parse()
  }

  fn create_orchestrator(&self, spec: oas3::Spec) -> Orchestrator {
    Orchestrator::new(
      spec,
      self.visibility,
      self.all_schemas,
      self.only_operations.as_ref(),
      self.excluded_operations.as_ref(),
      self.odata_support,
      self.preserve_case_variants,
      self.case_insensitive_enums,
      self.no_helpers,
    )
  }

  async fn write_output(&self, code: String) -> anyhow::Result<()> {
    if let Some(parent) = self.output.parent() {
      tokio::fs::create_dir_all(parent).await?;
    }
    tokio::fs::write(&self.output, code).await?;
    Ok(())
  }

  async fn write_module_output(&self, output: ClientModOutput) -> anyhow::Result<()> {
    tokio::fs::create_dir_all(&self.output).await?;
    tokio::fs::write(self.output.join("types.rs"), output.types_code).await?;
    tokio::fs::write(self.output.join("client.rs"), output.client_code).await?;
    tokio::fs::write(self.output.join("mod.rs"), output.mod_code).await?;
    Ok(())
  }
}

impl GenerateConfig {
  pub fn from_command(command: GenerateCommand) -> anyhow::Result<Self> {
    let GenerateCommand {
      mode,
      input,
      output,
      visibility,
      odata_support,
      enum_mode,
      no_helpers,
      all_schemas,
      only,
      exclude,
      verbose,
      quiet,
    } = command;

    let output = match (&mode, output) {
      (GenerateMode::ClientMod, None) => PathBuf::from("."),
      (_, None) => anyhow::bail!("Output path (-o) is required for types and client modes"),
      (_, Some(path)) => path,
    };
    let enum_policies = EnumPolicies::from(enum_mode);

    Ok(Self {
      mode,
      input,
      output,
      visibility,
      verbose,
      quiet,
      all_schemas,
      odata_support,
      preserve_case_variants: enum_policies.preserve_case_variants,
      case_insensitive_enums: enum_policies.case_insensitive_enums,
      only_operations: only.map(|ops| ops.into_iter().collect()),
      excluded_operations: exclude.map(|ops| ops.into_iter().collect()),
      no_helpers,
    })
  }
}

impl From<EnumCaseMode> for EnumPolicies {
  fn from(enum_mode: EnumCaseMode) -> Self {
    match enum_mode {
      EnumCaseMode::Merge => Self {
        preserve_case_variants: false,
        case_insensitive_enums: false,
      },
      EnumCaseMode::Preserve => Self {
        preserve_case_variants: true,
        case_insensitive_enums: false,
      },
      EnumCaseMode::Relaxed => Self {
        preserve_case_variants: false,
        case_insensitive_enums: true,
      },
    }
  }
}

struct GenerateLogger<'a> {
  config: &'a GenerateConfig,
  colors: &'a Colors,
}

impl<'a> GenerateLogger<'a> {
  fn new(config: &'a GenerateConfig, colors: &'a Colors) -> Self {
    Self { config, colors }
  }

  fn info(&self, message: &str) {
    if !self.config.quiet {
      println!("{} {message}", format_timestamp().with(self.colors.timestamp()));
    }
  }

  fn stat(&self, label: &str, value: String) {
    if !self.config.quiet {
      println!(
        "            {:<25} {}",
        label.with(self.colors.label()),
        value.with(self.colors.value())
      );
    }
  }

  fn log_loading(&self) {
    self.info(
      &format!("Loading OpenAPI spec from: {}", self.config.input.display())
        .with(self.colors.primary())
        .to_string(),
    );
  }

  fn log_generating(&self) {
    let message = match self.config.mode {
      GenerateMode::Types => "Generating Rust types...",
      GenerateMode::Client => "Generating Rust client...",
      GenerateMode::ClientMod => "Generating Rust client module...",
    };
    self.info(&message.with(self.colors.primary()).to_string());
  }

  fn print_statistics(&self, stats: &GenerationStats) {
    if self.config.quiet {
      return;
    }

    match self.config.mode {
      GenerateMode::Types => self.print_type_stats(stats),
      GenerateMode::Client => self.print_client_stats(stats),
      GenerateMode::ClientMod => {
        self.print_type_stats(stats);
        self.print_client_stats(stats);
      }
    }

    self.print_common_stats(stats);
    self.print_cycles(stats);
    self.print_orphaned_schemas(stats);
    self.print_warnings(stats);
  }

  fn print_type_stats(&self, stats: &GenerationStats) {
    self.stat("Types generated:", stats.types_generated.to_string());
    self.stat("", format!("{} structs", stats.structs_generated));
    if stats.enums_with_helpers_generated > 0 {
      self.stat(
        "",
        format!(
          "{} enums, {} have helpers",
          stats.enums_generated, stats.enums_with_helpers_generated
        ),
      );
    } else {
      self.stat("", format!("{} enums", stats.enums_generated));
    }
    self.stat("", format!("{} type aliases", stats.type_aliases_generated));
    self.stat("Operations converted:", stats.operations_converted.to_string());
    if stats.webhooks_converted > 0 {
      self.stat("", format!("{} webhooks", stats.webhooks_converted));
    }
  }

  fn print_client_stats(&self, stats: &GenerationStats) {
    if let Some(methods) = stats.client_methods_generated {
      self.stat("Methods generated:", methods.to_string());
    }
    if let Some(headers) = stats.client_headers_generated {
      self.stat("Headers generated:", headers.to_string());
    }
  }

  fn print_common_stats(&self, stats: &GenerationStats) {
    if !stats.warnings.is_empty() {
      self.stat("Warnings:", stats.warnings.len().to_string());
    }
  }

  fn print_cycles(&self, stats: &GenerationStats) {
    if stats.cycles_detected == 0 {
      return;
    }

    self.stat("Cycles:", stats.cycles_detected.to_string());

    if self.config.verbose {
      for (i, cycle) in stats.cycle_details.iter().enumerate() {
        println!(
          "              {}: {}",
          format!("Cycle {}", i + 1).with(self.colors.accent()),
          cycle.join(" -> ").with(self.colors.info())
        );
      }
    }
  }

  fn print_orphaned_schemas(&self, stats: &GenerationStats) {
    if stats.orphaned_schemas_count > 0 && self.config.verbose {
      self.stat("Orphaned schemas:", stats.orphaned_schemas_count.to_string());
    }
  }

  fn print_warnings(&self, stats: &GenerationStats) {
    if stats.warnings.is_empty() || !self.config.verbose || self.config.quiet {
      return;
    }

    println!();
    for warning in &stats.warnings {
      eprintln!(
        "{} {}",
        "Warning:".with(self.colors.accent()),
        format!("{warning}").with(self.colors.primary())
      );
    }
  }

  fn log_writing(&self) {
    self.info(
      &format!("Writing to: {}", self.config.output.display())
        .with(self.colors.primary())
        .to_string(),
    );
  }

  fn log_success(&self) {
    if !self.config.quiet {
      let message = match self.config.mode {
        GenerateMode::Types => "Successfully generated Rust types",
        GenerateMode::Client => "Successfully generated Rust client",
        GenerateMode::ClientMod => "Successfully generated Rust client module",
      };
      println!();
      println!(
        "{} {}",
        format_timestamp().with(self.colors.timestamp()),
        message.with(self.colors.success())
      );
    }
  }
}

pub async fn generate_code(config: GenerateConfig, colors: &Colors) -> anyhow::Result<()> {
  let logger = GenerateLogger::new(&config, colors);

  logger.log_loading();
  let spec = config.load_spec().await?;

  logger.log_generating();
  let orchestrator = config.create_orchestrator(spec);
  let source_path = config.input.display().to_string();

  match config.mode {
    GenerateMode::Types => {
      let (code, stats) = orchestrator.generate_with_header(&source_path)?;
      logger.print_statistics(&stats);
      logger.log_writing();
      config.write_output(code).await?;
    }
    GenerateMode::Client => {
      let (code, stats) = orchestrator.generate_client_with_header(&source_path)?;
      logger.print_statistics(&stats);
      logger.log_writing();
      config.write_output(code).await?;
    }
    GenerateMode::ClientMod => {
      let output = orchestrator.generate_client_mod(&source_path)?;
      logger.print_statistics(&output.stats);
      logger.log_writing();
      config.write_module_output(output).await?;
    }
  }

  logger.log_success();
  Ok(())
}