cooklang_import/builder.rs
1use std::time::Duration;
2
3use crate::{
4 config::{load_config, ProviderConfig},
5 converters::{self, ConversionMetadata, Converter},
6 images_to_text::ImageSource,
7 pipelines::RecipeComponents,
8 ImportError,
9};
10
11/// Represents the input source for a recipe
12#[derive(Debug, Clone)]
13pub enum InputSource {
14 /// Fetch recipe from a URL
15 Url(String),
16 /// Use text content (pre-formatted or requiring extraction)
17 Text { content: String, extract: bool },
18 /// Use images (paths or base64)
19 Images(Vec<ImageSource>),
20}
21
22/// Represents the desired output format
23#[derive(Debug, Clone, Copy, Default)]
24pub enum OutputMode {
25 /// Convert to Cooklang format (default)
26 #[default]
27 Cooklang,
28 /// Return Recipe struct without conversion
29 Recipe,
30}
31
32/// Result of a recipe import operation
33#[derive(Debug, Clone)]
34pub enum ImportResult {
35 /// Cooklang-formatted recipe with optional conversion metadata
36 Cooklang {
37 /// The converted Cooklang text
38 content: String,
39 /// Metadata about the LLM conversion (model, tokens, latency)
40 conversion_metadata: Option<ConversionMetadata>,
41 },
42 /// Recipe components (text, metadata, name) - no conversion metadata since no LLM was used
43 Components(RecipeComponents),
44}
45
46/// Optional LLM provider configuration
47#[derive(Debug, Clone)]
48pub enum LlmProvider {
49 OpenAI,
50 Anthropic,
51 Google,
52 AzureOpenAI,
53 Ollama,
54}
55
56impl LlmProvider {
57 // Note: Conversion to string is handled directly in the converter factory
58}
59
60/// Builder for configuring and executing recipe imports
61#[derive(Debug, Default)]
62pub struct RecipeImporterBuilder {
63 source: Option<InputSource>,
64 mode: OutputMode,
65 provider: Option<LlmProvider>,
66 timeout: Option<Duration>,
67 api_key: Option<String>,
68 model: Option<String>,
69}
70
71impl RecipeImporterBuilder {
72 /// Set the input source to a URL
73 ///
74 /// # Example
75 /// ```
76 /// use cooklang_import::RecipeImporter;
77 ///
78 /// let builder = RecipeImporter::builder()
79 /// .url("https://example.com/recipe");
80 /// ```
81 pub fn url(mut self, url: impl Into<String>) -> Self {
82 self.source = Some(InputSource::Url(url.into()));
83 self
84 }
85
86 /// Set the input source to pre-formatted text (no extraction needed)
87 ///
88 /// Use this when you have a recipe already formatted with ingredients and instructions.
89 /// The text will be converted directly to Cooklang without LLM extraction.
90 ///
91 /// # Example
92 /// ```
93 /// use cooklang_import::RecipeImporter;
94 ///
95 /// let recipe_text = "2 eggs\n1 cup flour\n\nMix together and bake at 350F for 30 minutes.";
96 /// let builder = RecipeImporter::builder()
97 /// .text(recipe_text);
98 /// ```
99 pub fn text(mut self, text: impl Into<String>) -> Self {
100 self.source = Some(InputSource::Text {
101 content: text.into(),
102 extract: false,
103 });
104 self
105 }
106
107 /// Set the input source to plain text that needs extraction
108 ///
109 /// Use this when you have a recipe in plain text format that needs to be parsed.
110 /// The LLM will extract ingredients and instructions from the text.
111 ///
112 /// # Example
113 /// ```
114 /// use cooklang_import::RecipeImporter;
115 ///
116 /// let recipe_text = "Take 2 eggs and 1 cup of flour. Mix them together and bake at 350F for 30 minutes.";
117 /// let builder = RecipeImporter::builder()
118 /// .text_with_extraction(recipe_text);
119 /// ```
120 pub fn text_with_extraction(mut self, text: impl Into<String>) -> Self {
121 self.source = Some(InputSource::Text {
122 content: text.into(),
123 extract: true,
124 });
125 self
126 }
127
128 /// Add an image file path to the input sources
129 ///
130 /// Use this when you have a recipe image that needs to be OCR'd.
131 /// Multiple images can be added by calling this method multiple times.
132 ///
133 /// Requires GOOGLE_API_KEY environment variable to be set.
134 ///
135 /// # Example
136 /// ```
137 /// use cooklang_import::RecipeImporter;
138 ///
139 /// let builder = RecipeImporter::builder()
140 /// .image_path("/path/to/recipe-image.jpg");
141 /// ```
142 pub fn image_path(mut self, path: impl Into<String>) -> Self {
143 match &mut self.source {
144 Some(InputSource::Images(images)) => {
145 images.push(ImageSource::Path(path.into()));
146 }
147 _ => {
148 self.source = Some(InputSource::Images(vec![ImageSource::Path(path.into())]));
149 }
150 }
151 self
152 }
153
154 /// Add a base64-encoded image to the input sources
155 ///
156 /// Use this when you have a recipe image as base64 data.
157 /// Multiple images can be added by calling this method multiple times.
158 ///
159 /// Requires GOOGLE_API_KEY environment variable to be set.
160 ///
161 /// # Example
162 /// ```
163 /// use cooklang_import::RecipeImporter;
164 ///
165 /// let builder = RecipeImporter::builder()
166 /// .image_base64("base64encodeddata...");
167 /// ```
168 pub fn image_base64(mut self, data: impl Into<String>) -> Self {
169 match &mut self.source {
170 Some(InputSource::Images(images)) => {
171 images.push(ImageSource::Base64(data.into()));
172 }
173 _ => {
174 self.source = Some(InputSource::Images(vec![ImageSource::Base64(data.into())]));
175 }
176 }
177 self
178 }
179
180 /// Set multiple images at once
181 ///
182 /// Use this to set all images in one call instead of using image_path or image_base64 multiple times.
183 ///
184 /// # Example
185 /// ```
186 /// use cooklang_import::{RecipeImporter, ImageSource};
187 ///
188 /// let images = vec![
189 /// ImageSource::Path("/path/to/image1.jpg".to_string()),
190 /// ImageSource::Path("/path/to/image2.jpg".to_string()),
191 /// ];
192 /// let builder = RecipeImporter::builder()
193 /// .images(images);
194 /// ```
195 pub fn images(mut self, images: Vec<ImageSource>) -> Self {
196 self.source = Some(InputSource::Images(images));
197 self
198 }
199
200 /// Set output mode to extract only (no conversion)
201 ///
202 /// This returns a Recipe struct without converting to Cooklang format.
203 ///
204 /// # Example
205 /// ```
206 /// use cooklang_import::RecipeImporter;
207 ///
208 /// let builder = RecipeImporter::builder()
209 /// .url("https://example.com/recipe")
210 /// .extract_only();
211 /// ```
212 pub fn extract_only(mut self) -> Self {
213 self.mode = OutputMode::Recipe;
214 self
215 }
216
217 /// Set a custom LLM provider for conversion
218 ///
219 /// # Example
220 /// ```
221 /// use cooklang_import::{RecipeImporter, LlmProvider};
222 ///
223 /// let builder = RecipeImporter::builder()
224 /// .url("https://example.com/recipe")
225 /// .provider(LlmProvider::Anthropic);
226 /// ```
227 pub fn provider(mut self, provider: LlmProvider) -> Self {
228 self.provider = Some(provider);
229 self
230 }
231
232 /// Set a timeout for HTTP requests
233 ///
234 /// # Example
235 /// ```
236 /// use cooklang_import::RecipeImporter;
237 /// use std::time::Duration;
238 ///
239 /// let builder = RecipeImporter::builder()
240 /// .url("https://example.com/recipe")
241 /// .timeout(Duration::from_secs(30));
242 /// ```
243 pub fn timeout(mut self, duration: Duration) -> Self {
244 self.timeout = Some(duration);
245 self
246 }
247
248 /// Set the API key for the LLM provider
249 ///
250 /// This allows passing the API key directly instead of relying on
251 /// environment variables or config files.
252 ///
253 /// # Example
254 /// ```
255 /// use cooklang_import::{RecipeImporter, LlmProvider};
256 ///
257 /// let builder = RecipeImporter::builder()
258 /// .url("https://example.com/recipe")
259 /// .provider(LlmProvider::Anthropic)
260 /// .api_key("your-api-key");
261 /// ```
262 pub fn api_key(mut self, key: impl Into<String>) -> Self {
263 self.api_key = Some(key.into());
264 self
265 }
266
267 /// Set the model name for the LLM provider
268 ///
269 /// # Example
270 /// ```
271 /// use cooklang_import::{RecipeImporter, LlmProvider};
272 ///
273 /// let builder = RecipeImporter::builder()
274 /// .url("https://example.com/recipe")
275 /// .provider(LlmProvider::Anthropic)
276 /// .model("claude-3-5-sonnet-20241022");
277 /// ```
278 pub fn model(mut self, model: impl Into<String>) -> Self {
279 self.model = Some(model.into());
280 self
281 }
282
283 /// Build and execute the recipe import operation
284 ///
285 /// # Returns
286 /// An `ImportResult` containing either a Cooklang string or Recipe struct
287 ///
288 /// # Errors
289 /// Returns `ImportError` if:
290 /// - No input source was specified
291 /// - URL fetch fails
292 /// - Recipe extraction fails
293 /// - Conversion fails
294 /// - Invalid combination of options (e.g., markdown + extract_only)
295 ///
296 /// # Example
297 /// ```no_run
298 /// # use cooklang_import::RecipeImporter;
299 /// # #[tokio::main]
300 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
301 /// let result = RecipeImporter::builder()
302 /// .url("https://example.com/recipe")
303 /// .build()
304 /// .await?;
305 /// # Ok(())
306 /// # }
307 /// ```
308 pub async fn build(self) -> Result<ImportResult, ImportError> {
309 // Validate that source is set
310 let source = self.source.clone().ok_or_else(|| {
311 ImportError::BuilderError(
312 "No input source specified. Use .url(), .text(), or .image_path()".to_string(),
313 )
314 })?;
315
316 // Route to the appropriate pipeline based on input source
317 let components = match source {
318 InputSource::Url(url) => crate::pipelines::url::process(&url)
319 .await
320 .map_err(|e| ImportError::BuilderError(e.to_string()))?,
321 InputSource::Text { content, extract } => {
322 crate::pipelines::text::process(&content, extract)
323 .await
324 .map_err(|e| ImportError::BuilderError(e.to_string()))?
325 }
326 InputSource::Images(images) => crate::pipelines::image::process(&images)
327 .await
328 .map_err(|e| ImportError::BuilderError(e.to_string()))?,
329 };
330
331 // Return based on output mode
332 match self.mode {
333 OutputMode::Cooklang => {
334 // Convert to Cooklang format using a converter
335 let (content, conversion_metadata) = self.convert_to_cooklang(&components).await?;
336 Ok(ImportResult::Cooklang {
337 content,
338 conversion_metadata: Some(conversion_metadata),
339 })
340 }
341 OutputMode::Recipe => Ok(ImportResult::Components(components)),
342 }
343 }
344
345 /// Convert RecipeComponents to Cooklang using configured converter
346 async fn convert_to_cooklang(
347 &self,
348 components: &RecipeComponents,
349 ) -> Result<(String, ConversionMetadata), ImportError> {
350 // Get converter configuration
351 let converter = self.get_converter().await?;
352
353 // Convert the text (ingredients + instructions) to Cooklang
354 let conversion_result = converter
355 .convert(&components.text)
356 .await
357 .map_err(|e| ImportError::ConversionError(e.to_string()))?;
358
359 // Build YAML frontmatter from metadata and name
360 let mut output = String::new();
361 let has_name = !components.name.is_empty();
362 let has_metadata = !components.metadata.is_empty();
363
364 if has_name || has_metadata {
365 output.push_str("---\n");
366 if has_name {
367 let title_yaml = crate::pipelines::metadata_to_yaml(&[(
368 "title".to_string(),
369 components.name.clone(),
370 )]);
371 output.push_str(&title_yaml);
372 }
373 if has_metadata {
374 output.push_str(&components.metadata);
375 if !components.metadata.ends_with('\n') {
376 output.push('\n');
377 }
378 }
379 output.push_str("---\n\n");
380 }
381 output.push_str(&conversion_result.content);
382
383 Ok((output, conversion_result.metadata))
384 }
385
386 /// Get the appropriate converter based on configuration
387 async fn get_converter(&self) -> Result<Box<dyn Converter>, ImportError> {
388 // Determine which provider to use
389 let provider_name: String = match &self.provider {
390 Some(LlmProvider::OpenAI) => "open_ai".to_string(),
391 Some(LlmProvider::Anthropic) => "anthropic".to_string(),
392 Some(LlmProvider::Google) => "google".to_string(),
393 Some(LlmProvider::AzureOpenAI) => "azure_openai".to_string(),
394 Some(LlmProvider::Ollama) => "ollama".to_string(),
395 None => {
396 // Try to load from config, or default to open_ai
397 load_config()
398 .map(|c| c.default_provider)
399 .unwrap_or_else(|_| "open_ai".to_string())
400 }
401 };
402
403 // Build provider config
404 let provider_config = self.build_provider_config(&provider_name);
405
406 // Create the converter
407 converters::create_converter(&provider_name, &provider_config).ok_or_else(|| {
408 ImportError::ConversionError(format!(
409 "Failed to create converter '{}'. Check API key and configuration.",
410 provider_name
411 ))
412 })
413 }
414
415 /// Build provider configuration from builder settings and environment
416 fn build_provider_config(&self, provider_name: &str) -> ProviderConfig {
417 // Try to load config from file first
418 let base_config = load_config()
419 .ok()
420 .and_then(|c| c.providers.get(provider_name).cloned());
421
422 // Build config with overrides from builder
423 ProviderConfig {
424 enabled: true,
425 model: self.model.clone().unwrap_or_else(|| {
426 base_config
427 .as_ref()
428 .map(|c| c.model.clone())
429 .unwrap_or_else(|| default_model_for_provider(provider_name).to_string())
430 }),
431 temperature: base_config.as_ref().map(|c| c.temperature).unwrap_or(0.7),
432 max_tokens: base_config.as_ref().map(|c| c.max_tokens).unwrap_or(4000),
433 api_key: self
434 .api_key
435 .clone()
436 .or_else(|| base_config.as_ref().and_then(|c| c.api_key.clone())),
437 base_url: base_config.as_ref().and_then(|c| c.base_url.clone()),
438 endpoint: base_config.as_ref().and_then(|c| c.endpoint.clone()),
439 deployment_name: base_config.as_ref().and_then(|c| c.deployment_name.clone()),
440 api_version: base_config.as_ref().and_then(|c| c.api_version.clone()),
441 project_id: base_config.as_ref().and_then(|c| c.project_id.clone()),
442 }
443 }
444}
445
446/// Get default model for a given provider
447fn default_model_for_provider(provider: &str) -> &'static str {
448 match provider {
449 "open_ai" => "gpt-4o-mini",
450 "anthropic" => "claude-haiku-4-5",
451 "google" => "gemini-1.5-flash",
452 "azure_openai" => "gpt-4",
453 "ollama" => "llama2",
454 _ => "gpt-4o-mini",
455 }
456}
457
458/// Main entry point for the builder API
459pub struct RecipeImporter;
460
461impl RecipeImporter {
462 /// Creates a new builder for importing recipes
463 ///
464 /// # Example
465 /// ```
466 /// use cooklang_import::RecipeImporter;
467 ///
468 /// let builder = RecipeImporter::builder();
469 /// ```
470 pub fn builder() -> RecipeImporterBuilder {
471 RecipeImporterBuilder::default()
472 }
473}