refactor: SDK core modules — error, config, output, constants

Move foundational modules into sunbeam-sdk. All crate-internal references
remain unchanged since these are sibling modules within the SDK crate.
This commit is contained in:
2026-03-21 14:34:23 +00:00
parent 2ffedb95cb
commit b92700d363
5 changed files with 896 additions and 0 deletions

404
sunbeam-sdk/src/config.rs Normal file
View File

@@ -0,0 +1,404 @@
use crate::error::{Result, ResultExt, SunbeamError};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::OnceLock;
// ---------------------------------------------------------------------------
// Config data model
// ---------------------------------------------------------------------------
/// Sunbeam configuration stored at ~/.sunbeam.json.
///
/// Supports kubectl-style named contexts. Each context bundles a domain,
/// kube context, SSH host, and infrastructure directory.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SunbeamConfig {
/// The active context name. If empty, uses "default".
#[serde(default, rename = "current-context")]
pub current_context: String,
/// Named contexts.
#[serde(default)]
pub contexts: HashMap<String, Context>,
// --- Legacy fields (migrated on load) ---
#[serde(default, skip_serializing_if = "String::is_empty")]
pub production_host: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub infra_directory: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub acme_email: String,
}
/// A named context — everything needed to target a specific environment.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Context {
/// The domain suffix (e.g. "sunbeam.pt", "192.168.105.3.sslip.io").
#[serde(default)]
pub domain: String,
/// Kubernetes context name (e.g. "production", "sunbeam").
#[serde(default, rename = "kube-context")]
pub kube_context: String,
/// SSH host for production tunnel (e.g. "sienna@62.210.145.138").
#[serde(default, rename = "ssh-host")]
pub ssh_host: String,
/// Infrastructure directory root.
#[serde(default, rename = "infra-dir")]
pub infra_dir: String,
/// ACME email for cert-manager.
#[serde(default, rename = "acme-email")]
pub acme_email: String,
}
// ---------------------------------------------------------------------------
// Active context (set once at startup, read everywhere)
// ---------------------------------------------------------------------------
static ACTIVE_CONTEXT: OnceLock<Context> = OnceLock::new();
/// Initialize the active context. Called once from cli::dispatch().
pub fn set_active_context(ctx: Context) {
let _ = ACTIVE_CONTEXT.set(ctx);
}
/// Get the active context. Panics if not initialized (should never happen
/// after dispatch starts).
pub fn active_context() -> &'static Context {
ACTIVE_CONTEXT.get().expect("active context not initialized")
}
/// Get the domain from the active context. Returns empty string if not set.
pub fn domain() -> &'static str {
ACTIVE_CONTEXT
.get()
.map(|c| c.domain.as_str())
.unwrap_or("")
}
// ---------------------------------------------------------------------------
// Config file I/O
// ---------------------------------------------------------------------------
fn config_path() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".sunbeam.json")
}
/// Load configuration from ~/.sunbeam.json, return default if not found.
/// Migrates legacy flat config to context-based format.
pub fn load_config() -> SunbeamConfig {
let path = config_path();
if !path.exists() {
return SunbeamConfig::default();
}
let mut config: SunbeamConfig = match std::fs::read_to_string(&path) {
Ok(content) => serde_json::from_str(&content).unwrap_or_else(|e| {
crate::output::warn(&format!(
"Failed to parse config from {}: {e}",
path.display()
));
SunbeamConfig::default()
}),
Err(e) => {
crate::output::warn(&format!(
"Failed to read config from {}: {e}",
path.display()
));
SunbeamConfig::default()
}
};
// Migrate legacy flat fields into a "production" context
if !config.production_host.is_empty() && !config.contexts.contains_key("production") {
let domain = derive_domain_from_host(&config.production_host);
config.contexts.insert(
"production".to_string(),
Context {
domain,
kube_context: "production".to_string(),
ssh_host: config.production_host.clone(),
infra_dir: config.infra_directory.clone(),
acme_email: config.acme_email.clone(),
},
);
if config.current_context.is_empty() {
config.current_context = "production".to_string();
}
}
config
}
/// Save configuration to ~/.sunbeam.json.
pub fn save_config(config: &SunbeamConfig) -> Result<()> {
let path = config_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).with_ctx(|| {
format!("Failed to create config directory: {}", parent.display())
})?;
}
let content = serde_json::to_string_pretty(config)?;
std::fs::write(&path, content)
.with_ctx(|| format!("Failed to save config to {}", path.display()))?;
crate::output::ok(&format!("Configuration saved to {}", path.display()));
Ok(())
}
/// Resolve the context to use, given CLI flags and config.
///
/// Priority (same as kubectl):
/// 1. `--context` flag (explicit context name)
/// 2. `current-context` from config
/// 3. Default to "local"
pub fn resolve_context(
config: &SunbeamConfig,
_env_flag: &str,
context_override: Option<&str>,
domain_override: &str,
) -> Context {
let context_name = if let Some(explicit) = context_override {
explicit.to_string()
} else if !config.current_context.is_empty() {
config.current_context.clone()
} else {
"local".to_string()
};
let mut ctx = config
.contexts
.get(&context_name)
.cloned()
.unwrap_or_else(|| {
// Synthesize defaults for well-known names
match context_name.as_str() {
"local" => Context {
kube_context: "sunbeam".to_string(),
..Default::default()
},
"production" => Context {
kube_context: "production".to_string(),
ssh_host: config.production_host.clone(),
infra_dir: config.infra_directory.clone(),
acme_email: config.acme_email.clone(),
domain: derive_domain_from_host(&config.production_host),
..Default::default()
},
_ => Default::default(),
}
});
// CLI flags override context values
if !domain_override.is_empty() {
ctx.domain = domain_override.to_string();
}
ctx
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// Derive a domain from an SSH host (e.g. "user@admin.sunbeam.pt" → "sunbeam.pt").
fn derive_domain_from_host(host: &str) -> String {
let raw = host.split('@').last().unwrap_or(host);
let raw = raw.split(':').next().unwrap_or(raw);
let parts: Vec<&str> = raw.split('.').collect();
if parts.len() >= 2 {
format!("{}.{}", parts[parts.len() - 2], parts[parts.len() - 1])
} else {
String::new()
}
}
/// Get production host from config or SUNBEAM_SSH_HOST environment variable.
pub fn get_production_host() -> String {
let config = load_config();
// Check active context first
if let Some(ctx) = ACTIVE_CONTEXT.get() {
if !ctx.ssh_host.is_empty() {
return ctx.ssh_host.clone();
}
}
if !config.production_host.is_empty() {
return config.production_host;
}
std::env::var("SUNBEAM_SSH_HOST").unwrap_or_default()
}
/// Infrastructure manifests directory as a Path.
pub fn get_infra_dir() -> PathBuf {
// Check active context
if let Some(ctx) = ACTIVE_CONTEXT.get() {
if !ctx.infra_dir.is_empty() {
return PathBuf::from(&ctx.infra_dir);
}
}
let configured = load_config().infra_directory;
if !configured.is_empty() {
return PathBuf::from(configured);
}
// Dev fallback
std::env::current_exe()
.ok()
.and_then(|p| p.canonicalize().ok())
.and_then(|p| {
let mut dir = p.as_path();
for _ in 0..10 {
dir = dir.parent()?;
if dir.join("infrastructure").is_dir() {
return Some(dir.join("infrastructure"));
}
}
None
})
.unwrap_or_else(|| PathBuf::from("infrastructure"))
}
/// Monorepo root directory (parent of the infrastructure directory).
pub fn get_repo_root() -> PathBuf {
get_infra_dir()
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| PathBuf::from("."))
}
/// Clear configuration file.
pub fn clear_config() -> Result<()> {
let path = config_path();
if path.exists() {
std::fs::remove_file(&path)
.with_ctx(|| format!("Failed to remove {}", path.display()))?;
crate::output::ok(&format!("Configuration cleared from {}", path.display()));
} else {
crate::output::warn("No configuration file found to clear");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config() {
let config = SunbeamConfig::default();
assert!(config.current_context.is_empty());
assert!(config.contexts.is_empty());
}
#[test]
fn test_derive_domain_from_host() {
assert_eq!(derive_domain_from_host("sienna@admin.sunbeam.pt"), "sunbeam.pt");
assert_eq!(derive_domain_from_host("user@62.210.145.138"), "145.138");
assert_eq!(derive_domain_from_host("sunbeam.pt"), "sunbeam.pt");
assert_eq!(derive_domain_from_host("localhost"), "");
}
#[test]
fn test_legacy_migration() {
let json = r#"{
"production_host": "sienna@62.210.145.138",
"infra_directory": "/path/to/infra",
"acme_email": "ops@sunbeam.pt"
}"#;
let config: SunbeamConfig = serde_json::from_str(json).unwrap();
// After load_config migration, contexts would be populated.
// Here we just test the struct deserializes legacy fields.
assert_eq!(config.production_host, "sienna@62.210.145.138");
assert!(config.contexts.is_empty()); // migration happens in load_config()
}
#[test]
fn test_context_roundtrip() {
let mut config = SunbeamConfig::default();
config.current_context = "production".to_string();
config.contexts.insert(
"production".to_string(),
Context {
domain: "sunbeam.pt".to_string(),
kube_context: "production".to_string(),
ssh_host: "sienna@server.sunbeam.pt".to_string(),
infra_dir: "/home/infra".to_string(),
acme_email: "ops@sunbeam.pt".to_string(),
},
);
let json = serde_json::to_string(&config).unwrap();
let loaded: SunbeamConfig = serde_json::from_str(&json).unwrap();
assert_eq!(loaded.current_context, "production");
let ctx = loaded.contexts.get("production").unwrap();
assert_eq!(ctx.domain, "sunbeam.pt");
assert_eq!(ctx.ssh_host, "sienna@server.sunbeam.pt");
}
#[test]
fn test_resolve_context_explicit_flag() {
let mut config = SunbeamConfig::default();
config.contexts.insert(
"production".to_string(),
Context {
domain: "sunbeam.pt".to_string(),
kube_context: "production".to_string(),
..Default::default()
},
);
// --context production explicitly selects the named context
let ctx = resolve_context(&config, "", Some("production"), "");
assert_eq!(ctx.domain, "sunbeam.pt");
assert_eq!(ctx.kube_context, "production");
}
#[test]
fn test_resolve_context_current_context() {
let mut config = SunbeamConfig::default();
config.current_context = "staging".to_string();
config.contexts.insert(
"staging".to_string(),
Context {
domain: "staging.example.com".to_string(),
..Default::default()
},
);
// No --context flag, uses current-context
let ctx = resolve_context(&config, "", None, "");
assert_eq!(ctx.domain, "staging.example.com");
}
#[test]
fn test_resolve_context_domain_override() {
let config = SunbeamConfig::default();
let ctx = resolve_context(&config, "", None, "custom.example.com");
assert_eq!(ctx.domain, "custom.example.com");
}
#[test]
fn test_resolve_context_defaults_local() {
let config = SunbeamConfig::default();
// No current-context, no --context flag → defaults to "local"
let ctx = resolve_context(&config, "", None, "");
assert_eq!(ctx.kube_context, "sunbeam");
}
#[test]
fn test_resolve_context_flag_overrides_current() {
let mut config = SunbeamConfig::default();
config.current_context = "staging".to_string();
config.contexts.insert(
"staging".to_string(),
Context { domain: "staging.example.com".to_string(), ..Default::default() },
);
config.contexts.insert(
"prod".to_string(),
Context { domain: "prod.example.com".to_string(), ..Default::default() },
);
// --context prod overrides current-context "staging"
let ctx = resolve_context(&config, "", Some("prod"), "");
assert_eq!(ctx.domain, "prod.example.com");
}
}

