rusticity-term 0.1.6

Terminal UI components for Rusticity
Documentation
pub mod actions;

use crate::common::translate_column;
use crate::common::{format_bytes, ColumnId, UTC_TIMESTAMP_WIDTH};
use crate::ui::table::Column as TableColumn;
use ratatui::prelude::*;
use std::collections::HashMap;

pub fn init(i18n: &mut HashMap<String, String>) {
    for col in [
        BucketColumn::Name,
        BucketColumn::Region,
        BucketColumn::CreationDate,
    ] {
        i18n.entry(col.id().to_string())
            .or_insert_with(|| col.default_name().to_string());
    }

    for col in [
        ObjectColumn::Key,
        ObjectColumn::Size,
        ObjectColumn::LastModified,
        ObjectColumn::StorageClass,
    ] {
        i18n.entry(col.id().to_string())
            .or_insert_with(|| col.default_name().to_string());
    }
}

pub fn console_url_buckets(region: &str) -> String {
    format!(
        "https://{}.console.aws.amazon.com/s3/buckets?region={}",
        region, region
    )
}

pub fn console_url_bucket(region: &str, bucket: &str, prefix: &str) -> String {
    if prefix.is_empty() {
        format!(
            "https://s3.console.aws.amazon.com/s3/buckets/{}?region={}",
            bucket, region
        )
    } else {
        format!(
            "https://s3.console.aws.amazon.com/s3/buckets/{}?region={}&prefix={}",
            bucket,
            region,
            urlencoding::encode(prefix)
        )
    }
}

#[derive(Debug, Clone)]
pub struct Bucket {
    pub name: String,
    pub region: String,
    pub creation_date: String,
}

#[derive(Debug, Clone)]
pub struct Object {
    pub key: String,
    pub size: i64,
    pub last_modified: String,
    pub is_prefix: bool,
    pub storage_class: String,
}

#[derive(Debug, Clone, Copy, PartialEq)]
pub enum BucketColumn {
    Name,
    Region,
    CreationDate,
}

impl BucketColumn {
    const ID_NAME: &'static str = "column.s3.bucket.name";
    const ID_REGION: &'static str = "column.s3.bucket.region";
    const ID_CREATION_DATE: &'static str = "column.s3.bucket.creation_date";

    pub const fn id(&self) -> &'static str {
        match self {
            BucketColumn::Name => Self::ID_NAME,
            BucketColumn::Region => Self::ID_REGION,
            BucketColumn::CreationDate => Self::ID_CREATION_DATE,
        }
    }

    pub const fn default_name(&self) -> &'static str {
        match self {
            BucketColumn::Name => "Name",
            BucketColumn::Region => "Region",
            BucketColumn::CreationDate => "Creation date",
        }
    }

    pub const fn all() -> [BucketColumn; 3] {
        [
            BucketColumn::Name,
            BucketColumn::Region,
            BucketColumn::CreationDate,
        ]
    }

    pub fn ids() -> Vec<ColumnId> {
        Self::all().iter().map(|c| c.id()).collect()
    }

    pub fn from_id(id: &str) -> Option<Self> {
        match id {
            Self::ID_NAME => Some(BucketColumn::Name),
            Self::ID_REGION => Some(BucketColumn::Region),
            Self::ID_CREATION_DATE => Some(BucketColumn::CreationDate),
            _ => None,
        }
    }

    pub fn name(&self) -> String {
        translate_column(self.id(), self.default_name())
    }
}

impl TableColumn<Bucket> for BucketColumn {
    fn name(&self) -> &str {
        Box::leak(translate_column(self.id(), self.default_name()).into_boxed_str())
    }

    fn width(&self) -> u16 {
        let translated = translate_column(self.id(), self.default_name());
        translated.len().max(match self {
            BucketColumn::Name => 30,
            BucketColumn::Region => 15,
            BucketColumn::CreationDate => UTC_TIMESTAMP_WIDTH as usize,
        }) as u16
    }

    fn render(&self, item: &Bucket) -> (String, Style) {
        let text = match self {
            BucketColumn::Name => item.name.clone(),
            BucketColumn::Region => {
                if item.region.is_empty() {
                    "?".to_string()
                } else {
                    item.region.clone()
                }
            }
            BucketColumn::CreationDate => item.creation_date.clone(),
        };
        (text, Style::default())
    }
}

#[derive(Debug, Clone, Copy, PartialEq)]
pub enum ObjectColumn {
    Key,
    Type,
    LastModified,
    Size,
    StorageClass,
}

impl ObjectColumn {
    const ID_KEY: &'static str = "column.s3.object.key";
    const ID_TYPE: &'static str = "column.s3.object.type";
    const ID_LAST_MODIFIED: &'static str = "column.s3.object.last_modified";
    const ID_SIZE: &'static str = "column.s3.object.size";
    const ID_STORAGE_CLASS: &'static str = "column.s3.object.storage_class";

