use crate::common::status::PathInfo; use crate::compression_engine::CompressionType; /// Common utilities shared across different modes in the Keep application. /// /// This module provides helper functions for formatting, configuration parsing, /// table creation, and environment variable handling used by various CLI modes. /// /// # Usage /// /// These utilities are typically used internally by mode implementations: /// /// ``` /// # use keep::modes::common::{format_size, OutputFormat}; /// let formatted = format_size(1024, true); // "1.0K" /// // let format = OutputFormat::from_str("json")?; /// ``` use crate::config; use crate::meta_plugin::MetaPluginType; use anyhow::{Result, anyhow}; use chrono::{DateTime, Utc}; use clap::Command; use clap::error::ErrorKind; use comfy_table::{Attribute, Cell, ContentArrangement, Table}; use log::debug; use regex::Regex; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::env; use std::io::IsTerminal; use std::str::FromStr; use strum::IntoEnumIterator; #[derive(Debug, Clone, strum::EnumString, strum::Display, PartialEq)] #[strum(ascii_case_insensitive)] /// Enum representing supported output formats for structured data. /// /// Used to determine how to display lists, info, and status information in CLI modes. /// Defaults to Table for human-readable output; JSON/YAML for machine parsing. /// /// # Variants /// /// * `Table` - Formatted table output (default). /// * `Json` - JSON structured output. /// * `Yaml` - YAML structured output. /// /// # Examples /// /// ``` /// # use keep::modes::common::OutputFormat; /// # use std::str::FromStr; /// assert_eq!(OutputFormat::from_str("json").unwrap(), OutputFormat::Json); /// ``` pub enum OutputFormat { Table, Json, Yaml, } static KEEP_META_RE: std::sync::LazyLock = std::sync::LazyLock::new(|| Regex::new(r"^KEEP_META_(.+)$").unwrap()); pub const IMPORT_FORMAT_ERROR: &str = "Unsupported import format: {} (expected .keep.tar or .meta.yml)"; pub fn get_meta_from_env() -> HashMap { debug!("COMMON: Getting meta from KEEP_META_*"); let mut meta_env: HashMap = HashMap::new(); for (key, value) in env::vars() { if let Some(meta_name_caps) = KEEP_META_RE.captures(key.as_str()) { let name = meta_name_caps.get(1).map(|m| m.as_str().to_string()); if let Some(name) = name { if name != "PLUGINS" { debug!("COMMON: Found meta: {}={}", name, value); meta_env.insert(name, value); } } } } meta_env } /// Formats a file size in bytes to human-readable or raw format. /// /// Uses the humansize crate for human-readable output with decimal units (KB, MB, etc.). /// /// # Arguments /// /// * `size` - Size in bytes as u64. /// * `human_readable` - If true, use units like KB, MB; otherwise, raw bytes as string. /// /// # Returns /// /// `String` - Formatted size string, e.g., "1.0K" or "1024". /// /// # Examples /// /// ``` /// # use keep::modes::common::format_size; /// let raw = format_size(1024, false); // "1024" /// let human = format_size(1024, true); // "1.0K" /// ``` pub fn format_size(size: u64, human_readable: bool) -> String { match human_readable { true => humansize::format_size(size, humansize::DECIMAL), false => size.to_string(), } } #[derive(Debug, Eq, PartialEq, Clone, strum::EnumIter, strum::Display)] #[strum(ascii_case_insensitive)] /// Enum representing column types for table display. /// /// Defines standard and meta columns for list/info modes. Supports "meta:" for specific metadata columns. /// /// # Variants /// /// * `Id` - Item ID column. /// * `Time` - Timestamp column. /// * `Size` - Content size column. /// * `Compression` - Compression type column. /// * `FileSize` - On-disk file size column. /// * `FilePath` - File path column. /// * `Tags` - Tags column. /// * `Meta` - Metadata column (with sub-type via string parsing). /// /// # Examples /// /// ``` /// # use keep::modes::common::ColumnType; /// # use std::str::FromStr; /// assert_eq!(ColumnType::from_str("id").unwrap(), ColumnType::Id); /// assert_eq!(ColumnType::from_str("meta:hostname").unwrap(), ColumnType::Meta); /// ``` pub enum ColumnType { Id, Time, Size, Compression, FileSize, FilePath, Tags, Meta, } impl std::str::FromStr for ColumnType { type Err = anyhow::Error; fn from_str(s: &str) -> anyhow::Result { let lower_s = s.to_lowercase(); if s.starts_with("meta:") { Ok(ColumnType::Meta) } else { for variant in ColumnType::iter() { if variant.to_string().to_lowercase() == lower_s { return Ok(variant); } } Err(anyhow::anyhow!("Invalid column type: {}", s)) } } } /// Extracts configured meta plugin types from settings and command. /// /// Handles comma-separated plugin names and validates against registered types. /// /// # Arguments /// /// * `cmd` - Mutable Clap command for error reporting. /// * `settings` - Application settings with plugin config. /// /// # Returns /// /// `Vec` - List of enabled plugin types. /// /// # Panics /// /// Exits via Clap error if unknown plugin type specified. pub fn settings_meta_plugin_types( cmd: &mut Command, settings: &config::Settings, ) -> Vec { let mut meta_plugin_types = Vec::new(); // Handle comma-separated values in each meta_plugins argument for meta_plugin_names_str in &settings.meta_plugins_names() { let meta_plugin_names: Vec<&str> = meta_plugin_names_str.split(',').collect(); for name in meta_plugin_names { let trimmed_name = name.trim(); if trimmed_name.is_empty() { continue; } // Try to find the MetaPluginType by meta name let mut found = false; for meta_plugin_type in MetaPluginType::iter() { if let Ok(meta_plugin) = crate::meta_plugin::get_meta_plugin(meta_plugin_type.clone(), None, None) && meta_plugin.meta_type().to_string() == trimmed_name { meta_plugin_types.push(meta_plugin_type); found = true; break; } } if !found { cmd.error( ErrorKind::InvalidValue, format!("Unknown meta plugin type: {trimmed_name}"), ) .exit(); } } } meta_plugin_types } /// Determines compression type from settings and command arguments. /// /// Validates the compression name and returns the corresponding enum variant. /// /// # Arguments /// /// * `cmd` - Mutable Clap command for error reporting. /// * `settings` - Application settings. /// /// # Returns /// /// `CompressionType` - The resolved compression type. /// /// # Panics /// /// Exits via Clap error if invalid compression specified. pub fn settings_compression_type( cmd: &mut Command, settings: &config::Settings, ) -> CompressionType { let compression_name = settings .compression() .unwrap_or(CompressionType::LZ4.to_string()); let compression_type_opt = CompressionType::from_str(&compression_name); if compression_type_opt.is_err() { cmd.error( ErrorKind::InvalidValue, format!("Invalid compression algorithm '{compression_name}'. Supported algorithms: lz4, gzip, xz, zstd"), ) .exit(); } compression_type_opt.unwrap() } /// Parses output format from settings. /// /// Defaults to `Table` if not specified or invalid. Uses case-insensitive string parsing. /// /// # Arguments /// /// * `settings` - Application settings with optional output_format field. /// /// # Returns /// /// `OutputFormat` - Parsed enum variant or Table as default. /// /// # Examples /// /// ``` /// # use keep::modes::common::{settings_output_format, OutputFormat}; /// // Example usage requires a Settings instance /// // let format = settings_output_format(&settings); /// ``` pub fn settings_output_format(settings: &config::Settings) -> OutputFormat { settings .output_format .as_ref() .and_then(|s| OutputFormat::from_str(s).ok()) .unwrap_or(OutputFormat::Table) } /// Trims trailing whitespace from each line in a multi-line string. /// /// Useful for cleaning up table output before printing. Preserves newlines but removes spaces/tabs at line ends. /// /// # Arguments /// /// * `s` - Input string with potential trailing whitespace, e.g., "line1 \nline2 ". /// /// # Returns /// /// `String` - Cleaned string with trimmed lines, e.g., "line1\nline2". /// /// # Examples /// /// ``` /// # use keep::modes::common::trim_lines_end; /// let cleaned = trim_lines_end("line1 \nline2 "); /// assert_eq!(cleaned, "line1\nline2"); /// ``` pub fn trim_lines_end(s: &str) -> String { s.lines() .map(|line| line.trim_end()) .collect::>() .join("\n") } /// Creates a new table with styling based on terminal detection. /// /// Loads appropriate preset (UTF8 or ASCII) if styling is enabled. /// /// # Arguments /// /// * `use_styling` - If true, apply visual styling. /// /// # Returns /// /// `Table` - Configured table instance. /// /// # Examples /// /// ``` /// # use keep::modes::common::create_table; /// let mut table = create_table(true); /// table.add_row(vec!["Header1", "Header2"]); /// ``` pub fn create_table(_use_styling: bool) -> Table { create_table_with_config(&crate::config::TableConfig::default()) } /// Creates a table configured from application table settings. /// /// Applies style presets, modifiers, content arrangement, and truncation indicators. /// /// # Arguments /// /// * `table_config` - Table configuration from settings. /// /// # Returns /// /// `Table` - Fully configured table. /// /// # Examples /// /// ``` /// # use keep::modes::common::create_table_with_config; /// # use keep::config::TableConfig; /// let config = TableConfig::default(); /// let table = create_table_with_config(&config); /// ``` pub fn create_table_with_config(table_config: &crate::config::TableConfig) -> Table { let mut table = Table::new(); // Set content arrangement match table_config.content_arrangement { crate::config::ContentArrangement::Dynamic => { table.set_content_arrangement(comfy_table::ContentArrangement::Dynamic) } crate::config::ContentArrangement::DynamicFullWidth => { table.set_content_arrangement(comfy_table::ContentArrangement::DynamicFullWidth) } crate::config::ContentArrangement::Disabled => { table.set_content_arrangement(comfy_table::ContentArrangement::Disabled) } }; // Set style preset match &table_config.style { crate::config::TableStyle::Ascii => { table.load_preset(comfy_table::presets::ASCII_FULL); } crate::config::TableStyle::Utf8 => { table.load_preset(comfy_table::presets::UTF8_FULL); } crate::config::TableStyle::Utf8Full => { table.load_preset(comfy_table::presets::UTF8_FULL); } crate::config::TableStyle::Nothing => { table.load_preset(comfy_table::presets::NOTHING); } crate::config::TableStyle::Custom(preset) => { // For custom presets, we'd need to parse the string // This is a placeholder for custom preset handling if preset == "ASCII_FULL" { table.load_preset(comfy_table::presets::ASCII_FULL); } else if preset == "UTF8_FULL" { table.load_preset(comfy_table::presets::UTF8_FULL); } else if preset == "NOTHING" { table.load_preset(comfy_table::presets::NOTHING); } // Add more presets as needed } }; // Apply modifiers for modifier in &table_config.modifiers { match modifier.as_str() { "UTF8_SOLID_INNER_BORDERS" => { table.apply_modifier(comfy_table::modifiers::UTF8_SOLID_INNER_BORDERS); } "UTF8_ROUND_CORNERS" => { table.apply_modifier(comfy_table::modifiers::UTF8_ROUND_CORNERS); } _ => {} // Ignore unknown modifiers } } // Set truncation indicator if specified if !table_config.truncation_indicator.is_empty() { table.set_truncation_indicator(&table_config.truncation_indicator); } if !std::io::stdout().is_terminal() { table.force_no_tty(); } table } /// Display data for a single item's detail view (used by --info). pub struct DisplayItemInfo { pub id: i64, pub timestamp: String, pub path: String, pub stream_size: String, pub compression: String, pub file_size: String, pub tags: Vec, pub metadata: Vec<(String, String)>, } /// Renders item detail table. Shared by local and client info modes. pub fn render_item_info_table(info: &DisplayItemInfo, table_config: &config::TableConfig) { use comfy_table::{Attribute, Cell}; let mut table = create_table_with_config(table_config); table.add_row(vec![ Cell::new("ID").add_attribute(Attribute::Bold), Cell::new(info.id.to_string()), ]); table.add_row(vec![ Cell::new("Time").add_attribute(Attribute::Bold), Cell::new(&info.timestamp), ]); table.add_row(vec![ Cell::new("Size").add_attribute(Attribute::Bold), Cell::new(&info.stream_size), ]); table.add_row(vec![ Cell::new("Compression").add_attribute(Attribute::Bold), Cell::new(&info.compression), ]); table.add_row(vec![ Cell::new("Tags").add_attribute(Attribute::Bold), Cell::new(info.tags.join(" ")), ]); for (key, value) in &info.metadata { table.add_row(vec![ Cell::new(format!("Meta: {key}")).add_attribute(Attribute::Bold), Cell::new(value), ]); } println!("{}", trim_lines_end(&table.trim_fmt())); } /// Renders list table with column format from config. Shared by local and client list modes. pub fn render_list_table_with_format( columns: &[config::ColumnConfig], rows: &[Vec], table_config: &config::TableConfig, ) { let mut table = create_table_with_config(table_config); let header_cells: Vec = columns .iter() .map(|col| Cell::new(&col.label).add_attribute(Attribute::Bold)) .collect(); table.set_header(header_cells); for row in rows { let cells: Vec = row .iter() .enumerate() .map(|(i, val)| { let mut cell = Cell::new(val); if let Some(col) = columns.get(i) { if let Some(ref fg) = col.fg_color { cell = apply_color(cell, fg, true); } if let Some(ref bg) = col.bg_color { cell = apply_color(cell, bg, false); } for attr in &col.attributes { cell = apply_table_attribute(cell, attr); } } cell }) .collect(); table.add_row(cells); } println!("{}", trim_lines_end(&table.trim_fmt())); } /// Applies config TableColor to a comfy-table Cell. pub fn apply_color(mut cell: Cell, color: &config::TableColor, is_foreground: bool) -> Cell { use comfy_table::Color; let comfy_color = match color { config::TableColor::Black => Color::Black, config::TableColor::Red => Color::Red, config::TableColor::Green => Color::Green, config::TableColor::Yellow => Color::Yellow, config::TableColor::Blue => Color::Blue, config::TableColor::Magenta => Color::Magenta, config::TableColor::Cyan => Color::Cyan, config::TableColor::White => Color::White, config::TableColor::Gray => Color::Grey, config::TableColor::DarkRed => Color::DarkRed, config::TableColor::DarkGreen => Color::DarkGreen, config::TableColor::DarkYellow => Color::DarkYellow, config::TableColor::DarkBlue => Color::DarkBlue, config::TableColor::DarkMagenta => Color::DarkMagenta, config::TableColor::DarkCyan => Color::DarkCyan, config::TableColor::Rgb(r, g, b) => Color::Rgb { r: *r, g: *g, b: *b, }, }; if is_foreground { cell = cell.fg(comfy_color); } else { cell = cell.bg(comfy_color); } cell } /// Ensures tags has at least one entry, adding "none" if empty. pub fn ensure_default_tag(tags: &mut Vec) { if tags.is_empty() { tags.push("none".to_string()); } } /// Prints a serializable value in JSON or YAML format based on output format. /// /// Only handles Json and Yaml variants; Table should be handled separately. pub fn print_serialized( value: &T, format: &OutputFormat, ) -> anyhow::Result<()> { match format { OutputFormat::Json => println!("{}", serde_json::to_string_pretty(value)?), OutputFormat::Yaml => println!("{}", serde_yaml::to_string(value)?), OutputFormat::Table => unreachable!(), } Ok(()) } /// Applies config TableAttribute to a comfy-table Cell. pub fn apply_table_attribute(mut cell: Cell, attribute: &config::TableAttribute) -> Cell { match attribute { config::TableAttribute::Bold => cell = cell.add_attribute(Attribute::Bold), config::TableAttribute::Dim => cell = cell.add_attribute(Attribute::Dim), config::TableAttribute::Italic => cell = cell.add_attribute(Attribute::Italic), config::TableAttribute::Underlined => cell = cell.add_attribute(Attribute::Underlined), config::TableAttribute::SlowBlink => cell = cell.add_attribute(Attribute::SlowBlink), config::TableAttribute::RapidBlink => cell = cell.add_attribute(Attribute::RapidBlink), config::TableAttribute::Reverse => cell = cell.add_attribute(Attribute::Reverse), config::TableAttribute::Hidden => cell = cell.add_attribute(Attribute::Hidden), config::TableAttribute::CrossedOut => cell = cell.add_attribute(Attribute::CrossedOut), } cell } /// Builds a table showing data and database path information. pub fn build_path_table(path_info: &PathInfo, table_config: &config::TableConfig) -> Table { let mut path_table = create_table_with_config(table_config); path_table.set_header(vec![ Cell::new("Type").add_attribute(Attribute::Bold), Cell::new("Path").add_attribute(Attribute::Bold), ]); path_table.add_row(vec!["Data", &path_info.data]); path_table.add_row(vec!["Database", &path_info.database]); path_table } /// Sanitize tags for use in filenames. /// /// Replaces non-alphanumeric characters with underscores and joins with `_`. /// Empty tags are filtered out to avoid double underscores. pub fn sanitize_tags(tags: &[String]) -> String { tags.iter() .filter(|t| !t.is_empty()) .map(|t| { t.chars() .map(|c| if c.is_alphanumeric() { c } else { '_' }) .collect::() }) .collect::>() .join("_") } /// Metadata structure for export to YAML. Shared by local and client export modes. #[derive(Debug, Serialize)] pub struct ExportMeta { pub ts: DateTime, pub compression: String, pub uncompressed_size: Option, pub tags: Vec, pub metadata: HashMap, } /// Metadata structure for import from YAML. Shared by local and client import modes. #[derive(Debug, Deserialize)] pub struct ImportMeta { pub ts: DateTime, pub compression: String, #[serde(default, alias = "size")] pub uncompressed_size: Option, #[serde(default)] pub tags: Vec, #[serde(default)] pub metadata: HashMap, } /// Resolve a single item ID from explicit IDs, tags, or latest item. /// /// Returns the first ID if provided, the newest item matching tags, /// or the newest item overall if neither is specified. pub fn resolve_item_id( client: &crate::client::KeepClient, ids: &[i64], tags: &[String], ) -> Result { if !ids.is_empty() { Ok(ids[0]) } else if !tags.is_empty() { let items = client.list_items(&[], tags, "newest", 0, 1, &HashMap::new())?; if items.is_empty() { return Err(anyhow!("No items found matching tags: {:?}", tags)); } Ok(items[0].id) } else { let items = client.list_items(&[], &[], "newest", 0, 1, &HashMap::new())?; if items.is_empty() { return Err(anyhow!("No items found")); } Ok(items[0].id) } } /// Resolve item IDs from explicit IDs or tags (multi-item variant). pub fn resolve_item_ids( client: &crate::client::KeepClient, ids: &[i64], tags: &[String], ) -> Result> { if !ids.is_empty() { Ok(ids.to_vec()) } else if !tags.is_empty() { let items = client.list_items(&[], tags, "newest", 0, 0, &HashMap::new())?; if items.is_empty() { return Err(anyhow!("No items found matching tags: {:?}", tags)); } Ok(items.into_iter().map(|i| i.id).collect()) } else { let items = client.list_items(&[], &[], "newest", 0, 1, &HashMap::new())?; if items.is_empty() { return Err(anyhow!("No items found")); } Ok(vec![items[0].id]) } } /// Check if binary content should be blocked from TTY output. /// /// Uses metadata `text` field as fast path, then falls back to byte sampling. /// Returns Err if content is binary and should not be displayed. pub fn check_binary_tty( metadata: &HashMap, data_sample: &[u8], force: bool, ) -> Result<()> { if force || !std::io::stdout().is_terminal() { return Ok(()); } if crate::common::is_binary::is_content_binary_from_metadata(metadata, data_sample) { return Err(anyhow!( "Refusing to output binary data to TTY, use --force to override" )); } Ok(()) }