prompt_store/api/
store.rs1use crate::core::crypto::decrypt_key_with_password;
4use crate::core::storage::{AppCtx, PromptData};
5use crate::core::utils::ensure_dir;
6use aes_gcm::aead::{Aead, KeyInit};
7use aes_gcm::{Aes256Gcm, Key, Nonce};
8use base64::{engine::general_purpose, Engine as _};
9use std::env;
10use std::fs;
11use std::path::{Path, PathBuf};
12
13use super::error::StoreError;
14use super::llm_bridge::LLMBackendRef;
15use super::runner::{ChainRunner, PromptRunner};
16
17pub struct PromptStore {
22 pub(crate) ctx: AppCtx,
23}
24
25impl PromptStore {
26 fn new_from_key(key_bytes: Vec<u8>) -> Result<Self, StoreError> {
27 let home = env::var("HOME").map_err(|e| StoreError::Init(e.to_string()))?;
28 let base_dir = PathBuf::from(home).join(".prompt-store");
29 let key_path = base_dir.join("keys").join("key.bin");
30 let workspaces_dir = base_dir.join("workspaces");
31 let registries_dir = base_dir.join("registries");
32
33 ensure_dir(&base_dir).map_err(StoreError::Init)?;
34 ensure_dir(&workspaces_dir).map_err(StoreError::Init)?;
35 ensure_dir(®istries_dir).map_err(StoreError::Init)?;
36 ensure_dir(&workspaces_dir.join("default")).map_err(StoreError::Init)?;
37
38 let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(&key_bytes));
39
40 let ctx = AppCtx {
41 base_dir,
42 workspaces_dir,
43 registries_dir,
44 key_path,
45 cipher,
46 };
47
48 Ok(Self { ctx })
49 }
50
51 pub fn init() -> Result<Self, StoreError> {
56 let ctx = AppCtx::init().map_err(StoreError::Init)?;
57 Ok(Self { ctx })
58 }
59
60 pub fn with_password(password: &str) -> Result<Self, StoreError> {
69 let home = env::var("HOME").map_err(|e| StoreError::Init(e.to_string()))?;
70 let key_path = PathBuf::from(home)
71 .join(".prompt-store")
72 .join("keys")
73 .join("key.bin");
74
75 if !key_path.exists() {
76 return Err(StoreError::Init(
77 "Key file does not exist. Run interactively once to create it.".to_string(),
78 ));
79 }
80
81 let key_data = fs::read(&key_path)?;
82 let decrypted_key =
83 decrypt_key_with_password(&key_data, password).map_err(StoreError::Init)?;
84
85 Self::new_from_key(decrypted_key)
86 }
87
88 pub fn prompt<'a>(&'a self, id_or_title: &'a str) -> PromptRunner<'a> {
94 PromptRunner::new(self, id_or_title)
95 }
96
97 pub fn chain<'a, B: Into<LLMBackendRef<'a>>>(&'a self, backend: B) -> ChainRunner<'a> {
104 ChainRunner::new(self, backend.into())
105 }
106
107 pub(crate) fn find_prompt(&self, id_or_title: &str) -> Result<PromptData, StoreError> {
110 let prompt_path = self.ctx.prompt_path(id_or_title);
112 if prompt_path.exists() {
113 return self.decrypt_prompt_file(&prompt_path);
114 }
115
116 let mut found_prompts = vec![];
118 if self.ctx.workspaces_dir.exists() {
119 self.find_prompts_by_title_recursive(
120 &self.ctx.workspaces_dir,
121 id_or_title,
122 &mut found_prompts,
123 )?;
124 }
125
126 if found_prompts.len() == 1 {
127 Ok(found_prompts.remove(0))
128 } else if found_prompts.is_empty() {
129 Err(StoreError::NotFound(id_or_title.to_string()))
130 } else {
131 Err(StoreError::AmbiguousTitle(id_or_title.to_string()))
132 }
133 }
134
135 fn find_prompts_by_title_recursive(
137 &self,
138 dir: &Path,
139 title_query: &str,
140 found: &mut Vec<PromptData>,
141 ) -> Result<(), StoreError> {
142 for entry in fs::read_dir(dir)? {
143 let path = entry?.path();
144 if path.is_dir() {
145 self.find_prompts_by_title_recursive(&path, title_query, found)?;
146 } else if path.is_file() && path.extension().and_then(|s| s.to_str()) == Some("prompt")
147 {
148 if let Ok(pd) = self.decrypt_prompt_file(&path) {
149 if pd.title.eq_ignore_ascii_case(title_query) {
150 found.push(pd);
151 }
152 }
153 }
154 }
155 Ok(())
156 }
157
158 fn decrypt_prompt_file(&self, path: &Path) -> Result<PromptData, StoreError> {
160 let encoded = fs::read_to_string(path)?;
161 let decoded = general_purpose::STANDARD
162 .decode(encoded.trim_end())
163 .map_err(|_| StoreError::Crypto("Invalid Base64 data.".to_string()))?;
164
165 if decoded.len() < 12 {
166 return Err(StoreError::Crypto(
167 "Data is too short to be valid.".to_string(),
168 ));
169 }
170
171 let (nonce_bytes, cipher_bytes) = decoded.split_at(12);
172 let plaintext = self
173 .ctx
174 .cipher
175 .decrypt(Nonce::from_slice(nonce_bytes), cipher_bytes)
176 .map_err(|_| {
177 StoreError::Crypto("Decryption failed. Check key or password.".to_string())
178 })?;
179
180 Ok(serde_json::from_slice(&plaintext)?)
181 }
182}