s3cli 0.1.1

CLI-first S3 storage for developers and AI agents
Documentation
use std::io::{self, Read};
use std::path::Path;
use std::sync::Arc;

use crate::config::Config;
use crate::models::FileMetadata;
use crate::storage::Storage;
use super::CmdResult;

pub async fn push(
    config: &Config,
    file: Option<String>,
    public: bool,
    expires: Option<String>,
    content_type: Option<String>,
) -> CmdResult<String> {
    let storage = get_storage(config).await?;

    let data: Vec<u8> = match file.as_deref() {
        Some("-") | None => {
            let mut buffer = Vec::new();
            io::stdin().read_to_end(&mut buffer)?;
            buffer
        }
        Some(path) => {
            std::fs::read(path)?
        }
    };

    let key = if let Some(ref f) = file {
        if f == "-" {
            "stdin"
        } else {
            Path::new(f)
                .file_name()
                .and_then(|n| n.to_str())
                .unwrap_or("unknown")
        }
    } else {
        "stdin"
    };

    let mut metadata = FileMetadata {
        original_name: key.to_string(),
        content_type: content_type.or_else(|| guess_content_type(key)),
        is_public: public,
        ..Default::default()
    };

    if let Some(ref exp) = expires {
        metadata.expires_after = Some(parse_duration(exp)?);
    }

    let data = io::Cursor::new(data);
    let reader = Box::new(data) as Box<dyn Read + Send + Sync>;

    let entry = storage.put(key, reader, &metadata).await?;

    println!("{}", entry.key);
    Ok(entry.key)
}

async fn get_storage(config: &Config) -> CmdResult<Arc<dyn Storage>> {
    use crate::storage::S3Storage;
    
    let storage = S3Storage::new(config).await?;
    Ok(Arc::new(storage))
}

fn guess_content_type(path: &str) -> Option<String> {
    let ext = Path::new(path)
        .extension()
        .and_then(|e| e.to_str())?;

    Some(match ext.to_lowercase().as_str() {
        "html" | "htm" => "text/html",
        "css" => "text/css",
        "js" => "application/javascript",
        "json" => "application/json",
        "xml" => "application/xml",
        "txt" => "text/plain",
        "md" => "text/markdown",
        "png" => "image/png",
        "jpg" | "jpeg" => "image/jpeg",
        "gif" => "image/gif",
        "svg" => "image/svg+xml",
        "pdf" => "application/pdf",
        "zip" => "application/zip",
        "tar" => "application/x-tar",
        "gz" => "application/gzip",
        "mp4" => "video/mp4",
        "mp3" => "audio/mpeg",
        "wav" => "audio/wav",
        "ogg" => "audio/ogg",
        _ => "application/octet-stream",
    }.to_string())
}

fn parse_duration(s: &str) -> CmdResult<std::time::Duration> {
    let s = s.trim();
    
    if s.is_empty() {
        return Err(super::CommandError::InvalidInput("Empty duration".to_string()));
    }

    let (num_str, unit) = s.split_at(s.len() - 1);
    let num: u64 = num_str.parse()
        .map_err(|_| super::CommandError::InvalidInput(format!("Invalid duration: {}", s)))?;

    let secs = match unit {
        "s" => num,
        "m" => num * 60,
        "h" => num * 3600,
        "d" => num * 86400,
        "w" => num * 604800,
        _ => return Err(super::CommandError::InvalidInput(format!("Unknown unit: {}", unit))),
    };

    Ok(std::time::Duration::from_secs(secs))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_parse_duration() {
        assert_eq!(parse_duration("30s").unwrap().as_secs(), 30);
        assert_eq!(parse_duration("5m").unwrap().as_secs(), 300);
        assert_eq!(parse_duration("2h").unwrap().as_secs(), 7200);
        assert_eq!(parse_duration("1d").unwrap().as_secs(), 86400);
        assert_eq!(parse_duration("1w").unwrap().as_secs(), 604800);
    }

    #[test]
    fn test_parse_duration_invalid() {
        assert!(parse_duration("").is_err());
        assert!(parse_duration("xyz").is_err());
        assert!(parse_duration("10x").is_err());
    }

    #[test]
    fn test_guess_content_type() {
        assert_eq!(guess_content_type("test.png"), Some("image/png".to_string()));
        assert_eq!(guess_content_type("test.JPEG"), Some("image/jpeg".to_string()));
        assert_eq!(guess_content_type("test.mp4"), Some("video/mp4".to_string()));
        assert_eq!(guess_content_type("test.unknown"), Some("application/octet-stream".to_string()));
    }
}