use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct RecipeFilter {
pub tags: Vec<String>,
pub max_time_minutes: Option<u32>,
pub source: Option<String>,
}
impl RecipeFilter {
pub fn is_empty(&self) -> bool {
self.tags.is_empty() && self.max_time_minutes.is_none() && self.source.is_none()
}
}
pub fn parse_time_minutes(s: &str) -> Option<u32> {
let s = s.trim().to_lowercase();
if s.is_empty() {
return None;
}
let mut total_minutes: f64 = 0.0;
let mut found_any = false;
let mut remaining = s.as_str();
while !remaining.is_empty() {
remaining = remaining.trim_start();
if remaining.is_empty() {
break;
}
let (num_str, rest) = split_number(remaining);
if num_str.is_empty() {
let skip = remaining
.find(|c: char| c.is_ascii_digit())
.unwrap_or(remaining.len());
remaining = &remaining[skip..];
continue;
}
let num: f64 = num_str.parse().ok()?;
let rest = rest.trim_start();
let (unit, after_unit) = split_unit(rest);
match classify_unit(&unit) {
TimeUnit::Hours => {
total_minutes += num * 60.0;
found_any = true;
}
TimeUnit::Minutes => {
total_minutes += num;
found_any = true;
}
TimeUnit::Unknown => {
if !found_any {
total_minutes += num;
found_any = true;
}
}
}
remaining = after_unit;
}
if found_any && total_minutes >= 0.0 {
Some(total_minutes.round() as u32)
} else {
None
}
}
pub fn escape_fts5_query(input: &str) -> String {
let tokens: Vec<String> = input
.split_whitespace()
.filter(|t| !t.is_empty())
.map(|token| {
let clean: String = token.chars().filter(|&c| c != '"').collect();
if clean.is_empty() {
return String::new();
}
format!("\"{clean}\"")
})
.filter(|t| !t.is_empty())
.collect();
tokens.join(" ")
}
#[derive(Debug)]
enum TimeUnit {
Hours,
Minutes,
Unknown,
}
fn classify_unit(s: &str) -> TimeUnit {
match s {
"h" | "hr" | "hrs" | "hour" | "hours" => TimeUnit::Hours,
"m" | "min" | "mins" | "minute" | "minutes" => TimeUnit::Minutes,
_ => TimeUnit::Unknown,
}
}
fn split_number(s: &str) -> (&str, &str) {
let end = s
.find(|c: char| !c.is_ascii_digit() && c != '.')
.unwrap_or(s.len());
(&s[..end], &s[end..])
}
fn split_unit(s: &str) -> (String, &str) {
let end = s
.find(|c: char| !c.is_ascii_alphabetic())
.unwrap_or(s.len());
(s[..end].to_string(), &s[end..])
}
pub fn update_tags_in_cook_source(content: &str, new_tags: &[String]) -> String {
let lines: Vec<&str> = content.lines().collect();
let mut fence_start = None;
let mut fence_end = None;
for (i, line) in lines.iter().enumerate() {
if line.trim() == "---" {
if fence_start.is_none() {
fence_start = Some(i);
} else {
fence_end = Some(i);
break;
}
}
}
let (Some(start), Some(end)) = (fence_start, fence_end) else {
if new_tags.is_empty() {
return content.to_string();
}
let tags_line = format!("tags: {}", new_tags.join(", "));
return format!("---\n{tags_line}\n---\n{content}");
};
let mut tag_line_idx = None;
let mut tag_end_idx = None;
for i in (start + 1)..end {
let trimmed = lines[i].trim();
if trimmed.starts_with("tags:") {
tag_line_idx = Some(i);
let mut j = i + 1;
while j < end && lines[j].trim().starts_with("- ") {
j += 1;
}
tag_end_idx = Some(j);
break;
}
}
let tags_value = if new_tags.is_empty() {
String::new()
} else {
new_tags.join(", ")
};
let mut result_lines: Vec<String> = Vec::new();
if let Some(tag_start) = tag_line_idx {
let tag_end = tag_end_idx.unwrap_or(tag_start + 1);
for (i, line) in lines.iter().enumerate() {
if i == tag_start {
if new_tags.is_empty() {
continue;
}
result_lines.push(format!("tags: {tags_value}"));
} else if i > tag_start && i < tag_end {
continue;
} else {
result_lines.push(line.to_string());
}
}
} else if !new_tags.is_empty() {
for (i, line) in lines.iter().enumerate() {
if i == end {
result_lines.push(format!("tags: {tags_value}"));
}
result_lines.push(line.to_string());
}
} else {
return content.to_string();
}
let mut result = result_lines.join("\n");
if content.ends_with('\n') && !result.ends_with('\n') {
result.push('\n');
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_minutes_only() {
assert_eq!(parse_time_minutes("45 min"), Some(45));
assert_eq!(parse_time_minutes("45 minutes"), Some(45));
assert_eq!(parse_time_minutes("45min"), Some(45));
assert_eq!(parse_time_minutes("30"), Some(30));
}
#[test]
fn parse_hours_only() {
assert_eq!(parse_time_minutes("1 hour"), Some(60));
assert_eq!(parse_time_minutes("2 hours"), Some(120));
assert_eq!(parse_time_minutes("1.5 hours"), Some(90));
}
#[test]
fn parse_compound() {
assert_eq!(parse_time_minutes("1 hour 30 minutes"), Some(90));
assert_eq!(parse_time_minutes("1h 30m"), Some(90));
assert_eq!(parse_time_minutes("1h30m"), Some(90));
assert_eq!(parse_time_minutes("2 hours 15 min"), Some(135));
}
#[test]
fn parse_empty_and_invalid() {
assert_eq!(parse_time_minutes(""), None);
assert_eq!(parse_time_minutes(" "), None);
}
#[test]
fn escape_simple_terms() {
assert_eq!(escape_fts5_query("chicken adobo"), "\"chicken\" \"adobo\"");
}
#[test]
fn escape_fts5_operators() {
assert_eq!(
escape_fts5_query("chicken AND garlic"),
"\"chicken\" \"AND\" \"garlic\""
);
}
#[test]
fn escape_empty() {
assert_eq!(escape_fts5_query(""), "");
assert_eq!(escape_fts5_query(" "), "");
}
#[test]
fn escape_quotes() {
assert_eq!(escape_fts5_query("\"test\""), "\"test\"");
}
#[test]
fn update_tags_inline() {
let content = "---\ntitle: Test\ntags: old, tags\n---\n\nStep 1.\n";
let result = update_tags_in_cook_source(content, &["new".into(), "tags".into()]);
assert!(result.contains("tags: new, tags"));
assert!(!result.contains("old"));
assert!(result.contains("Step 1."));
}
#[test]
fn update_tags_yaml_list() {
let content = "---\ntitle: Test\ntags:\n - old\n - tags\n---\n\nStep 1.\n";
let result = update_tags_in_cook_source(content, &["a".into(), "b".into()]);
assert!(result.contains("tags: a, b"));
assert!(!result.contains(" - old"));
}
#[test]
fn add_tags_to_metadata_without_tags() {
let content = "---\ntitle: Test\n---\n\nStep 1.\n";
let result = update_tags_in_cook_source(content, &["new".into()]);
assert!(result.contains("tags: new"));
}
#[test]
fn remove_all_tags() {
let content = "---\ntitle: Test\ntags: old\n---\n\nStep 1.\n";
let result = update_tags_in_cook_source(content, &[]);
assert!(!result.contains("tags:"));
}
#[test]
fn filter_default_is_empty() {
let f = RecipeFilter::default();
assert!(f.is_empty());
}
#[test]
fn filter_with_tag_is_not_empty() {
let f = RecipeFilter {
tags: vec!["chicken".into()],
..Default::default()
};
assert!(!f.is_empty());
}
}