dojo_cli/
config.rs

1use aes_gcm::{
2    aead::{Aead, KeyInit},
3    Aes256Gcm, Nonce,
4};
5use base64::{engine::general_purpose, Engine as _};
6use serde::{Deserialize, Serialize};
7use std::fs;
8use std::path::PathBuf;
9
10#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
11pub enum Environment {
12    Dev,
13    Prod,
14}
15
16impl Environment {
17    pub fn api_base_url(&self) -> &'static str {
18        match self {
19            Environment::Dev => "https://vkyme566n8.execute-api.us-east-1.amazonaws.com",
20            Environment::Prod => "https://o7eumduni6.execute-api.us-east-1.amazonaws.com",
21        }
22    }
23
24    pub fn display_name(&self) -> &'static str {
25        match self {
26            Environment::Dev => "development",
27            Environment::Prod => "production",
28        }
29    }
30}
31
32impl std::str::FromStr for Environment {
33    type Err = String;
34
35    fn from_str(s: &str) -> Result<Self, Self::Err> {
36        match s.to_lowercase().as_str() {
37            "dev" | "development" => Ok(Environment::Dev),
38            "prod" | "production" => Ok(Environment::Prod),
39            _ => Err(format!("Unknown environment: {}. Use 'dev' or 'prod'", s)),
40        }
41    }
42}
43
44// Get the current environment at compile time
45pub fn get_current_environment() -> Environment {
46    #[cfg(debug_assertions)]
47    {
48        Environment::Dev
49    }
50    #[cfg(not(debug_assertions))]
51    {
52        Environment::Prod
53    }
54}
55
56#[derive(Debug, Serialize, Deserialize)]
57pub struct Config {
58    pub workspace_directory: String,
59    pub api_token: String,
60    pub curriculum_version: String,
61}
62
63// Internal struct for serialization/deserialization with encrypted token
64#[derive(Debug, Serialize, Deserialize)]
65struct ConfigFile {
66    workspace_directory: String,
67    encrypted_api_token: String,
68    curriculum_version: String,
69}
70
71impl Config {
72    pub fn new(workspace_directory: String, api_token: String, curriculum_version: String) -> Self {
73        Self {
74            workspace_directory,
75            api_token,
76            curriculum_version,
77        }
78    }
79
80    pub fn get_api_base_url(&self) -> String {
81        get_current_environment().api_base_url().to_string()
82    }
83
84    pub fn get_environment(&self) -> Environment {
85        get_current_environment()
86    }
87
88    pub fn get_curriculum_version(&self) -> &str {
89        &self.curriculum_version
90    }
91
92    pub fn get_config_dir() -> Result<PathBuf, Box<dyn std::error::Error>> {
93        let home_dir = dirs::home_dir().ok_or("Could not find home directory")?;
94
95        let config_dir = home_dir.join(".dojo-cli");
96
97        // Create the directory if it doesn't exist
98        if !config_dir.exists() {
99            fs::create_dir_all(&config_dir)?;
100        }
101
102        Ok(config_dir)
103    }
104
105    pub fn get_config_path() -> Result<PathBuf, Box<dyn std::error::Error>> {
106        let config_dir = Self::get_config_dir()?;
107        Ok(config_dir.join("config.toml"))
108    }
109
110    pub fn save(&self) -> Result<(), Box<dyn std::error::Error>> {
111        let config_path = Self::get_config_path()?;
112
113        // Encrypt the API token before saving
114        let encrypted_token = Self::encrypt_token(&self.api_token)?;
115
116        let config_file = ConfigFile {
117            workspace_directory: self.workspace_directory.clone(),
118            encrypted_api_token: encrypted_token,
119            curriculum_version: self.curriculum_version.clone(),
120        };
121
122        let toml_string = toml::to_string_pretty(&config_file)?;
123        fs::write(config_path, toml_string)?;
124        Ok(())
125    }
126
127    pub fn load() -> Result<Self, Box<dyn std::error::Error>> {
128        let config_path = Self::get_config_path()?;
129        let content = fs::read_to_string(config_path)?;
130
131        // Try to load as encrypted format first
132        if let Ok(config_file) = toml::from_str::<ConfigFile>(&content) {
133            // Decrypt the API token
134            let decrypted_token = Self::decrypt_token(&config_file.encrypted_api_token)?;
135
136            return Ok(Config {
137                workspace_directory: config_file.workspace_directory,
138                api_token: decrypted_token,
139                curriculum_version: config_file.curriculum_version,
140            });
141        }
142
143        // Try to parse as the new format without encryption (for testing)
144        let config: Config = toml::from_str(&content)?;
145        Ok(config)
146    }
147
148    pub fn exists() -> bool {
149        Self::get_config_path()
150            .map(|path| path.exists())
151            .unwrap_or(false)
152    }
153
154    // Generate a key from a passphrase using a simple hash-based approach
155    fn derive_key_from_passphrase(passphrase: &str) -> [u8; 32] {
156        use std::collections::hash_map::DefaultHasher;
157        use std::hash::{Hash, Hasher};
158
159        let mut key = [0u8; 32];
160
161        // Generate multiple hashes to fill the 32-byte key
162        for i in 0..4 {
163            let mut hasher = DefaultHasher::new();
164            // Create a unique seed for each hash by appending the index
165            let seed = format!("{}{}", passphrase, i);
166            seed.hash(&mut hasher);
167            let hash = hasher.finish();
168
169            // Extract 8 bytes from each hash (64 bits = 8 bytes)
170            for j in 0..8 {
171                let byte_index = i * 8 + j;
172                if byte_index < 32 {
173                    key[byte_index] = ((hash >> (j * 8)) & 0xFF) as u8;
174                }
175            }
176        }
177
178        key
179    }
180
181    // Encrypt the API token
182    fn encrypt_token(token: &str) -> Result<String, Box<dyn std::error::Error>> {
183        // Use a simple passphrase based on the user's home directory
184        let home_dir = dirs::home_dir()
185            .ok_or("Could not find home directory")?
186            .to_string_lossy()
187            .to_string();
188
189        let passphrase = format!("bitcoin-dojo-cli-{}", home_dir);
190        let key = Self::derive_key_from_passphrase(&passphrase);
191
192        let cipher = Aes256Gcm::new_from_slice(&key)
193            .map_err(|e| format!("Failed to create cipher: {}", e))?;
194
195        // Generate a random nonce
196        let mut nonce_bytes = [0u8; 12];
197        getrandom::getrandom(&mut nonce_bytes)
198            .map_err(|e| format!("Failed to generate nonce: {}", e))?;
199        let nonce = Nonce::from_slice(&nonce_bytes);
200
201        let ciphertext = cipher
202            .encrypt(nonce, token.as_bytes())
203            .map_err(|e| format!("Failed to encrypt token: {}", e))?;
204
205        // Combine nonce and ciphertext, then base64 encode
206        let mut combined = Vec::new();
207        combined.extend_from_slice(&nonce_bytes);
208        combined.extend_from_slice(&ciphertext);
209
210        Ok(general_purpose::STANDARD.encode(combined))
211    }
212
213    // Decrypt the API token
214    fn decrypt_token(encrypted_token: &str) -> Result<String, Box<dyn std::error::Error>> {
215        // Use the same passphrase as encryption
216        let home_dir = dirs::home_dir()
217            .ok_or("Could not find home directory")?
218            .to_string_lossy()
219            .to_string();
220
221        let passphrase = format!("bitcoin-dojo-cli-{}", home_dir);
222        let key = Self::derive_key_from_passphrase(&passphrase);
223
224        let cipher = Aes256Gcm::new_from_slice(&key)
225            .map_err(|e| format!("Failed to create cipher: {}", e))?;
226
227        // Decode from base64
228        let combined = general_purpose::STANDARD
229            .decode(encrypted_token)
230            .map_err(|e| format!("Failed to decode base64: {}", e))?;
231
232        // Extract nonce (first 12 bytes) and ciphertext
233        if combined.len() < 12 {
234            return Err("Invalid encrypted token format".into());
235        }
236
237        let nonce = Nonce::from_slice(&combined[..12]);
238        let ciphertext = &combined[12..];
239
240        let plaintext = cipher
241            .decrypt(nonce, ciphertext)
242            .map_err(|e| format!("Failed to decrypt token: {}", e))?;
243        Ok(String::from_utf8(plaintext)
244            .map_err(|e| format!("Failed to convert to string: {}", e))?)
245    }
246
247    /// Initialize curriculum version by calling the backend API
248    pub async fn init_curriculum_version(
249        api_token: &str,
250    ) -> Result<String, Box<dyn std::error::Error>> {
251        let base_url = get_current_environment().api_base_url();
252        let stage = match get_current_environment() {
253            Environment::Dev => "dev",
254            Environment::Prod => "prod",
255        };
256
257        let url = format!("{}/{}/curriculum_version/init", base_url, stage);
258
259        let client = reqwest::Client::new();
260        let response = client
261            .patch(&url)
262            .bearer_auth(api_token)
263            .header("x-bdj-stage", stage)
264            .send()
265            .await?;
266
267        // Debug: Print response details
268        let status = response.status();
269        println!("Response status: {}", status);
270        let response_text = response.text().await?;
271        println!("Response body: {}", response_text);
272
273        if !status.is_success() {
274            return Err(Self::parse_api_error(
275                "Failed to initialize curriculum version",
276                &response_text,
277            )
278            .into());
279        }
280
281        let json_response: serde_json::Value = serde_json::from_str(&response_text)?;
282        let curriculum_version = json_response["curriculum_version"]
283            .as_str()
284            .ok_or("curriculum_version not found in response")?;
285
286        Ok(curriculum_version.to_string())
287    }
288
289    /// Update curriculum version by calling the backend API
290    pub async fn update_curriculum_version(
291        api_token: &str,
292        target_version: Option<&str>,
293    ) -> Result<String, Box<dyn std::error::Error>> {
294        let base_url = get_current_environment().api_base_url();
295        let stage = match get_current_environment() {
296            Environment::Dev => "dev",
297            Environment::Prod => "prod",
298        };
299
300        let url = format!("{}/{}/curriculum_version/update", base_url, stage);
301
302        // Load current config to get the current curriculum version
303        let current_config = Config::load()?;
304        let current_curriculum_version = current_config.get_curriculum_version();
305
306        let client = reqwest::Client::new();
307        let mut request = client
308            .patch(&url)
309            .bearer_auth(api_token)
310            .header("x-bdj-stage", stage)
311            .header("x-bdj-curriculum-version", current_curriculum_version);
312
313        // Add target version header if specified
314        if let Some(version) = target_version {
315            request = request.header("x-bdj-target-version", version);
316        }
317
318        let response = request.send().await?;
319
320        // Debug: Print response details
321        let status = response.status();
322        println!("Response status: {}", status);
323        let response_text = response.text().await?;
324        println!("Response body: {}", response_text);
325
326        if !status.is_success() {
327            return Err(Self::parse_api_error(
328                "Failed to update curriculum version",
329                &response_text,
330            )
331            .into());
332        }
333
334        let json_response: serde_json::Value = serde_json::from_str(&response_text)?;
335        let new_curriculum_version = json_response["curriculum_version"]
336            .as_str()
337            .ok_or("curriculum_version not found in response")?;
338
339        // Check if the version was actually updated
340        let updated = json_response["updated"].as_bool().unwrap_or(false);
341
342        if updated {
343            // Update the config file with the new curriculum version
344            let mut updated_config = current_config;
345            updated_config.curriculum_version = new_curriculum_version.to_string();
346            updated_config.save()?;
347            println!("📝 Config file updated with new curriculum version");
348        } else if target_version.is_some() {
349            println!("â„šī¸  Curriculum version is already at the requested version");
350        } else {
351            println!("â„šī¸  Curriculum version is already up to date");
352        }
353
354        Ok(new_curriculum_version.to_string())
355    }
356
357    /// Parse API error responses and return appropriate error messages
358    fn parse_api_error(context: &str, response_text: &str) -> String {
359        // Try to parse as JSON to extract structured error messages
360        if let Ok(error_json) = serde_json::from_str::<serde_json::Value>(response_text) {
361            if let Some(error_msg) = error_json["message"].as_str() {
362                if error_msg == "Forbidden" {
363                    return "❌ Authorization Error: Access forbidden".to_string();
364                } else {
365                    return format!("❌ API Error: {}", error_msg);
366                }
367            }
368        }
369
370        // Fallback to generic error message
371        format!("❌ {}: {}", context, response_text)
372    }
373}