use std::fmt;
use std::time::Duration;
use crate::{ImportError, RecipeComponents};
#[cfg(feature = "uniffi")]
uniffi::setup_scaffolding!();
#[derive(Debug, Clone)]
#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
pub struct FfiRecipeComponents {
pub text: String,
pub metadata: String,
pub name: String,
}
impl From<RecipeComponents> for FfiRecipeComponents {
fn from(components: RecipeComponents) -> Self {
FfiRecipeComponents {
text: components.text,
metadata: components.metadata,
name: components.name,
}
}
}
impl From<FfiRecipeComponents> for RecipeComponents {
fn from(ffi: FfiRecipeComponents) -> Self {
RecipeComponents {
text: ffi.text,
metadata: ffi.metadata,
name: ffi.name,
}
}
}
#[derive(Debug, Clone, Copy)]
#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
pub enum FfiLlmProvider {
OpenAI,
Anthropic,
Google,
AzureOpenAI,
Ollama,
}
impl From<FfiLlmProvider> for crate::LlmProvider {
fn from(provider: FfiLlmProvider) -> Self {
match provider {
FfiLlmProvider::OpenAI => crate::LlmProvider::OpenAI,
FfiLlmProvider::Anthropic => crate::LlmProvider::Anthropic,
FfiLlmProvider::Google => crate::LlmProvider::Google,
FfiLlmProvider::AzureOpenAI => crate::LlmProvider::AzureOpenAI,
FfiLlmProvider::Ollama => crate::LlmProvider::Ollama,
}
}
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
pub enum FfiImportResult {
Cooklang { content: String },
Components { components: FfiRecipeComponents },
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "uniffi", derive(uniffi::Error))]
pub enum FfiImportError {
FetchError { reason: String },
ParseError { reason: String },
NoExtractorMatched { reason: String },
ConversionError { reason: String },
InvalidInput { reason: String },
BuilderError { reason: String },
ConfigError { reason: String },
RuntimeError { reason: String },
}
impl fmt::Display for FfiImportError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
FfiImportError::FetchError { reason } => write!(f, "Fetch error: {}", reason),
FfiImportError::ParseError { reason } => write!(f, "Parse error: {}", reason),
FfiImportError::NoExtractorMatched { reason } => {
write!(f, "No extractor matched: {}", reason)
}
FfiImportError::ConversionError { reason } => {
write!(f, "Conversion error: {}", reason)
}
FfiImportError::InvalidInput { reason } => write!(f, "Invalid input: {}", reason),
FfiImportError::BuilderError { reason } => write!(f, "Builder error: {}", reason),
FfiImportError::ConfigError { reason } => write!(f, "Config error: {}", reason),
FfiImportError::RuntimeError { reason } => write!(f, "Runtime error: {}", reason),
}
}
}
impl std::error::Error for FfiImportError {}
impl From<ImportError> for FfiImportError {
fn from(err: ImportError) -> Self {
match err {
ImportError::FetchError(e) => FfiImportError::FetchError {
reason: e.to_string(),
},
ImportError::ParseError(msg) => FfiImportError::ParseError { reason: msg },
ImportError::NoExtractorMatched => FfiImportError::NoExtractorMatched {
reason: "No extractor could parse the recipe from this webpage".to_string(),
},
ImportError::ConversionError(msg) => FfiImportError::ConversionError { reason: msg },
ImportError::InvalidMarkdown(msg) => FfiImportError::InvalidInput { reason: msg },
ImportError::BuilderError(msg) => FfiImportError::BuilderError { reason: msg },
ImportError::ExtractionError(msg) => FfiImportError::ParseError { reason: msg },
ImportError::HeaderError(e) => FfiImportError::FetchError {
reason: e.to_string(),
},
ImportError::EnvError(e) => FfiImportError::ConfigError {
reason: e.to_string(),
},
ImportError::ConfigError(e) => FfiImportError::ConfigError {
reason: e.to_string(),
},
}
}
}
#[derive(Debug, Clone, Default)]
#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
pub struct FfiImportConfig {
pub provider: Option<FfiLlmProvider>,
pub api_key: Option<String>,
pub model: Option<String>,
pub timeout_seconds: Option<u64>,
pub extract_only: bool,
}
fn create_runtime() -> Result<tokio::runtime::Runtime, FfiImportError> {
tokio::runtime::Runtime::new().map_err(|e| FfiImportError::RuntimeError {
reason: format!("Failed to create async runtime: {}", e),
})
}
#[cfg_attr(feature = "uniffi", uniffi::export)]
pub fn import_from_url(
url: String,
config: Option<FfiImportConfig>,
) -> Result<FfiImportResult, FfiImportError> {
let rt = create_runtime()?;
rt.block_on(async { import_from_url_async(&url, config).await })
}
async fn import_from_url_async(
url: &str,
config: Option<FfiImportConfig>,
) -> Result<FfiImportResult, FfiImportError> {
let config = config.unwrap_or_default();
let mut builder = crate::RecipeImporter::builder().url(url);
if let Some(provider) = config.provider {
builder = builder.provider(provider.into());
}
if let Some(api_key) = config.api_key {
builder = builder.api_key(api_key);
}
if let Some(model) = config.model {
builder = builder.model(model);
}
if let Some(timeout_secs) = config.timeout_seconds {
builder = builder.timeout(Duration::from_secs(timeout_secs));
}
if config.extract_only {
builder = builder.extract_only();
}
let result = builder.build().await?;
Ok(match result {
crate::ImportResult::Cooklang { content, .. } => FfiImportResult::Cooklang { content },
crate::ImportResult::Components(components) => FfiImportResult::Components {
components: components.into(),
},
})
}
#[cfg_attr(feature = "uniffi", uniffi::export)]
pub fn convert_text_to_cooklang(
text: String,
config: Option<FfiImportConfig>,
) -> Result<String, FfiImportError> {
let rt = create_runtime()?;
rt.block_on(async { convert_text_async(&text, config).await })
}
async fn convert_text_async(
text: &str,
config: Option<FfiImportConfig>,
) -> Result<String, FfiImportError> {
let config = config.unwrap_or_default();
let mut builder = crate::RecipeImporter::builder().text(text);
if let Some(provider) = config.provider {
builder = builder.provider(provider.into());
}
if let Some(api_key) = config.api_key {
builder = builder.api_key(api_key);
}
if let Some(model) = config.model {
builder = builder.model(model);
}
let result = builder.build().await?;
match result {
crate::ImportResult::Cooklang { content, .. } => Ok(content),
crate::ImportResult::Components(_) => Err(FfiImportError::BuilderError {
reason: "Unexpected components result when converting text".to_string(),
}),
}
}
#[cfg_attr(feature = "uniffi", uniffi::export)]
pub fn convert_image_to_cooklang(
image_path: String,
config: Option<FfiImportConfig>,
) -> Result<String, FfiImportError> {
let rt = create_runtime()?;
rt.block_on(async { convert_image_async(&image_path, config).await })
}
async fn convert_image_async(
image_path: &str,
config: Option<FfiImportConfig>,
) -> Result<String, FfiImportError> {
let config = config.unwrap_or_default();
let mut builder = crate::RecipeImporter::builder().image_path(image_path);
if let Some(provider) = config.provider {
builder = builder.provider(provider.into());
}
if let Some(api_key) = config.api_key {
builder = builder.api_key(api_key);
}
if let Some(model) = config.model {
builder = builder.model(model);
}
let result = builder.build().await?;
match result {
crate::ImportResult::Cooklang { content, .. } => Ok(content),
crate::ImportResult::Components(_) => Err(FfiImportError::BuilderError {
reason: "Unexpected components result when converting image".to_string(),
}),
}
}
#[cfg_attr(feature = "uniffi", uniffi::export)]
pub fn extract_recipe_from_url(
url: String,
timeout_seconds: Option<u64>,
) -> Result<FfiRecipeComponents, FfiImportError> {
let rt = create_runtime()?;
rt.block_on(async {
let mut builder = crate::RecipeImporter::builder().url(&url).extract_only();
if let Some(timeout_secs) = timeout_seconds {
builder = builder.timeout(Duration::from_secs(timeout_secs));
}
let result = builder.build().await?;
match result {
crate::ImportResult::Components(components) => Ok(components.into()),
crate::ImportResult::Cooklang { .. } => Err(FfiImportError::BuilderError {
reason: "Unexpected Cooklang result when extracting".to_string(),
}),
}
})
}
#[cfg_attr(feature = "uniffi", uniffi::export)]
pub fn simple_import(url: String) -> Result<String, FfiImportError> {
import_from_url(url, None).map(|result| match result {
FfiImportResult::Cooklang { content } => content,
FfiImportResult::Components { components } => components.text,
})
}
#[cfg_attr(feature = "uniffi", uniffi::export)]
pub fn get_version() -> String {
env!("CARGO_PKG_VERSION").to_string()
}
#[cfg_attr(feature = "uniffi", uniffi::export)]
pub fn is_provider_available(provider: FfiLlmProvider) -> bool {
match provider {
FfiLlmProvider::OpenAI => std::env::var("OPENAI_API_KEY").is_ok(),
FfiLlmProvider::Anthropic => std::env::var("ANTHROPIC_API_KEY").is_ok(),
FfiLlmProvider::Google => std::env::var("GOOGLE_API_KEY").is_ok(),
FfiLlmProvider::AzureOpenAI => {
std::env::var("AZURE_OPENAI_API_KEY").is_ok()
&& std::env::var("AZURE_OPENAI_ENDPOINT").is_ok()
}
FfiLlmProvider::Ollama => {
true
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_ffi_recipe_components_conversion() {
let components = RecipeComponents {
text: "2 eggs\n1 cup flour\n\nMix together and bake.".to_string(),
metadata: "author: Chef".to_string(),
name: "Test Recipe".to_string(),
};
let ffi_components: FfiRecipeComponents = components.clone().into();
assert_eq!(ffi_components.name, "Test Recipe");
assert_eq!(ffi_components.metadata, "author: Chef");
assert!(ffi_components.text.contains("2 eggs"));
let back: RecipeComponents = ffi_components.into();
assert_eq!(back.name, components.name);
assert_eq!(back.metadata, components.metadata);
assert_eq!(back.text, components.text);
}
#[test]
fn test_get_version() {
let version = get_version();
assert!(!version.is_empty());
}
}