1pub mod config;
7pub mod encryption;
8pub mod providers;
9
10pub 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#[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#[derive(Debug, Clone)]
58pub struct ConfigFile {
59 pub name: String,
60 pub path: PathBuf,
61 pub content: Vec<u8>,
62}
63
64pub struct ConfigResolver;
66
67impl ConfigResolver {
68 pub fn get_config_dir() -> Result<PathBuf> {
70 crate::config::Config::config_dir()
71 }
72
73 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 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 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 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 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 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
181pub 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
311pub 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 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; println!(
347 " {} {} ({}, {} KB)",
348 "•".blue(),
349 file.name,
350 file_type,
351 size_kb
352 );
353 }
354
355 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 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 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
426pub 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 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; println!(
470 " {} {} ({}, {} KB)",
471 "•".blue(),
472 file.name,
473 file_type,
474 size_kb
475 );
476 }
477
478 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 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 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 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 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 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 #[test]
637 fn test_config_resolver_returns_both_config_files() -> Result<()> {
638 let temp_dir = TempDir::new()?;
640 let config_dir = temp_dir.path().join("lc");
641 fs::create_dir_all(&config_dir)?;
642
643 let config_content = "[providers]\ntest_provider = { endpoint = 'https://example.com' }";
645 let logs_content = "SQLite format 3\x00"; fs::write(config_dir.join("config.toml"), config_content)?;
648 fs::write(config_dir.join("logs.db"), logs_content)?;
649
650 fs::write(config_dir.join("should_be_ignored.txt"), "ignored content")?;
652 fs::write(config_dir.join("README.md"), "# Documentation")?;
653
654 fs::write(config_dir.join("mcp.toml"), "mcp_config = true")?;
656 fs::write(config_dir.join("search_config.toml"), "search_config = true")?;
657
658 let config_files = get_config_files_from_dir(&config_dir)?;
660
661 assert_eq!(config_files.len(), 4, "Should return exactly 4 files: config.toml, logs.db, mcp.toml, and search_config.toml");
663
664 let file_names: std::collections::HashSet<_> = config_files.iter().map(|f| &f.name).collect();
666
667 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 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 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 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 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}