use crate::error::{BoxenError, ErrorRecommendation};
use crate::options::{BoxenOptions, Height, Spacing, Width};
use crate::terminal::{get_terminal_height, get_terminal_width};
use crate::text::text_width;
#[derive(Debug, Clone)]
pub struct MinimumDimensions {
pub width: usize,
pub height: usize,
pub content_width: usize,
pub content_height: usize,
}
#[derive(Debug)]
pub struct ValidationResult {
pub is_valid: bool,
pub warnings: Vec<ErrorRecommendation>,
pub errors: Vec<BoxenError>,
pub minimum_dimensions: Option<MinimumDimensions>,
}
impl ValidationResult {
#[must_use]
pub fn valid() -> Self {
Self {
is_valid: true,
warnings: vec![],
errors: vec![],
minimum_dimensions: None,
}
}
#[must_use]
pub fn with_warnings(warnings: Vec<ErrorRecommendation>) -> Self {
Self {
is_valid: true,
warnings,
errors: vec![],
minimum_dimensions: None,
}
}
#[must_use]
pub fn with_errors(errors: Vec<BoxenError>) -> Self {
Self {
is_valid: false,
warnings: vec![],
errors,
minimum_dimensions: None,
}
}
pub fn add_warning(&mut self, warning: ErrorRecommendation) {
self.warnings.push(warning);
}
pub fn add_error(&mut self, error: BoxenError) {
self.is_valid = false;
self.errors.push(error);
}
}
#[must_use]
pub fn calculate_minimum_dimensions(text: &str, options: &BoxenOptions) -> MinimumDimensions {
let lines: Vec<&str> = text.lines().collect();
let content_height = if lines.is_empty() { 1 } else { lines.len() };
let content_width = lines
.iter()
.map(|line| text_width(line))
.max()
.unwrap_or(0)
.max(1);
let border_width = if options.border_style.is_visible() {
2
} else {
0
};
let total_padding_width = options.padding.horizontal();
let total_padding_height = options.padding.vertical();
let min_width = content_width + border_width + total_padding_width;
let min_height = content_height + border_width + total_padding_height;
MinimumDimensions {
width: min_width,
height: min_height,
content_width,
content_height,
}
}
fn validate_width_constraints(
result: &mut ValidationResult,
options: &BoxenOptions,
min_dims: &MinimumDimensions,
) {
if let Some(ref width_spec) = options.width {
let terminal_width = get_terminal_width();
let specified_width = width_spec.calculate(terminal_width);
if specified_width < min_dims.width {
let recommendations = vec![
ErrorRecommendation::with_auto_fix(
"Specified width is too small".to_string(),
format!(
"Increase width to at least {} (current: {})",
min_dims.width, specified_width
),
format!(".width({})", min_dims.width),
),
ErrorRecommendation::suggestion_only(
"Alternative: Reduce padding".to_string(),
format!(
"Current padding adds {} to width. Consider reducing padding.",
options.padding.horizontal()
),
),
ErrorRecommendation::suggestion_only(
"Alternative: Use no border".to_string(),
"Set border_style to BorderStyle::None to save 2 characters width".to_string(),
),
ErrorRecommendation::with_auto_fix(
"Auto-adjust width".to_string(),
"Let the system automatically adjust the width".to_string(),
".auto_adjust(text)".to_string(),
),
];
result.add_error(BoxenError::invalid_dimensions(
format!(
"Width {} is too small. Minimum required: {}",
specified_width, min_dims.width
),
Some(specified_width),
None,
recommendations,
));
}
}
}
fn validate_height_constraints(
result: &mut ValidationResult,
options: &BoxenOptions,
min_dims: &MinimumDimensions,
) {
if let Some(ref height_spec) = options.height {
let terminal_height = get_terminal_height();
let specified_height = height_spec.calculate(terminal_height.unwrap_or(24));
if specified_height < min_dims.height {
let recommendations = vec![
ErrorRecommendation::with_auto_fix(
"Specified height is too small".to_string(),
format!(
"Increase height to at least {} (current: {})",
min_dims.height, specified_height
),
format!(".height({})", min_dims.height),
),
ErrorRecommendation::suggestion_only(
"Alternative: Reduce padding".to_string(),
format!(
"Current padding adds {} to height. Consider reducing padding.",
options.padding.vertical()
),
),
ErrorRecommendation::suggestion_only(
"Alternative: Use no border".to_string(),
"Set border_style to BorderStyle::None to save 2 characters height".to_string(),
),
ErrorRecommendation::with_auto_fix(
"Auto-adjust height".to_string(),
"Let the system automatically adjust the height".to_string(),
".auto_adjust(text)".to_string(),
),
];
result.add_error(BoxenError::invalid_dimensions(
format!(
"Height {} is too small. Minimum required: {}",
specified_height, min_dims.height
),
None,
Some(specified_height),
recommendations,
));
}
}
}
fn validate_terminal_constraints(
result: &mut ValidationResult,
options: &BoxenOptions,
min_dims: &MinimumDimensions,
) {
let terminal_width: usize = get_terminal_width();
let terminal_height: Option<usize> = get_terminal_height();
let actual_width = options
.width
.as_ref()
.map(|w| w.calculate(terminal_width))
.unwrap_or(min_dims.width);
let actual_height = options
.height
.as_ref()
.map(|h| h.calculate(terminal_height.unwrap_or(24)))
.unwrap_or(min_dims.height);
let total_width = actual_width + options.margin.horizontal();
let total_height = actual_height + options.margin.vertical();
if total_width > terminal_width {
let recommendations = vec![
ErrorRecommendation::with_auto_fix(
"Box exceeds terminal width".to_string(),
format!(
"Reduce width or margins. Current total: {total_width}, terminal: {terminal_width}"
),
format!(
".width({})",
terminal_width.saturating_sub(options.margin.horizontal() + 4)
),
),
ErrorRecommendation::suggestion_only(
"Alternative: Reduce margins".to_string(),
format!(
"Current margins add {} to width",
options.margin.horizontal()
),
),
];
result.add_error(BoxenError::configuration_error(
format!("Box width ({total_width}) exceeds terminal width ({terminal_width})"),
recommendations,
));
}
if let Some(term_height) = terminal_height {
if total_height > term_height {
let recommendations = vec![
ErrorRecommendation::with_auto_fix(
"Box exceeds terminal height".to_string(),
format!(
"Reduce height or margins. Current total: {total_height}, terminal: {term_height}"
),
format!(
".height({})",
term_height.saturating_sub(options.margin.vertical() + 4)
),
),
ErrorRecommendation::suggestion_only(
"Alternative: Reduce margins".to_string(),
format!(
"Current margins add {} to height",
options.margin.vertical()
),
),
];
result.add_error(BoxenError::configuration_error(
format!("Box height ({total_height}) exceeds terminal height ({term_height})"),
recommendations,
));
}
}
}
fn collect_configuration_warnings(
result: &mut ValidationResult,
text: &str,
options: &BoxenOptions,
) {
if options.padding.horizontal() > 20 {
result.add_warning(ErrorRecommendation::suggestion_only(
"Large horizontal padding".to_string(),
format!(
"Horizontal padding of {} might be excessive",
options.padding.horizontal()
),
));
}
if options.padding.vertical() > 10 {
result.add_warning(ErrorRecommendation::suggestion_only(
"Large vertical padding".to_string(),
format!(
"Vertical padding of {} might be excessive",
options.padding.vertical()
),
));
}
if text.lines().count() > 50 {
result.add_warning(ErrorRecommendation::suggestion_only(
"Large text content".to_string(),
"Text has many lines, consider using height constraints or text wrapping".to_string(),
));
}
}
#[must_use]
pub fn validate_configuration(text: &str, options: &BoxenOptions) -> ValidationResult {
let mut result = ValidationResult::valid();
let min_dims = calculate_minimum_dimensions(text, options);
result.minimum_dimensions = Some(min_dims.clone());
validate_width_constraints(&mut result, options, &min_dims);
validate_height_constraints(&mut result, options, &min_dims);
validate_terminal_constraints(&mut result, options, &min_dims);
collect_configuration_warnings(&mut result, text, options);
result
}
#[must_use]
pub fn suggest_optimal_dimensions(text: &str, options: &BoxenOptions) -> (usize, usize) {
let min_dims = calculate_minimum_dimensions(text, options);
let terminal_width = get_terminal_width();
let terminal_height = get_terminal_height();
let max_usable_width = terminal_width.saturating_sub(options.margin.horizontal() + 4);
let optimal_width = min_dims.width.min(max_usable_width).max(20);
let max_usable_height =
terminal_height.map_or(50, |h| h.saturating_sub(options.margin.vertical() + 4));
let optimal_height = min_dims.height.min(max_usable_height).max(3);
(optimal_width, optimal_height)
}
#[must_use]
pub fn auto_adjust_options(text: &str, mut options: BoxenOptions) -> BoxenOptions {
let validation = validate_configuration(text, &options);
if !validation.is_valid {
let min_dims = calculate_minimum_dimensions(text, &options);
let terminal_width = get_terminal_width();
let terminal_height = get_terminal_height();
if let Some(ref width_spec) = options.width {
let width = width_spec.calculate(terminal_width);
if width < min_dims.width {
options.width = Some(Width::Fixed(min_dims.width));
}
}
if let Some(ref height_spec) = options.height {
let height = height_spec.calculate(terminal_height.unwrap_or(24));
if height < min_dims.height {
options.height = Some(Height::Fixed(min_dims.height));
}
}
let actual_width = options
.width
.as_ref()
.map(|w| w.calculate(terminal_width))
.unwrap_or(min_dims.width);
let total_width = actual_width + options.margin.horizontal();
if total_width > terminal_width {
let max_width = terminal_width.saturating_sub(options.margin.horizontal());
if max_width >= min_dims.width {
options.width = Some(Width::Fixed(max_width));
} else {
let required_margin_reduction = total_width - terminal_width;
let new_horizontal_margin = options
.margin
.horizontal()
.saturating_sub(required_margin_reduction);
options.margin = Spacing {
left: new_horizontal_margin / 2,
right: new_horizontal_margin / 2,
..options.margin
};
}
}
let actual_height = options
.height
.as_ref()
.map(|h| h.calculate(terminal_height.unwrap_or(24)))
.unwrap_or(min_dims.height);
let total_height = actual_height + options.margin.vertical();
if let Some(term_height) = terminal_height {
if total_height > term_height {
let max_height = term_height.saturating_sub(options.margin.vertical());
if max_height >= min_dims.height {
options.height = Some(Height::Fixed(max_height));
} else {
let required_margin_reduction = total_height - term_height;
let new_vertical_margin = options
.margin
.vertical()
.saturating_sub(required_margin_reduction);
options.margin = Spacing {
top: new_vertical_margin / 2,
bottom: new_vertical_margin / 2,
..options.margin
};
}
}
}
}
options
}
pub mod recovery {
use super::{
BoxenError, calculate_minimum_dimensions, get_terminal_height, get_terminal_width,
validate_configuration,
};
use crate::options::{BorderStyle, BoxenOptions, Height, Spacing, Width};
#[must_use]
pub fn recover_from_invalid_width(
text: &str,
mut options: BoxenOptions,
target_width: usize,
) -> BoxenOptions {
let min_dims = calculate_minimum_dimensions(text, &options);
if target_width < min_dims.width {
if options.padding.horizontal() > 0 {
let reduction_needed = min_dims.width - target_width;
let current_horizontal = options.padding.horizontal();
if current_horizontal >= reduction_needed {
let new_horizontal = current_horizontal - reduction_needed;
options.padding = Spacing {
left: new_horizontal / 2,
right: new_horizontal / 2,
..options.padding
};
return options;
}
}
if !matches!(options.border_style, BorderStyle::None) {
options.border_style = BorderStyle::None;
let new_min_dims = calculate_minimum_dimensions(text, &options);
if target_width >= new_min_dims.width {
return options;
}
}
options.width = Some(Width::Fixed(min_dims.width));
}
options
}
#[must_use]
pub fn recover_from_invalid_height(
text: &str,
mut options: BoxenOptions,
target_height: usize,
) -> BoxenOptions {
let min_dims = calculate_minimum_dimensions(text, &options);
if target_height < min_dims.height {
if options.padding.vertical() > 0 {
let reduction_needed = min_dims.height - target_height;
let current_vertical = options.padding.vertical();
if current_vertical >= reduction_needed {
let new_vertical = current_vertical - reduction_needed;
options.padding = Spacing {
top: new_vertical / 2,
bottom: new_vertical / 2,
..options.padding
};
return options;
}
}
if !matches!(options.border_style, BorderStyle::None) {
options.border_style = BorderStyle::None;
let new_min_dims = calculate_minimum_dimensions(text, &options);
if target_height >= new_min_dims.height {
return options;
}
}
options.height = Some(Height::Fixed(min_dims.height));
}
options
}
#[must_use]
pub fn recover_from_terminal_overflow(text: &str, mut options: BoxenOptions) -> BoxenOptions {
let terminal_width = get_terminal_width();
let terminal_height = get_terminal_height();
let actual_width = options
.width
.as_ref()
.map(|w| w.calculate(terminal_width))
.unwrap_or_else(|| {
let min_dims = calculate_minimum_dimensions(text, &options);
min_dims.width
});
let total_width = actual_width + options.margin.horizontal();
if total_width > terminal_width {
let margin_horizontal = options.margin.horizontal();
options = recover_from_invalid_width(text, options, terminal_width - margin_horizontal);
}
if let Some(term_height) = terminal_height {
let actual_height = options
.height
.as_ref()
.map(|h| h.calculate(term_height))
.unwrap_or_else(|| {
let min_dims = calculate_minimum_dimensions(text, &options);
min_dims.height
});
let total_height = actual_height + options.margin.vertical();
if total_height > term_height {
let margin_vertical = options.margin.vertical();
options = recover_from_invalid_height(text, options, term_height - margin_vertical);
}
}
options
}
#[must_use]
pub fn smart_recovery(text: &str, options: BoxenOptions) -> BoxenOptions {
let validation = validate_configuration(text, &options);
if validation.is_valid {
return options;
}
let mut recovered_options = options;
for error in &validation.errors {
match error {
BoxenError::InvalidDimensions { width, height, .. } => {
if let Some(w) = width {
recovered_options = recover_from_invalid_width(text, recovered_options, *w);
}
if let Some(h) = height {
recovered_options =
recover_from_invalid_height(text, recovered_options, *h);
}
}
BoxenError::ConfigurationError { message, .. } => {
if message.contains("terminal width") || message.contains("terminal height") {
recovered_options = recover_from_terminal_overflow(text, recovered_options);
}
}
_ => {}
}
}
recovered_options
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_calculate_minimum_dimensions() {
let options = BoxenOptions::default();
let min_dims = calculate_minimum_dimensions("Hello", &options);
assert_eq!(min_dims.content_width, 5); assert_eq!(min_dims.content_height, 1); assert_eq!(min_dims.width, 7); assert_eq!(min_dims.height, 3); }
#[test]
fn test_calculate_minimum_dimensions_with_padding() {
let options = BoxenOptions {
padding: Spacing::from(1), ..Default::default()
};
let min_dims = calculate_minimum_dimensions("Hello", &options);
assert_eq!(min_dims.width, 13); assert_eq!(min_dims.height, 5); }
#[test]
fn test_validate_configuration_valid() {
let options = BoxenOptions::default();
let result = validate_configuration("Hello", &options);
assert!(result.is_valid);
assert!(result.errors.is_empty());
}
#[test]
fn test_validate_configuration_width_too_small() {
let options = BoxenOptions {
width: Some(Width::Fixed(5)), ..Default::default()
};
let result = validate_configuration("Hello", &options);
assert!(!result.is_valid);
assert_eq!(result.errors.len(), 1);
let recommendations = result.errors[0].recommendations();
assert!(!recommendations.is_empty());
assert!(recommendations[0].auto_fix.is_some());
}
#[test]
fn test_suggest_optimal_dimensions() {
let options = BoxenOptions::default();
let (width, height) = suggest_optimal_dimensions("Hello\nWorld", &options);
assert!(width >= 7); assert!(height >= 4); assert!(width <= get_terminal_width()); if let Some(term_height) = get_terminal_height() {
assert!(height <= term_height); }
}
#[test]
fn test_auto_adjust_options() {
let original_options = BoxenOptions {
width: Some(Width::Fixed(5)), height: Some(Height::Fixed(2)), ..Default::default()
};
let adjusted = auto_adjust_options("Hello\nWorld", original_options);
let terminal_width = get_terminal_width();
let terminal_height = get_terminal_height().unwrap_or(24);
assert!(
adjusted
.width
.as_ref()
.map(|w| w.calculate(terminal_width))
.unwrap()
>= 7
); assert!(
adjusted
.height
.as_ref()
.map(|h| h.calculate(terminal_height))
.unwrap()
>= 4
); }
#[test]
fn test_validation_warnings() {
let options = BoxenOptions {
padding: Spacing {
top: 15,
right: 25,
bottom: 15,
left: 25,
},
..Default::default()
};
let result = validate_configuration("Hello", &options);
assert!(!result.warnings.is_empty());
}
}