    pub const fn id(&self) -> &'static str {
        match self {
            ObjectColumn::Key => Self::ID_KEY,
            ObjectColumn::Type => Self::ID_TYPE,
            ObjectColumn::LastModified => Self::ID_LAST_MODIFIED,
            ObjectColumn::Size => Self::ID_SIZE,
            ObjectColumn::StorageClass => Self::ID_STORAGE_CLASS,
        }
    }

    pub const fn default_name(&self) -> &'static str {
        match self {
            ObjectColumn::Key => "Name",
            ObjectColumn::Type => "Type",
            ObjectColumn::LastModified => "Last modified",
            ObjectColumn::Size => "Size",
            ObjectColumn::StorageClass => "Storage class",
        }
    }

    pub const fn all() -> [ObjectColumn; 5] {
        [
            ObjectColumn::Key,
            ObjectColumn::Type,
            ObjectColumn::LastModified,
            ObjectColumn::Size,
            ObjectColumn::StorageClass,
        ]
    }

    pub fn from_id(id: &str) -> Option<Self> {
        match id {
            Self::ID_KEY => Some(ObjectColumn::Key),
            Self::ID_TYPE => Some(ObjectColumn::Type),
            Self::ID_LAST_MODIFIED => Some(ObjectColumn::LastModified),
            Self::ID_SIZE => Some(ObjectColumn::Size),
            Self::ID_STORAGE_CLASS => Some(ObjectColumn::StorageClass),
            _ => None,
        }
    }

    pub fn name(&self) -> String {
        translate_column(self.id(), self.default_name())
    }
}

impl TableColumn<Object> for ObjectColumn {
    fn name(&self) -> &str {
        Box::leak(translate_column(self.id(), self.default_name()).into_boxed_str())
    }

    fn width(&self) -> u16 {
        let translated = translate_column(self.id(), self.default_name());
        translated.len().max(match self {
            ObjectColumn::Key => 40,
            ObjectColumn::Type => 10,
            ObjectColumn::LastModified => UTC_TIMESTAMP_WIDTH as usize,
            ObjectColumn::Size => 15,
            ObjectColumn::StorageClass => 15,
        }) as u16
    }

    fn render(&self, item: &Object) -> (String, Style) {
        let text = match self {
            ObjectColumn::Key => {
                let icon = if item.is_prefix { "📁" } else { "📄" };
                format!("{} {}", icon, item.key)
            }
            ObjectColumn::Type => {
                if item.is_prefix {
                    "Folder".to_string()
                } else {
                    "File".to_string()
                }
            }
            ObjectColumn::LastModified => {
                if item.last_modified.is_empty() {
                    String::new()
                } else {
                    format!(
                        "{} (UTC)",
                        item.last_modified
                            .split('T')
                            .next()
                            .unwrap_or(&item.last_modified)
                    )
                }
            }
            ObjectColumn::Size => {
                if item.is_prefix {
                    String::new()
                } else {
                    format_bytes(item.size)
                }
            }
            ObjectColumn::StorageClass => {
                if item.storage_class.is_empty() {
                    String::new()
                } else {
                    item.storage_class
                        .chars()
                        .next()
                        .unwrap()
                        .to_uppercase()
                        .to_string()
                        + &item.storage_class[1..].to_lowercase()
                }
            }
        };
        (text, Style::default())
    }
}

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

    #[test]
    fn test_bucket_column_ids_have_correct_prefix() {
        for col in BucketColumn::all() {
            assert!(
                col.id().starts_with("column.s3.bucket."),
                "BucketColumn ID '{}' should start with 'column.s3.bucket.'",
                col.id()
            );
        }
    }

    #[test]
    fn test_bucket_region_column_shows_question_mark_when_unknown() {
        use crate::ui::table::Column;
        let bucket = Bucket {
            name: "my-bucket".to_string(),
            region: String::new(), // unknown — not yet fetched
            creation_date: String::new(),
        };
        let (value, _) = BucketColumn::Region.render(&bucket);
        assert_eq!(
            value, "?",
            "Unknown region must render as '?' not empty string"
        );
    }

    #[test]
    fn test_bucket_region_column_shows_region_when_known() {
        use crate::ui::table::Column;
        let bucket = Bucket {
            name: "my-bucket".to_string(),
            region: "us-east-1".to_string(),
            creation_date: String::new(),
        };
        let (value, _) = BucketColumn::Region.render(&bucket);
        assert_eq!(value, "us-east-1");
    }

    #[test]
    fn test_object_column_ids_have_correct_prefix() {
        for col in ObjectColumn::all() {
            assert!(
                col.id().starts_with("column.s3.object."),
                "ObjectColumn ID '{}' should start with 'column.s3.object.'",
                col.id()
            );
        }
    }
}