use super::dispatch::*;
use super::layout::*;
use super::plugins::*;
use std::borrow::Cow;
use std::collections::BTreeMap;
use std::str::FromStr;
use linesmith_plugin::PluginRegistry;
use crate::segments::{
self, built_in_by_id, LineItem, PowerlineWidth, Segment, Separator, WidthBounds,
BUILT_IN_SEGMENT_IDS, DEFAULT_SEGMENT_IDS,
};
use crate::{config, input, theme};
fn built(cfg: Option<&config::Config>) -> Vec<LineItem> {
build_segments(cfg, None, |_| {})
}
fn built_with_warns(cfg: Option<&config::Config>) -> (Vec<LineItem>, Vec<String>) {
let mut warns = Vec::new();
let items = build_segments(cfg, None, |m| warns.push(m.to_string()));
(items, warns)
}
fn segment_count(items: &[LineItem]) -> usize {
items
.iter()
.filter(|i| matches!(i, LineItem::Segment { .. }))
.count()
}
#[track_caller]
fn nth_segment(items: &[LineItem], n: usize) -> &dyn Segment {
items
.iter()
.filter_map(|i| match i {
LineItem::Segment { segment, .. } => Some(segment.as_ref()),
LineItem::Separator(_) => None,
})
.nth(n)
.unwrap_or_else(|| {
panic!(
"expected at least {} segments, got {}",
n + 1,
segment_count(items)
)
})
}
fn first_inline_separator(items: &[LineItem]) -> Option<&Separator> {
items.iter().find_map(|i| match i {
LineItem::Separator(s) => Some(s),
LineItem::Segment { .. } => None,
})
}
fn resolve_inline_separator(separator_toml_value: &str) -> (Separator, Vec<String>) {
let cfg = config::Config::from_str(&format!(
r#"
[line]
segments = ["model", "workspace"]
[layout_options]
separator = {separator_toml_value}
"#,
))
.expect("parse");
let (items, warns) = built_with_warns(Some(&cfg));
let sep = first_inline_separator(&items)
.expect("two-segment line must have one inline separator")
.clone();
(sep, warns)
}
#[test]
fn build_segments_uses_default_order_when_config_missing() {
assert_eq!(segment_count(&built(None)), DEFAULT_SEGMENT_IDS.len());
}
#[test]
fn layout_separator_powerline_lays_chevrons_between_segments() {
let cfg = config::Config::from_str(
r#"
[line]
segments = ["model", "workspace"]
[layout_options]
separator = "powerline"
"#,
)
.expect("parse");
let items = built(Some(&cfg));
assert_eq!(
first_inline_separator(&items),
Some(&Separator::powerline()),
);
}
#[test]
fn layout_separator_space_is_default() {
let (sep, warns) = resolve_inline_separator("\"space\"");
assert_eq!(sep, Separator::Space);
assert!(warns.is_empty());
}
#[test]
fn layout_separator_capsule_warns_and_falls_back_to_space() {
let (sep, warns) = resolve_inline_separator("\"capsule\"");
assert_eq!(sep, 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 (sep, warns) = resolve_inline_separator("\" | \"");
assert_eq!(
sep,
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 (sep, warns) = resolve_inline_separator("\"\"");
assert_eq!(sep, 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!(segment_count(&built(Some(&cfg))), DEFAULT_SEGMENT_IDS.len());
}
#[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 plugin_runtime_separator_override_replaces_inline_separator() {
struct OverrideNoneSeg(&'static str);
impl segments::Segment for OverrideNoneSeg {
fn render(
&self,
_: &crate::data_context::DataContext,
_: &segments::RenderContext,
) -> segments::RenderResult {
Ok(Some(segments::RenderedSegment::with_separator(
self.0,
Separator::None,
)))
}
fn defaults(&self) -> segments::SegmentDefaults {
segments::SegmentDefaults::with_priority(0)
}
}
let items: Vec<LineItem> = vec![
LineItem::Segment {
id: Cow::Borrowed("a"),
segment: Box::new(OverrideNoneSeg("a")),
},
LineItem::Separator(Separator::powerline()),
LineItem::Segment {
id: Cow::Borrowed("b"),
segment: Box::new(OverrideNoneSeg("b")),
},
];
let mut warn = |_: &str| {};
let mut observers = crate::layout::LayoutObservers::new(&mut warn);
let line = crate::layout::render_with_observers(
&items,
&stub_ctx(),
100,
&mut observers,
theme::default_theme(),
theme::Capability::None,
false,
);
assert_eq!(line, "ab");
}
#[test]
fn plugin_runtime_literal_override_replaces_inline_powerline() {
struct OverrideLiteralSeg(&'static str);
impl segments::Segment for OverrideLiteralSeg {
fn render(
&self,
_: &crate::data_context::DataContext,
_: &segments::RenderContext,
) -> segments::RenderResult {
Ok(Some(segments::RenderedSegment::with_separator(
self.0,
Separator::Literal(std::borrow::Cow::Borrowed(" | ")),
)))
}
fn defaults(&self) -> segments::SegmentDefaults {
segments::SegmentDefaults::with_priority(0)
}
}
let items: Vec<LineItem> = vec![
LineItem::Segment {
id: Cow::Borrowed("a"),
segment: Box::new(OverrideLiteralSeg("a")),
},
LineItem::Separator(Separator::powerline()),
LineItem::Segment {
id: Cow::Borrowed("b"),
segment: Box::new(OverrideLiteralSeg("b")),
},
];
let mut warn = |_: &str| {};
let mut observers = crate::layout::LayoutObservers::new(&mut warn);
let line = crate::layout::render_with_observers(
&items,
&stub_ctx(),
100,
&mut observers,
theme::default_theme(),
theme::Capability::None,
false,
);
assert_eq!(line, "a | b");
}
#[test]
fn plugin_runtime_override_on_last_segment_is_silently_discarded() {
struct OverrideNoneSeg(&'static str);
impl segments::Segment for OverrideNoneSeg {
fn render(
&self,
_: &crate::data_context::DataContext,
_: &segments::RenderContext,
) -> segments::RenderResult {
Ok(Some(segments::RenderedSegment::with_separator(
self.0,
Separator::powerline(),
)))
}
fn defaults(&self) -> segments::SegmentDefaults {
segments::SegmentDefaults::with_priority(0)
}
}
let items: Vec<LineItem> = vec![LineItem::Segment {
id: Cow::Borrowed("a"),
segment: Box::new(OverrideNoneSeg("a")),
}];
let mut warn = |_: &str| {};
let mut observers = crate::layout::LayoutObservers::new(&mut warn);
let line = crate::layout::render_with_observers(
&items,
&stub_ctx(),
100,
&mut observers,
theme::default_theme(),
theme::Capability::None,
false,
);
assert_eq!(line, "a");
}
#[test]
fn plugin_compact_form_separator_override_wins_over_pre_shrink_inline() {
struct ChevronUnlessCompactSeg;
impl segments::Segment for ChevronUnlessCompactSeg {
fn render(
&self,
_: &crate::data_context::DataContext,
_: &segments::RenderContext,
) -> segments::RenderResult {
Ok(Some(segments::RenderedSegment::with_separator(
"longprefix-with-tail",
Separator::powerline(),
)))
}
fn shrink_to_fit(
&self,
_: &crate::data_context::DataContext,
_: &segments::RenderContext,
target: u16,
) -> Option<segments::RenderedSegment> {
let r = segments::RenderedSegment::with_separator("compact", Separator::None);
(r.width() <= target).then_some(r)
}
fn defaults(&self) -> segments::SegmentDefaults {
segments::SegmentDefaults::with_priority(200)
}
}
struct AnchorSeg;
impl segments::Segment for AnchorSeg {
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 items: Vec<LineItem> = vec![
LineItem::Segment {
id: Cow::Borrowed("chevron"),
segment: Box::new(ChevronUnlessCompactSeg),
},
LineItem::Separator(Separator::powerline()),
LineItem::Segment {
id: Cow::Borrowed("anchor"),
segment: Box::new(AnchorSeg),
},
];
let mut warn = |_: &str| {};
let mut observers = crate::layout::LayoutObservers::new(&mut warn);
let line = crate::layout::render_with_observers(
&items,
&stub_ctx(),
11,
&mut observers,
theme::default_theme(),
theme::Capability::None,
false,
);
assert!(
!line.contains('\u{E0B0}'),
"chevron must be suppressed: {line:?}"
);
assert_eq!(line, "compactX");
}
#[test]
fn user_constructed_adjacent_separators_drop_second() {
struct RawSeg(&'static str);
impl segments::Segment for RawSeg {
fn render(
&self,
_: &crate::data_context::DataContext,
_: &segments::RenderContext,
) -> segments::RenderResult {
Ok(Some(segments::RenderedSegment::new(self.0)))
}
fn defaults(&self) -> segments::SegmentDefaults {
segments::SegmentDefaults::with_priority(0)
}
}
let items: Vec<LineItem> = vec![
LineItem::Segment {
id: Cow::Borrowed("a"),
segment: Box::new(RawSeg("a")),
},
LineItem::Separator(Separator::Literal(std::borrow::Cow::Borrowed(" | "))),
LineItem::Separator(Separator::Literal(std::borrow::Cow::Borrowed(" - "))),
LineItem::Segment {
id: Cow::Borrowed("b"),
segment: Box::new(RawSeg("b")),
},
];
let mut warn = |_: &str| {};
let mut observers = crate::layout::LayoutObservers::new(&mut warn);
let line = crate::layout::render_with_observers(
&items,
&stub_ctx(),
100,
&mut observers,
theme::default_theme(),
theme::Capability::None,
false,
);
assert_eq!(line, "a | b");
}
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),
})
}
#[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 powerline_width_2_propagates_to_inline_separator() {
let cfg = config::Config::from_str(
r#"
[line]
segments = ["model", "workspace"]
[layout_options]
separator = "powerline"
powerline_width = 2
"#,
)
.expect("parse");
let items = built(Some(&cfg));
assert_eq!(
first_inline_separator(&items),
Some(&Separator::Powerline {
width: PowerlineWidth::Two,
}),
);
}
#[test]
fn powerline_width_default_is_1_when_unset() {
let (sep, _) = resolve_inline_separator("\"powerline\"");
assert_eq!(sep, Separator::powerline());
}
#[test]
fn powerline_width_invalid_warns_and_falls_back_to_1() {
let cfg = config::Config::from_str(
r#"
[line]
segments = ["model", "workspace"]
[layout_options]
separator = "powerline"
powerline_width = 3
"#,
)
.expect("parse");
let (items, warns) = built_with_warns(Some(&cfg));
assert_eq!(
first_inline_separator(&items),
Some(&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", "workspace"]
"#,
)
.expect("parse");
let items = built(Some(&cfg));
assert_eq!(first_inline_separator(&items), Some(&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!(segment_count(&got), 2);
assert_eq!(nth_segment(&got, 0).defaults().priority, 16); assert_eq!(nth_segment(&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!(nth_segment(&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 = nth_segment(&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!(segment_count(&got), 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!(segment_count(&got), 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!(segment_count(&got), 1);
assert_eq!(nth_segment(&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 = nth_segment(&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 = nth_segment(&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 = nth_segment(&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 = nth_segment(&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 = nth_segment(&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 = 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!(segment_count(&built), 2);
assert_eq!(nth_segment(&built, 0).defaults().priority, 64);
assert_eq!(nth_segment(&built, 1).defaults().priority, 128);
let dc = model_ctx("Sonnet");
let plugin_render = nth_segment(&built, 1)
.render(&dc, &rc())
.expect("plugin render ok")
.expect("visible");
assert_eq!(plugin_render.text(), "from-plugin");
let plugin_item = built
.iter()
.find_map(|i| match i {
LineItem::Segment { id, .. } if id.as_ref() == "my_plugin" => Some(id.clone()),
_ => None,
})
.expect("plugin slot in built items");
assert!(
matches!(plugin_item, Cow::Owned(_)),
"plugin id must be Cow::Owned, got {plugin_item:?}",
);
}
#[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!(
segment_count(&segs),
2,
"expected line 1's two segments, got {} segs",
segment_count(&segs),
);
let actual: Vec<u8> = segs
.iter()
.filter_map(|i| match i {
LineItem::Segment { segment, .. } => Some(segment.defaults().priority),
LineItem::Separator(_) => None,
})
.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 = 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!(segment_count(&lines[0]), 2, "line 1 keeps plugin + model");
assert_eq!(
segment_count(&lines[1]),
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 = 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 = 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!(segment_count(&built), 1);
let dc = model_ctx("Sonnet");
let rendered = nth_segment(&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 = 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!(segment_count(&built), 1);
assert_eq!(nth_segment(&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!(segment_count(&built(Some(&cfg))), 1);
}
fn lines(cfg: Option<&config::Config>) -> Vec<Vec<LineItem>> {
build_lines(cfg, None, |_| {})
}
fn lines_with_warns(cfg: Option<&config::Config>) -> (Vec<Vec<LineItem>>, Vec<String>) {
let mut warns = Vec::new();
let result = build_lines(cfg, None, |m| warns.push(m.to_string()));
(result, warns)
}
fn line_segment_priorities(items: &[LineItem]) -> Vec<u8> {
items
.iter()
.filter_map(|i| match i {
LineItem::Segment { segment, .. } => Some(segment.defaults().priority),
LineItem::Separator(_) => None,
})
.collect()
}
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<LineItem>]) -> Vec<Vec<u8>> {
built
.iter()
.map(|line| line_segment_priorities(line))
.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!(segment_count(&result[0]), 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 = 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!(segment_count(&lines[0]), 2, "line 1: plugin + model");
assert_eq!(
segment_count(&lines[1]),
1,
"line 2: plugin dropped, only workspace"
);
assert_eq!(
segment_count(&lines[2]),
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 = line_segment_priorities(&segs);
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:?}"
);
}
fn separators_in_order(items: &[LineItem]) -> Vec<&Separator> {
items
.iter()
.filter_map(|i| match i {
LineItem::Separator(s) => Some(s),
LineItem::Segment { .. } => None,
})
.collect()
}
#[test]
fn inline_table_separator_with_character_overrides_global_default() {
let cfg = config::Config::from_str(
r#"
[line]
segments = ["model", { type = "separator", character = " | " }, "workspace"]
"#,
)
.expect("parse");
let (items, warns) = built_with_warns(Some(&cfg));
assert!(warns.is_empty(), "no warnings expected: {warns:?}");
assert_eq!(segment_count(&items), 2);
let seps = separators_in_order(&items);
assert_eq!(seps.len(), 1, "exactly one separator between two segments");
assert_eq!(
seps[0],
&Separator::Literal(std::borrow::Cow::Owned(" | ".to_string())),
);
}
#[test]
fn inline_table_separator_without_character_uses_layout_default() {
let cfg = config::Config::from_str(
r#"
[line]
segments = ["model", { type = "separator" }, "workspace"]
[layout_options]
separator = " · "
"#,
)
.expect("parse");
let items = built(Some(&cfg));
let seps = separators_in_order(&items);
assert_eq!(seps.len(), 1);
assert_eq!(
seps[0],
&Separator::Literal(std::borrow::Cow::Owned(" · ".to_string())),
);
}
#[test]
fn merge_flag_suppresses_implicit_interleave_at_boundary() {
let cfg = config::Config::from_str(
r#"
[line]
segments = [{ type = "model", merge = true }, "workspace"]
[layout_options]
separator = " | "
"#,
)
.expect("parse");
let items = built(Some(&cfg));
assert_eq!(segment_count(&items), 2);
assert_eq!(
separators_in_order(&items).len(),
0,
"merge=true on left segment must drop the boundary separator",
);
}
#[test]
fn merge_flag_suppresses_explicit_separator_entry_at_boundary() {
let cfg = config::Config::from_str(
r#"
[line]
segments = [
{ type = "model", merge = true },
{ type = "separator", character = " | " },
"workspace",
]
"#,
)
.expect("parse");
let items = built(Some(&cfg));
assert_eq!(segment_count(&items), 2);
assert_eq!(
separators_in_order(&items).len(),
0,
"merge=true must consume the next explicit separator AND skip implicit interleave",
);
}
#[test]
fn merge_flag_clears_after_one_boundary() {
let cfg = config::Config::from_str(
r#"
[line]
segments = [{ type = "model", merge = true }, "workspace", "cost"]
[layout_options]
separator = " | "
"#,
)
.expect("parse");
let items = built(Some(&cfg));
assert_eq!(segment_count(&items), 3);
let seps = separators_in_order(&items);
assert_eq!(seps.len(), 1, "only the second boundary gets a separator");
assert_eq!(
seps[0],
&Separator::Literal(std::borrow::Cow::Owned(" | ".to_string())),
);
}
#[test]
fn back_to_back_merge_chains_drop_every_intermediate_separator() {
let cfg = config::Config::from_str(
r#"
[line]
segments = [
{ type = "model", merge = true },
{ type = "workspace", merge = true },
"cost",
]
[layout_options]
separator = " | "
"#,
)
.expect("parse");
let items = built(Some(&cfg));
assert_eq!(segment_count(&items), 3);
assert_eq!(separators_in_order(&items).len(), 0);
}
#[test]
fn consecutive_separator_entries_warn_with_specific_message() {
let cfg = config::Config::from_str(
r#"
[line]
segments = [
"model",
{ type = "separator", character = " | " },
{ type = "separator", character = " · " },
"workspace",
]
"#,
)
.expect("parse");
let (items, warns) = built_with_warns(Some(&cfg));
assert_eq!(segment_count(&items), 2);
assert_eq!(
separators_in_order(&items).len(),
1,
"duplicate adjacent separator entries collapse to one",
);
assert!(
warns.iter().any(|w| w.contains("consecutive separator")),
"missing 'consecutive separator' warn: {warns:?}",
);
assert!(
!warns.iter().any(|w| w.contains("without a preceding")),
"incorrect 'without a preceding' warn fired for adjacent separators: {warns:?}",
);
}
#[test]
fn leading_separator_entry_warns_with_specific_message() {
let cfg = config::Config::from_str(
r#"
[line]
segments = [{ type = "separator", character = " | " }, "model"]
"#,
)
.expect("parse");
let (items, warns) = built_with_warns(Some(&cfg));
assert_eq!(segment_count(&items), 1);
assert_eq!(separators_in_order(&items).len(), 0);
assert!(
warns.iter().any(|w| w.contains("leads with a separator")),
"missing 'leads with a separator' warn: {warns:?}",
);
}
#[test]
fn kindless_inline_table_entry_warns_and_drops() {
let cfg = config::Config::from_str(
r#"
[line]
segments = ["model", { character = " | " }, "workspace"]
"#,
)
.expect("parse");
let (items, warns) = built_with_warns(Some(&cfg));
assert_eq!(segment_count(&items), 2, "kindless entry dropped");
assert!(
warns.iter().any(|w| w.contains("missing `type`")),
"missing kindless-entry warn: {warns:?}",
);
}
#[test]
fn merge_field_on_separator_entry_warns_and_is_ignored() {
let cfg = config::Config::from_str(
r#"
[line]
segments = [
"model",
{ type = "separator", character = " | ", merge = true },
"workspace",
"cost",
]
[layout_options]
separator = " · "
"#,
)
.expect("parse");
let (items, warns) = built_with_warns(Some(&cfg));
assert_eq!(segment_count(&items), 3);
let seps = separators_in_order(&items);
assert_eq!(
seps.len(),
2,
"boundary count unaffected by separator merge"
);
assert!(
warns.iter().any(|w| w.contains("`merge")),
"missing merge-on-separator warn: {warns:?}",
);
}
#[test]
fn character_field_on_segment_entry_warns_and_is_ignored() {
let cfg = config::Config::from_str(
r#"
[line]
segments = [{ type = "model", character = "ignored" }, "workspace"]
"#,
)
.expect("parse");
let (_, warns) = built_with_warns(Some(&cfg));
assert!(
warns.iter().any(|w| w.contains("`character")),
"missing character-on-segment warn: {warns:?}",
);
}
#[test]
fn unknown_inline_table_keys_round_trip_through_extra_bag() {
let cfg = config::Config::from_str(
r#"
[line]
segments = [
"model",
{ type = "separator", character = " | ", color = "red", bold = true },
"workspace",
]
"#,
)
.expect("config with unknown inline-table keys must parse");
let (items, _warns) = built_with_warns(Some(&cfg));
let seps = separators_in_order(&items);
assert_eq!(
seps[0],
&Separator::Literal(std::borrow::Cow::Owned(" | ".to_string())),
);
let line = cfg.line.as_ref().expect("line config present");
let entry = &line.segments[1];
let extra_keys: Vec<&str> = match entry {
config::LineEntry::Item(item) => item.extra.keys().map(String::as_str).collect(),
config::LineEntry::Id(_) => panic!("expected LineEntry::Item"),
};
assert!(
extra_keys.contains(&"color"),
"color preserved: {extra_keys:?}",
);
assert!(
extra_keys.contains(&"bold"),
"bold preserved: {extra_keys:?}",
);
}
#[test]
fn malformed_segment_entry_with_wrong_value_type_warns_at_build_time() {
let cfg = config::Config::from_str(
r#"
[line]
segments = ["model", { type = 42 }, "workspace"]
"#,
)
.expect("malformed entry must not abort the whole parse");
let (items, warns) = built_with_warns(Some(&cfg));
assert_eq!(
segment_count(&items),
2,
"well-formed neighbors render; malformed entry drops",
);
assert!(
warns.iter().any(|w| w.contains("missing `type`")),
"missing-type warn must fire on the malformed entry: {warns:?}",
);
}
#[test]
fn resolve_segment_id_returns_borrowed_for_every_built_in() {
for built_in in BUILT_IN_SEGMENT_IDS {
let resolved = resolve_segment_id(built_in);
assert_eq!(
resolved.as_ref(),
*built_in,
"resolved id content must round-trip — catches a regression that returns a fixed borrowed constant",
);
assert!(
matches!(resolved, Cow::Borrowed(_)),
"built-in id {built_in:?} must resolve to Cow::Borrowed, got {resolved:?}",
);
}
}
#[test]
fn resolve_segment_id_returns_owned_for_non_built_in_ids() {
for non_built_in in &["my_plugin", "totally-not-a-segment", ""] {
let resolved = resolve_segment_id(non_built_in);
assert!(
matches!(resolved, Cow::Owned(_)),
"non-built-in id {non_built_in:?} must resolve to Cow::Owned, got {resolved:?}",
);
}
}
#[test]
fn build_default_segments_emits_borrowed_ids_in_canonical_order() {
let items = build_default_segments();
let ids: Vec<&str> = items
.iter()
.filter_map(|i| match i {
LineItem::Segment { id, .. } => Some(id.as_ref()),
LineItem::Separator(_) => None,
})
.collect();
assert_eq!(ids, DEFAULT_SEGMENT_IDS.to_vec());
for item in &items {
if let LineItem::Segment { id, .. } = item {
assert!(
matches!(id, Cow::Borrowed(_)),
"default-path id {id:?} must be Cow::Borrowed",
);
}
}
}
#[test]
fn build_segments_records_config_id_on_each_segment() {
let cfg = config::Config::from_str(
r#"
[line]
segments = ["model", "git_branch", "workspace"]
"#,
)
.expect("parse");
let (items, warns) = built_with_warns(Some(&cfg));
assert!(warns.is_empty(), "expected no warnings, got {warns:?}");
let ids: Vec<&str> = items
.iter()
.filter_map(|i| match i {
LineItem::Segment { id, .. } => Some(id.as_ref()),
LineItem::Separator(_) => None,
})
.collect();
assert_eq!(ids, vec!["model", "git_branch", "workspace"]);
for item in &items {
if let LineItem::Segment { id, .. } = item {
assert!(
matches!(id, Cow::Borrowed(_)),
"built-in id {id:?} must be Cow::Borrowed on the production-config path",
);
}
}
}
#[test]
fn inline_table_separator_round_trips_through_config_parse() {
let cfg = config::Config::from_str(
r#"
[line]
segments = ["model", { type = "separator", character = " | " }, "workspace"]
"#,
)
.expect("parse");
let line = cfg.line.as_ref().expect("line present");
match &line.segments[1] {
config::LineEntry::Item(item) => {
assert_eq!(item.kind.as_deref(), Some("separator"));
assert_eq!(item.character.as_deref(), Some(" | "));
}
other => panic!("expected LineEntry::Item, got {other:?}"),
}
}