Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 93 additions & 33 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,34 +48,18 @@ pub struct CostConfig {
pub critical_style: Option<String>,
pub format: Option<String>,
// Sub-field per-display configs (map to [cship.cost.total_cost_usd] etc.)
pub total_cost_usd: Option<CostSubfieldConfig>,
pub total_duration_ms: Option<CostSubfieldConfig>,
pub total_api_duration_ms: Option<CostSubfieldConfig>,
pub total_lines_added: Option<CostSubfieldConfig>,
pub total_lines_removed: Option<CostSubfieldConfig>,
pub total_cost_usd: Option<SubfieldConfig>,
pub total_duration_ms: Option<SubfieldConfig>,
pub total_api_duration_ms: Option<SubfieldConfig>,
pub total_lines_added: Option<SubfieldConfig>,
pub total_lines_removed: Option<SubfieldConfig>,
}

/// Configuration for individual `[cship.cost.*]` sub-field modules.
/// Unified configuration for individual sub-field modules
/// (e.g. `[cship.cost.total_cost_usd]`, `[cship.context_window.used_percentage]`).
#[derive(Debug, Deserialize, Default)]
pub struct CostSubfieldConfig {
pub struct SubfieldConfig {
pub style: Option<String>,
/// Reserved — not yet rendered; included for config schema consistency.
pub symbol: Option<String>,
pub disabled: Option<bool>,
/// Reserved — not yet rendered; included for config schema consistency.
pub label: Option<String>,
pub warn_threshold: Option<f64>,
pub warn_style: Option<String>,
pub critical_threshold: Option<f64>,
pub critical_style: Option<String>,
pub format: Option<String>,
}

/// Configuration for individual `[cship.context_window.*]` sub-field modules.
#[derive(Debug, Deserialize, Default)]
pub struct ContextWindowSubfieldConfig {
pub style: Option<String>,
/// Used only in the format path (via `$symbol`); ignored in the default render path.
pub symbol: Option<String>,
pub disabled: Option<bool>,
pub warn_threshold: Option<f64>,
Expand All @@ -89,6 +73,82 @@ pub struct ContextWindowSubfieldConfig {
pub invert_threshold: Option<bool>,
}

/// Backwards-compatible type aliases (used by test code).
#[cfg(test)]
pub type CostSubfieldConfig = SubfieldConfig;
#[cfg(test)]
pub type ContextWindowSubfieldConfig = SubfieldConfig;

/// Trait for uniform access to style/threshold fields shared by config types.
/// Used by `render_styled_value()` to resolve sub-field → parent fallback.
///
/// `format_str()` and `symbol_str()` default to `None`. Only parent configs
/// whose sub-fields should inherit format/symbol (i.e., `ContextWindowConfig`)
/// override these.
pub trait HasThresholdStyle {
fn style(&self) -> Option<&str>;
fn warn_threshold(&self) -> Option<f64>;
fn warn_style(&self) -> Option<&str>;
fn critical_threshold(&self) -> Option<f64>;
fn critical_style(&self) -> Option<&str>;
fn format_str(&self) -> Option<&str> {
None
}
fn symbol_str(&self) -> Option<&str> {
None
}
}

macro_rules! impl_has_threshold_style {
($t:ty) => {
impl HasThresholdStyle for $t {
fn style(&self) -> Option<&str> {
self.style.as_deref()
}
fn warn_threshold(&self) -> Option<f64> {
self.warn_threshold
}
fn warn_style(&self) -> Option<&str> {
self.warn_style.as_deref()
}
fn critical_threshold(&self) -> Option<f64> {
self.critical_threshold
}
fn critical_style(&self) -> Option<&str> {
self.critical_style.as_deref()
}
}
};
}

impl_has_threshold_style!(CostConfig);
impl_has_threshold_style!(ContextBarConfig);
impl_has_threshold_style!(UsageLimitsConfig);

impl HasThresholdStyle for ContextWindowConfig {
fn style(&self) -> Option<&str> {
self.style.as_deref()
}
fn warn_threshold(&self) -> Option<f64> {
self.warn_threshold
}
fn warn_style(&self) -> Option<&str> {
self.warn_style.as_deref()
}
fn critical_threshold(&self) -> Option<f64> {
self.critical_threshold
}
fn critical_style(&self) -> Option<&str> {
self.critical_style.as_deref()
}
fn format_str(&self) -> Option<&str> {
self.format.as_deref()
}
fn symbol_str(&self) -> Option<&str> {
self.symbol.as_deref()
}
}

/// Configuration for `[cship.context_bar]` — visual progress bar with thresholds.
/// Implemented in Story 2.2. Defined here so all Epic 2 config is available.
#[derive(Debug, Deserialize, Default)]
Expand Down Expand Up @@ -119,15 +179,15 @@ pub struct ContextWindowConfig {
pub critical_style: Option<String>,
pub format: Option<String>,
// Per-sub-field configs (map to [cship.context_window.used_percentage] etc.)
pub used_percentage: Option<ContextWindowSubfieldConfig>,
pub remaining_percentage: Option<ContextWindowSubfieldConfig>,
pub size: Option<ContextWindowSubfieldConfig>,
pub total_input_tokens: Option<ContextWindowSubfieldConfig>,
pub total_output_tokens: Option<ContextWindowSubfieldConfig>,
pub current_usage_input_tokens: Option<ContextWindowSubfieldConfig>,
pub current_usage_output_tokens: Option<ContextWindowSubfieldConfig>,
pub current_usage_cache_creation_input_tokens: Option<ContextWindowSubfieldConfig>,
pub current_usage_cache_read_input_tokens: Option<ContextWindowSubfieldConfig>,
pub used_percentage: Option<SubfieldConfig>,
pub remaining_percentage: Option<SubfieldConfig>,
pub size: Option<SubfieldConfig>,
pub total_input_tokens: Option<SubfieldConfig>,
pub total_output_tokens: Option<SubfieldConfig>,
pub current_usage_input_tokens: Option<SubfieldConfig>,
pub current_usage_output_tokens: Option<SubfieldConfig>,
pub current_usage_cache_creation_input_tokens: Option<SubfieldConfig>,
pub current_usage_cache_read_input_tokens: Option<SubfieldConfig>,
}

/// Configuration for `[cship.vim]` — vim mode display.
Expand Down
163 changes: 163 additions & 0 deletions src/format.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,76 @@
//!
//! [Source: architecture.md#Core Architectural Decisions, epics.md#Story 2.5]

/// Centralized style/threshold/format rendering for sub-field render functions.
///
/// Resolves style, thresholds, and format strings using sub-field → parent fallback,
/// handling `invert_threshold` for decreasing-health indicators (e.g. remaining_percentage).
///
/// # Arguments
/// - `val_str`: Already-formatted display string (e.g. `"85"`, `"0.0123"`)
/// - `threshold_val`: Numeric value for threshold comparison; `None` for non-threshold fields
/// - `sub_cfg`: The sub-field's own `SubfieldConfig` (may be `None`)
/// - `parent`: Parent config implementing `HasThresholdStyle` for fallback (may be `None`)
///
/// # Returns
/// `None` when the format path renders empty (conditional group with absent `$value`).
/// `Some(styled_string)` otherwise.
pub fn render_styled_value(
val_str: &str,
threshold_val: Option<f64>,
sub_cfg: Option<&crate::config::SubfieldConfig>,
parent: Option<&dyn crate::config::HasThresholdStyle>,
) -> Option<String> {
// Resolve all fields with sub → parent fallback
let style = sub_cfg
.and_then(|c| c.style.as_deref())
.or_else(|| parent.and_then(|p| p.style()));
let mut effective_val = threshold_val;
let mut warn_threshold = sub_cfg
.and_then(|c| c.warn_threshold)
.or_else(|| parent.and_then(|p| p.warn_threshold()));
let mut warn_style = sub_cfg
.and_then(|c| c.warn_style.as_deref())
.or_else(|| parent.and_then(|p| p.warn_style()));
let mut critical_threshold = sub_cfg
.and_then(|c| c.critical_threshold)
.or_else(|| parent.and_then(|p| p.critical_threshold()));
let mut critical_style = sub_cfg
.and_then(|c| c.critical_style.as_deref())
.or_else(|| parent.and_then(|p| p.critical_style()));

// Inverted thresholds: use sub-only values (negated) and negate the comparison value.
// Parent thresholds are in the non-inverted domain and must not be inherited.
if sub_cfg.and_then(|c| c.invert_threshold).unwrap_or(false) {
effective_val = threshold_val.map(|v| -v);
warn_threshold = sub_cfg.and_then(|c| c.warn_threshold).map(|t| -t);
warn_style = sub_cfg.and_then(|c| c.warn_style.as_deref());
critical_threshold = sub_cfg.and_then(|c| c.critical_threshold).map(|t| -t);
critical_style = sub_cfg.and_then(|c| c.critical_style.as_deref());
}

// Format path: resolve symbol, threshold style, then apply format
let fmt = sub_cfg
.and_then(|c| c.format.as_deref())
.or_else(|| parent.and_then(|p| p.format_str()));
if let Some(fmt) = fmt {
let symbol = sub_cfg
.and_then(|c| c.symbol.as_deref())
.or_else(|| parent.and_then(|p| p.symbol_str()));
let effective_style = crate::ansi::resolve_threshold_style(
effective_val, style, warn_threshold, warn_style,
critical_threshold, critical_style,
);
return apply_module_format(fmt, Some(val_str), symbol, effective_style);
}

// Default path: apply_style_with_threshold handles no-threshold gracefully
Some(crate::ansi::apply_style_with_threshold(
val_str, effective_val, style, warn_threshold, warn_style,
critical_threshold, critical_style,
))
}

/// Apply a per-module format string.
///
/// # Arguments
Expand Down Expand Up @@ -277,4 +347,97 @@ mod tests {
assert!(s.contains("🧠"), "symbol present: {s:?}");
assert!(s.contains('\x1b'), "ANSI codes present: {s:?}");
}

// --- render_styled_value tests ---

#[test]
fn test_render_styled_value_with_format_string() {
// Format string present → returns formatted ANSI output
let sub = crate::config::SubfieldConfig {
format: Some("[$value]($style)".to_string()),
style: Some("bold green".to_string()),
..Default::default()
};
let result = render_styled_value("85", Some(85.0), Some(&sub), None);
let s = result.unwrap();
assert!(s.contains("85"), "value present: {s:?}");
assert!(s.contains('\x1b'), "ANSI codes present: {s:?}");
}

#[test]
fn test_render_styled_value_no_format_threshold_above_warn() {
// No format, threshold above warn → returns threshold-styled output
let sub = crate::config::SubfieldConfig {
warn_threshold: Some(50.0),
warn_style: Some("yellow".to_string()),
critical_threshold: Some(90.0),
critical_style: Some("bold red".to_string()),
..Default::default()
};
let result = render_styled_value("75", Some(75.0), Some(&sub), None);
let s = result.unwrap();
assert!(s.contains("75"), "value present: {s:?}");
assert!(s.contains('\x1b'), "ANSI codes for warn style: {s:?}");
}

#[test]
fn test_render_styled_value_no_format_no_threshold() {
// No format, no threshold → returns plain styled output
let sub = crate::config::SubfieldConfig {
style: Some("cyan".to_string()),
..Default::default()
};
let result = render_styled_value("hello", None, Some(&sub), None);
let s = result.unwrap();
assert!(s.contains("hello"), "value present: {s:?}");
assert!(s.contains('\x1b'), "ANSI codes for base style: {s:?}");
}

#[test]
fn test_render_styled_value_invert_threshold() {
// invert_threshold: value 20 with warn_threshold 30 → inverted: -20 >= -30 → warn fires
let sub = crate::config::SubfieldConfig {
invert_threshold: Some(true),
warn_threshold: Some(30.0),
warn_style: Some("yellow".to_string()),
critical_threshold: Some(10.0),
critical_style: Some("bold red".to_string()),
..Default::default()
};
let result = render_styled_value("20", Some(20.0), Some(&sub), None);
let s = result.unwrap();
assert!(s.contains("20"), "value present: {s:?}");
assert!(s.contains('\x1b'), "ANSI codes for inverted warn: {s:?}");
}

#[test]
fn test_render_styled_value_parent_fallback_style() {
// No sub_cfg style → uses parent style
let sub = crate::config::SubfieldConfig {
..Default::default()
};
let parent = crate::config::ContextWindowConfig {
style: Some("bold magenta".to_string()),
..Default::default()
};
let result = render_styled_value(
"42",
None,
Some(&sub),
Some(&parent as &dyn crate::config::HasThresholdStyle),
);
let s = result.unwrap();
assert!(s.contains("42"), "value present: {s:?}");
assert!(
s.contains('\x1b'),
"ANSI codes from parent style: {s:?}"
);
}

#[test]
fn test_render_styled_value_no_sub_no_parent() {
// No sub_cfg, no parent → returns plain val_str
let result = render_styled_value("plain", None, None, None);
assert_eq!(result, Some("plain".to_string()));
}
}
Loading
Loading