use std::collections::{BTreeMap, HashMap};
use std::sync::Arc;
use rhai::{Array, Dynamic, Engine, Map};
use super::{
built_in_by_id, OverriddenSegment, PowerlineWidth, Segment, Separator, WidthBounds,
DEFAULT_SEGMENT_IDS,
};
use crate::config;
use crate::plugins::{CompiledPlugin, PluginRegistry, RhaiSegment};
use crate::theme;
#[must_use]
pub fn build_default_segments() -> Vec<Box<dyn Segment>> {
DEFAULT_SEGMENT_IDS
.iter()
.filter_map(|id| built_in_by_id(id, None, &mut |_| {}))
.collect()
}
pub fn build_segments(
config: Option<&config::Config>,
plugins: Option<(PluginRegistry, Arc<Engine>)>,
mut warn: impl FnMut(&str),
) -> Vec<Box<dyn Segment>> {
let configured_line = config.and_then(|c| c.line.as_ref());
let layout_mode = config.map(|c| c.layout).unwrap_or_default();
if matches!(layout_mode, config::LayoutMode::MultiLine) {
if let Some(first) = validated_numbered_lines(configured_line, &mut warn)
.and_then(|mut v| (!v.is_empty()).then(|| v.remove(0)))
{
warn("layout = \"multi-line\" passed to build_segments (the single-line API); rendering line 1 only. Call build_lines to render every [line.N] sub-table.");
let layout_separator = resolve_layout_separator(config, &mut warn);
let ids: Vec<&str> = first.iter().map(String::as_str).collect();
let mut plugin_bundle = bundle_plugins(plugins);
let mut consumed = std::collections::HashSet::new();
return build_one_line(
&ids,
config,
&mut plugin_bundle,
&mut consumed,
&layout_separator,
&mut warn,
);
}
}
if let Some(line) = configured_line {
if line.segments.is_empty() {
warn("[line].segments is empty; no segments will render");
}
}
let layout_separator = resolve_layout_separator(config, &mut warn);
let ids: Vec<&str> = match configured_line {
Some(l) => l.segments.iter().map(String::as_str).collect(),
None => DEFAULT_SEGMENT_IDS.to_vec(),
};
let mut plugin_bundle = bundle_plugins(plugins);
let mut consumed = std::collections::HashSet::new();
build_one_line(
&ids,
config,
&mut plugin_bundle,
&mut consumed,
&layout_separator,
&mut warn,
)
}
pub fn build_lines(
config: Option<&config::Config>,
plugins: Option<(PluginRegistry, Arc<Engine>)>,
mut warn: impl FnMut(&str),
) -> Vec<Vec<Box<dyn Segment>>> {
let mode = config.map(|c| c.layout).unwrap_or_default();
let line_cfg = config.and_then(|c| c.line.as_ref());
let line_id_lists: Vec<Vec<String>> = match mode {
config::LayoutMode::SingleLine => {
let has_numbered = line_cfg.is_some_and(|l| !l.numbered.is_empty());
let has_segments = line_cfg.is_some_and(|l| !l.segments.is_empty());
if has_numbered && !has_segments {
if let Some(promoted) = validated_numbered_lines(line_cfg, &mut warn) {
warn("[line.N] sub-tables present but no top-level `layout` field; treating as multi-line. Add `layout = \"multi-line\"` to silence this warning.");
promoted
} else {
warn("[line.N] sub-tables present but none are usable, and [line].segments is empty; nothing will render");
single_line_ids(line_cfg, &mut warn)
}
} else {
if has_numbered {
warn("layout is single-line but [line.N] sub-tables are present; ignoring numbered tables and rendering [line].segments");
}
single_line_ids(line_cfg, &mut warn)
}
}
config::LayoutMode::MultiLine => match validated_numbered_lines(line_cfg, &mut warn) {
Some(lines) => lines,
None => {
warn("layout = \"multi-line\" but no usable [line.N] sub-tables; falling back to single-line using [line].segments");
single_line_ids(line_cfg, &mut warn)
}
},
};
let layout_separator = resolve_layout_separator(config, &mut warn);
let mut plugin_bundle = bundle_plugins(plugins);
let mut consumed_plugins = std::collections::HashSet::<String>::new();
line_id_lists
.into_iter()
.map(|owned_ids| {
let ids: Vec<&str> = owned_ids.iter().map(String::as_str).collect();
build_one_line(
&ids,
config,
&mut plugin_bundle,
&mut consumed_plugins,
&layout_separator,
&mut warn,
)
})
.collect()
}
fn single_line_ids(
line_cfg: Option<&config::LineConfig>,
warn: &mut impl FnMut(&str),
) -> Vec<Vec<String>> {
if let Some(line) = line_cfg {
if line.segments.is_empty() {
warn("[line].segments is empty; no segments will render");
}
}
let ids: Vec<String> = match line_cfg {
Some(l) => l.segments.clone(),
None => DEFAULT_SEGMENT_IDS.iter().map(|&s| s.to_string()).collect(),
};
vec![ids]
}
fn validated_numbered_lines(
line_cfg: Option<&config::LineConfig>,
warn: &mut impl FnMut(&str),
) -> Option<Vec<Vec<String>>> {
let line = line_cfg?;
if line.numbered.is_empty() {
return None;
}
let mut valid: Vec<(u32, Vec<String>)> = line
.numbered
.iter()
.filter_map(|(key, value)| {
if !matches!(value, toml::Value::Table(_)) {
warn(&format!(
"[line] has unknown key '{key}' ({}); expected `[line.N]` sub-tables only. Skipping.",
describe_toml_value(value)
));
return None;
}
let n = match key.parse::<u32>() {
Ok(n) if n > 0 => n,
_ => {
warn(&format!(
"[line.{key}] is not a positive integer key; skipping"
));
return None;
}
};
extract_line_segments(key, value, warn).map(|segs| (n, segs))
})
.collect();
if valid.is_empty() {
return None;
}
valid.sort_by_key(|(n, _)| *n);
for (n, segs) in &valid {
if segs.is_empty() {
warn(&format!(
"[line.{n}].segments is empty; that line will render nothing"
));
}
}
Some(valid.into_iter().map(|(_, segs)| segs).collect())
}
fn extract_line_segments(
key: &str,
value: &toml::Value,
warn: &mut impl FnMut(&str),
) -> Option<Vec<String>> {
let table = match value {
toml::Value::Table(t) => t,
other => {
warn(&format!(
"[line] key '{key}' is a {} (expected a sub-table with `segments = [...]`); skipping",
describe_toml_value(other)
));
return None;
}
};
let segments_value = match table.get("segments") {
Some(v) => v,
None => {
warn(&format!("[line.{key}] has no `segments` array; skipping"));
return None;
}
};
let array = match segments_value {
toml::Value::Array(a) => a,
other => {
warn(&format!(
"[line.{key}].segments is a {} (expected an array of strings); skipping",
describe_toml_value(other)
));
return None;
}
};
let mut segs = Vec::with_capacity(array.len());
for (i, item) in array.iter().enumerate() {
match item {
toml::Value::String(s) => segs.push(s.clone()),
other => {
warn(&format!(
"[line.{key}].segments[{i}] is a {} (expected a string); skipping that item",
describe_toml_value(other)
));
}
}
}
Some(segs)
}
fn describe_toml_value(v: &toml::Value) -> &'static str {
match v {
toml::Value::String(_) => "string",
toml::Value::Integer(_) => "integer",
toml::Value::Float(_) => "float",
toml::Value::Boolean(_) => "boolean",
toml::Value::Datetime(_) => "datetime",
toml::Value::Array(_) => "array",
toml::Value::Table(_) => "table",
}
}
fn resolve_layout_separator(
config: Option<&config::Config>,
warn: &mut impl FnMut(&str),
) -> Separator {
let powerline_width = config
.and_then(|c| c.layout_options.as_ref())
.and_then(|lo| lo.powerline_width)
.map(|w| validate_powerline_width(w, warn))
.unwrap_or_default();
config
.and_then(|c| c.layout_options.as_ref())
.and_then(|lo| lo.separator.as_deref())
.map(|s| parse_layout_separator(s, powerline_width, warn))
.unwrap_or(Separator::Space)
}
fn bundle_plugins(
plugins: Option<(PluginRegistry, Arc<Engine>)>,
) -> Option<(HashMap<String, CompiledPlugin>, Arc<Engine>)> {
plugins.map(|(registry, engine)| {
let lookup: HashMap<String, CompiledPlugin> = registry
.into_plugins()
.into_iter()
.map(|p| (p.id().to_string(), p))
.collect();
(lookup, engine)
})
}
fn build_one_line(
ids: &[&str],
config: Option<&config::Config>,
plugin_bundle: &mut Option<(HashMap<String, CompiledPlugin>, Arc<Engine>)>,
consumed_plugins: &mut std::collections::HashSet<String>,
layout_separator: &Separator,
warn: &mut impl FnMut(&str),
) -> Vec<Box<dyn Segment>> {
let mut seen = std::collections::HashSet::<String>::new();
ids.iter()
.filter_map(|&id| {
if !seen.insert(id.to_string()) {
warn(&format!(
"segment '{id}' listed more than once; keeping first occurrence"
));
return None;
}
let cfg_override = config.and_then(|c| c.segments.get(id));
let extras = cfg_override.map(|ov| &ov.extra);
let inner = if let Some(b) = built_in_by_id(id, extras, warn) {
Some(b)
} else if let Some((lookup, engine)) = plugin_bundle.as_mut() {
lookup.remove(id).map(|plugin| {
consumed_plugins.insert(id.to_string());
let plugin_config = cfg_override.map_or_else(
|| Dynamic::from_map(Map::new()),
|ov| toml_table_to_dynamic(&ov.extra),
);
Box::new(RhaiSegment::from_compiled(
plugin,
engine.clone(),
plugin_config,
)) as Box<dyn Segment>
})
} else {
None
};
let inner = inner.or_else(|| {
if consumed_plugins.contains(id) {
warn(&format!(
"plugin '{id}' was rendered on an earlier line; v0.1 supports each plugin on at most one line per render — skipping"
));
} else {
warn(&format!("unknown segment id '{id}' — skipping"));
}
None
})?;
let with_per_segment = apply_override(id, inner, cfg_override, warn);
Some(apply_layout_separator(with_per_segment, layout_separator))
})
.collect()
}
fn parse_layout_separator(
value: &str,
powerline_width: PowerlineWidth,
warn: &mut impl FnMut(&str),
) -> Separator {
if value.is_empty() {
return Separator::None;
}
let normalized = value.trim().to_ascii_lowercase();
match normalized.as_str() {
"space" => Separator::Space,
"powerline" => Separator::Powerline {
width: powerline_width,
},
"capsule" | "flex" => {
warn(&format!(
"[layout_options].separator '{value}' is reserved for v0.2+; rendering as 'space'"
));
Separator::Space
}
_ => Separator::Literal(std::borrow::Cow::Owned(value.to_string())),
}
}
fn validate_powerline_width(width: u16, warn: &mut impl FnMut(&str)) -> PowerlineWidth {
match width {
1 => PowerlineWidth::One,
2 => PowerlineWidth::Two,
other => {
warn(&format!(
"[layout_options].powerline_width = {other} is not 1 or 2; using 1"
));
PowerlineWidth::One
}
}
}
fn apply_layout_separator(segment: Box<dyn Segment>, sep: &Separator) -> Box<dyn Segment> {
if matches!(sep, Separator::Space) {
return segment;
}
match segment.defaults().default_separator {
Separator::Space | Separator::Theme => {
Box::new(OverriddenSegment::new(segment).with_default_separator(sep.clone()))
}
_ => segment,
}
}
fn toml_table_to_dynamic(table: &BTreeMap<String, toml::Value>) -> Dynamic {
let mut map = Map::new();
for (k, v) in table {
map.insert(k.as_str().into(), toml_value_to_dynamic(v));
}
Dynamic::from_map(map)
}
fn toml_value_to_dynamic(value: &toml::Value) -> Dynamic {
match value {
toml::Value::String(s) => Dynamic::from(s.clone()),
toml::Value::Integer(i) => Dynamic::from(*i),
toml::Value::Float(f) => Dynamic::from(*f),
toml::Value::Boolean(b) => Dynamic::from(*b),
toml::Value::Datetime(dt) => Dynamic::from(dt.to_string()),
toml::Value::Array(items) => {
let arr: Array = items.iter().map(toml_value_to_dynamic).collect();
Dynamic::from_array(arr)
}
toml::Value::Table(t) => {
let mut m = Map::new();
for (k, v) in t {
m.insert(k.as_str().into(), toml_value_to_dynamic(v));
}
Dynamic::from_map(m)
}
}
}
fn apply_override(
id: &str,
inner: Box<dyn Segment>,
ov: Option<&config::SegmentOverride>,
warn: &mut impl FnMut(&str),
) -> Box<dyn Segment> {
let Some(ov) = ov else { return inner };
let base_width = inner.defaults().width;
let mut wrapped = OverriddenSegment::new(inner);
if let Some(p) = ov.priority {
wrapped = wrapped.with_priority(p);
}
if let Some(w) = ov.width {
let min = w.min.or_else(|| base_width.map(|b| b.min())).unwrap_or(0);
let max = w
.max
.or_else(|| base_width.map(|b| b.max()))
.unwrap_or(u16::MAX);
match WidthBounds::new(min, max) {
Some(bounds) => wrapped = wrapped.with_width(bounds),
None => warn(&format!(
"segments.{id}.width: min ({min}) > max ({max}); ignoring override"
)),
}
}
if let Some(style_str) = ov.style.as_deref().filter(|s| !s.trim().is_empty()) {
match theme::parse_style(style_str) {
Ok(style) => wrapped = wrapped.with_user_style(style),
Err(e) => warn(&format!("segments.{id}.style: {e}; ignoring override")),
}
}
Box::new(wrapped)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::input;
use crate::segments::{self, BUILT_IN_SEGMENT_IDS};
use std::str::FromStr;
fn built(cfg: Option<&config::Config>) -> Vec<Box<dyn Segment>> {
build_segments(cfg, None, |_| {})
}
fn built_with_warns(cfg: Option<&config::Config>) -> (Vec<Box<dyn Segment>>, Vec<String>) {
let mut warns = Vec::new();
let segs = build_segments(cfg, None, |m| warns.push(m.to_string()));
(segs, warns)
}
#[test]
fn build_segments_uses_default_order_when_config_missing() {
assert_eq!(built(None).len(), DEFAULT_SEGMENT_IDS.len());
}
#[test]
fn layout_separator_powerline_swaps_default_separator() {
let cfg = config::Config::from_str(
r#"
[line]
segments = ["model", "workspace"]
[layout_options]
separator = "powerline"
"#,
)
.expect("parse");
let segs = built(Some(&cfg));
for seg in &segs {
assert_eq!(
seg.defaults().default_separator,
Separator::powerline(),
"segment didn't pick up powerline separator"
);
}
}
#[test]
fn layout_separator_space_is_passthrough() {
let cfg = config::Config::from_str(
r#"
[line]
segments = ["model"]
[layout_options]
separator = "space"
"#,
)
.expect("parse");
let segs = built(Some(&cfg));
assert_eq!(segs[0].defaults().default_separator, Separator::Space);
}
#[test]
fn layout_separator_capsule_warns_and_falls_back_to_space() {
let cfg = config::Config::from_str(
r#"
[line]
segments = ["model"]
[layout_options]
separator = "capsule"
"#,
)
.expect("parse");
let (segs, warns) = built_with_warns(Some(&cfg));
assert_eq!(segs[0].defaults().default_separator, Separator::Space);
assert!(
warns
.iter()
.any(|m| m.contains("capsule") && m.contains("v0.2+")),
"missing capsule deferral warning: {warns:?}"
);
}
#[test]
fn layout_separator_arbitrary_string_renders_as_literal() {
let cfg = config::Config::from_str(
r#"
[line]
segments = ["model"]
[layout_options]
separator = " | "
"#,
)
.expect("parse");
let (segs, warns) = built_with_warns(Some(&cfg));
assert_eq!(
segs[0].defaults().default_separator,
Separator::Literal(std::borrow::Cow::Owned(" | ".to_string()))
);
assert!(warns.is_empty(), "no warnings on literal: {warns:?}");
}
#[test]
fn layout_separator_empty_string_yields_none() {
let cfg = config::Config::from_str(
r#"
[line]
segments = ["model"]
[layout_options]
separator = ""
"#,
)
.expect("parse");
let (segs, warns) = built_with_warns(Some(&cfg));
assert_eq!(segs[0].defaults().default_separator, Separator::None);
assert!(
warns.is_empty(),
"empty string is a valid choice: {warns:?}"
);
}
#[test]
fn build_segments_empty_config_falls_back_to_defaults() {
let cfg = config::Config::default();
assert_eq!(built(Some(&cfg)).len(), DEFAULT_SEGMENT_IDS.len());
}
#[test]
fn layout_separator_preserves_segment_literal_default() {
struct PipeSeg;
impl segments::Segment for PipeSeg {
fn render(
&self,
_: &crate::data_context::DataContext,
_: &segments::RenderContext,
) -> segments::RenderResult {
Ok(Some(segments::RenderedSegment::new("x")))
}
fn defaults(&self) -> segments::SegmentDefaults {
segments::SegmentDefaults::with_priority(0)
.with_default_separator(Separator::Literal(std::borrow::Cow::Borrowed(" | ")))
}
}
let wrapped = apply_layout_separator(Box::new(PipeSeg), &Separator::powerline());
assert_eq!(
wrapped.defaults().default_separator,
Separator::Literal(std::borrow::Cow::Borrowed(" | ")),
);
}
#[test]
fn layout_separator_preserves_segment_none_default() {
struct NoSepSeg;
impl segments::Segment for NoSepSeg {
fn render(
&self,
_: &crate::data_context::DataContext,
_: &segments::RenderContext,
) -> segments::RenderResult {
Ok(Some(segments::RenderedSegment::new("x")))
}
fn defaults(&self) -> segments::SegmentDefaults {
segments::SegmentDefaults::with_priority(0).with_default_separator(Separator::None)
}
}
let wrapped = apply_layout_separator(Box::new(NoSepSeg), &Separator::powerline());
assert_eq!(wrapped.defaults().default_separator, Separator::None);
}
#[test]
fn layout_separator_does_not_double_wrap_when_default_already_powerline() {
struct PowerlineSeg;
impl segments::Segment for PowerlineSeg {
fn render(
&self,
_: &crate::data_context::DataContext,
_: &segments::RenderContext,
) -> segments::RenderResult {
Ok(Some(segments::RenderedSegment::new("x")))
}
fn defaults(&self) -> segments::SegmentDefaults {
segments::SegmentDefaults::with_priority(0)
.with_default_separator(Separator::powerline())
}
}
let wrapped = apply_layout_separator(Box::new(PowerlineSeg), &Separator::powerline());
assert_eq!(wrapped.defaults().default_separator, Separator::powerline());
}
#[test]
fn layout_separator_handles_mixed_case_and_whitespace() {
let mut warns = Vec::new();
let mut warn = |m: &str| warns.push(m.to_string());
assert_eq!(
parse_layout_separator("Powerline", PowerlineWidth::One, &mut warn),
Separator::powerline()
);
assert_eq!(
parse_layout_separator(" POWERLINE ", PowerlineWidth::One, &mut warn),
Separator::powerline()
);
assert_eq!(
parse_layout_separator(" Space ", PowerlineWidth::One, &mut warn),
Separator::Space
);
assert!(
warns.is_empty(),
"no warnings on case/whitespace: {warns:?}"
);
}
#[test]
fn layout_separator_whitespace_only_renders_as_literal() {
let mut warns = Vec::new();
let mut warn = |m: &str| warns.push(m.to_string());
assert_eq!(
parse_layout_separator(" ", PowerlineWidth::One, &mut warn),
Separator::Literal(std::borrow::Cow::Owned(" ".to_string()))
);
assert_eq!(
parse_layout_separator("", PowerlineWidth::One, &mut warn),
Separator::None
);
assert!(
warns.is_empty(),
"no warns on whitespace literal: {warns:?}"
);
}
#[test]
fn layout_separator_typo_renders_as_literal_not_warn() {
let mut warns = Vec::new();
let mut warn = |m: &str| warns.push(m.to_string());
assert_eq!(
parse_layout_separator("powereline", PowerlineWidth::One, &mut warn),
Separator::Literal(std::borrow::Cow::Owned("powereline".to_string()))
);
assert!(warns.is_empty(), "typos don't warn: {warns:?}");
}
#[test]
fn layout_separator_powerline_overrides_runtime_right_separator() {
struct RuntimeSpaceSeg;
impl segments::Segment for RuntimeSpaceSeg {
fn render(
&self,
_: &crate::data_context::DataContext,
_: &segments::RenderContext,
) -> segments::RenderResult {
Ok(Some(segments::RenderedSegment::with_separator(
"x",
Separator::Space,
)))
}
fn defaults(&self) -> segments::SegmentDefaults {
segments::SegmentDefaults::with_priority(0)
}
}
let layout_sep = parse_layout_separator("powerline", PowerlineWidth::One, &mut |_| {});
let wrapped = apply_layout_separator(Box::new(RuntimeSpaceSeg), &layout_sep);
let rendered = wrapped
.render(&stub_ctx(), &stub_rc())
.unwrap()
.expect("rendered");
assert_eq!(
rendered.right_separator(),
Some(&Separator::powerline()),
"layout-options separator must override runtime Space"
);
}
#[test]
fn plugin_runtime_space_emits_chevron_through_render_with_warn() {
struct RuntimeSpaceSeg(&'static str);
impl segments::Segment for RuntimeSpaceSeg {
fn render(
&self,
_: &crate::data_context::DataContext,
_: &segments::RenderContext,
) -> segments::RenderResult {
Ok(Some(segments::RenderedSegment::with_separator(
self.0,
Separator::Space,
)))
}
fn defaults(&self) -> segments::SegmentDefaults {
segments::SegmentDefaults::with_priority(0)
}
}
let layout_sep = parse_layout_separator("powerline", PowerlineWidth::One, &mut |_| {});
let segs: Vec<Box<dyn segments::Segment>> = vec![
apply_layout_separator(Box::new(RuntimeSpaceSeg("a")), &layout_sep),
apply_layout_separator(Box::new(RuntimeSpaceSeg("b")), &layout_sep),
];
let line = crate::layout::render_with_warn(
&segs,
&stub_ctx(),
100,
&mut |_| {},
theme::default_theme(),
theme::Capability::None,
false,
);
assert!(line.contains(" \u{E0B0} "), "chevron in output: {line:?}");
assert!(
!line.contains("a b"),
"Space should not survive between a and b: {line:?}"
);
}
#[test]
fn layout_separator_powerline_preserves_runtime_literal_right_separator() {
struct RuntimePipeSeg;
impl segments::Segment for RuntimePipeSeg {
fn render(
&self,
_: &crate::data_context::DataContext,
_: &segments::RenderContext,
) -> segments::RenderResult {
Ok(Some(segments::RenderedSegment::with_separator(
"x",
Separator::Literal(std::borrow::Cow::Borrowed(" | ")),
)))
}
fn defaults(&self) -> segments::SegmentDefaults {
segments::SegmentDefaults::with_priority(0)
}
}
let layout_sep = parse_layout_separator("powerline", PowerlineWidth::One, &mut |_| {});
let wrapped = apply_layout_separator(Box::new(RuntimePipeSeg), &layout_sep);
let rendered = wrapped
.render(&stub_ctx(), &stub_rc())
.unwrap()
.expect("rendered");
assert_eq!(
rendered.right_separator(),
Some(&Separator::Literal(std::borrow::Cow::Borrowed(" | ")))
);
}
fn stub_ctx() -> crate::data_context::DataContext {
use crate::input::{ModelInfo, StatusContext, Tool, WorkspaceInfo};
use std::path::PathBuf;
use std::sync::Arc;
crate::data_context::DataContext::new(StatusContext {
tool: Tool::ClaudeCode,
model: Some(ModelInfo {
display_name: "X".into(),
}),
workspace: Some(WorkspaceInfo {
project_dir: PathBuf::from("/r"),
git_worktree: None,
}),
context_window: None,
cost: None,
effort: None,
vim: None,
output_style: None,
agent_name: None,
version: None,
raw: Arc::new(serde_json::Value::Null),
})
}
fn stub_rc() -> segments::RenderContext {
segments::RenderContext::new(80)
}
#[test]
fn layout_separator_pipe_literal_no_warning() {
let mut warns = Vec::new();
let mut warn = |m: &str| warns.push(m.to_string());
assert_eq!(
parse_layout_separator("|", PowerlineWidth::One, &mut warn),
Separator::Literal(std::borrow::Cow::Owned("|".to_string()))
);
assert!(warns.is_empty(), "no warning on literal: {warns:?}");
}
#[test]
fn layout_separator_single_space_renders_as_literal_not_keyword() {
let mut warns = Vec::new();
let mut warn = |m: &str| warns.push(m.to_string());
assert_eq!(
parse_layout_separator(" ", PowerlineWidth::One, &mut warn),
Separator::Literal(std::borrow::Cow::Owned(" ".to_string()))
);
assert!(
warns.is_empty(),
"no warning on single-space literal: {warns:?}"
);
}
#[test]
fn apply_layout_separator_wraps_when_configured_literal_replaces_space_default() {
struct SpaceDefaultSeg;
impl segments::Segment for SpaceDefaultSeg {
fn render(
&self,
_: &crate::data_context::DataContext,
_: &segments::RenderContext,
) -> segments::RenderResult {
Ok(Some(segments::RenderedSegment::new("x")))
}
fn defaults(&self) -> segments::SegmentDefaults {
segments::SegmentDefaults::with_priority(0)
}
}
let sep = Separator::Literal(std::borrow::Cow::Owned(" | ".to_string()));
let wrapped = apply_layout_separator(Box::new(SpaceDefaultSeg), &sep);
assert_eq!(wrapped.defaults().default_separator, sep);
}
#[test]
fn apply_layout_separator_wraps_when_configured_none_replaces_space_default() {
struct SpaceDefaultSeg;
impl segments::Segment for SpaceDefaultSeg {
fn render(
&self,
_: &crate::data_context::DataContext,
_: &segments::RenderContext,
) -> segments::RenderResult {
Ok(Some(segments::RenderedSegment::new("x")))
}
fn defaults(&self) -> segments::SegmentDefaults {
segments::SegmentDefaults::with_priority(0)
}
}
let wrapped = apply_layout_separator(Box::new(SpaceDefaultSeg), &Separator::None);
assert_eq!(wrapped.defaults().default_separator, Separator::None);
}
#[test]
fn powerline_width_2_propagates_to_separator_variant() {
let cfg = config::Config::from_str(
r#"
[line]
segments = ["model"]
[layout_options]
separator = "powerline"
powerline_width = 2
"#,
)
.expect("parse");
let segs = built(Some(&cfg));
assert_eq!(
segs[0].defaults().default_separator,
Separator::Powerline {
width: PowerlineWidth::Two,
}
);
}
#[test]
fn powerline_width_default_is_1_when_unset() {
let cfg = config::Config::from_str(
r#"
[line]
segments = ["model"]
[layout_options]
separator = "powerline"
"#,
)
.expect("parse");
let segs = built(Some(&cfg));
assert_eq!(segs[0].defaults().default_separator, Separator::powerline(),);
}
#[test]
fn powerline_width_invalid_warns_and_falls_back_to_1() {
let cfg = config::Config::from_str(
r#"
[line]
segments = ["model"]
[layout_options]
separator = "powerline"
powerline_width = 3
"#,
)
.expect("parse");
let (segs, warns) = built_with_warns(Some(&cfg));
assert_eq!(segs[0].defaults().default_separator, Separator::powerline());
assert!(
warns
.iter()
.any(|m| m.contains("powerline_width") && m.contains("3")),
"missing invalid-width warning: {warns:?}"
);
}
#[test]
fn powerline_width_zero_warns_and_falls_back_to_1() {
let mut warns = Vec::new();
let mut warn = |m: &str| warns.push(m.to_string());
assert_eq!(validate_powerline_width(0, &mut warn), PowerlineWidth::One);
assert!(
warns
.iter()
.any(|m| m.contains("powerline_width") && m.contains('0')),
"missing zero warning: {warns:?}"
);
}
#[test]
fn powerline_width_max_warns_and_falls_back_to_1() {
let mut warns = Vec::new();
let mut warn = |m: &str| warns.push(m.to_string());
assert_eq!(
validate_powerline_width(u16::MAX, &mut warn),
PowerlineWidth::One
);
assert!(
warns.iter().any(|m| m.contains("powerline_width")),
"missing max-width warning: {warns:?}"
);
}
#[test]
fn layout_separator_absent_section_resolves_to_space() {
let cfg = config::Config::from_str(
r#"
[line]
segments = ["model"]
"#,
)
.expect("parse");
let segs = built(Some(&cfg));
assert_eq!(segs[0].defaults().default_separator, Separator::Space);
}
#[test]
fn build_segments_uses_configured_line_order() {
let cfg = config::Config::from_str(
r#"
[line]
segments = ["workspace", "model"]
"#,
)
.expect("parse");
let got = built(Some(&cfg));
assert_eq!(got.len(), 2);
assert_eq!(got[0].defaults().priority, 16); assert_eq!(got[1].defaults().priority, 64); }
#[test]
fn build_segments_applies_priority_override() {
let cfg = config::Config::from_str(
r#"
[line]
segments = ["model"]
[segments.model]
priority = 0
"#,
)
.expect("parse");
let got = built(Some(&cfg));
assert_eq!(got[0].defaults().priority, 0);
}
#[test]
fn build_segments_applies_width_override() {
let cfg = config::Config::from_str(
r#"
[line]
segments = ["workspace"]
[segments.workspace.width]
min = 5
max = 30
"#,
)
.expect("parse");
let got = built(Some(&cfg));
let bounds = got[0].defaults().width.expect("width set");
assert_eq!(bounds.min(), 5);
assert_eq!(bounds.max(), 30);
}
#[test]
fn build_segments_skips_unknown_ids_and_warns() {
let cfg = config::Config::from_str(
r#"
[line]
segments = ["model", "does_not_exist", "workspace"]
"#,
)
.expect("parse");
let mut warnings = Vec::new();
let got = build_segments(Some(&cfg), None, |msg| warnings.push(msg.to_string()));
assert_eq!(got.len(), 2);
assert_eq!(warnings.len(), 1);
assert!(warnings[0].contains("does_not_exist"));
}
#[test]
fn build_segments_dedupes_duplicates_with_warning() {
let cfg = config::Config::from_str(
r#"
[line]
segments = ["model", "model", "workspace"]
"#,
)
.expect("parse");
let mut warnings = Vec::new();
let got = build_segments(Some(&cfg), None, |msg| warnings.push(msg.to_string()));
assert_eq!(got.len(), 2); assert_eq!(warnings.len(), 1);
assert!(warnings[0].contains("model"));
assert!(warnings[0].contains("more than once"));
}
#[test]
fn build_segments_warns_on_explicitly_empty_segment_list() {
let cfg = config::Config::from_str(
r#"
[line]
segments = []
"#,
)
.expect("parse");
let mut warnings = Vec::new();
let got = build_segments(Some(&cfg), None, |msg| warnings.push(msg.to_string()));
assert!(got.is_empty());
assert_eq!(warnings.len(), 1);
assert!(warnings[0].contains("empty"));
}
#[test]
fn build_segments_warns_on_inverted_width_bounds() {
let cfg = config::Config::from_str(
r#"
[line]
segments = ["workspace"]
[segments.workspace.width]
min = 40
max = 10
"#,
)
.expect("parse");
let mut warnings = Vec::new();
let got = build_segments(Some(&cfg), None, |msg| warnings.push(msg.to_string()));
assert_eq!(got.len(), 1);
assert_eq!(got[0].defaults().width, None);
assert_eq!(warnings.len(), 1);
assert!(warnings[0].contains("min"));
assert!(warnings[0].contains("max"));
}
struct StubWithWidth;
impl Segment for StubWithWidth {
fn render(
&self,
_: &crate::data_context::DataContext,
_: &segments::RenderContext,
) -> segments::RenderResult {
Ok(Some(segments::RenderedSegment::new("x")))
}
fn defaults(&self) -> segments::SegmentDefaults {
segments::SegmentDefaults::with_priority(128)
.with_width(WidthBounds::new(10, 50).expect("valid"))
}
}
fn merge_width(min: Option<u16>, max: Option<u16>) -> WidthBounds {
let ov = config::SegmentOverride {
priority: None,
width: Some(config::WidthBoundsConfig { min, max }),
style: None,
extra: BTreeMap::new(),
};
let wrapped = apply_override("stub", Box::new(StubWithWidth), Some(&ov), &mut |_| {});
wrapped.defaults().width.expect("width preserved")
}
#[test]
fn width_merge_min_only_inherits_max_from_inner_default() {
let got = merge_width(Some(5), None);
assert_eq!(got.min(), 5);
assert_eq!(got.max(), 50);
}
#[test]
fn width_merge_max_only_inherits_min_from_inner_default() {
let got = merge_width(None, Some(80));
assert_eq!(got.min(), 10);
assert_eq!(got.max(), 80);
}
#[test]
fn width_merge_both_sides_override_inner_default() {
let got = merge_width(Some(3), Some(40));
assert_eq!(got.min(), 3);
assert_eq!(got.max(), 40);
}
#[test]
fn width_merge_empty_override_keeps_inner_default() {
let got = merge_width(None, None);
assert_eq!(got.min(), 10);
assert_eq!(got.max(), 50);
}
fn rc() -> crate::segments::RenderContext {
crate::segments::RenderContext::new(80)
}
fn model_ctx(display_name: &str) -> crate::data_context::DataContext {
use crate::input::{ModelInfo, Tool, WorkspaceInfo};
use std::path::PathBuf;
use std::sync::Arc;
crate::data_context::DataContext::new(input::StatusContext {
tool: Tool::ClaudeCode,
model: Some(ModelInfo {
display_name: display_name.into(),
}),
workspace: Some(WorkspaceInfo {
project_dir: PathBuf::from("/repo"),
git_worktree: None,
}),
context_window: None,
cost: None,
effort: None,
vim: None,
output_style: None,
agent_name: None,
version: None,
raw: Arc::new(serde_json::Value::Null),
})
}
#[test]
fn style_override_replaces_segment_declared_style_at_render_time() {
use crate::theme::{Color, Role};
let cfg = config::Config::from_str(
r#"
[line]
segments = ["model"]
[segments.model]
style = "role:accent bold italic"
"#,
)
.expect("parse");
let built = build_segments(Some(&cfg), None, |_| {});
let rendered = built[0]
.render(&model_ctx("Claude Sonnet 4.6"), &rc())
.expect("render ok")
.expect("visible");
assert_eq!(rendered.style.role, Some(Role::Accent));
assert_eq!(rendered.style.fg, None::<Color>);
assert!(rendered.style.bold);
assert!(rendered.style.italic);
assert!(!rendered.style.underline);
assert!(!rendered.style.dim);
}
#[test]
fn style_override_with_explicit_fg_populates_fg_slot() {
use crate::theme::Color;
let cfg = config::Config::from_str(
r#"
[line]
segments = ["model"]
[segments.model]
style = "fg:#ff0000 underline"
"#,
)
.expect("parse");
let built = build_segments(Some(&cfg), None, |_| {});
let rendered = built[0]
.render(&model_ctx("Claude Sonnet 4.6"), &rc())
.expect("render ok")
.expect("visible");
assert_eq!(
rendered.style.fg,
Some(Color::TrueColor { r: 255, g: 0, b: 0 })
);
assert!(rendered.style.underline);
}
#[test]
fn invalid_style_string_warns_and_leaves_segment_style_unchanged() {
use crate::theme::Role;
let cfg = config::Config::from_str(
r#"
[line]
segments = ["model"]
[segments.model]
style = "role:mauve"
"#,
)
.expect("parse");
let mut warnings = Vec::new();
let built = build_segments(Some(&cfg), None, |m| warnings.push(m.to_string()));
let rendered = built[0]
.render(&model_ctx("Claude Sonnet 4.6"), &rc())
.expect("render ok")
.expect("visible");
assert_eq!(rendered.style.role, Some(Role::Primary));
assert_eq!(warnings.len(), 1);
assert!(warnings[0].contains("segments.model.style"));
assert!(warnings[0].contains("mauve"));
assert!(warnings[0].contains("ignoring"));
}
#[test]
fn empty_style_string_is_noop_and_preserves_segment_declared_style() {
use crate::theme::Role;
let cfg = config::Config::from_str(
r#"
[line]
segments = ["model"]
[segments.model]
style = ""
"#,
)
.expect("parse");
let built = build_segments(Some(&cfg), None, |_| {});
let rendered = built[0]
.render(&model_ctx("Claude Sonnet 4.6"), &rc())
.expect("render ok")
.expect("visible");
assert_eq!(rendered.style.role, Some(Role::Primary));
}
#[test]
fn whitespace_only_style_string_is_noop_and_preserves_segment_declared_style() {
use crate::theme::Role;
let cfg = config::Config::from_str(
r#"
[line]
segments = ["model"]
[segments.model]
style = " "
"#,
)
.expect("parse");
let built = build_segments(Some(&cfg), None, |_| {});
let rendered = built[0]
.render(&model_ctx("Claude Sonnet 4.6"), &rc())
.expect("render ok")
.expect("visible");
assert_eq!(rendered.style.role, Some(Role::Primary));
}
fn write_plugin(dir: &std::path::Path, name: &str, src: &str) -> std::path::PathBuf {
let p = dir.join(name);
std::fs::write(&p, src).expect("write plugin");
p
}
#[test]
fn plugin_id_resolves_through_build_segments() {
let tmp = tempfile::TempDir::new().expect("tempdir");
write_plugin(
tmp.path(),
"p.rhai",
r#"
const ID = "my_plugin";
fn render(ctx) { #{ runs: [#{ text: "from-plugin" }] } }
"#,
);
let engine = crate::plugins::build_engine();
let registry = crate::plugins::PluginRegistry::load_with_xdg(
&[tmp.path().to_path_buf()],
None,
&engine,
BUILT_IN_SEGMENT_IDS,
);
assert!(
registry.load_errors().is_empty(),
"load errors: {:?}",
registry.load_errors()
);
let cfg = config::Config::from_str(
r#"
[line]
segments = ["model", "my_plugin"]
"#,
)
.expect("parse");
let built = build_segments(Some(&cfg), Some((registry, engine)), |_| {});
assert_eq!(built.len(), 2);
assert_eq!(built[0].defaults().priority, 64);
assert_eq!(built[1].defaults().priority, 128);
let dc = model_ctx("Sonnet");
let plugin_render = built[1]
.render(&dc, &rc())
.expect("plugin render ok")
.expect("visible");
assert_eq!(plugin_render.text(), "from-plugin");
}
#[test]
fn build_segments_falls_back_to_first_line_for_multi_line_configs() {
let cfg = config::Config::from_str(
r#"
layout = "multi-line"
[line.1]
segments = ["model", "workspace"]
[line.2]
segments = ["context_window", "cost"]
"#,
)
.expect("parse");
let (segs, warns) = built_with_warns(Some(&cfg));
assert_eq!(
segs.len(),
2,
"expected line 1's two segments, got {} segs",
segs.len()
);
let actual: Vec<u8> = segs.iter().map(|s| s.defaults().priority).collect();
assert_eq!(actual, priorities_for(&["model", "workspace"]));
assert!(
warns
.iter()
.any(|w| w.contains("multi-line") && w.contains("build_lines")),
"expected migration hint pointing at build_lines, got: {warns:?}"
);
}
#[test]
fn build_lines_plugin_referenced_in_two_lines_warns_specifically_on_second() {
let tmp = tempfile::TempDir::new().expect("tempdir");
write_plugin(
tmp.path(),
"p.rhai",
r#"
const ID = "my_plugin";
fn render(ctx) { #{ runs: [#{ text: "from-plugin" }] } }
"#,
);
let engine = crate::plugins::build_engine();
let registry = crate::plugins::PluginRegistry::load_with_xdg(
&[tmp.path().to_path_buf()],
None,
&engine,
BUILT_IN_SEGMENT_IDS,
);
assert!(registry.load_errors().is_empty());
let cfg = config::Config::from_str(
r#"
layout = "multi-line"
[line.1]
segments = ["my_plugin", "model"]
[line.2]
segments = ["my_plugin", "workspace"]
"#,
)
.expect("parse");
let mut warns: Vec<String> = Vec::new();
let lines = build_lines(Some(&cfg), Some((registry, engine)), |m| {
warns.push(m.to_string())
});
assert_eq!(lines.len(), 2);
assert_eq!(lines[0].len(), 2, "line 1 keeps plugin + model");
assert_eq!(
lines[1].len(),
1,
"line 2 drops the reused plugin, keeps workspace"
);
assert!(
warns
.iter()
.any(|w| w.contains("'my_plugin'") && w.contains("rendered on an earlier line")),
"expected specific cross-line plugin warning, got: {warns:?}"
);
assert!(
!warns
.iter()
.any(|w| w.contains("unknown segment id 'my_plugin'")),
"should NOT use the generic 'unknown segment id' text for cross-line reuse, got: {warns:?}"
);
}
#[test]
fn unknown_id_with_plugin_registry_still_warns() {
let tmp = tempfile::TempDir::new().expect("tempdir");
write_plugin(
tmp.path(),
"p.rhai",
r#"
const ID = "loaded";
fn render(ctx) { () }
"#,
);
let engine = crate::plugins::build_engine();
let registry = crate::plugins::PluginRegistry::load_with_xdg(
&[tmp.path().to_path_buf()],
None,
&engine,
BUILT_IN_SEGMENT_IDS,
);
let cfg = config::Config::from_str(
r#"
[line]
segments = ["loaded", "missing_plugin"]
"#,
)
.expect("parse");
let mut warnings = Vec::new();
let built = build_segments(Some(&cfg), Some((registry, engine)), |m| {
warnings.push(m.to_string())
});
assert_eq!(built.len(), 1);
assert_eq!(warnings.len(), 1);
assert!(warnings[0].contains("missing_plugin"));
}
#[test]
fn plugin_receives_extra_keys_from_segments_table_as_ctx_config() {
let tmp = tempfile::TempDir::new().expect("tempdir");
write_plugin(
tmp.path(),
"labelled.rhai",
r#"
const ID = "labelled";
fn render(ctx) {
#{ runs: [#{ text: ctx.config.label }] }
}
"#,
);
let engine = crate::plugins::build_engine();
let registry = crate::plugins::PluginRegistry::load_with_xdg(
&[tmp.path().to_path_buf()],
None,
&engine,
BUILT_IN_SEGMENT_IDS,
);
let cfg = config::Config::from_str(
r#"
[line]
segments = ["labelled"]
[segments.labelled]
label = "from-toml"
"#,
)
.expect("parse");
let built = build_segments(Some(&cfg), Some((registry, engine)), |_| {});
assert_eq!(built.len(), 1);
let dc = model_ctx("Sonnet");
let rendered = built[0]
.render(&dc, &rc())
.expect("render ok")
.expect("visible");
assert_eq!(rendered.text(), "from-toml");
}
#[test]
fn built_in_id_wins_over_plugin_with_same_id() {
let tmp = tempfile::TempDir::new().expect("tempdir");
write_plugin(
tmp.path(),
"ghost.rhai",
r#"
const ID = "model";
fn render(_) { #{ runs: [#{ text: "from-plugin" }] } }
"#,
);
let engine = crate::plugins::build_engine();
let registry = crate::plugins::PluginRegistry::load_with_xdg(
&[tmp.path().to_path_buf()],
None,
&engine,
&[],
);
let cfg = config::Config::from_str(
r#"
[line]
segments = ["model"]
"#,
)
.expect("parse");
let built = build_segments(Some(&cfg), Some((registry, engine)), |_| {});
assert_eq!(built.len(), 1);
assert_eq!(built[0].defaults().priority, 64);
}
#[test]
fn build_segments_forward_compat_keys_dont_break_parsing() {
let cfg = config::Config::from_str(
r#"
theme = "catppuccin-mocha"
preset = "developer"
layout = "single-line"
[line]
segments = ["model"]
[layout_options]
separator = "powerline"
"#,
)
.expect("parse");
assert_eq!(built(Some(&cfg)).len(), 1);
}
fn lines(cfg: Option<&config::Config>) -> Vec<Vec<Box<dyn Segment>>> {
build_lines(cfg, None, |_| {})
}
fn lines_with_warns(cfg: Option<&config::Config>) -> (Vec<Vec<Box<dyn Segment>>>, Vec<String>) {
let mut warns = Vec::new();
let result = build_lines(cfg, None, |m| warns.push(m.to_string()));
(result, warns)
}
fn priorities_for(ids: &[&str]) -> Vec<u8> {
ids.iter()
.map(|id| {
built_in_by_id(id, None, &mut |_| {})
.unwrap_or_else(|| panic!("unknown built-in id in test fixture: {id}"))
.defaults()
.priority
})
.collect()
}
fn priorities_per_line(built: &[Vec<Box<dyn Segment>>]) -> Vec<Vec<u8>> {
built
.iter()
.map(|line| line.iter().map(|s| s.defaults().priority).collect())
.collect()
}
#[test]
fn build_lines_single_line_default_returns_one_line_with_default_segments() {
let result = lines(None);
assert_eq!(result.len(), 1);
assert_eq!(result[0].len(), DEFAULT_SEGMENT_IDS.len());
}
#[test]
fn build_lines_explicit_single_line_returns_one_line_from_segments() {
let cfg = config::Config::from_str(
r#"
layout = "single-line"
[line]
segments = ["model", "workspace"]
"#,
)
.expect("parse");
let result = lines(Some(&cfg));
assert_eq!(
priorities_per_line(&result),
vec![priorities_for(&["model", "workspace"])]
);
}
#[test]
fn build_lines_multi_line_returns_one_inner_vec_per_numbered_table() {
let cfg = config::Config::from_str(
r#"
layout = "multi-line"
[line.1]
segments = ["model", "context_window"]
[line.2]
segments = ["workspace", "cost"]
"#,
)
.expect("parse");
let result = lines(Some(&cfg));
assert_eq!(
priorities_per_line(&result),
vec![
priorities_for(&["model", "context_window"]),
priorities_for(&["workspace", "cost"]),
]
);
}
#[test]
fn build_lines_multi_line_sorts_by_parsed_integer_not_lexicographic() {
let cfg = config::Config::from_str(
r#"
layout = "multi-line"
[line.2]
segments = ["workspace"]
[line.10]
segments = ["context_window"]
[line.1]
segments = ["model"]
"#,
)
.expect("parse");
let result = lines(Some(&cfg));
assert_eq!(
priorities_per_line(&result),
vec![
priorities_for(&["model"]),
priorities_for(&["workspace"]),
priorities_for(&["context_window"]),
]
);
}
#[test]
fn build_lines_multi_line_with_no_numbered_tables_falls_back_to_single_line() {
let cfg = config::Config::from_str(
r#"
layout = "multi-line"
[line]
segments = ["model", "workspace"]
"#,
)
.expect("parse");
let (result, warns) = lines_with_warns(Some(&cfg));
assert_eq!(
priorities_per_line(&result),
vec![priorities_for(&["model", "workspace"])]
);
assert!(
warns.iter().any(|w| w.contains("no usable [line.N]")),
"expected fallback warning, got: {warns:?}"
);
}
#[test]
fn build_lines_single_line_with_numbered_tables_warns_and_ignores_them() {
let cfg = config::Config::from_str(
r#"
layout = "single-line"
[line]
segments = ["model"]
[line.1]
segments = ["workspace"]
"#,
)
.expect("parse");
let (result, warns) = lines_with_warns(Some(&cfg));
assert_eq!(
priorities_per_line(&result),
vec![priorities_for(&["model"])]
);
assert!(
warns
.iter()
.any(|w| w.contains("single-line") && w.contains("[line.N]")),
"expected mode-mismatch warning, got: {warns:?}"
);
}
#[test]
fn build_lines_default_layout_with_numbered_tables_warns_and_ignores_them() {
let cfg = config::Config::from_str(
r#"
[line]
segments = ["model"]
[line.1]
segments = ["workspace"]
"#,
)
.expect("parse");
let (result, warns) = lines_with_warns(Some(&cfg));
assert_eq!(
priorities_per_line(&result),
vec![priorities_for(&["model"])]
);
assert!(
warns.iter().any(|w| w.contains("[line.N]")),
"expected mode-mismatch warning, got: {warns:?}"
);
}
#[test]
fn build_lines_promotes_to_multi_line_when_layout_unset_and_segments_empty() {
let cfg = config::Config::from_str(
r#"
[line.1]
segments = ["model"]
[line.2]
segments = ["workspace"]
"#,
)
.expect("parse");
let (result, warns) = lines_with_warns(Some(&cfg));
assert_eq!(
priorities_per_line(&result),
vec![priorities_for(&["model"]), priorities_for(&["workspace"]),],
"must render both numbered lines, not a blank single-line"
);
assert!(
warns
.iter()
.any(|w| w.contains("treating as multi-line") && w.contains("layout")),
"expected auto-promote hint, got: {warns:?}"
);
}
#[test]
fn build_lines_does_not_promote_when_segments_populated() {
let cfg = config::Config::from_str(
r#"
[line]
segments = ["model"]
[line.1]
segments = ["workspace"]
"#,
)
.expect("parse");
let (result, warns) = lines_with_warns(Some(&cfg));
assert_eq!(
priorities_per_line(&result),
vec![priorities_for(&["model"])],
"must render single-line `[line].segments`, not promote"
);
assert!(
warns.iter().any(|w| w.contains("ignoring numbered tables")),
"expected the existing 'ignoring' warning, not the promote hint, got: {warns:?}"
);
assert!(
!warns.iter().any(|w| w.contains("treating as multi-line")),
"must NOT auto-promote when segments is populated, got: {warns:?}"
);
}
#[test]
fn build_lines_unknown_scalar_key_under_line_warns_and_drops() {
let cfg = config::Config::from_str(
r#"
layout = "multi-line"
[line]
segmnts = ["model"]
[line.1]
segments = ["workspace"]
"#,
)
.expect("parse");
let (result, warns) = lines_with_warns(Some(&cfg));
assert_eq!(
priorities_per_line(&result),
vec![priorities_for(&["workspace"])]
);
assert!(
warns
.iter()
.any(|w| w.contains("unknown key 'segmnts'") && w.contains("array")),
"expected unknown-key warning naming the key + type, got: {warns:?}"
);
}
#[test]
fn build_lines_consumed_plugins_threads_across_three_or_more_lines() {
let tmp = tempfile::TempDir::new().expect("tempdir");
write_plugin(
tmp.path(),
"p.rhai",
r#"
const ID = "my_plugin";
fn render(ctx) { #{ runs: [#{ text: "from-plugin" }] } }
"#,
);
let engine = crate::plugins::build_engine();
let registry = crate::plugins::PluginRegistry::load_with_xdg(
&[tmp.path().to_path_buf()],
None,
&engine,
BUILT_IN_SEGMENT_IDS,
);
let cfg = config::Config::from_str(
r#"
layout = "multi-line"
[line.1]
segments = ["my_plugin", "model"]
[line.2]
segments = ["my_plugin", "workspace"]
[line.3]
segments = ["my_plugin", "context_window"]
"#,
)
.expect("parse");
let mut warns: Vec<String> = Vec::new();
let lines = build_lines(Some(&cfg), Some((registry, engine)), |m| {
warns.push(m.to_string())
});
assert_eq!(lines.len(), 3);
assert_eq!(lines[0].len(), 2, "line 1: plugin + model");
assert_eq!(lines[1].len(), 1, "line 2: plugin dropped, only workspace");
assert_eq!(
lines[2].len(),
1,
"line 3: plugin dropped, only context_window"
);
let cross_line_warns = warns
.iter()
.filter(|w| w.contains("rendered on an earlier line"))
.count();
assert_eq!(
cross_line_warns, 2,
"expected exactly two cross-line warnings (lines 2 + 3), got {cross_line_warns}: {warns:?}"
);
}
#[test]
fn build_segments_falls_back_to_line_one_even_when_top_segments_populated() {
let cfg = config::Config::from_str(
r#"
layout = "multi-line"
[line]
segments = ["cost"]
[line.1]
segments = ["model"]
"#,
)
.expect("parse");
let (segs, _warns) = built_with_warns(Some(&cfg));
let actual: Vec<u8> = segs.iter().map(|s| s.defaults().priority).collect();
assert_eq!(
actual,
priorities_for(&["model"]),
"fallback must use [line.1].segments, not the top-level [line].segments"
);
}
#[test]
fn build_segments_multi_line_with_only_invalid_numbered_keys_falls_through_to_single_line() {
let cfg = config::Config::from_str(
r#"
layout = "multi-line"
[line.foo]
segments = ["bogus"]
"#,
)
.expect("parse");
let (segs, warns) = built_with_warns(Some(&cfg));
assert!(segs.is_empty(), "no usable line means no segments rendered");
assert!(
warns
.iter()
.any(|w| w.contains("[line.foo]") && w.contains("not a positive integer")),
"must warn about the dropped non-numeric key, got: {warns:?}"
);
assert!(
warns.iter().any(|w| w.contains("[line].segments is empty")),
"must warn that the fallback finds nothing to render, got: {warns:?}"
);
}
#[test]
fn build_lines_multi_line_drops_non_numeric_keys_with_warning() {
let cfg = config::Config::from_str(
r#"
layout = "multi-line"
[line.1]
segments = ["model"]
[line.foo]
segments = ["bogus"]
[line.2]
segments = ["workspace"]
"#,
)
.expect("parse");
let (result, warns) = lines_with_warns(Some(&cfg));
assert_eq!(
priorities_per_line(&result),
vec![priorities_for(&["model"]), priorities_for(&["workspace"])]
);
assert!(
warns
.iter()
.any(|w| w.contains("[line.foo]") && w.contains("not a positive integer")),
"expected non-numeric-key warning, got: {warns:?}"
);
}
#[test]
fn build_lines_multi_line_drops_zero_and_negative_keys() {
let cfg = config::Config::from_str(
r#"
layout = "multi-line"
[line.0]
segments = ["context_window"]
[line.1]
segments = ["model"]
[line."-1"]
segments = ["cost"]
"#,
)
.expect("parse");
let (result, warns) = lines_with_warns(Some(&cfg));
assert_eq!(
priorities_per_line(&result),
vec![priorities_for(&["model"])]
);
assert!(warns.iter().any(|w| w.contains("[line.0]")));
assert!(warns.iter().any(|w| w.contains("[line.-1]")));
}
#[test]
fn build_lines_multi_line_with_only_invalid_keys_falls_back_to_single_line() {
let cfg = config::Config::from_str(
r#"
layout = "multi-line"
[line]
segments = ["model"]
[line.foo]
segments = ["bogus"]
"#,
)
.expect("parse");
let (result, warns) = lines_with_warns(Some(&cfg));
assert_eq!(
priorities_per_line(&result),
vec![priorities_for(&["model"])]
);
assert!(warns.iter().any(|w| w.contains("[line.foo]")));
assert!(warns.iter().any(|w| w.contains("no usable [line.N]")));
}
#[test]
fn build_lines_multi_line_warns_per_empty_numbered_segments() {
let cfg = config::Config::from_str(
r#"
layout = "multi-line"
[line.1]
segments = ["model"]
[line.2]
segments = []
"#,
)
.expect("parse");
let (result, warns) = lines_with_warns(Some(&cfg));
assert_eq!(
priorities_per_line(&result),
vec![priorities_for(&["model"]), Vec::<u8>::new()]
);
assert!(
warns
.iter()
.any(|w| w.contains("[line.2].segments is empty")),
"expected empty-segments warning for line 2, got: {warns:?}"
);
}
#[test]
fn build_lines_multi_line_ignores_top_level_segments_when_numbered_present() {
let cfg = config::Config::from_str(
r#"
layout = "multi-line"
[line]
segments = ["workspace"]
[line.1]
segments = ["model"]
"#,
)
.expect("parse");
let result = lines(Some(&cfg));
assert_eq!(
priorities_per_line(&result),
vec![priorities_for(&["model"])]
);
}
#[test]
fn build_lines_multi_line_dedupes_within_line_but_not_across_lines() {
let cfg = config::Config::from_str(
r#"
layout = "multi-line"
[line.1]
segments = ["model", "model", "workspace"]
[line.2]
segments = ["model"]
"#,
)
.expect("parse");
let (result, warns) = lines_with_warns(Some(&cfg));
assert_eq!(
priorities_per_line(&result),
vec![
priorities_for(&["model", "workspace"]),
priorities_for(&["model"]),
]
);
let dedup_warns: Vec<_> = warns
.iter()
.filter(|w| w.contains("listed more than once"))
.collect();
assert_eq!(
dedup_warns.len(),
1,
"expected one dedup warning, got: {warns:?}"
);
}
}