use crate::fetcher::{get_recipe_str, FetchError};
use crate::menu::{list_menus_for_date as list_menus_for_date_internal, MenuError};
use crate::model::{Metadata, RecipeEntry, RecipeEntryError, StepImageCollection};
use crate::search::{search as search_internal, SearchError};
use crate::tree::{build_tree as build_tree_internal, RecipeTree, TreeError};
use camino::{Utf8Path, Utf8PathBuf};
use std::sync::Arc;
#[derive(Debug, Clone, uniffi::Error)]
pub enum CooklangError {
NotFound { reason: String },
IoError { reason: String },
ParseError { reason: String },
InvalidPath { reason: String },
SearchError { reason: String },
TreeError { reason: String },
MenuError { reason: String },
}
impl std::fmt::Display for CooklangError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CooklangError::NotFound { reason } => write!(f, "Not found: {}", reason),
CooklangError::IoError { reason } => write!(f, "IO error: {}", reason),
CooklangError::ParseError { reason } => write!(f, "Parse error: {}", reason),
CooklangError::InvalidPath { reason } => write!(f, "Invalid path: {}", reason),
CooklangError::SearchError { reason } => write!(f, "Search error: {}", reason),
CooklangError::TreeError { reason } => write!(f, "Tree error: {}", reason),
CooklangError::MenuError { reason } => write!(f, "Menu error: {}", reason),
}
}
}
impl std::error::Error for CooklangError {}
impl From<FetchError> for CooklangError {
fn from(e: FetchError) -> Self {
match e {
FetchError::IoError(e) => CooklangError::IoError {
reason: e.to_string(),
},
FetchError::RecipeEntryError(e) => e.into(),
FetchError::InvalidPath(p) => CooklangError::NotFound {
reason: format!("Recipe not found: {}", p),
},
}
}
}
impl From<RecipeEntryError> for CooklangError {
fn from(e: RecipeEntryError) -> Self {
match e {
RecipeEntryError::IoError(e) => CooklangError::IoError {
reason: e.to_string(),
},
RecipeEntryError::InvalidPath(p) => CooklangError::InvalidPath {
reason: p.to_string(),
},
RecipeEntryError::ParseError(msg) => CooklangError::ParseError { reason: msg },
RecipeEntryError::MetadataError(msg) => CooklangError::ParseError { reason: msg },
}
}
}
impl From<SearchError> for CooklangError {
fn from(e: SearchError) -> Self {
CooklangError::SearchError {
reason: e.to_string(),
}
}
}
impl From<TreeError> for CooklangError {
fn from(e: TreeError) -> Self {
CooklangError::TreeError {
reason: e.to_string(),
}
}
}
impl From<MenuError> for CooklangError {
fn from(e: MenuError) -> Self {
CooklangError::MenuError {
reason: e.to_string(),
}
}
}
#[derive(Debug, Clone, uniffi::Record)]
pub struct MetadataEntry {
pub key: String,
pub value: String,
}
#[derive(Debug, Clone, uniffi::Record)]
pub struct FfiMetadata {
pub title: Option<String>,
pub servings: Option<i64>,
pub tags: Vec<String>,
pub image_url: Option<String>,
pub raw_json: String,
}
impl From<&Metadata> for FfiMetadata {
fn from(m: &Metadata) -> Self {
let raw_json = serde_json::to_string(&m).unwrap_or_default();
FfiMetadata {
title: m.title().map(|s| s.to_string()),
servings: m.servings(),
tags: m.tags(),
image_url: m.image_url(),
raw_json,
}
}
}
#[derive(Debug, Clone, uniffi::Record)]
pub struct StepImageEntry {
pub section: u32,
pub step: u32,
pub image_path: String,
}
#[derive(Debug, Clone, uniffi::Record)]
pub struct FfiStepImages {
pub images: Vec<StepImageEntry>,
pub count: u32,
}
impl From<&StepImageCollection> for FfiStepImages {
fn from(c: &StepImageCollection) -> Self {
let mut images = Vec::new();
for (section_idx, steps) in &c.images {
for (step_idx, path) in steps {
let section = if *section_idx == 0 {
0 } else {
(*section_idx + 1) as u32 };
let step = (*step_idx + 1) as u32;
images.push(StepImageEntry {
section,
step,
image_path: path.clone(),
});
}
}
images.sort_by(|a, b| {
let section_cmp = a.section.cmp(&b.section);
if section_cmp == std::cmp::Ordering::Equal {
a.step.cmp(&b.step)
} else {
section_cmp
}
});
FfiStepImages {
count: images.len() as u32,
images,
}
}
}
#[derive(uniffi::Object)]
pub struct FfiRecipeEntry {
inner: RecipeEntry,
}
#[uniffi::export]
impl FfiRecipeEntry {
pub fn name(&self) -> Option<String> {
self.inner.name().clone()
}
pub fn path(&self) -> Option<String> {
self.inner.path().map(|p| p.to_string())
}
pub fn file_name(&self) -> Option<String> {
self.inner.file_name()
}
pub fn content(&self) -> Result<String, CooklangError> {
self.inner.content().map_err(|e| e.into())
}
pub fn metadata(&self) -> FfiMetadata {
FfiMetadata::from(self.inner.metadata())
}
pub fn tags(&self) -> Vec<String> {
self.inner.tags()
}
pub fn title_image(&self) -> Option<String> {
self.inner.title_image().clone()
}
pub fn step_images(&self) -> FfiStepImages {
FfiStepImages::from(self.inner.step_images())
}
pub fn is_menu(&self) -> bool {
self.inner.is_menu()
}
pub fn get_step_image(&self, section: u32, step: u32) -> Option<String> {
self.inner
.step_images()
.get(section as usize, step as usize)
.cloned()
}
pub fn get_metadata_value(&self, key: String) -> Option<String> {
self.inner
.metadata()
.get(&key)
.map(|v| serde_json::to_string(v).unwrap_or_default())
}
pub fn related_files(&self) -> Vec<String> {
self.inner
.related_files()
.into_iter()
.map(|p| p.to_string())
.collect()
}
}
impl FfiRecipeEntry {
fn new(entry: RecipeEntry) -> Self {
FfiRecipeEntry { inner: entry }
}
}
#[derive(Debug, Clone, uniffi::Record)]
pub struct FfiTreeNode {
pub name: String,
pub path: String,
pub has_recipe: bool,
pub children: Vec<String>,
}
#[derive(uniffi::Object)]
pub struct FfiRecipeTree {
inner: RecipeTree,
}
#[uniffi::export]
impl FfiRecipeTree {
pub fn root(&self) -> FfiTreeNode {
tree_to_node(&self.inner)
}
pub fn all_nodes(&self) -> Vec<FfiTreeNode> {
let mut nodes = Vec::new();
collect_nodes(&self.inner, &mut nodes);
nodes
}
pub fn all_recipes(&self) -> Vec<Arc<FfiRecipeEntry>> {
let mut recipes = Vec::new();
collect_recipes(&self.inner, &mut recipes);
recipes
}
pub fn get_child(&self, name: String) -> Option<FfiTreeNode> {
self.inner.children.get(&name).map(tree_to_node)
}
pub fn recipe(&self) -> Option<Arc<FfiRecipeEntry>> {
self.inner
.recipe
.as_ref()
.map(|r| Arc::new(FfiRecipeEntry::new(r.clone())))
}
pub fn get_recipe_at_path(&self, path: Vec<String>) -> Option<Arc<FfiRecipeEntry>> {
let mut current = &self.inner;
for component in &path {
current = current.children.get(component)?;
}
current
.recipe
.as_ref()
.map(|r| Arc::new(FfiRecipeEntry::new(r.clone())))
}
}
fn tree_to_node(tree: &RecipeTree) -> FfiTreeNode {
FfiTreeNode {
name: tree.name.clone(),
path: tree.path.to_string(),
has_recipe: tree.recipe.is_some(),
children: tree.children.keys().cloned().collect(),
}
}
fn collect_nodes(tree: &RecipeTree, nodes: &mut Vec<FfiTreeNode>) {
nodes.push(tree_to_node(tree));
for child in tree.children.values() {
collect_nodes(child, nodes);
}
}
fn collect_recipes(tree: &RecipeTree, recipes: &mut Vec<Arc<FfiRecipeEntry>>) {
if let Some(recipe) = &tree.recipe {
recipes.push(Arc::new(FfiRecipeEntry::new(recipe.clone())));
}
for child in tree.children.values() {
collect_recipes(child, recipes);
}
}
#[uniffi::export]
pub fn get_recipe(
base_dirs: Vec<String>,
name: String,
) -> Result<Arc<FfiRecipeEntry>, CooklangError> {
let entry = get_recipe_str(base_dirs, &name)?;
Ok(Arc::new(FfiRecipeEntry::new(entry)))
}
#[uniffi::export]
pub fn recipe_from_content(
content: String,
name: Option<String>,
) -> Result<Arc<FfiRecipeEntry>, CooklangError> {
let entry = RecipeEntry::from_content(content, name)?;
Ok(Arc::new(FfiRecipeEntry::new(entry)))
}
#[uniffi::export]
pub fn recipe_from_path(path: String) -> Result<Arc<FfiRecipeEntry>, CooklangError> {
let entry = RecipeEntry::from_path(path.into())?;
Ok(Arc::new(FfiRecipeEntry::new(entry)))
}
#[uniffi::export]
pub fn search(base_dir: String, query: String) -> Result<Vec<Arc<FfiRecipeEntry>>, CooklangError> {
let results = search_internal(Utf8Path::new(&base_dir), &query)?;
Ok(results
.into_iter()
.map(|r| Arc::new(FfiRecipeEntry::new(r)))
.collect())
}
#[uniffi::export]
pub fn list_menus_for_date(
base_dirs: Vec<String>,
date: String,
) -> Result<Vec<Arc<FfiRecipeEntry>>, CooklangError> {
let dirs: Vec<Utf8PathBuf> = base_dirs.into_iter().map(Utf8PathBuf::from).collect();
let results = list_menus_for_date_internal(&dirs, &date)?;
Ok(results
.into_iter()
.map(|r| Arc::new(FfiRecipeEntry::new(r)))
.collect())
}
#[uniffi::export]
pub fn build_tree(base_dir: String) -> Result<Arc<FfiRecipeTree>, CooklangError> {
let tree = build_tree_internal(&base_dir)?;
Ok(Arc::new(FfiRecipeTree { inner: tree }))
}
#[uniffi::export]
pub fn library_version() -> String {
env!("CARGO_PKG_VERSION").to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use indoc::indoc;
use std::fs;
use tempfile::TempDir;
fn create_test_recipe(dir: &str, name: &str, content: &str) -> String {
let path = format!("{}/{}.cook", dir, name);
fs::write(&path, content).unwrap();
path
}
#[test]
fn test_recipe_from_content() {
let content = indoc! {r#"
---
title: Test Recipe
servings: 4
tags: [breakfast, easy]
---
Add @eggs{2} and mix"#};
let recipe = recipe_from_content(content.to_string(), None).unwrap();
assert_eq!(recipe.name(), Some("Test Recipe".to_string()));
assert_eq!(recipe.metadata().servings, Some(4));
assert_eq!(recipe.tags(), vec!["breakfast", "easy"]);
}
#[test]
fn test_search_recipes() {
let temp_dir = TempDir::new().unwrap();
let temp_path = temp_dir.path().to_str().unwrap();
create_test_recipe(
temp_path,
"pancakes",
indoc! {r#"
---
title: Fluffy Pancakes
---
Mix and cook"#},
);
create_test_recipe(
temp_path,
"waffles",
indoc! {r#"
---
title: Crispy Waffles
---
Make waffles"#},
);
let results = search(temp_path.to_string(), "pancakes".to_string()).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].name(), Some("Fluffy Pancakes".to_string()));
}
#[test]
fn test_build_tree() {
let temp_dir = TempDir::new().unwrap();
let temp_path = temp_dir.path().to_str().unwrap();
let breakfast_dir = format!("{}/breakfast", temp_path);
fs::create_dir_all(&breakfast_dir).unwrap();
create_test_recipe(
&breakfast_dir,
"pancakes",
indoc! {r#"
---
title: Pancakes
---
Make pancakes"#},
);
let tree = build_tree(temp_path.to_string()).unwrap();
let nodes = tree.all_nodes();
assert!(nodes.len() >= 2);
let recipes = tree.all_recipes();
assert_eq!(recipes.len(), 1);
}
#[test]
fn test_step_images_conversion() {
use std::collections::HashMap;
let mut collection = StepImageCollection::default();
collection.images.insert(0, HashMap::new());
collection
.images
.get_mut(&0)
.unwrap()
.insert(0, "/path/to/image1.jpg".to_string());
collection
.images
.get_mut(&0)
.unwrap()
.insert(2, "/path/to/image3.jpg".to_string());
let ffi_images = FfiStepImages::from(&collection);
assert_eq!(ffi_images.count, 2);
assert_eq!(ffi_images.images[0].section, 0);
assert_eq!(ffi_images.images[0].step, 1);
assert_eq!(ffi_images.images[1].step, 3);
}
#[test]
fn test_library_version() {
let version = library_version();
assert!(!version.is_empty());
assert_eq!(version, env!("CARGO_PKG_VERSION"));
}
#[test]
fn test_related_files_ffi() {
let temp_dir = TempDir::new().unwrap();
let temp_path = temp_dir.path().to_str().unwrap();
let sauces_dir = format!("{}/sauces", temp_path);
fs::create_dir_all(&sauces_dir).unwrap();
create_test_recipe(&sauces_dir, "Hollandaise", "Melt @butter{100%g}");
fs::write(format!("{}/Hollandaise.jpg", sauces_dir), b"").unwrap();
let path = create_test_recipe(
temp_path,
"EggsBenedict",
"Pour @./sauces/Hollandaise{150%g} over eggs.",
);
let recipe = recipe_from_path(path).unwrap();
let files = recipe.related_files();
assert_eq!(files.len(), 2);
assert!(files.iter().any(|f| f.ends_with("Hollandaise.cook")));
assert!(files.iter().any(|f| f.ends_with("Hollandaise.jpg")));
}
}