use std::fs;
use assert_cmd::Command;
use predicates::prelude::*;
use tempfile::TempDir;
fn fond(tmp: &TempDir) -> Command {
let mut cmd = Command::cargo_bin("fond").unwrap();
cmd.env("FOND_DATA_DIR", tmp.path());
cmd
}
fn write_fixture(tmp: &TempDir, name: &str, content: &str) {
let recipes = tmp.path().join("recipes");
fs::create_dir_all(&recipes).unwrap();
fs::write(recipes.join(name), content).unwrap();
}
const CHICKEN_COOK: &str = "\
---
title: Chicken Adobo
servings: 4
tags:
- filipino
- braised
---
Combine @soy sauce{1/2 cup} and @vinegar{1/2 cup} in a bowl.
Add @chicken thighs{2 lbs} and marinate for ~{30 minutes}.
Cook over medium heat with @garlic{6 cloves} for ~{45 minutes}.
";
#[test]
fn help_flag_shows_usage() {
let tmp = TempDir::new().unwrap();
fond(&tmp)
.arg("--help")
.assert()
.success()
.stdout(predicate::str::contains("local-first personal cooking"));
}
#[test]
fn version_flag_shows_version() {
let tmp = TempDir::new().unwrap();
fond(&tmp)
.arg("--version")
.assert()
.success()
.stdout(predicate::str::contains("fond"));
}
#[test]
fn init_creates_directories() {
let tmp = TempDir::new().unwrap();
fond(&tmp)
.arg("init")
.assert()
.success()
.stdout(predicate::str::contains("Initialised fond at"));
assert!(tmp.path().join("recipes").exists());
}
#[test]
fn reindex_empty_dir() {
let tmp = TempDir::new().unwrap();
fond(&tmp).arg("init").assert().success();
fond(&tmp)
.arg("reindex")
.assert()
.success()
.stdout(predicate::str::contains("Reindexed 0"));
}
#[test]
fn reindex_with_fixture() {
let tmp = TempDir::new().unwrap();
fond(&tmp).arg("init").assert().success();
write_fixture(&tmp, "chicken-adobo.cook", CHICKEN_COOK);
fond(&tmp)
.arg("reindex")
.assert()
.success()
.stdout(predicate::str::contains("Reindexed 1"));
}
#[test]
fn reindex_json_output() {
let tmp = TempDir::new().unwrap();
fond(&tmp).arg("init").assert().success();
write_fixture(&tmp, "chicken-adobo.cook", CHICKEN_COOK);
fond(&tmp)
.args(["--json", "reindex"])
.assert()
.success()
.stdout(predicate::str::contains("\"indexed\": 1"));
}
#[test]
fn list_empty_shows_message() {
let tmp = TempDir::new().unwrap();
fond(&tmp).arg("init").assert().success();
fond(&tmp)
.arg("list")
.assert()
.success()
.stdout(predicate::str::contains("No recipes indexed"));
}
#[test]
fn list_after_reindex_shows_recipe() {
let tmp = TempDir::new().unwrap();
fond(&tmp).arg("init").assert().success();
write_fixture(&tmp, "chicken-adobo.cook", CHICKEN_COOK);
fond(&tmp).arg("reindex").assert().success();
fond(&tmp)
.arg("list")
.assert()
.success()
.stdout(predicate::str::contains("Chicken Adobo"))
.stdout(predicate::str::contains("chicken-adobo"))
.stdout(predicate::str::contains("1 recipe(s)"));
}
#[test]
fn list_json_output() {
let tmp = TempDir::new().unwrap();
fond(&tmp).arg("init").assert().success();
write_fixture(&tmp, "chicken-adobo.cook", CHICKEN_COOK);
fond(&tmp).arg("reindex").assert().success();
fond(&tmp)
.args(["--json", "list"])
.assert()
.success()
.stdout(predicate::str::contains("\"slug\": \"chicken-adobo\""))
.stdout(predicate::str::contains("\"title\": \"Chicken Adobo\""));
}
#[test]
fn view_existing_recipe() {
let tmp = TempDir::new().unwrap();
fond(&tmp).arg("init").assert().success();
write_fixture(&tmp, "chicken-adobo.cook", CHICKEN_COOK);
fond(&tmp).arg("reindex").assert().success();
fond(&tmp)
.args(["view", "chicken-adobo"])
.assert()
.success()
.stdout(predicate::str::contains("# Chicken Adobo"))
.stdout(predicate::str::contains("soy sauce"));
}
#[test]
fn view_missing_recipe_fails() {
let tmp = TempDir::new().unwrap();
fond(&tmp).arg("init").assert().success();
fond(&tmp)
.args(["view", "nonexistent"])
.assert()
.failure()
.stderr(predicate::str::contains("no recipe found with slug"));
}
#[test]
fn view_json_output() {
let tmp = TempDir::new().unwrap();
fond(&tmp).arg("init").assert().success();
write_fixture(&tmp, "chicken-adobo.cook", CHICKEN_COOK);
fond(&tmp).arg("reindex").assert().success();
fond(&tmp)
.args(["--json", "view", "chicken-adobo"])
.assert()
.success()
.stdout(predicate::str::contains("\"title\": \"Chicken Adobo\""))
.stdout(predicate::str::contains("\"slug\": \"chicken-adobo\""));
}
#[test]
fn search_finds_by_title() {
let tmp = TempDir::new().unwrap();
fond(&tmp).arg("init").assert().success();
write_fixture(&tmp, "chicken-adobo.cook", CHICKEN_COOK);
fond(&tmp).arg("reindex").assert().success();
fond(&tmp)
.args(["search", "adobo"])
.assert()
.success()
.stdout(predicate::str::contains("Chicken Adobo"));
}
#[test]
fn search_finds_by_ingredient() {
let tmp = TempDir::new().unwrap();
fond(&tmp).arg("init").assert().success();
write_fixture(&tmp, "chicken-adobo.cook", CHICKEN_COOK);
fond(&tmp).arg("reindex").assert().success();
fond(&tmp)
.args(["search", "vinegar"])
.assert()
.success()
.stdout(predicate::str::contains("Chicken Adobo"));
}
#[test]
fn search_no_results() {
let tmp = TempDir::new().unwrap();
fond(&tmp).arg("init").assert().success();
write_fixture(&tmp, "chicken-adobo.cook", CHICKEN_COOK);
fond(&tmp).arg("reindex").assert().success();
fond(&tmp)
.args(["search", "xylophone"])
.assert()
.success()
.stdout(predicate::str::contains("No results"));
}
#[test]
fn search_json_output() {
let tmp = TempDir::new().unwrap();
fond(&tmp).arg("init").assert().success();
write_fixture(&tmp, "chicken-adobo.cook", CHICKEN_COOK);
fond(&tmp).arg("reindex").assert().success();
fond(&tmp)
.args(["--json", "search", "chicken"])
.assert()
.success()
.stdout(predicate::str::contains("\"slug\": \"chicken-adobo\""));
}
#[test]
fn add_from_file() {
let tmp = TempDir::new().unwrap();
fond(&tmp).arg("init").assert().success();
let source = tmp.path().join("external.cook");
fs::write(
&source,
"---\ntitle: Test Recipe\n---\n\nAdd @salt{1 tsp}.\n",
)
.unwrap();
fond(&tmp)
.args(["add", "--file", source.to_str().unwrap()])
.assert()
.success()
.stdout(predicate::str::contains("Added: Test Recipe"));
assert!(tmp.path().join("recipes").join("external.cook").exists());
fond(&tmp)
.arg("list")
.assert()
.success()
.stdout(predicate::str::contains("Test Recipe"));
}
#[test]
fn add_from_file_rejects_non_cook() {
let tmp = TempDir::new().unwrap();
fond(&tmp).arg("init").assert().success();
let source = tmp.path().join("readme.txt");
fs::write(&source, "just text").unwrap();
fond(&tmp)
.args(["add", "--file", source.to_str().unwrap()])
.assert()
.failure()
.stderr(predicate::str::contains("expected a .cook file"));
}
#[test]
fn add_from_file_rejects_collision() {
let tmp = TempDir::new().unwrap();
fond(&tmp).arg("init").assert().success();
write_fixture(
&tmp,
"duplicate.cook",
"---\ntitle: Original\n---\n\nStep 1.\n",
);
let source = tmp.path().join("duplicate.cook");
fs::write(&source, "---\ntitle: Duplicate\n---\n\nStep 1.\n").unwrap();
fond(&tmp)
.args(["add", "--file", source.to_str().unwrap()])
.assert()
.failure()
.stderr(predicate::str::contains("already exists"));
}
#[test]
fn add_title_json_creates_file() {
let tmp = TempDir::new().unwrap();
fond(&tmp).arg("init").assert().success();
fond(&tmp)
.args(["--json", "add", "--title", "Pasta Carbonara"])
.assert()
.success()
.stdout(predicate::str::contains("\"slug\": \"pasta-carbonara\""))
.stdout(predicate::str::contains("\"action\": \"added\""));
assert!(
tmp.path()
.join("recipes")
.join("pasta-carbonara.cook")
.exists()
);
}
#[test]
fn add_json_without_inputs_fails() {
let tmp = TempDir::new().unwrap();
fond(&tmp).arg("init").assert().success();
fond(&tmp)
.args(["--json", "add"])
.assert()
.failure()
.stderr(predicate::str::contains("non-interactive"));
}
#[test]
fn rm_with_yes_removes_recipe() {
let tmp = TempDir::new().unwrap();
fond(&tmp).arg("init").assert().success();
write_fixture(&tmp, "chicken-adobo.cook", CHICKEN_COOK);
fond(&tmp).arg("reindex").assert().success();
fond(&tmp)
.args(["rm", "chicken-adobo", "--yes"])
.assert()
.success()
.stdout(predicate::str::contains("Removed: Chicken Adobo"));
assert!(
!tmp.path()
.join("recipes")
.join("chicken-adobo.cook")
.exists()
);
fond(&tmp)
.arg("list")
.assert()
.success()
.stdout(predicate::str::contains("No recipes indexed"));
}
#[test]
fn rm_missing_recipe_fails() {
let tmp = TempDir::new().unwrap();
fond(&tmp).arg("init").assert().success();
fond(&tmp)
.args(["rm", "nonexistent", "--yes"])
.assert()
.failure()
.stderr(predicate::str::contains("no recipe found with slug"));
}
#[test]
fn rm_json_output() {
let tmp = TempDir::new().unwrap();
fond(&tmp).arg("init").assert().success();
write_fixture(&tmp, "chicken-adobo.cook", CHICKEN_COOK);
fond(&tmp).arg("reindex").assert().success();
fond(&tmp)
.args(["--json", "rm", "chicken-adobo"])
.assert()
.success()
.stdout(predicate::str::contains("\"action\": \"removed\""))
.stdout(predicate::str::contains("\"slug\": \"chicken-adobo\""));
}
#[test]
fn completions_generates_bash() {
let tmp = TempDir::new().unwrap();
fond(&tmp)
.args(["completions", "bash"])
.assert()
.success()
.stdout(predicate::str::contains("complete"));
}
#[test]
fn completions_generates_powershell() {
let tmp = TempDir::new().unwrap();
fond(&tmp)
.args(["completions", "powershell"])
.assert()
.success()
.stdout(predicate::str::contains("fond"));
}
#[test]
fn format_flag_json_is_equivalent_to_json_flag() {
let tmp = TempDir::new().unwrap();
fond(&tmp).arg("init").assert().success();
write_fixture(&tmp, "chicken-adobo.cook", CHICKEN_COOK);
fond(&tmp).arg("reindex").assert().success();
let out1 = fond(&tmp)
.args(["--format", "json", "list"])
.output()
.unwrap();
let out2 = fond(&tmp).args(["--json", "list"]).output().unwrap();
assert_eq!(out1.stdout, out2.stdout);
}
const PASTA_COOK: &str = "\
---
title: Pasta Carbonara
servings: 4
tags:
- italian
- pasta
prep time: 10 min
cook time: 20 min
---
Cook @pasta{1 lb} in boiling water for ~{10 minutes}.
Whisk @eggs{3} with @pecorino{1 cup} and @black pepper{1 tsp}.
Fry @guanciale{6 oz} until crispy, ~{8 minutes}.
Toss hot pasta with egg mixture and guanciale.
";
#[test]
fn list_filter_by_tag() {
let tmp = TempDir::new().unwrap();
fond(&tmp).arg("init").assert().success();
write_fixture(&tmp, "chicken-adobo.cook", CHICKEN_COOK);
write_fixture(&tmp, "pasta-carbonara.cook", PASTA_COOK);
fond(&tmp).arg("reindex").assert().success();
fond(&tmp)
.args(["list", "--tag", "italian"])
.assert()
.success()
.stdout(predicate::str::contains("Pasta Carbonara"))
.stdout(predicate::str::contains("1 recipe(s)"));
}
#[test]
fn list_filter_by_cuisine() {
let tmp = TempDir::new().unwrap();
fond(&tmp).arg("init").assert().success();
write_fixture(&tmp, "chicken-adobo.cook", CHICKEN_COOK);
write_fixture(&tmp, "pasta-carbonara.cook", PASTA_COOK);
fond(&tmp).arg("reindex").assert().success();
fond(&tmp)
.args(["list", "--cuisine", "filipino"])
.assert()
.success()
.stdout(predicate::str::contains("Chicken Adobo"))
.stdout(predicate::str::contains("1 recipe(s)"));
}
#[test]
fn list_filter_by_max_time() {
let tmp = TempDir::new().unwrap();
fond(&tmp).arg("init").assert().success();
write_fixture(&tmp, "pasta-carbonara.cook", PASTA_COOK);
fond(&tmp).arg("reindex").assert().success();
fond(&tmp)
.args(["list", "--max-time", "30"])
.assert()
.success()
.stdout(predicate::str::contains("Pasta Carbonara"));
fond(&tmp)
.args(["list", "--max-time", "15"])
.assert()
.success()
.stdout(predicate::str::contains("No recipes match"));
}
#[test]
fn list_filter_no_matches() {
let tmp = TempDir::new().unwrap();
fond(&tmp).arg("init").assert().success();
write_fixture(&tmp, "chicken-adobo.cook", CHICKEN_COOK);
fond(&tmp).arg("reindex").assert().success();
fond(&tmp)
.args(["list", "--tag", "nonexistent-tag"])
.assert()
.success()
.stdout(predicate::str::contains("No recipes match"));
}
#[test]
fn list_filter_combined_tag_and_max_time() {
let tmp = TempDir::new().unwrap();
fond(&tmp).arg("init").assert().success();
write_fixture(&tmp, "chicken-adobo.cook", CHICKEN_COOK);
write_fixture(&tmp, "pasta-carbonara.cook", PASTA_COOK);
fond(&tmp).arg("reindex").assert().success();
fond(&tmp)
.args(["list", "--max-time", "35"])
.assert()
.success()
.stdout(predicate::str::contains("Pasta Carbonara"))
.stdout(predicate::str::contains("1 recipe(s)"));
}
#[test]
fn list_filter_json_output() {
let tmp = TempDir::new().unwrap();
fond(&tmp).arg("init").assert().success();
write_fixture(&tmp, "chicken-adobo.cook", CHICKEN_COOK);
write_fixture(&tmp, "pasta-carbonara.cook", PASTA_COOK);
fond(&tmp).arg("reindex").assert().success();
fond(&tmp)
.args(["--json", "list", "--tag", "italian"])
.assert()
.success()
.stdout(predicate::str::contains("\"slug\": \"pasta-carbonara\""))
.stdout(predicate::str::contains("\"tags\""));
}
#[test]
fn search_with_tag_filter() {
let tmp = TempDir::new().unwrap();
fond(&tmp).arg("init").assert().success();
write_fixture(&tmp, "chicken-adobo.cook", CHICKEN_COOK);
write_fixture(&tmp, "pasta-carbonara.cook", PASTA_COOK);
fond(&tmp).arg("reindex").assert().success();
fond(&tmp)
.args(["search", "cook", "--tag", "italian"])
.assert()
.success()
.stdout(predicate::str::contains("Pasta Carbonara"));
}
#[test]
fn search_with_max_time_filter() {
let tmp = TempDir::new().unwrap();
fond(&tmp).arg("init").assert().success();
write_fixture(&tmp, "chicken-adobo.cook", CHICKEN_COOK);
write_fixture(&tmp, "pasta-carbonara.cook", PASTA_COOK);
fond(&tmp).arg("reindex").assert().success();
fond(&tmp)
.args(["search", "cook", "--max-time", "30"])
.assert()
.success()
.stdout(predicate::str::contains("Pasta Carbonara"));
}
#[test]
fn search_json_includes_tags_and_source() {
let tmp = TempDir::new().unwrap();
fond(&tmp).arg("init").assert().success();
write_fixture(&tmp, "chicken-adobo.cook", CHICKEN_COOK);
fond(&tmp).arg("reindex").assert().success();
fond(&tmp)
.args(["--json", "search", "chicken"])
.assert()
.success()
.stdout(predicate::str::contains("\"tags\""))
.stdout(predicate::str::contains("\"source\""));
}
#[test]
fn tag_list_all() {
let tmp = TempDir::new().unwrap();
fond(&tmp).arg("init").assert().success();
write_fixture(&tmp, "chicken-adobo.cook", CHICKEN_COOK);
fond(&tmp).arg("reindex").assert().success();
fond(&tmp)
.args(["tag", "--list"])
.assert()
.success()
.stdout(predicate::str::contains("filipino"))
.stdout(predicate::str::contains("braised"));
}
#[test]
fn tag_list_json() {
let tmp = TempDir::new().unwrap();
fond(&tmp).arg("init").assert().success();
write_fixture(&tmp, "chicken-adobo.cook", CHICKEN_COOK);
fond(&tmp).arg("reindex").assert().success();
fond(&tmp)
.args(["--json", "tag", "--list"])
.assert()
.success()
.stdout(predicate::str::contains("\"name\""))
.stdout(predicate::str::contains("\"count\""));
}
#[test]
fn tag_show_for_recipe() {
let tmp = TempDir::new().unwrap();
fond(&tmp).arg("init").assert().success();
write_fixture(&tmp, "chicken-adobo.cook", CHICKEN_COOK);
fond(&tmp).arg("reindex").assert().success();
fond(&tmp)
.args(["tag", "chicken-adobo"])
.assert()
.success()
.stdout(predicate::str::contains("filipino"))
.stdout(predicate::str::contains("braised"));
}
#[test]
fn tag_add_and_verify() {
let tmp = TempDir::new().unwrap();
fond(&tmp).arg("init").assert().success();
write_fixture(&tmp, "chicken-adobo.cook", CHICKEN_COOK);
fond(&tmp).arg("reindex").assert().success();
fond(&tmp)
.args(["tag", "chicken-adobo", "--add", "dinner,easy"])
.assert()
.success()
.stdout(predicate::str::contains("Added: dinner, easy"));
fond(&tmp)
.args(["tag", "chicken-adobo"])
.assert()
.success()
.stdout(predicate::str::contains("dinner"))
.stdout(predicate::str::contains("easy"))
.stdout(predicate::str::contains("filipino"));
}
#[test]
fn tag_remove_and_verify() {
let tmp = TempDir::new().unwrap();
fond(&tmp).arg("init").assert().success();
write_fixture(&tmp, "chicken-adobo.cook", CHICKEN_COOK);
fond(&tmp).arg("reindex").assert().success();
fond(&tmp)
.args(["tag", "chicken-adobo", "--remove", "braised"])
.assert()
.success()
.stdout(predicate::str::contains("Removed: braised"));
let output = fond(&tmp).args(["tag", "chicken-adobo"]).output().unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
!stdout.contains("braised"),
"braised should be removed, got: {stdout}"
);
assert!(stdout.contains("filipino"), "filipino should remain");
}
#[test]
fn tag_add_json_output() {
let tmp = TempDir::new().unwrap();
fond(&tmp).arg("init").assert().success();
write_fixture(&tmp, "chicken-adobo.cook", CHICKEN_COOK);
fond(&tmp).arg("reindex").assert().success();
fond(&tmp)
.args(["--json", "tag", "chicken-adobo", "--add", "quick"])
.assert()
.success()
.stdout(predicate::str::contains("\"added\""))
.stdout(predicate::str::contains("\"quick\""));
}
#[test]
fn tag_survives_reindex() {
let tmp = TempDir::new().unwrap();
fond(&tmp).arg("init").assert().success();
write_fixture(&tmp, "chicken-adobo.cook", CHICKEN_COOK);
fond(&tmp).arg("reindex").assert().success();
fond(&tmp)
.args(["tag", "chicken-adobo", "--add", "weeknight"])
.assert()
.success();
fond(&tmp).arg("reindex").assert().success();
fond(&tmp)
.args(["tag", "chicken-adobo"])
.assert()
.success()
.stdout(predicate::str::contains("weeknight"));
}
#[test]
fn tag_add_then_search_finds_by_new_tag() {
let tmp = TempDir::new().unwrap();
fond(&tmp).arg("init").assert().success();
write_fixture(&tmp, "chicken-adobo.cook", CHICKEN_COOK);
fond(&tmp).arg("reindex").assert().success();
fond(&tmp)
.args(["tag", "chicken-adobo", "--add", "weeknight"])
.assert()
.success();
fond(&tmp)
.args(["search", "weeknight"])
.assert()
.success()
.stdout(predicate::str::contains("Chicken Adobo"));
fond(&tmp)
.args(["list", "--tag", "weeknight"])
.assert()
.success()
.stdout(predicate::str::contains("Chicken Adobo"));
}