View File

@@ -0,0 +1,16 @@
//! Shared constants used across multiple modules.
pub const GITEA_ADMIN_USER: &str = "gitea_admin";
pub const MANAGED_NS: &[&str] = &[
"data",
"devtools",
"ingress",
"lasuite",
"matrix",
"media",
"monitoring",
"ory",
"storage",
"vault-secrets-operator",
];

365
sunbeam-sdk/src/error.rs Normal file
View File

@@ -0,0 +1,365 @@
//! Unified error tree for the sunbeam CLI.
//!
//! Every module returns `Result<T, SunbeamError>`. Errors bubble up to `main`,
//! which maps them to exit codes and log output.
/// Exit codes for the sunbeam CLI.
#[allow(dead_code)]
pub mod exit {
pub const SUCCESS: i32 = 0;
pub const GENERAL: i32 = 1;
pub const USAGE: i32 = 2;
pub const KUBE: i32 = 3;
pub const CONFIG: i32 = 4;
pub const NETWORK: i32 = 5;
pub const SECRETS: i32 = 6;
pub const BUILD: i32 = 7;
pub const IDENTITY: i32 = 8;
pub const EXTERNAL_TOOL: i32 = 9;
}
/// Top-level error type for the sunbeam CLI.
///
/// Each variant maps to a logical error category with its own exit code.
/// Leaf errors (io, json, yaml, kube, reqwest, etc.) are converted via `From` impls.
#[derive(Debug, thiserror::Error)]
pub enum SunbeamError {
/// Kubernetes API or cluster-related error.
#[error("{context}")]
Kube {
context: String,
#[source]
source: Option<kube::Error>,
},
/// Configuration error (missing config, invalid config, bad arguments).
#[error("{0}")]
Config(String),
/// Network/HTTP error.
#[error("{context}")]
Network {
context: String,
#[source]
source: Option<reqwest::Error>,
},
/// OpenBao / Vault error.
#[error("{0}")]
Secrets(String),
/// Image build error.
#[error("{0}")]
Build(String),
/// Identity / user management error (Kratos, Hydra).
#[error("{0}")]
Identity(String),
/// External tool error (kustomize, linkerd, buildctl, yarn, etc.).
#[error("{tool}: {detail}")]
ExternalTool { tool: String, detail: String },
/// IO error.
#[error("{context}: {source}")]
Io {
context: String,
source: std::io::Error,
},
/// JSON serialization/deserialization error.
#[error("{0}")]
Json(#[from] serde_json::Error),
/// YAML serialization/deserialization error.
#[error("{0}")]
Yaml(#[from] serde_yaml::Error),
/// Catch-all for errors that don't fit a specific category.
#[error("{0}")]
Other(String),
}
/// Convenience type alias used throughout the codebase.
pub type Result<T> = std::result::Result<T, SunbeamError>;
impl SunbeamError {
/// Map this error to a process exit code.
pub fn exit_code(&self) -> i32 {
match self {
SunbeamError::Config(_) => exit::CONFIG,
SunbeamError::Kube { .. } => exit::KUBE,
SunbeamError::Network { .. } => exit::NETWORK,
SunbeamError::Secrets(_) => exit::SECRETS,
SunbeamError::Build(_) => exit::BUILD,
SunbeamError::Identity(_) => exit::IDENTITY,
SunbeamError::ExternalTool { .. } => exit::EXTERNAL_TOOL,
SunbeamError::Io { .. } => exit::GENERAL,
SunbeamError::Json(_) => exit::GENERAL,
SunbeamError::Yaml(_) => exit::GENERAL,
SunbeamError::Other(_) => exit::GENERAL,
}
}
}
// ---------------------------------------------------------------------------
// From impls for automatic conversion
// ---------------------------------------------------------------------------
impl From<kube::Error> for SunbeamError {
fn from(e: kube::Error) -> Self {
SunbeamError::Kube {
context: e.to_string(),
source: Some(e),
}
}
}
impl From<reqwest::Error> for SunbeamError {
fn from(e: reqwest::Error) -> Self {
SunbeamError::Network {
context: e.to_string(),
source: Some(e),
}
}
}
impl From<std::io::Error> for SunbeamError {
fn from(e: std::io::Error) -> Self {
SunbeamError::Io {
context: "IO error".into(),
source: e,
}
}
}
impl From<lettre::transport::smtp::Error> for SunbeamError {
fn from(e: lettre::transport::smtp::Error) -> Self {
SunbeamError::Network {
context: format!("SMTP error: {e}"),
source: None,
}
}
}
impl From<lettre::error::Error> for SunbeamError {
fn from(e: lettre::error::Error) -> Self {
SunbeamError::Other(format!("Email error: {e}"))
}
}
impl From<base64::DecodeError> for SunbeamError {
fn from(e: base64::DecodeError) -> Self {
SunbeamError::Other(format!("Base64 decode error: {e}"))
}
}
impl From<std::string::FromUtf8Error> for SunbeamError {
fn from(e: std::string::FromUtf8Error) -> Self {
SunbeamError::Other(format!("UTF-8 error: {e}"))
}
}
// ---------------------------------------------------------------------------
// Context extension trait (replaces anyhow's .context())
// ---------------------------------------------------------------------------
/// Extension trait that adds `.ctx()` to `Result<T, E>` for adding context strings.
/// Replaces `anyhow::Context`.
pub trait ResultExt<T> {
/// Add context to an error, converting it to `SunbeamError`.
fn ctx(self, context: &str) -> Result<T>;
/// Add lazy context to an error.
fn with_ctx<F: FnOnce() -> String>(self, f: F) -> Result<T>;
}
impl<T, E: Into<SunbeamError>> ResultExt<T> for std::result::Result<T, E> {
fn ctx(self, context: &str) -> Result<T> {
self.map_err(|e| {
let inner = e.into();
match inner {
SunbeamError::Kube { source, .. } => SunbeamError::Kube {
context: context.to_string(),
source,
},
SunbeamError::Network { source, .. } => SunbeamError::Network {
context: context.to_string(),
source,
},
SunbeamError::Io { source, .. } => SunbeamError::Io {
context: context.to_string(),
source,
},
SunbeamError::Secrets(msg) => SunbeamError::Secrets(format!("{context}: {msg}")),
SunbeamError::Config(msg) => SunbeamError::Config(format!("{context}: {msg}")),
SunbeamError::Build(msg) => SunbeamError::Build(format!("{context}: {msg}")),
SunbeamError::Identity(msg) => SunbeamError::Identity(format!("{context}: {msg}")),
SunbeamError::ExternalTool { tool, detail } => SunbeamError::ExternalTool {
tool,
detail: format!("{context}: {detail}"),
},
other => SunbeamError::Other(format!("{context}: {other}")),
}
})
}
fn with_ctx<F: FnOnce() -> String>(self, f: F) -> Result<T> {
self.map_err(|e| {
let context = f();
let inner = e.into();
match inner {
SunbeamError::Kube { source, .. } => SunbeamError::Kube {
context,
source,
},
SunbeamError::Network { source, .. } => SunbeamError::Network {
context,
source,
},
SunbeamError::Io { source, .. } => SunbeamError::Io {
context,
source,
},
SunbeamError::Secrets(msg) => SunbeamError::Secrets(format!("{context}: {msg}")),
SunbeamError::Config(msg) => SunbeamError::Config(format!("{context}: {msg}")),
SunbeamError::Build(msg) => SunbeamError::Build(format!("{context}: {msg}")),
SunbeamError::Identity(msg) => SunbeamError::Identity(format!("{context}: {msg}")),
SunbeamError::ExternalTool { tool, detail } => SunbeamError::ExternalTool {
tool,
detail: format!("{context}: {detail}"),
},
other => SunbeamError::Other(format!("{context}: {other}")),
}
})
}
}
impl<T> ResultExt<T> for Option<T> {
fn ctx(self, context: &str) -> Result<T> {
self.ok_or_else(|| SunbeamError::Other(context.to_string()))
}
fn with_ctx<F: FnOnce() -> String>(self, f: F) -> Result<T> {
self.ok_or_else(|| SunbeamError::Other(f()))
}
}
// ---------------------------------------------------------------------------
// Convenience constructors
// ---------------------------------------------------------------------------
impl SunbeamError {
pub fn kube(context: impl Into<String>) -> Self {
SunbeamError::Kube {
context: context.into(),
source: None,
}
}
pub fn config(msg: impl Into<String>) -> Self {
SunbeamError::Config(msg.into())
}
pub fn network(context: impl Into<String>) -> Self {
SunbeamError::Network {
context: context.into(),
source: None,
}
}
pub fn secrets(msg: impl Into<String>) -> Self {
SunbeamError::Secrets(msg.into())
}
pub fn build(msg: impl Into<String>) -> Self {
SunbeamError::Build(msg.into())
}
pub fn identity(msg: impl Into<String>) -> Self {
SunbeamError::Identity(msg.into())
}
pub fn tool(tool: impl Into<String>, detail: impl Into<String>) -> Self {
SunbeamError::ExternalTool {
tool: tool.into(),
detail: detail.into(),
}
}
}
// ---------------------------------------------------------------------------
// bail! macro replacement
// ---------------------------------------------------------------------------
/// Like anyhow::bail! but produces a SunbeamError::Other.
#[macro_export]
macro_rules! bail {
($($arg:tt)*) => {
return Err($crate::error::SunbeamError::Other(format!($($arg)*)))
};
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_exit_codes() {
assert_eq!(SunbeamError::config("bad").exit_code(), exit::CONFIG);
assert_eq!(SunbeamError::kube("fail").exit_code(), exit::KUBE);
assert_eq!(SunbeamError::network("fail").exit_code(), exit::NETWORK);
assert_eq!(SunbeamError::secrets("fail").exit_code(), exit::SECRETS);
assert_eq!(SunbeamError::build("fail").exit_code(), exit::BUILD);
assert_eq!(SunbeamError::identity("fail").exit_code(), exit::IDENTITY);
assert_eq!(
SunbeamError::tool("kustomize", "not found").exit_code(),
exit::EXTERNAL_TOOL
);
assert_eq!(SunbeamError::Other("oops".into()).exit_code(), exit::GENERAL);
}
#[test]
fn test_display_formatting() {
let e = SunbeamError::tool("kustomize", "build failed");
assert_eq!(e.to_string(), "kustomize: build failed");
let e = SunbeamError::config("missing --domain");
assert_eq!(e.to_string(), "missing --domain");
}
#[test]
fn test_kube_from() {
// Just verify the From impl compiles and categorizes correctly
let e = SunbeamError::kube("test");
assert!(matches!(e, SunbeamError::Kube { .. }));
}
#[test]
fn test_context_extension() {
let result: std::result::Result<(), std::io::Error> =
Err(std::io::Error::new(std::io::ErrorKind::NotFound, "gone"));
let mapped = result.ctx("reading config");
assert!(mapped.is_err());
let e = mapped.unwrap_err();
assert!(e.to_string().starts_with("reading config"));
assert_eq!(e.exit_code(), exit::GENERAL); // IO maps to general
}
#[test]
fn test_option_context() {
let val: Option<i32> = None;
let result = val.ctx("value not found");
assert!(result.is_err());
assert_eq!(result.unwrap_err().to_string(), "value not found");
}
#[test]
fn test_bail_macro() {
fn failing() -> Result<()> {
bail!("something went wrong: {}", 42);
}
let e = failing().unwrap_err();
assert_eq!(e.to_string(), "something went wrong: 42");
}
}

19
sunbeam-sdk/src/lib.rs Normal file
View File

@@ -0,0 +1,19 @@
#[macro_use]
pub mod error;
pub mod auth;
pub mod checks;
pub mod cluster;
pub mod config;
pub mod constants;
pub mod gitea;
pub mod images;
pub mod kube;
pub mod manifests;
pub mod openbao;
pub mod output;
pub mod pm;
pub mod secrets;
pub mod services;
pub mod update;
pub mod users;

92
sunbeam-sdk/src/output.rs Normal file
View File

@@ -0,0 +1,92 @@
/// Print a step header.
pub fn step(msg: &str) {
println!("\n==> {msg}");
}
/// Print a success/info line.
pub fn ok(msg: &str) {
println!(" {msg}");
}
/// Print a warning to stderr.
pub fn warn(msg: &str) {
eprintln!(" WARN: {msg}");
}
/// Return an aligned text table. Columns padded to max width.
pub fn table(rows: &[Vec<String>], headers: &[&str]) -> String {
if headers.is_empty() {
return String::new();
}
let mut col_widths: Vec<usize> = headers.iter().map(|h| h.len()).collect();
for row in rows {
for (i, cell) in row.iter().enumerate() {
if i < col_widths.len() {
col_widths[i] = col_widths[i].max(cell.len());
}
}
}
let header_line: String = headers
.iter()
.enumerate()
.map(|(i, h)| format!("{:<width$}", h, width = col_widths[i]))
.collect::<Vec<_>>()
.join(" ");
let separator: String = col_widths
.iter()
.map(|&w| "-".repeat(w))
.collect::<Vec<_>>()
.join(" ");
let mut lines = vec![header_line, separator];
for row in rows {
let cells: Vec<String> = (0..headers.len())
.map(|i| {
let val = row.get(i).map(|s| s.as_str()).unwrap_or("");
format!("{:<width$}", val, width = col_widths[i])
})
.collect();
lines.push(cells.join(" "));
}
lines.join("\n")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_table_basic() {
let rows = vec![
vec!["abc".to_string(), "def".to_string()],
vec!["x".to_string(), "longer".to_string()],
];
let result = table(&rows, &["Col1", "Col2"]);
assert!(result.contains("Col1"));
assert!(result.contains("Col2"));
assert!(result.contains("abc"));
assert!(result.contains("longer"));
}
#[test]
fn test_table_empty_headers() {
let result = table(&[], &[]);
assert!(result.is_empty());
}
#[test]
fn test_table_column_widths() {
let rows = vec![vec!["short".to_string(), "x".to_string()]];
let result = table(&rows, &["LongHeader", "H2"]);
// Header should set minimum width
for line in result.lines().skip(2) {
// Data row: "short" should be padded to "LongHeader" width
assert!(line.starts_with("short "));
}
}
}