use anyhow::{Context, Result};
use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
use chrono::Utc;
use colored::Colorize;
use serde::Serialize;
use std::io::{self, Write};
use std::sync::mpsc;
use std::thread;
use crate::cli::OutputFormat;
use crate::cloud::client::{CloudClient, PushSession, SessionMetadata};
use crate::cloud::credentials::{require_login, CredentialsStore};
use crate::cloud::encryption::{
decode_base64, decode_key_hex, decrypt_data, derive_key, encode_base64, encode_key_hex,
encrypt_data,
};
use crate::config::Config;
use crate::daemon::SyncState;
use crate::storage::models::{Message, Session};
use crate::storage::Database;
#[derive(clap::Args)]
#[command(after_help = "EXAMPLES:\n \
lore cloud status Show cloud sync status\n \
lore cloud push Push local sessions to cloud\n \
lore cloud pull Pull sessions from cloud\n \
lore cloud sync Pull then push (bidirectional sync)\n \
lore cloud reset-sync Reset sync status to re-upload all sessions")]
pub struct Args {
#[command(subcommand)]
pub command: CloudSubcommand,
}
#[derive(clap::Subcommand)]
pub enum CloudSubcommand {
#[command(
long_about = "Shows the current cloud sync status including session count,\n\
storage used, and last sync time. Also shows how many local\n\
sessions are pending sync."
)]
Status {
#[arg(short, long, value_enum, default_value = "text")]
format: OutputFormat,
},
#[command(
long_about = "Uploads sessions that have not been synced to the cloud.\n\
Session messages are encrypted locally before upload using your\n\
encryption passphrase. On first push, you will be prompted to\n\
create a passphrase."
)]
Push {
#[arg(long)]
dry_run: bool,
},
#[command(
long_about = "Downloads sessions from the cloud that were created on other\n\
machines. Requires your encryption passphrase to decrypt the\n\
session content."
)]
Pull {
#[arg(long)]
all: bool,
},
#[command(long_about = "Performs a full bidirectional sync with the cloud.\n\
First pulls any new sessions from other machines, then pushes\n\
local sessions that haven't been synced yet.")]
Sync,
#[command(
name = "reset-sync",
long_about = "Resets the sync status of local sessions, marking them as unsynced.\n\
This is useful when switching cloud environments or fixing sync issues.\n\
After running this command, use 'lore cloud push' to re-upload sessions."
)]
ResetSync {
#[arg(long, value_name = "ID")]
session: Option<Vec<String>>,
#[arg(long)]
force: bool,
},
}
#[derive(Serialize)]
struct StatusOutput {
logged_in: bool,
email: Option<String>,
plan: Option<String>,
cloud: Option<CloudStatus>,
local: LocalStatus,
}
#[derive(Serialize)]
struct CloudStatus {
session_count: i64,
storage_used_bytes: i64,
last_sync_at: Option<String>,
}
#[derive(Serialize)]
struct LocalStatus {
total_sessions: i32,
unsynced_sessions: i32,
last_sync_at: Option<String>,
next_auto_sync_at: Option<String>,
}
pub fn run(args: Args) -> Result<()> {
match args.command {
CloudSubcommand::Status { format } => run_status(format),
CloudSubcommand::Push { dry_run } => run_push(dry_run),
CloudSubcommand::Pull { all } => run_pull(all),
CloudSubcommand::Sync => run_sync(),
CloudSubcommand::ResetSync { session, force } => run_reset_sync(session, force),
}
}
fn run_status(format: OutputFormat) -> Result<()> {
let db = Database::open_default()?;
let config = Config::load()?;
let store = CredentialsStore::with_keychain(config.use_keychain);
let creds = store.load().context("Failed to check login status")?;
let total_sessions = db.session_count()?;
let unsynced_sessions = db.unsynced_session_count()?;
let last_local_sync = db.last_sync_time()?;
let sync_state = SyncState::load().ok();
let next_auto_sync = sync_state.as_ref().and_then(|s| s.next_sync_at);
match format {
OutputFormat::Json => {
let cloud_status = if let Some(ref creds) = creds {
let client = CloudClient::with_url(&creds.cloud_url).with_api_key(&creds.api_key);
match client.status() {
Ok(status) => Some(CloudStatus {
session_count: status.session_count,
storage_used_bytes: status.storage_used_bytes,
last_sync_at: status.last_sync_at.map(|t| t.to_rfc3339()),
}),
Err(e) => {
tracing::debug!("Failed to get cloud status: {e}");
None
}
}
} else {
None
};
let output = StatusOutput {
logged_in: creds.is_some(),
email: creds.as_ref().map(|c| c.email.clone()),
plan: creds.as_ref().map(|c| c.plan.clone()),
cloud: cloud_status,
local: LocalStatus {
total_sessions,
unsynced_sessions,
last_sync_at: last_local_sync.map(|t| t.to_rfc3339()),
next_auto_sync_at: next_auto_sync.map(|t| t.to_rfc3339()),
},
};
println!("{}", serde_json::to_string_pretty(&output)?);
}
OutputFormat::Text | OutputFormat::Markdown => {
println!("{}", "Cloud Sync".bold());
println!();
match creds {
Some(creds) => {
println!("{}", "Account:".bold());
println!(" Email: {}", creds.email.cyan());
println!(" Plan: {}", creds.plan);
println!();
let client =
CloudClient::with_url(&creds.cloud_url).with_api_key(&creds.api_key);
match client.status() {
Ok(status) => {
println!("{}", "Cloud:".bold());
println!(" Sessions: {}", status.session_count);
println!(" Storage: {}", format_bytes(status.storage_used_bytes));
if let Some(last_sync) = status.last_sync_at {
println!(" Last sync: {}", format_relative_time(last_sync));
}
println!();
}
Err(e) => {
println!("{}: {}", "Cloud status unavailable".yellow(), e);
println!();
}
}
}
None => {
println!(
"{} Run 'lore login' to authenticate.",
"Not logged in.".yellow()
);
println!();
}
}
println!("{}", "Local:".bold());
println!(" Total sessions: {}", total_sessions);
println!(" Pending sync: {}", unsynced_sessions);
if let Some(last_sync) = last_local_sync {
println!(" Last sync: {}", format_relative_time(last_sync));
}
match next_auto_sync {
Some(next_sync) => {
println!(
" Next auto-sync: {}",
format_future_relative_time(next_sync)
);
}
None => {
println!(" Next auto-sync: {}", "Not scheduled".dimmed());
}
}
}
}
Ok(())
}
const PUSH_BATCH_SIZE: usize = 3;
fn run_push(dry_run: bool) -> Result<()> {
let creds = require_login()?;
let db = Database::open_default()?;
let config = Config::load()?;
let client = CloudClient::with_url(&creds.cloud_url).with_api_key(&creds.api_key);
if let Some(ref local_salt) = config.encryption_salt {
match client.get_salt() {
Ok(None) => {
if let Err(e) = client.set_salt(local_salt) {
tracing::debug!("Could not sync salt to cloud: {e}");
} else {
tracing::debug!("Synced encryption salt to cloud");
}
}
Ok(Some(_)) => {
}
Err(e) => {
tracing::debug!("Could not check cloud salt: {e}");
}
}
}
let sessions = db.get_unsynced_sessions()?;
if sessions.is_empty() {
println!("{}", "All sessions are already synced.".green());
return Ok(());
}
println!("Found {} sessions to sync.", sessions.len());
if dry_run {
println!();
println!("{}", "Dry run - would push:".yellow());
for session in &sessions {
println!(
" {} ({}, {} messages)",
&session.id.to_string()[..8],
session.tool,
session.message_count
);
}
return Ok(());
}
let store = CredentialsStore::with_keychain(config.use_keychain);
let mut config = config; let encryption_key = match store.load_encryption_key()? {
Some(key_hex) => decode_key_hex(&key_hex)?,
None => {
println!();
println!("{}", "First sync - set up encryption".bold());
println!(
"Your session content will be encrypted with a passphrase that only you know."
);
println!("The cloud service cannot read your session content.");
println!();
let passphrase = prompt_new_passphrase()?;
let salt_b64 = config.get_or_create_encryption_salt()?;
let salt = BASE64.decode(&salt_b64)?;
let key = derive_key(&passphrase, &salt)?;
store.store_encryption_key(&encode_key_hex(&key))?;
if let Err(e) = client.set_salt(&salt_b64) {
tracing::debug!("Could not sync salt to cloud (may already exist): {e}");
}
key
}
};
let machine_id = config.get_or_create_machine_id()?;
println!();
print!(" Reading sessions...");
io::stdout().flush()?;
let session_data: Vec<_> = sessions
.iter()
.map(|session| {
let messages = db.get_messages(&session.id)?;
Ok((session.clone(), messages))
})
.collect::<Result<Vec<_>>>()?;
println!(" done");
let batches: Vec<Vec<_>> = session_data
.chunks(PUSH_BATCH_SIZE)
.map(|chunk| chunk.to_vec())
.collect();
let total_batches = batches.len();
let (tx, rx) = mpsc::sync_channel::<Result<(usize, Vec<PushSession>)>>(2);
let encrypt_handle = thread::spawn(move || {
for (batch_idx, batch) in batches.into_iter().enumerate() {
let mut push_sessions = Vec::new();
for (session, messages) in batch {
let encrypted = match encrypt_session_messages(&messages, &encryption_key) {
Ok(e) => e,
Err(e) => {
let _ = tx.send(Err(e));
return;
}
};
push_sessions.push(PushSession {
id: session.id.to_string(),
machine_id: machine_id.clone(),
encrypted_data: encrypted,
metadata: SessionMetadata {
tool_name: session.tool.clone(),
project_path: session.working_directory.clone(),
started_at: session.started_at,
ended_at: session.ended_at,
message_count: session.message_count,
},
updated_at: session.ended_at.unwrap_or_else(Utc::now),
});
}
if tx.send(Ok((batch_idx, push_sessions))).is_err() {
return; }
}
});
println!(" Encrypting and uploading ({} batches)...", total_batches);
let mut total_synced: i64 = 0;
let mut batch_errors: Vec<(usize, String)> = Vec::new();
let mut too_large_sessions: Vec<String> = Vec::new();
let mut quota_exceeded: Option<QuotaInfo> = None;
for received in rx {
let (batch_idx, batch) = received?;
let batch_num = batch_idx + 1;
print!(" Batch {}/{}... ", batch_num, total_batches);
io::stdout().flush()?;
match client.push(batch.to_vec()) {
Ok(response) => {
println!("done");
let batch_session_ids: Vec<_> = batch
.iter()
.filter_map(|ps| uuid::Uuid::parse_str(&ps.id).ok())
.collect();
db.mark_sessions_synced(&batch_session_ids, response.server_time)?;
total_synced += response.synced_count;
}
Err(e) => {
let error_str = e.to_string();
if is_quota_error(&error_str) {
println!("{}", "quota limit reached".yellow());
quota_exceeded = parse_quota_info(&error_str);
break; } else if is_size_error(&error_str) {
println!("{}", "failed (retrying individually)".yellow());
for session in &batch {
let session_short_id = &session.id[..8];
print!(" Session {}... ", session_short_id);
io::stdout().flush()?;
match client.push(vec![session.clone()]) {
Ok(response) => {
println!("done");
if let Ok(session_id) = uuid::Uuid::parse_str(&session.id) {
db.mark_sessions_synced(&[session_id], response.server_time)?;
}
total_synced += response.synced_count;
}
Err(individual_err) => {
let individual_error_str = individual_err.to_string();
if is_quota_error(&individual_error_str) {
println!("{}", "quota limit reached".yellow());
quota_exceeded = parse_quota_info(&individual_error_str);
break; } else if is_size_error(&individual_error_str) {
println!("{}", "too large, skipping".yellow());
too_large_sessions.push(session.id.clone());
} else {
println!("{}", "failed".red());
batch_errors.push((
batch_num,
format!(
"Session {}: {}",
session_short_id, individual_error_str
),
));
}
}
}
}
if quota_exceeded.is_some() {
break;
}
} else {
println!("{}", "failed".red());
batch_errors.push((batch_num, error_str));
}
}
}
}
encrypt_handle.join().expect("Encryption thread panicked");
println!();
if let Some(quota) = quota_exceeded {
if total_synced > 0 {
println!(
"{} Synced {} sessions (reached {} plan limit of {}).",
"Done.".green().bold(),
total_synced,
quota.plan,
quota.limit
);
} else {
println!(
"{} Could not sync - {} plan limit of {} sessions reached ({}/{} used).",
"Limit reached.".yellow().bold(),
quota.plan,
quota.limit,
quota.current,
quota.limit
);
}
let remaining = sessions.len() as i64 - total_synced;
if remaining > 0 {
println!("{} sessions could not be synced.", remaining);
}
println!();
println!(
"Upgrade to Pro for unlimited sessions: {}",
"https://lore.varalys.com/pricing".cyan()
);
return Ok(());
}
if batch_errors.is_empty() && too_large_sessions.is_empty() {
println!(
"{} Synced {} sessions to the cloud.",
"Success!".green().bold(),
total_synced
);
} else if batch_errors.is_empty() {
println!(
"{} Synced {} sessions to the cloud.",
"Success!".green().bold(),
total_synced
);
println!(
"{} {} session(s) were too large to sync:",
"Note:".yellow(),
too_large_sessions.len()
);
for session_id in &too_large_sessions {
println!(" {}", &session_id[..8]);
}
} else {
if total_synced > 0 {
println!(
"{} Synced {} sessions, but {} error(s) occurred:",
"Partial success.".yellow().bold(),
total_synced,
batch_errors.len()
);
} else {
println!("{} All batches failed:", "Error!".red().bold());
}
for (batch_num, error) in &batch_errors {
println!(" Batch {}: {}", batch_num, error);
}
if !too_large_sessions.is_empty() {
println!(
"{} {} session(s) were too large to sync:",
"Note:".yellow(),
too_large_sessions.len()
);
for session_id in &too_large_sessions {
println!(" {}", &session_id[..8]);
}
}
}
Ok(())
}
fn run_pull(all: bool) -> Result<()> {
let creds = require_login()?;
let mut db = Database::open_default()?;
let since = if all { None } else { db.last_sync_time()? };
let client = CloudClient::with_url(&creds.cloud_url).with_api_key(&creds.api_key);
let mut config = Config::load()?;
let store = CredentialsStore::with_keychain(config.use_keychain);
let encryption_key = match store.load_encryption_key()? {
Some(key_hex) => decode_key_hex(&key_hex)?,
None => {
println!("Enter your encryption passphrase to decrypt sessions:");
let passphrase = prompt_passphrase()?;
let salt_b64 = match &config.encryption_salt {
Some(salt) => salt.clone(),
None => {
let cloud_salt = client.get_salt()?.ok_or_else(|| {
anyhow::anyhow!(
"No encryption salt found locally or on cloud. Run 'lore cloud push' on a machine with existing sessions first."
)
})?;
config.encryption_salt = Some(cloud_salt.clone());
config.save()?;
cloud_salt
}
};
let salt = BASE64.decode(&salt_b64)?;
let key = derive_key(&passphrase, &salt)?;
store.store_encryption_key(&encode_key_hex(&key))?;
key
}
};
println!("Downloading sessions from cloud...");
let response = client.pull(since)?;
if response.sessions.is_empty() {
println!("{}", "No new sessions to pull.".green());
return Ok(());
}
println!("Found {} sessions to process.", response.sessions.len());
let mut imported = 0;
let mut updated = 0;
let mut skipped = 0;
let mut failed = 0;
let total = response.sessions.len();
let config = Config::load()?;
let local_machine_id = config.machine_id.clone();
for (idx, pull_session) in response.sessions.into_iter().enumerate() {
eprint!("\r Processing sessions... {}/{}", idx + 1, total);
if Some(&pull_session.machine_id) == local_machine_id.as_ref() {
skipped += 1;
continue;
}
let existing_session = db
.find_session_by_id_prefix(&pull_session.id)
.ok()
.flatten();
let is_update = if let Some(ref existing) = existing_session {
let cloud_has_more_messages =
pull_session.metadata.message_count > existing.message_count;
let cloud_has_later_ended_at = match (pull_session.metadata.ended_at, existing.ended_at)
{
(Some(cloud_end), Some(local_end)) => cloud_end > local_end,
(Some(_), None) => true, _ => false,
};
cloud_has_more_messages || cloud_has_later_ended_at
} else {
false
};
if existing_session.is_some() && !is_update {
skipped += 1;
continue;
}
let messages = match decrypt_session_messages(&pull_session.encrypted_data, &encryption_key)
{
Ok(msgs) => msgs,
Err(e) => {
failed += 1;
tracing::debug!("Failed to decrypt session {}: {}", &pull_session.id[..8], e);
continue;
}
};
let session_id = uuid::Uuid::parse_str(&pull_session.id).context("Invalid session ID")?;
let session = Session {
id: session_id,
tool: pull_session.metadata.tool_name,
tool_version: None,
started_at: pull_session.metadata.started_at,
ended_at: pull_session.metadata.ended_at,
model: None,
working_directory: pull_session.metadata.project_path,
git_branch: None,
source_path: None,
message_count: pull_session.metadata.message_count,
machine_id: Some(pull_session.machine_id),
};
db.import_session_with_messages(&session, &messages, Some(response.server_time))?;
if is_update {
updated += 1;
} else {
imported += 1;
}
}
eprintln!();
if failed > 0 {
println!(
"{} Imported {} sessions, updated {} ({} skipped, {} failed to decrypt).",
"Done.".yellow().bold(),
imported,
updated,
skipped,
failed
);
} else if updated > 0 {
println!(
"{} Imported {} sessions, updated {} ({} skipped).",
"Success!".green().bold(),
imported,
updated,
skipped
);
} else {
println!(
"{} Imported {} sessions ({} skipped).",
"Success!".green().bold(),
imported,
skipped
);
}
Ok(())
}
fn run_sync() -> Result<()> {
println!("{}", "Cloud Sync".bold());
println!();
println!("{}", "Step 1: Pull".bold());
if let Err(e) = run_pull(false) {
println!("{} Pull failed: {}", "Warning:".yellow(), e);
println!("Continuing with push...");
println!();
}
println!();
println!("{}", "Step 2: Push".bold());
run_push(false)?;
Ok(())
}
fn run_reset_sync(session_ids: Option<Vec<String>>, force: bool) -> Result<()> {
let db = Database::open_default()?;
match session_ids {
Some(ids) => {
let mut resolved_sessions = Vec::new();
for id_or_prefix in &ids {
match db.find_session_by_id_prefix(id_or_prefix) {
Ok(Some(session)) => resolved_sessions.push(session),
Ok(None) => {
anyhow::bail!("Session not found: {}", id_or_prefix);
}
Err(e) => {
anyhow::bail!("Error finding session '{}': {}", id_or_prefix, e);
}
}
}
if resolved_sessions.is_empty() {
println!("{}", "No sessions to reset.".yellow());
return Ok(());
}
if !force {
println!(
"This will mark {} session(s) as unsynced.",
resolved_sessions.len()
);
println!("They will be re-uploaded on the next 'lore cloud push'.");
println!();
print!("Continue? [y/N] ");
io::stdout().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
if !input.trim().eq_ignore_ascii_case("y") {
println!("{}", "Cancelled".dimmed());
return Ok(());
}
}
let session_uuids: Vec<_> = resolved_sessions.iter().map(|s| s.id).collect();
let count = db.clear_sync_status_for_sessions(&session_uuids)?;
println!();
println!(
"{} Reset sync status for {} session(s).",
"Done.".green(),
count
);
println!("Run 'lore cloud push' to sync to cloud.");
}
None => {
let total = db.session_count()?;
if total == 0 {
println!("{}", "No sessions to reset.".yellow());
return Ok(());
}
if !force {
println!("This will mark all {} sessions as unsynced.", total);
println!("They will be re-uploaded on the next 'lore cloud push'.");
println!();
print!("Continue? [y/N] ");
io::stdout().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
if !input.trim().eq_ignore_ascii_case("y") {
println!("{}", "Cancelled".dimmed());
return Ok(());
}
}
let count = db.clear_sync_status()?;
println!();
println!(
"{} Reset sync status for {} sessions.",
"Done.".green(),
count
);
println!("Run 'lore cloud push' to sync to cloud.");
}
}
Ok(())
}
fn encrypt_session_messages(messages: &[Message], key: &[u8]) -> Result<String> {
let json = serde_json::to_vec(messages)?;
let encrypted = encrypt_data(&json, key)?;
Ok(encode_base64(&encrypted))
}
fn decrypt_session_messages(encrypted_b64: &str, key: &[u8]) -> Result<Vec<Message>> {
let encrypted = decode_base64(encrypted_b64)?;
let decrypted = decrypt_data(&encrypted, key)?;
let messages: Vec<Message> = serde_json::from_slice(&decrypted)?;
Ok(messages)
}
fn prompt_new_passphrase() -> Result<String> {
loop {
print!("Enter passphrase: ");
io::stdout().flush()?;
let passphrase = rpassword::read_password()?;
if passphrase.len() < 8 {
println!("{}", "Passphrase must be at least 8 characters.".red());
continue;
}
print!("Confirm passphrase: ");
io::stdout().flush()?;
let confirm = rpassword::read_password()?;
if passphrase != confirm {
println!("{}", "Passphrases do not match.".red());
continue;
}
return Ok(passphrase);
}
}
fn prompt_passphrase() -> Result<String> {
print!("Passphrase: ");
io::stdout().flush()?;
let passphrase = rpassword::read_password()?;
Ok(passphrase)
}
fn format_bytes(bytes: i64) -> String {
const KB: i64 = 1024;
const MB: i64 = KB * 1024;
const GB: i64 = MB * 1024;
if bytes >= GB {
format!("{:.1} GB", bytes as f64 / GB as f64)
} else if bytes >= MB {
format!("{:.1} MB", bytes as f64 / MB as f64)
} else if bytes >= KB {
format!("{:.1} KB", bytes as f64 / KB as f64)
} else {
format!("{} bytes", bytes)
}
}
fn is_size_error(error_msg: &str) -> bool {
error_msg.contains("413")
|| error_msg.to_lowercase().contains("too large")
|| error_msg.to_lowercase().contains("payload")
}
fn is_quota_error(error_msg: &str) -> bool {
error_msg.contains("Would exceed session limit")
|| error_msg.contains("quota")
|| (error_msg.contains("403") && error_msg.contains("limit"))
}
struct QuotaInfo {
current: i64,
limit: i64,
plan: String,
}
fn parse_quota_info(error_msg: &str) -> Option<QuotaInfo> {
if let Some(start) = error_msg.find('{') {
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&error_msg[start..]) {
if let Some(details) = json.get("details") {
return Some(QuotaInfo {
current: details.get("current").and_then(|v| v.as_i64()).unwrap_or(0),
limit: details.get("limit").and_then(|v| v.as_i64()).unwrap_or(0),
plan: details
.get("plan")
.and_then(|v| v.as_str())
.unwrap_or("free")
.to_string(),
});
}
}
}
None
}
fn format_relative_time(time: chrono::DateTime<chrono::Utc>) -> String {
let now = Utc::now();
let duration = now.signed_duration_since(time);
let hours = duration.num_hours();
if hours < 1 {
let minutes = duration.num_minutes();
if minutes < 1 {
"just now".to_string()
} else {
format!("{} minutes ago", minutes)
}
} else if hours < 24 {
format!("{} hours ago", hours)
} else {
let days = duration.num_days();
format!("{} days ago", days)
}
}
fn format_future_relative_time(time: chrono::DateTime<chrono::Utc>) -> String {
let now = Utc::now();
let duration = time.signed_duration_since(now);
if duration.num_seconds() <= 0 {
return "now".to_string();
}
let hours = duration.num_hours();
let minutes = duration.num_minutes();
if hours >= 24 {
let days = duration.num_days();
if days == 1 {
"in 1 day".to_string()
} else {
format!("in {} days", days)
}
} else if hours >= 1 {
let remaining_minutes = minutes % 60;
if remaining_minutes > 0 {
if hours == 1 {
format!("in 1 hour {} minutes", remaining_minutes)
} else {
format!("in {} hours {} minutes", hours, remaining_minutes)
}
} else if hours == 1 {
"in 1 hour".to_string()
} else {
format!("in {} hours", hours)
}
} else if minutes == 1 {
"in 1 minute".to_string()
} else {
format!("in {} minutes", minutes)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_format_bytes() {
assert_eq!(format_bytes(0), "0 bytes");
assert_eq!(format_bytes(512), "512 bytes");
assert_eq!(format_bytes(1024), "1.0 KB");
assert_eq!(format_bytes(1024 * 1024), "1.0 MB");
assert_eq!(format_bytes(1024 * 1024 * 1024), "1.0 GB");
}
#[test]
fn test_is_size_error() {
assert!(is_size_error("HTTP error: 413 Payload Too Large"));
assert!(is_size_error("Server returned 413"));
assert!(is_size_error("Request body too large"));
assert!(is_size_error("Session Too Large to upload"));
assert!(is_size_error("Payload size exceeded"));
assert!(is_size_error("Request payload too big"));
assert!(!is_size_error("Connection refused"));
assert!(!is_size_error("HTTP error: 500 Internal Server Error"));
assert!(!is_size_error("Authentication failed"));
assert!(!is_size_error("Network timeout"));
}
#[test]
fn test_is_quota_error() {
assert!(is_quota_error(
"Server error (403): {\"error\":\"Would exceed session limit\"}"
));
assert!(is_quota_error("quota exceeded"));
assert!(is_quota_error("User quota limit reached"));
assert!(is_quota_error("403 limit reached"));
assert!(is_quota_error("Server returned 403: limit exceeded"));
assert!(!is_quota_error("Connection refused"));
assert!(!is_quota_error("500 Internal Server Error"));
assert!(!is_quota_error("HTTP error: 403 Forbidden")); assert!(!is_quota_error("Session limit")); }
#[test]
fn test_parse_quota_info() {
let error_msg = "Server error (403): {\"error\":\"Would exceed session limit\",\"details\":{\"current\":48,\"limit\":50,\"requested\":3,\"available\":2,\"plan\":\"free\"}}";
let quota = parse_quota_info(error_msg).expect("Should parse quota info");
assert_eq!(quota.current, 48);
assert_eq!(quota.limit, 50);
assert_eq!(quota.plan, "free");
}
#[test]
fn test_parse_quota_info_pro_plan() {
let error_msg = "{\"error\":\"Would exceed session limit\",\"details\":{\"current\":999,\"limit\":1000,\"requested\":5,\"available\":1,\"plan\":\"pro\"}}";
let quota = parse_quota_info(error_msg).expect("Should parse quota info");
assert_eq!(quota.current, 999);
assert_eq!(quota.limit, 1000);
assert_eq!(quota.plan, "pro");
}
#[test]
fn test_parse_quota_info_missing() {
assert!(parse_quota_info("Some random error").is_none());
assert!(parse_quota_info("403 Forbidden").is_none());
assert!(parse_quota_info("{\"error\":\"Something went wrong\"}").is_none());
}
#[test]
fn test_parse_quota_info_partial_details() {
let error_msg = "{\"error\":\"limit\",\"details\":{\"limit\":100}}";
let quota = parse_quota_info(error_msg).expect("Should parse with defaults");
assert_eq!(quota.current, 0); assert_eq!(quota.limit, 100);
assert_eq!(quota.plan, "free"); }
#[test]
fn test_encrypt_decrypt_roundtrip() {
use crate::cloud::encryption::generate_salt;
use crate::storage::models::{MessageContent, MessageRole};
use chrono::Utc;
use uuid::Uuid;
let salt = generate_salt();
let key = derive_key("test passphrase", &salt).unwrap();
let messages = vec![Message {
id: Uuid::new_v4(),
session_id: Uuid::new_v4(),
parent_id: None,
index: 0,
timestamp: Utc::now(),
role: MessageRole::User,
content: MessageContent::Text("Hello, world!".to_string()),
model: None,
git_branch: None,
cwd: None,
}];
let encrypted = encrypt_session_messages(&messages, &key).unwrap();
let decrypted = decrypt_session_messages(&encrypted, &key).unwrap();
assert_eq!(decrypted.len(), 1);
assert_eq!(decrypted[0].content.text(), "Hello, world!");
}
#[test]
fn test_format_future_relative_time_minutes() {
let now = Utc::now();
let time = now + chrono::Duration::minutes(2);
let result = format_future_relative_time(time);
assert!(
result.contains("minute"),
"Expected 'minute' in result, got: {}",
result
);
let time = now + chrono::Duration::minutes(30);
let result = format_future_relative_time(time);
assert!(
result.starts_with("in 29 minutes") || result.starts_with("in 30 minutes"),
"Expected ~30 minutes, got: {}",
result
);
}
#[test]
fn test_format_future_relative_time_hours() {
let now = Utc::now();
let time = now + chrono::Duration::hours(2);
let result = format_future_relative_time(time);
assert!(
result.contains("hour"),
"Expected 'hour' in result, got: {}",
result
);
let time = now + chrono::Duration::hours(3) + chrono::Duration::minutes(42);
let result = format_future_relative_time(time);
assert!(
result.starts_with("in 3 hours"),
"Expected 'in 3 hours...', got: {}",
result
);
}
#[test]
fn test_format_future_relative_time_days() {
let now = Utc::now();
let time = now + chrono::Duration::days(2);
let result = format_future_relative_time(time);
assert!(
result.contains("day"),
"Expected 'day' in result, got: {}",
result
);
}
#[test]
fn test_format_future_relative_time_past() {
let now = Utc::now();
let time = now - chrono::Duration::minutes(5);
assert_eq!(format_future_relative_time(time), "now");
}
}