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. Honors
15    /// `GHOST_DATA_DIR` (Core sets it to the profile-aware `~/.ryu{profile}/ghost`, so
16    /// a dev and a release Ghost don't share one recipe store); falls back to the
17    /// legacy `~/.ghost` when unset.
18    pub fn open() -> Result<Self> {
19        let base = match std::env::var_os("GHOST_DATA_DIR") {
20            Some(dir) => PathBuf::from(dir),
21            None => dirs::home_dir()
22                .ok_or_else(|| anyhow::anyhow!("Cannot determine home directory"))?
23                .join(".ghost"),
24        };
25        let dir = base.join("recipes");
26        std::fs::create_dir_all(&dir)
27            .with_context(|| format!("Failed to create recipe dir: {}", dir.display()))?;
28        Ok(Self { dir })
29    }
30
31    /// Open at a custom directory (for testing).
32    pub fn open_at(dir: PathBuf) -> Result<Self> {
33        std::fs::create_dir_all(&dir)?;
34        Ok(Self { dir })
35    }
36
37    /// List all recipes.
38    pub fn list(&self) -> Result<Vec<Recipe>> {
39        let mut recipes = vec![];
40        for entry in std::fs::read_dir(&self.dir)? {
41            let entry = entry?;
42            let path = entry.path();
43            if path.extension().and_then(|e| e.to_str()) == Some("json") {
44                if let Ok(r) = self.load_file(&path) {
45                    recipes.push(r);
46                }
47            }
48        }
49        recipes.sort_by(|a, b| a.name.cmp(&b.name));
50        Ok(recipes)
51    }
52
53    /// Get a recipe by name.
54    pub fn get(&self, name: &str) -> Result<Recipe> {
55        let path = self.recipe_path(name);
56        self.load_file(&path)
57            .with_context(|| format!("Recipe '{}' not found", name))
58    }
59
60    /// Save a recipe (create or overwrite).
61    pub fn save(&self, recipe: &Recipe) -> Result<()> {
62        let path = self.recipe_path(&recipe.name);
63        let json = serde_json::to_string_pretty(recipe)?;
64        std::fs::write(&path, json)
65            .with_context(|| format!("Failed to write recipe to {}", path.display()))
66    }
67
68    /// Save a recipe from a JSON string.
69    pub fn save_json(&self, json: &str) -> Result<Recipe> {
70        let recipe: Recipe = serde_json::from_str(json).context("Invalid recipe JSON")?;
71        self.save(&recipe)?;
72        Ok(recipe)
73    }
74
75    /// Delete a recipe by name.
76    pub fn delete(&self, name: &str) -> Result<()> {
77        let path = self.recipe_path(name);
78        if path.exists() {
79            std::fs::remove_file(&path)
80                .with_context(|| format!("Failed to delete recipe '{}'", name))
81        } else {
82            Err(anyhow::anyhow!("Recipe '{}' not found", name))
83        }
84    }
85
86    fn recipe_path(&self, name: &str) -> PathBuf {
87        let safe = name.replace(['/', '\\', ':', '*', '?', '"', '<', '>', '|'], "_");
88        self.dir.join(format!("{safe}.json"))
89    }
90
91    fn load_file(&self, path: &PathBuf) -> Result<Recipe> {
92        let data = std::fs::read_to_string(path)?;
93        serde_json::from_str(&data).with_context(|| format!("Invalid JSON in {}", path.display()))
94    }
95}