Skip to main content

ghost_core/recipe/
store.rs

1// Recipe store: CRUD operations on ~/.ghost/recipes/ JSON files.
2
3use anyhow::{Context, Result};
4use std::path::PathBuf;
5
6use super::types::Recipe;
7
8/// Persistent recipe store backed by JSON files in ~/.ghost/recipes/.
9pub struct RecipeStore {
10    dir: PathBuf,
11}
12
13impl RecipeStore {
14    /// Open (or create) the recipe store at the default location.
15    pub fn open() -> Result<Self> {
16        let dir = dirs::home_dir()
17            .ok_or_else(|| anyhow::anyhow!("Cannot determine home directory"))?
18            .join(".ghost")
19            .join("recipes");
20        std::fs::create_dir_all(&dir)
21            .with_context(|| format!("Failed to create recipe dir: {}", dir.display()))?;
22        Ok(Self { dir })
23    }
24
25    /// Open at a custom directory (for testing).
26    pub fn open_at(dir: PathBuf) -> Result<Self> {
27        std::fs::create_dir_all(&dir)?;
28        Ok(Self { dir })
29    }
30
31    /// List all recipes.
32    pub fn list(&self) -> Result<Vec<Recipe>> {
33        let mut recipes = vec![];
34        for entry in std::fs::read_dir(&self.dir)? {
35            let entry = entry?;
36            let path = entry.path();
37            if path.extension().and_then(|e| e.to_str()) == Some("json") {
38                if let Ok(r) = self.load_file(&path) {
39                    recipes.push(r);
40                }
41            }
42        }
43        recipes.sort_by(|a, b| a.name.cmp(&b.name));
44        Ok(recipes)
45    }
46
47    /// Get a recipe by name.
48    pub fn get(&self, name: &str) -> Result<Recipe> {
49        let path = self.recipe_path(name);
50        self.load_file(&path)
51            .with_context(|| format!("Recipe '{}' not found", name))
52    }
53
54    /// Save a recipe (create or overwrite).
55    pub fn save(&self, recipe: &Recipe) -> Result<()> {
56        let path = self.recipe_path(&recipe.name);
57        let json = serde_json::to_string_pretty(recipe)?;
58        std::fs::write(&path, json)
59            .with_context(|| format!("Failed to write recipe to {}", path.display()))
60    }
61
62    /// Save a recipe from a JSON string.
63    pub fn save_json(&self, json: &str) -> Result<Recipe> {
64        let recipe: Recipe = serde_json::from_str(json).context("Invalid recipe JSON")?;
65        self.save(&recipe)?;
66        Ok(recipe)
67    }
68
69    /// Delete a recipe by name.
70    pub fn delete(&self, name: &str) -> Result<()> {
71        let path = self.recipe_path(name);
72        if path.exists() {
73            std::fs::remove_file(&path)
74                .with_context(|| format!("Failed to delete recipe '{}'", name))
75        } else {
76            Err(anyhow::anyhow!("Recipe '{}' not found", name))
77        }
78    }
79
80    fn recipe_path(&self, name: &str) -> PathBuf {
81        let safe = name.replace(['/', '\\', ':', '*', '?', '"', '<', '>', '|'], "_");
82        self.dir.join(format!("{safe}.json"))
83    }
84
85    fn load_file(&self, path: &PathBuf) -> Result<Recipe> {
86        let data = std::fs::read_to_string(path)?;
87        serde_json::from_str(&data).with_context(|| format!("Invalid JSON in {}", path.display()))
88    }
89}