lc/
sync.rs

1//! Configuration synchronization module
2//!
3//! This module provides functionality to sync configuration files to and from cloud providers.
4//! Currently supports Amazon S3 with optional AES256 encryption.
5
6pub mod config;
7pub mod encryption;
8pub mod providers;
9
10// Re-export all public items from submodules
11pub use config::*;
12pub use encryption::*;
13pub use providers::*;
14
15use anyhow::Result;
16use colored::Colorize;
17use rpassword::read_password;
18use std::fs;
19use std::io::{self, Write};
20use std::path::PathBuf;
21
22/// Supported cloud providers
23#[derive(Debug, Clone)]
24pub enum CloudProvider {
25    S3,
26}
27
28impl CloudProvider {
29    pub fn from_str(s: &str) -> Result<Self> {
30        match s.to_lowercase().as_str() {
31            "s3" | "amazon-s3" | "aws-s3" | "cloudflare" | "backblaze" => Ok(CloudProvider::S3),
32            _ => anyhow::bail!(
33                "Unsupported cloud provider: '{}'. Supported providers: s3",
34                s
35            ),
36        }
37    }
38
39    pub fn name(&self) -> &'static str {
40        match self {
41            CloudProvider::S3 => "s3",
42        }
43    }
44
45
46    pub fn display_name_for_provider(provider_name: &str) -> &'static str {
47        match provider_name.to_lowercase().as_str() {
48            "s3" | "amazon-s3" | "aws-s3" => "Amazon S3",
49            "cloudflare" => "Cloudflare R2",
50            "backblaze" => "Backblaze B2",
51            _ => "S3-Compatible Storage",
52        }
53    }
54}
55
56/// Configuration file information
57#[derive(Debug, Clone)]
58pub struct ConfigFile {
59    pub name: String,
60    pub path: PathBuf,
61    pub content: Vec<u8>,
62}
63
64/// Cross-platform configuration directory resolver
65pub struct ConfigResolver;
66
67impl ConfigResolver {
68    /// Get the configuration directory for the current platform
69    pub fn get_config_dir() -> Result<PathBuf> {
70        crate::config::Config::config_dir()
71    }
72
73    /// Get all .toml configuration files and logs.db from the lc directory and providers subdirectory
74    pub fn get_config_files() -> Result<Vec<ConfigFile>> {
75        let config_dir = Self::get_config_dir()?;
76        let mut config_files = Vec::new();
77
78        crate::debug_log!("Looking in directory: {:?}", config_dir);
79
80        if !config_dir.exists() {
81            crate::debug_log!("Directory does not exist");
82            return Ok(config_files);
83        }
84
85        // Read all .toml files and logs.db from the lc directory
86        for entry in fs::read_dir(&config_dir)? {
87            let entry = entry?;
88            let path = entry.path();
89
90            if path.is_file() {
91                let name = path
92                    .file_name()
93                    .and_then(|n| n.to_str())
94                    .unwrap_or("unknown");
95                let extension = path.extension().and_then(|e| e.to_str());
96
97                crate::debug_log!("Found file: {} (extension: {:?})", name, extension);
98
99                let should_include =
100                    name == "logs.db" || extension.map(|e| e == "toml").unwrap_or(false);
101
102                crate::debug_log!("Should include {}: {}", name, should_include);
103
104                if should_include {
105                    let content = fs::read(&path)?;
106
107                    config_files.push(ConfigFile {
108                        name: name.to_string(),
109                        path: path.clone(),
110                        content,
111                    });
112                }
113            } else if path.is_dir() {
114                let dir_name = path
115                    .file_name()
116                    .and_then(|n| n.to_str())
117                    .unwrap_or("unknown");
118
119                // Include providers directory
120                if dir_name == "providers" {
121                    crate::debug_log!("Found providers directory, scanning for .toml files");
122                    
123                    for provider_entry in fs::read_dir(&path)? {
124                        let provider_entry = provider_entry?;
125                        let provider_path = provider_entry.path();
126
127                        if provider_path.is_file() {
128                            let provider_name = provider_path
129                                .file_name()
130                                .and_then(|n| n.to_str())
131                                .unwrap_or("unknown");
132                            let provider_extension = provider_path.extension().and_then(|e| e.to_str());
133
134                            crate::debug_log!("Found provider file: {} (extension: {:?})", provider_name, provider_extension);
135
136                            if provider_extension.map(|e| e == "toml").unwrap_or(false) {
137                                let content = fs::read(&provider_path)?;
138                                
139                                // Store with relative path to preserve directory structure
140                                let relative_name = format!("providers/{}", provider_name);
141                                
142                                config_files.push(ConfigFile {
143                                    name: relative_name,
144                                    path: provider_path.clone(),
145                                    content,
146                                });
147                                
148                                crate::debug_log!("Added provider file: {}", provider_name);
149                            }
150                        }
151                    }
152                }
153            }
154        }
155
156        crate::debug_log!("Total files found: {}", config_files.len());
157        Ok(config_files)
158    }
159
160    /// Write configuration files back to the lc directory, preserving directory structure
161    pub fn write_config_files(files: &[ConfigFile]) -> Result<()> {
162        let config_dir = Self::get_config_dir()?;
163        fs::create_dir_all(&config_dir)?;
164
165        for file in files {
166            let target_path = config_dir.join(&file.name);
167            
168            // Create parent directories if needed (e.g., for providers/bedrock.toml)
169            if let Some(parent) = target_path.parent() {
170                fs::create_dir_all(parent)?;
171            }
172            
173            fs::write(&target_path, &file.content)?;
174            println!("{} Restored: {}", "✓".green(), file.name);
175        }
176
177        Ok(())
178    }
179}
180
181/// Handle sync providers command
182pub async fn handle_sync_providers() -> Result<()> {
183    println!("\n{}", "Supported Cloud Providers:".bold().blue());
184    println!(
185        "  {} {} - Amazon Simple Storage Service",
186        "•".blue(),
187        "s3".bold()
188    );
189    println!(
190        "  {} {} - Cloudflare R2 (S3-compatible)",
191        "•".blue(),
192        "cloudflare".bold()
193    );
194    println!(
195        "  {} {} - Backblaze B2 (S3-compatible)",
196        "•".blue(),
197        "backblaze".bold()
198    );
199
200    println!("\n{}", "Usage:".bold().blue());
201    println!(
202        "  {} Sync to cloud: {}",
203        "•".blue(),
204        "lc sync to <provider>".dimmed()
205    );
206    println!(
207        "  {} Sync from cloud: {}",
208        "•".blue(),
209        "lc sync from <provider>".dimmed()
210    );
211    println!(
212        "  {} With encryption: {}",
213        "•".blue(),
214        "lc sync to <provider> --encrypted".dimmed()
215    );
216
217    println!("\n{}", "Examples:".bold().blue());
218    println!(
219        "  {} {}",
220        "•".blue(),
221        "lc sync to s3".dimmed()
222    );
223    println!(
224        "  {} {}",
225        "•".blue(),
226        "lc sync to cloudflare".dimmed()
227    );
228    println!(
229        "  {} {}",
230        "•".blue(),
231        "lc sync from backblaze --encrypted".dimmed()
232    );
233
234    println!("\n{}", "What gets synced:".bold().blue());
235    println!("  {} Configuration files (*.toml)", "•".blue());
236    println!("  {} Provider configurations (providers/*.toml)", "•".blue());
237    println!("  {} Chat logs database (logs.db)", "•".blue());
238
239    println!("\n{}", "Configuration:".bold().blue());
240    println!("  {} Configure each provider separately:", "•".blue());
241    println!(
242        "    {} {}",
243        "•".blue(),
244        "lc sync configure s3 setup".dimmed()
245    );
246    println!(
247        "    {} {}",
248        "•".blue(),
249        "lc sync configure cloudflare setup".dimmed()
250    );
251    println!(
252        "    {} {}",
253        "•".blue(),
254        "lc sync configure backblaze setup".dimmed()
255    );
256
257    println!("\n{}", "Alternative: Environment Variables:".bold().blue());
258    println!("  {} S3 credentials can also be provided via:", "•".blue());
259    println!("      {} LC_S3_BUCKET=your-bucket-name", "export".dimmed());
260    println!("      {} LC_S3_REGION=us-east-1", "export".dimmed());
261    println!(
262        "      {} AWS_ACCESS_KEY_ID=your-access-key",
263        "export".dimmed()
264    );
265    println!(
266        "      {} AWS_SECRET_ACCESS_KEY=your-secret-key",
267        "export".dimmed()
268    );
269    println!(
270        "      {} LC_S3_ENDPOINT=https://s3.amazonaws.com  # Optional",
271        "export".dimmed()
272    );
273
274    println!("\n{}", "S3-Compatible Service Endpoints:".bold().blue());
275    println!(
276        "  {} AWS S3: {} (default)",
277        "•".blue(),
278        "https://s3.amazonaws.com".dimmed()
279    );
280    println!(
281        "  {} Backblaze B2: {}",
282        "•".blue(),
283        "https://s3.us-west-004.backblazeb2.com".dimmed()
284    );
285    println!(
286        "  {} Cloudflare R2: {}",
287        "•".blue(),
288        "https://your-account-id.r2.cloudflarestorage.com".dimmed()
289    );
290
291    println!("\n{}", "Database Management:".bold().blue());
292    println!(
293        "  {} Purge old logs: {}",
294        "•".blue(),
295        "lc logs purge --older-than-days 30".dimmed()
296    );
297    println!(
298        "  {} Keep recent logs: {}",
299        "•".blue(),
300        "lc logs purge --keep-recent 1000".dimmed()
301    );
302    println!(
303        "  {} Size-based purge: {}",
304        "•".blue(),
305        "lc logs purge --max-size-mb 50".dimmed()
306    );
307
308    Ok(())
309}
310
311/// Handle sync to cloud command
312pub async fn handle_sync_to(provider_name: &str, encrypted: bool, yes: bool) -> Result<()> {
313    let provider = CloudProvider::from_str(provider_name)?;
314
315    println!(
316        "{} Starting sync to {} ({})",
317        "🔄".blue(),
318        CloudProvider::display_name_for_provider(provider_name),
319        provider.name()
320    );
321
322    // Get configuration files
323    let config_files = ConfigResolver::get_config_files()?;
324
325    if config_files.is_empty() {
326        println!("{} No configuration files found to sync", "⚠️".yellow());
327        return Ok(());
328    }
329
330    println!(
331        "{} Found {} files to sync:",
332        "📁".blue(),
333        config_files.len()
334    );
335    for file in &config_files {
336        let file_type = if file.name.starts_with("providers/") && file.name.ends_with(".toml") {
337            "provider"
338        } else if file.name.ends_with(".toml") {
339            "config"
340        } else if file.name == "logs.db" {
341            "database"
342        } else {
343            "file"
344        };
345        let size_kb = (file.content.len() + 1023) / 1024; // Round up to KB
346        println!(
347            "  {} {} ({}, {} KB)",
348            "•".blue(),
349            file.name,
350            file_type,
351            size_kb
352        );
353    }
354
355    // Ask for confirmation unless --yes flag is provided
356    if !yes {
357        println!();
358        print!(
359            "Are you sure you want to sync {} files to {} cloud storage? (y/N): ",
360            config_files.len(),
361            CloudProvider::display_name_for_provider(provider_name)
362        );
363        io::stdout().flush()?;
364        
365        let mut input = String::new();
366        io::stdin().read_line(&mut input)?;
367        
368        if !input.trim().to_lowercase().starts_with('y') {
369            println!("Sync cancelled.");
370            return Ok(());
371        }
372    }
373
374    // Handle encryption if requested
375    let files_to_upload = if encrypted {
376        println!("\n{} Encryption enabled", "🔒".yellow());
377        print!("Enter encryption password: ");
378        io::stdout().flush()?;
379        let password = read_password()?;
380
381        if password.is_empty() {
382            anyhow::bail!("Password cannot be empty");
383        }
384
385        let key = derive_key_from_password(&password)?;
386        let mut encrypted_files = Vec::new();
387
388        for file in &config_files {
389            let encrypted_content = encrypt_data(&file.content, &key)?;
390            let encrypted_file = ConfigFile {
391                name: format!("{}.enc", file.name),
392                path: file.path.clone(),
393                content: encrypted_content,
394            };
395            encrypted_files.push(encrypted_file);
396        }
397
398        encrypted_files
399    } else {
400        config_files
401    };
402
403    // Upload to cloud provider
404    match provider {
405        CloudProvider::S3 => {
406            let s3_client = S3Provider::new_with_provider(provider_name).await?;
407            s3_client
408                .upload_configs(&files_to_upload, encrypted)
409                .await?;
410        }
411    }
412
413    println!(
414        "\n{} Sync to {} completed successfully!",
415        "🎉".green(),
416        CloudProvider::display_name_for_provider(provider_name)
417    );
418
419    if encrypted {
420        println!("{} Files were encrypted before upload", "🔒".green());
421    }
422
423    Ok(())
424}
425
426/// Handle sync from cloud command
427pub async fn handle_sync_from(provider_name: &str, encrypted: bool, yes: bool) -> Result<()> {
428    let provider = CloudProvider::from_str(provider_name)?;
429
430    println!(
431        "{} Starting sync from {} ({})",
432        "🔄".blue(),
433        CloudProvider::display_name_for_provider(provider_name),
434        provider.name()
435    );
436
437    // Download from cloud provider
438    let downloaded_files = match provider {
439        CloudProvider::S3 => {
440            let s3_client = S3Provider::new_with_provider(provider_name).await?;
441            s3_client.download_configs(encrypted).await?
442        }
443    };
444
445    if downloaded_files.is_empty() {
446        println!(
447            "{} No configuration files found in cloud storage",
448            "⚠️".yellow()
449        );
450        return Ok(());
451    }
452
453    println!(
454        "{} Downloaded {} files:",
455        "📥".blue(),
456        downloaded_files.len()
457    );
458    for file in &downloaded_files {
459        let file_type = if file.name.starts_with("providers/") && file.name.ends_with(".toml") {
460            "provider"
461        } else if file.name.ends_with(".toml") {
462            "config"
463        } else if file.name == "logs.db" {
464            "database"
465        } else {
466            "file"
467        };
468        let size_kb = (file.content.len() + 1023) / 1024; // Round up to KB
469        println!(
470            "  {} {} ({}, {} KB)",
471            "•".blue(),
472            file.name,
473            file_type,
474            size_kb
475        );
476    }
477
478    // Ask for confirmation unless --yes flag is provided
479    if !yes {
480        println!();
481        print!(
482            "Are you sure you want to overwrite your local configuration with {} files from {} cloud storage? (y/N): ",
483            downloaded_files.len(),
484            CloudProvider::display_name_for_provider(provider_name)
485        );
486        io::stdout().flush()?;
487        
488        let mut input = String::new();
489        io::stdin().read_line(&mut input)?;
490        
491        if !input.trim().to_lowercase().starts_with('y') {
492            println!("Sync cancelled.");
493            return Ok(());
494        }
495    }
496
497    // Handle decryption if needed
498    let final_files = if encrypted {
499        println!("\n{} Decryption enabled", "🔓".yellow());
500        print!("Enter decryption password: ");
501        io::stdout().flush()?;
502        let password = read_password()?;
503
504        if password.is_empty() {
505            anyhow::bail!("Password cannot be empty");
506        }
507
508        let key = derive_key_from_password(&password)?;
509        let mut decrypted_files = Vec::new();
510
511        for file in &downloaded_files {
512            // Remove .enc extension if present
513            let original_name = if file.name.ends_with(".enc") {
514                file.name.strip_suffix(".enc").unwrap().to_string()
515            } else {
516                file.name.clone()
517            };
518
519            let decrypted_content = decrypt_data(&file.content, &key).map_err(|e| {
520                anyhow::anyhow!(
521                    "Failed to decrypt {}: {}. Check your password.",
522                    file.name,
523                    e
524                )
525            })?;
526
527            let decrypted_file = ConfigFile {
528                name: original_name,
529                path: file.path.clone(),
530                content: decrypted_content,
531            };
532            decrypted_files.push(decrypted_file);
533        }
534
535        decrypted_files
536    } else {
537        downloaded_files
538    };
539
540    // Write files to local config directory
541    ConfigResolver::write_config_files(&final_files)?;
542
543    println!(
544        "\n{} Sync from {} completed successfully!",
545        "🎉".green(),
546        CloudProvider::display_name_for_provider(provider_name)
547    );
548
549    if encrypted {
550        println!("{} Files were decrypted after download", "🔓".green());
551    }
552
553    let config_dir = ConfigResolver::get_config_dir()?;
554    println!(
555        "{} Configuration files restored to: {}",
556        "📁".blue(),
557        config_dir.display()
558    );
559
560    Ok(())
561}
562
563#[cfg(test)]
564mod tests {
565    use super::*;
566    use tempfile::TempDir;
567
568    #[test]
569    fn test_cloud_provider_from_str() {
570        assert!(matches!(
571            CloudProvider::from_str("s3"),
572            Ok(CloudProvider::S3)
573        ));
574        assert!(matches!(
575            CloudProvider::from_str("S3"),
576            Ok(CloudProvider::S3)
577        ));
578        assert!(matches!(
579            CloudProvider::from_str("amazon-s3"),
580            Ok(CloudProvider::S3)
581        ));
582        assert!(CloudProvider::from_str("invalid").is_err());
583    }
584
585    #[test]
586    fn test_cloud_provider_names() {
587        let s3 = CloudProvider::S3;
588        assert_eq!(s3.name(), "s3");
589        assert_eq!(CloudProvider::display_name_for_provider("s3"), "Amazon S3");
590        assert_eq!(CloudProvider::display_name_for_provider("cloudflare"), "Cloudflare R2");
591        assert_eq!(CloudProvider::display_name_for_provider("backblaze"), "Backblaze B2");
592        assert_eq!(CloudProvider::display_name_for_provider("unknown"), "S3-Compatible Storage");
593    }
594
595    /// Helper function to test get_config_files with a custom directory
596    /// This allows us to test the file detection logic in isolation
597    fn get_config_files_from_dir(dir: &PathBuf) -> Result<Vec<ConfigFile>> {
598        let mut config_files = Vec::new();
599
600        if !dir.exists() {
601            return Ok(config_files);
602        }
603
604        // Read all .toml files and logs.db from the directory
605        for entry in fs::read_dir(dir)? {
606            let entry = entry?;
607            let path = entry.path();
608
609            if path.is_file() {
610                let name = path
611                    .file_name()
612                    .and_then(|n| n.to_str())
613                    .unwrap_or("unknown");
614                let extension = path.extension().and_then(|e| e.to_str());
615
616                let should_include =
617                    name == "logs.db" || extension.map(|e| e == "toml").unwrap_or(false);
618
619                if should_include {
620                    let content = fs::read(&path)?;
621
622                    config_files.push(ConfigFile {
623                        name: name.to_string(),
624                        path: path.clone(),
625                        content,
626                    });
627                }
628            }
629        }
630
631        Ok(config_files)
632    }
633
634    /// Regression test to ensure ConfigResolver::get_config_files() returns both config.toml and logs.db
635    /// This prevents future regressions where one of the files might be missed.
636    #[test]
637    fn test_config_resolver_returns_both_config_files() -> Result<()> {
638        // Create a temporary directory structure
639        let temp_dir = TempDir::new()?;
640        let config_dir = temp_dir.path().join("lc");
641        fs::create_dir_all(&config_dir)?;
642
643        // Create both expected files with test content
644        let config_content = "[providers]\ntest_provider = { endpoint = 'https://example.com' }";
645        let logs_content = "SQLite format 3\x00"; // Mock SQLite header
646        
647        fs::write(config_dir.join("config.toml"), config_content)?;
648        fs::write(config_dir.join("logs.db"), logs_content)?;
649        
650        // Also create files that should be ignored
651        fs::write(config_dir.join("should_be_ignored.txt"), "ignored content")?;
652        fs::write(config_dir.join("README.md"), "# Documentation")?;
653        
654        // Create additional .toml files that should be included
655        fs::write(config_dir.join("mcp.toml"), "mcp_config = true")?;
656        fs::write(config_dir.join("search_config.toml"), "search_config = true")?;
657
658        // Test our helper function with the temporary directory
659        let config_files = get_config_files_from_dir(&config_dir)?;
660
661        // Assertions
662        assert_eq!(config_files.len(), 4, "Should return exactly 4 files: config.toml, logs.db, mcp.toml, and search_config.toml");
663        
664        // Collect the file names for easier assertions
665        let file_names: std::collections::HashSet<_> = config_files.iter().map(|f| &f.name).collect();
666        
667        // Must include these critical files
668        assert!(file_names.contains(&"config.toml".to_string()), "Should include config.toml");
669        assert!(file_names.contains(&"logs.db".to_string()), "Should include logs.db");
670        
671        // Should also include other .toml files  
672        assert!(file_names.contains(&"mcp.toml".to_string()), "Should include mcp.toml");
673        assert!(file_names.contains(&"search_config.toml".to_string()), "Should include search_config.toml");
674        
675        // Should not include non-.toml files (except logs.db)
676        assert!(!file_names.contains(&"should_be_ignored.txt".to_string()), "Should not include .txt files");
677        assert!(!file_names.contains(&"README.md".to_string()), "Should not include .md files");
678        
679        // Verify content is correctly read for the main files
680        let config_file = config_files.iter().find(|f| f.name == "config.toml").unwrap();
681        let logs_file = config_files.iter().find(|f| f.name == "logs.db").unwrap();
682        
683        assert_eq!(String::from_utf8_lossy(&config_file.content), config_content);
684        assert_eq!(&logs_file.content[..logs_content.len()], logs_content.as_bytes());
685        
686        // Verify paths are correct
687        assert_eq!(config_file.path, config_dir.join("config.toml"));
688        assert_eq!(logs_file.path, config_dir.join("logs.db"));
689
690        Ok(())
691    }
692}