Skip to main content

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}