prompt_store/api/
store.rs

1//! The main entry point for interacting with the prompt store.
2
3use 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
17/// The main entry point for interacting with the prompt store.
18///
19/// This structure is designed to be created once and shared throughout your application.
20/// It holds the necessary context, including the encryption cipher.
21pub 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(&registries_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    /// Initializes the PromptStore by prompting for a password if the key is encrypted.
52    ///
53    /// This function will locate `~/.prompt-store`, load the encryption key,
54    /// and interactively prompt for a password if required.
55    pub fn init() -> Result<Self, StoreError> {
56        let ctx = AppCtx::init().map_err(StoreError::Init)?;
57        Ok(Self { ctx })
58    }
59
60    /// Initializes the PromptStore non-interactively with a password.
61    ///
62    /// This is useful for server environments where interactive prompts are not possible.
63    /// The password can be provided from an environment variable or a secret manager.
64    ///
65    /// # Arguments
66    ///
67    /// * `password` - The password to decrypt the master key.
68    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    /// Creates a runner for executing a single prompt.
89    ///
90    /// # Arguments
91    ///
92    /// * `id_or_title` - The ID or exact title of the prompt to run.
93    pub fn prompt<'a>(&'a self, id_or_title: &'a str) -> PromptRunner<'a> {
94        PromptRunner::new(self, id_or_title)
95    }
96
97    /// Creates a runner to define and execute a chain of prompts.
98    ///
99    /// # Arguments
100    ///
101    /// * `backend` - The LLM backend to use for the chain. This must be a type
102    ///   that can be converted into `LLMBackendRef`, typically a `&LLMRegistry`.
103    pub fn chain<'a, B: Into<LLMBackendRef<'a>>>(&'a self, backend: B) -> ChainRunner<'a> {
104        ChainRunner::new(self, backend.into())
105    }
106
107    /// Internal logic for finding and decrypting a prompt by its ID or title.
108    /// Searches local prompts, chain prompts, and cached prompts from deployed packs.
109    pub(crate) fn find_prompt(&self, id_or_title: &str) -> Result<PromptData, StoreError> {
110        // First, try to load by full ID directly (e.g., "abcdef12", "chain/1", or "pack::abc").
111        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        // If not found, search all prompts by title. This is more expensive.
117        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    /// Recursive helper to find prompts by title.
136    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    /// Helper to decrypt a single prompt file.
159    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}