Skip to main content

prompts_cli/
storage.rs

1use anyhow::Result;
2use async_trait::async_trait;
3use dirs;
4use sha2::{Sha256, Digest};
5use serde::{Serialize, Deserialize};
6use std::path::PathBuf;
7use libsql::Connection;
8use libsql::Builder;
9use std::fs;
10
11/// Represents a prompt with its content, metadata, and a unique hash.
12#[derive(Debug, PartialEq, Serialize, Deserialize, Clone)]
13pub struct Prompt {
14    /// The text content of the prompt.
15    pub content: String,
16    /// Optional tags associated with the prompt.
17    pub tags: Option<Vec<String>>,
18    /// Optional categories for grouping prompts.
19    pub categories: Option<Vec<String>>,
20    /// A unique SHA256 hash of the prompt's content, used for identification.
21    pub hash: String,
22}
23
24impl Prompt {
25    /// Creates a new `Prompt` instance.
26    ///
27    /// The `hash` is automatically generated from the content.
28    pub fn new(content: &str, tags: Option<Vec<String>>, categories: Option<Vec<String>>) -> Self {
29        let hash = Sha256::digest(content.as_bytes());
30        Self {
31            content: content.to_string(),
32            tags,
33            categories,
34            hash: format!("{:x}", hash),
35        }
36    }
37}
38
39/// A trait defining the interface for prompt storage.
40#[async_trait]
41pub trait Storage {
42    /// Saves a prompt to the storage.
43    async fn save_prompt(&self, prompt: &mut Prompt) -> Result<()>;
44    /// Loads all prompts from the storage.
45    async fn load_prompts(&self) -> Result<Vec<Prompt>>;
46    /// Deletes a prompt from the storage by its hash.
47    async fn delete_prompt(&self, hash: &str) -> Result<()>;
48}
49
50/// A storage implementation that uses JSON files.
51///
52/// Each prompt is stored as a separate JSON file in a specified directory.
53pub struct JsonStorage {
54    storage_path: PathBuf,
55}
56
57/// Returns the default storage directory for the application.
58///
59/// This is typically `~/.config/prompts-cli`.
60fn get_default_storage_dir() -> Result<PathBuf> {
61    let mut path = dirs::config_dir().ok_or_else(|| anyhow::anyhow!("Could not find config directory"))?;
62    path.push("prompts-cli");
63    Ok(path)
64}
65
66impl JsonStorage {
67    /// Creates a new `JsonStorage` instance.
68    ///
69    /// If `storage_path` is `None`, a default directory is used.
70    pub fn new(storage_path: Option<PathBuf>) -> Result<Self> {
71        let path = match storage_path {
72            Some(path) => path,
73            None => {
74                let mut default_path = get_default_storage_dir()?;
75                default_path.push("prompts");
76                fs::create_dir_all(&default_path)?;
77                default_path
78            }
79        };
80        Ok(Self { storage_path: path })
81    }
82}
83
84#[async_trait]
85impl Storage for JsonStorage {
86    async fn save_prompt(&self, prompt: &mut Prompt) -> Result<()> {
87        let file_path = self.storage_path.join(format!("{}.json", prompt.hash));
88        let json = serde_json::to_string_pretty(prompt)?;
89        tokio::fs::write(file_path, json).await?;
90        Ok(())
91    }
92
93    async fn load_prompts(&self) -> Result<Vec<Prompt>> {
94        let mut prompts = Vec::new();
95        let mut read_dir = tokio::fs::read_dir(&self.storage_path).await?;
96        while let Some(entry) = read_dir.next_entry().await? {
97            let path = entry.path();
98            if path.is_file() && path.extension().map_or(false, |ext| ext == "json") {
99                let json = tokio::fs::read_to_string(&path).await?;
100                let prompt: Prompt = serde_json::from_str(&json)?;
101                prompts.push(prompt);
102            }
103        }
104        Ok(prompts)
105    }
106
107    async fn delete_prompt(&self, hash: &str) -> Result<()> {
108        let file_path = self.storage_path.join(format!("{}.json", hash));
109        if file_path.exists() {
110            tokio::fs::remove_file(file_path).await?;
111        }
112        Ok(())
113    }
114}
115
116/// A storage implementation that uses a LibSQL database.
117///
118/// All prompts are stored in a single database file.
119pub struct LibSQLStorage {
120    conn: Connection,
121}
122
123impl LibSQLStorage {
124    /// Creates a new `LibSQLStorage` instance.
125    ///
126    /// If `storage_path` is `None`, a default database file is used.
127    /// This will also create the necessary tables if they don't exist.
128    pub async fn new(storage_path: Option<PathBuf>) -> Result<Self> {
129        let db_path = match storage_path {
130            Some(path) => path,
131            None => {
132                let mut path = get_default_storage_dir()?;
133                fs::create_dir_all(&path)?;
134                path.push("prompts.db");
135                path
136            }
137        };
138
139        let db = Builder::new_local(db_path.to_str().unwrap()).build().await?;
140        let conn = db.connect()?;
141
142        conn.execute(
143            "CREATE TABLE IF NOT EXISTS prompts (
144                hash TEXT PRIMARY KEY,
145                content TEXT NOT NULL,
146                tags TEXT,
147                categories TEXT
148            )",
149            (),
150        ).await?;
151
152        Ok(Self { conn })
153    }
154}
155
156#[async_trait]
157impl Storage for LibSQLStorage {
158    async fn save_prompt(&self, prompt: &mut Prompt) -> Result<()> {
159        let tags = serde_json::to_string(&prompt.tags.as_deref().unwrap_or_default())?;
160        let categories = serde_json::to_string(&prompt.categories.as_deref().unwrap_or_default())?;
161
162        self.conn.execute(
163            "INSERT INTO prompts (hash, content, tags, categories) VALUES (?1, ?2, ?3, ?4)",
164            libsql::params![prompt.hash.clone(), prompt.content.clone(), tags, categories],
165        ).await?;
166
167        Ok(())
168    }
169
170    async fn load_prompts(&self) -> Result<Vec<Prompt>> {
171        let mut rows = self.conn.query("SELECT hash, content, tags, categories FROM prompts", ()).await?;
172        let mut prompts = Vec::new();
173
174        while let Some(row) = rows.next().await? {
175            let hash: String = row.get(0)?;
176            let content: String = row.get(1)?;
177            let tags_str: String = row.get(2)?;
178            let categories_str: String = row.get(3)?;
179
180            let tags: Option<Vec<String>> = serde_json::from_str(&tags_str)?;
181            let categories: Option<Vec<String>> = serde_json::from_str(&categories_str)?;
182
183            prompts.push(Prompt {
184                hash,
185                content,
186                tags,
187                categories,
188            });
189        }
190
191        Ok(prompts)
192    }
193
194    async fn delete_prompt(&self, hash: &str) -> Result<()> {
195        self.conn.execute(
196            "DELETE FROM prompts WHERE hash = ?1",
197            libsql::params![hash],
198        ).await?;
199        Ok(())
200    }
201}