1use std::collections::{BTreeMap, HashMap};
6use std::sync::Arc;
7
8use rhai::{Array, Dynamic, Engine, Map};
9
10use super::{
11 built_in_by_id, OverriddenSegment, PowerlineWidth, Segment, Separator, WidthBounds,
12 DEFAULT_SEGMENT_IDS,
13};
14use crate::config;
15use crate::plugins::{CompiledPlugin, PluginRegistry, RhaiSegment};
16use crate::theme;
17
18#[must_use]
21pub fn build_default_segments() -> Vec<Box<dyn Segment>> {
22 DEFAULT_SEGMENT_IDS
23 .iter()
24 .filter_map(|id| built_in_by_id(id, None, &mut |_| {}))
25 .collect()
26}
27
28pub fn build_segments(
52 config: Option<&config::Config>,
53 plugins: Option<(PluginRegistry, Arc<Engine>)>,
54 mut warn: impl FnMut(&str),
55) -> Vec<Box<dyn Segment>> {
56 let configured_line = config.and_then(|c| c.line.as_ref());
57 let layout_mode = config.map(|c| c.layout).unwrap_or_default();
58
59 if matches!(layout_mode, config::LayoutMode::MultiLine) {
64 if let Some(first) = validated_numbered_lines(configured_line, &mut warn)
65 .and_then(|mut v| (!v.is_empty()).then(|| v.remove(0)))
66 {
67 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.");
68 let layout_separator = resolve_layout_separator(config, &mut warn);
69 let ids: Vec<&str> = first.iter().map(String::as_str).collect();
70 let mut plugin_bundle = bundle_plugins(plugins);
71 let mut consumed = std::collections::HashSet::new();
72 return build_one_line(
73 &ids,
74 config,
75 &mut plugin_bundle,
76 &mut consumed,
77 &layout_separator,
78 &mut warn,
79 );
80 }
81 }
84
85 if let Some(line) = configured_line {
86 if line.segments.is_empty() {
87 warn("[line].segments is empty; no segments will render");
88 }
89 }
90
91 let layout_separator = resolve_layout_separator(config, &mut warn);
92
93 let ids: Vec<&str> = match configured_line {
94 Some(l) => l.segments.iter().map(String::as_str).collect(),
95 None => DEFAULT_SEGMENT_IDS.to_vec(),
96 };
97
98 let mut plugin_bundle = bundle_plugins(plugins);
99 let mut consumed = std::collections::HashSet::new();
100 build_one_line(
101 &ids,
102 config,
103 &mut plugin_bundle,
104 &mut consumed,
105 &layout_separator,
106 &mut warn,
107 )
108}
109
110pub fn build_lines(
135 config: Option<&config::Config>,
136 plugins: Option<(PluginRegistry, Arc<Engine>)>,
137 mut warn: impl FnMut(&str),
138) -> Vec<Vec<Box<dyn Segment>>> {
139 let mode = config.map(|c| c.layout).unwrap_or_default();
140 let line_cfg = config.and_then(|c| c.line.as_ref());
141
142 let line_id_lists: Vec<Vec<String>> = match mode {
143 config::LayoutMode::SingleLine => {
144 let has_numbered = line_cfg.is_some_and(|l| !l.numbered.is_empty());
152 let has_segments = line_cfg.is_some_and(|l| !l.segments.is_empty());
153 if has_numbered && !has_segments {
154 if let Some(promoted) = validated_numbered_lines(line_cfg, &mut warn) {
155 warn("[line.N] sub-tables present but no top-level `layout` field; treating as multi-line. Add `layout = \"multi-line\"` to silence this warning.");
156 promoted
157 } else {
158 warn("[line.N] sub-tables present but none are usable, and [line].segments is empty; nothing will render");
159 single_line_ids(line_cfg, &mut warn)
160 }
161 } else {
162 if has_numbered {
163 warn("layout is single-line but [line.N] sub-tables are present; ignoring numbered tables and rendering [line].segments");
164 }
165 single_line_ids(line_cfg, &mut warn)
166 }
167 }
168 config::LayoutMode::MultiLine => match validated_numbered_lines(line_cfg, &mut warn) {
169 Some(lines) => lines,
170 None => {
171 warn("layout = \"multi-line\" but no usable [line.N] sub-tables; falling back to single-line using [line].segments");
172 single_line_ids(line_cfg, &mut warn)
173 }
174 },
175 };
176
177 let layout_separator = resolve_layout_separator(config, &mut warn);
178 let mut plugin_bundle = bundle_plugins(plugins);
179 let mut consumed_plugins = std::collections::HashSet::<String>::new();
180
181 line_id_lists
182 .into_iter()
183 .map(|owned_ids| {
184 let ids: Vec<&str> = owned_ids.iter().map(String::as_str).collect();
185 build_one_line(
186 &ids,
187 config,
188 &mut plugin_bundle,
189 &mut consumed_plugins,
190 &layout_separator,
191 &mut warn,
192 )
193 })
194 .collect()
195}
196
197fn single_line_ids(
201 line_cfg: Option<&config::LineConfig>,
202 warn: &mut impl FnMut(&str),
203) -> Vec<Vec<String>> {
204 if let Some(line) = line_cfg {
205 if line.segments.is_empty() {
206 warn("[line].segments is empty; no segments will render");
207 }
208 }
209 let ids: Vec<String> = match line_cfg {
210 Some(l) => l.segments.clone(),
211 None => DEFAULT_SEGMENT_IDS.iter().map(|&s| s.to_string()).collect(),
212 };
213 vec![ids]
214}
215
216fn validated_numbered_lines(
226 line_cfg: Option<&config::LineConfig>,
227 warn: &mut impl FnMut(&str),
228) -> Option<Vec<Vec<String>>> {
229 let line = line_cfg?;
230 if line.numbered.is_empty() {
231 return None;
232 }
233 let mut valid: Vec<(u32, Vec<String>)> = line
234 .numbered
235 .iter()
236 .filter_map(|(key, value)| {
237 if !matches!(value, toml::Value::Table(_)) {
243 warn(&format!(
244 "[line] has unknown key '{key}' ({}); expected `[line.N]` sub-tables only. Skipping.",
245 describe_toml_value(value)
246 ));
247 return None;
248 }
249 let n = match key.parse::<u32>() {
250 Ok(n) if n > 0 => n,
251 _ => {
252 warn(&format!(
253 "[line.{key}] is not a positive integer key; skipping"
254 ));
255 return None;
256 }
257 };
258 extract_line_segments(key, value, warn).map(|segs| (n, segs))
259 })
260 .collect();
261 if valid.is_empty() {
262 return None;
263 }
264 valid.sort_by_key(|(n, _)| *n);
265 for (n, segs) in &valid {
266 if segs.is_empty() {
267 warn(&format!(
268 "[line.{n}].segments is empty; that line will render nothing"
269 ));
270 }
271 }
272 Some(valid.into_iter().map(|(_, segs)| segs).collect())
273}
274
275fn extract_line_segments(
282 key: &str,
283 value: &toml::Value,
284 warn: &mut impl FnMut(&str),
285) -> Option<Vec<String>> {
286 let table = match value {
287 toml::Value::Table(t) => t,
288 other => {
289 warn(&format!(
290 "[line] key '{key}' is a {} (expected a sub-table with `segments = [...]`); skipping",
291 describe_toml_value(other)
292 ));
293 return None;
294 }
295 };
296 let segments_value = match table.get("segments") {
297 Some(v) => v,
298 None => {
299 warn(&format!("[line.{key}] has no `segments` array; skipping"));
300 return None;
301 }
302 };
303 let array = match segments_value {
304 toml::Value::Array(a) => a,
305 other => {
306 warn(&format!(
307 "[line.{key}].segments is a {} (expected an array of strings); skipping",
308 describe_toml_value(other)
309 ));
310 return None;
311 }
312 };
313 let mut segs = Vec::with_capacity(array.len());
314 for (i, item) in array.iter().enumerate() {
315 match item {
316 toml::Value::String(s) => segs.push(s.clone()),
317 other => {
318 warn(&format!(
319 "[line.{key}].segments[{i}] is a {} (expected a string); skipping that item",
320 describe_toml_value(other)
321 ));
322 }
323 }
324 }
325 Some(segs)
326}
327
328fn describe_toml_value(v: &toml::Value) -> &'static str {
329 match v {
330 toml::Value::String(_) => "string",
331 toml::Value::Integer(_) => "integer",
332 toml::Value::Float(_) => "float",
333 toml::Value::Boolean(_) => "boolean",
334 toml::Value::Datetime(_) => "datetime",
335 toml::Value::Array(_) => "array",
336 toml::Value::Table(_) => "table",
337 }
338}
339
340fn resolve_layout_separator(
344 config: Option<&config::Config>,
345 warn: &mut impl FnMut(&str),
346) -> Separator {
347 let powerline_width = config
348 .and_then(|c| c.layout_options.as_ref())
349 .and_then(|lo| lo.powerline_width)
350 .map(|w| validate_powerline_width(w, warn))
351 .unwrap_or_default();
352 config
353 .and_then(|c| c.layout_options.as_ref())
354 .and_then(|lo| lo.separator.as_deref())
355 .map(|s| parse_layout_separator(s, powerline_width, warn))
356 .unwrap_or(Separator::Space)
357}
358
359fn bundle_plugins(
366 plugins: Option<(PluginRegistry, Arc<Engine>)>,
367) -> Option<(HashMap<String, CompiledPlugin>, Arc<Engine>)> {
368 plugins.map(|(registry, engine)| {
369 let lookup: HashMap<String, CompiledPlugin> = registry
370 .into_plugins()
371 .into_iter()
372 .map(|p| (p.id().to_string(), p))
373 .collect();
374 (lookup, engine)
375 })
376}
377
378fn build_one_line(
393 ids: &[&str],
394 config: Option<&config::Config>,
395 plugin_bundle: &mut Option<(HashMap<String, CompiledPlugin>, Arc<Engine>)>,
396 consumed_plugins: &mut std::collections::HashSet<String>,
397 layout_separator: &Separator,
398 warn: &mut impl FnMut(&str),
399) -> Vec<Box<dyn Segment>> {
400 let mut seen = std::collections::HashSet::<String>::new();
401 ids.iter()
402 .filter_map(|&id| {
403 if !seen.insert(id.to_string()) {
404 warn(&format!(
405 "segment '{id}' listed more than once; keeping first occurrence"
406 ));
407 return None;
408 }
409 let cfg_override = config.and_then(|c| c.segments.get(id));
410 let extras = cfg_override.map(|ov| &ov.extra);
411 let inner = if let Some(b) = built_in_by_id(id, extras, warn) {
412 Some(b)
413 } else if let Some((lookup, engine)) = plugin_bundle.as_mut() {
414 lookup.remove(id).map(|plugin| {
415 consumed_plugins.insert(id.to_string());
416 let plugin_config = cfg_override.map_or_else(
420 || Dynamic::from_map(Map::new()),
421 |ov| toml_table_to_dynamic(&ov.extra),
422 );
423 Box::new(RhaiSegment::from_compiled(
424 plugin,
425 engine.clone(),
426 plugin_config,
427 )) as Box<dyn Segment>
428 })
429 } else {
430 None
431 };
432 let inner = inner.or_else(|| {
433 if consumed_plugins.contains(id) {
434 warn(&format!(
435 "plugin '{id}' was rendered on an earlier line; v0.1 supports each plugin on at most one line per render — skipping"
436 ));
437 } else {
438 warn(&format!("unknown segment id '{id}' — skipping"));
439 }
440 None
441 })?;
442 let with_per_segment = apply_override(id, inner, cfg_override, warn);
443 Some(apply_layout_separator(with_per_segment, layout_separator))
444 })
445 .collect()
446}
447
448fn parse_layout_separator(
465 value: &str,
466 powerline_width: PowerlineWidth,
467 warn: &mut impl FnMut(&str),
468) -> Separator {
469 if value.is_empty() {
475 return Separator::None;
476 }
477 let normalized = value.trim().to_ascii_lowercase();
478 match normalized.as_str() {
479 "space" => Separator::Space,
480 "powerline" => Separator::Powerline {
481 width: powerline_width,
482 },
483 "capsule" | "flex" => {
484 warn(&format!(
485 "[layout_options].separator '{value}' is reserved for v0.2+; rendering as 'space'"
486 ));
487 Separator::Space
488 }
489 _ => Separator::Literal(std::borrow::Cow::Owned(value.to_string())),
490 }
491}
492
493fn validate_powerline_width(width: u16, warn: &mut impl FnMut(&str)) -> PowerlineWidth {
498 match width {
499 1 => PowerlineWidth::One,
500 2 => PowerlineWidth::Two,
501 other => {
502 warn(&format!(
503 "[layout_options].powerline_width = {other} is not 1 or 2; using 1"
504 ));
505 PowerlineWidth::One
506 }
507 }
508}
509
510fn apply_layout_separator(segment: Box<dyn Segment>, sep: &Separator) -> Box<dyn Segment> {
517 if matches!(sep, Separator::Space) {
518 return segment;
519 }
520 match segment.defaults().default_separator {
521 Separator::Space | Separator::Theme => {
522 Box::new(OverriddenSegment::new(segment).with_default_separator(sep.clone()))
523 }
524 _ => segment,
525 }
526}
527
528fn toml_table_to_dynamic(table: &BTreeMap<String, toml::Value>) -> Dynamic {
531 let mut map = Map::new();
532 for (k, v) in table {
533 map.insert(k.as_str().into(), toml_value_to_dynamic(v));
534 }
535 Dynamic::from_map(map)
536}
537
538fn toml_value_to_dynamic(value: &toml::Value) -> Dynamic {
539 match value {
540 toml::Value::String(s) => Dynamic::from(s.clone()),
541 toml::Value::Integer(i) => Dynamic::from(*i),
542 toml::Value::Float(f) => Dynamic::from(*f),
543 toml::Value::Boolean(b) => Dynamic::from(*b),
544 toml::Value::Datetime(dt) => Dynamic::from(dt.to_string()),
547 toml::Value::Array(items) => {
548 let arr: Array = items.iter().map(toml_value_to_dynamic).collect();
549 Dynamic::from_array(arr)
550 }
551 toml::Value::Table(t) => {
552 let mut m = Map::new();
553 for (k, v) in t {
554 m.insert(k.as_str().into(), toml_value_to_dynamic(v));
555 }
556 Dynamic::from_map(m)
557 }
558 }
559}
560
561fn apply_override(
562 id: &str,
563 inner: Box<dyn Segment>,
564 ov: Option<&config::SegmentOverride>,
565 warn: &mut impl FnMut(&str),
566) -> Box<dyn Segment> {
567 let Some(ov) = ov else { return inner };
568 let base_width = inner.defaults().width;
569 let mut wrapped = OverriddenSegment::new(inner);
570 if let Some(p) = ov.priority {
571 wrapped = wrapped.with_priority(p);
572 }
573 if let Some(w) = ov.width {
574 let min = w.min.or_else(|| base_width.map(|b| b.min())).unwrap_or(0);
578 let max = w
579 .max
580 .or_else(|| base_width.map(|b| b.max()))
581 .unwrap_or(u16::MAX);
582 match WidthBounds::new(min, max) {
583 Some(bounds) => wrapped = wrapped.with_width(bounds),
584 None => warn(&format!(
585 "segments.{id}.width: min ({min}) > max ({max}); ignoring override"
586 )),
587 }
588 }
589 if let Some(style_str) = ov.style.as_deref().filter(|s| !s.trim().is_empty()) {
592 match theme::parse_style(style_str) {
593 Ok(style) => wrapped = wrapped.with_user_style(style),
594 Err(e) => warn(&format!("segments.{id}.style: {e}; ignoring override")),
595 }
596 }
597 Box::new(wrapped)
598}
599
600#[cfg(test)]
601mod tests {
602 use super::*;
603 use crate::input;
604 use crate::segments::{self, BUILT_IN_SEGMENT_IDS};
605 use std::str::FromStr;
606
607 fn built(cfg: Option<&config::Config>) -> Vec<Box<dyn Segment>> {
608 build_segments(cfg, None, |_| {})
609 }
610
611 fn built_with_warns(cfg: Option<&config::Config>) -> (Vec<Box<dyn Segment>>, Vec<String>) {
612 let mut warns = Vec::new();
613 let segs = build_segments(cfg, None, |m| warns.push(m.to_string()));
614 (segs, warns)
615 }
616
617 #[test]
618 fn build_segments_uses_default_order_when_config_missing() {
619 assert_eq!(built(None).len(), DEFAULT_SEGMENT_IDS.len());
620 }
621
622 #[test]
623 fn layout_separator_powerline_swaps_default_separator() {
624 let cfg = config::Config::from_str(
630 r#"
631 [line]
632 segments = ["model", "workspace"]
633 [layout_options]
634 separator = "powerline"
635 "#,
636 )
637 .expect("parse");
638 let segs = built(Some(&cfg));
639 for seg in &segs {
640 assert_eq!(
641 seg.defaults().default_separator,
642 Separator::powerline(),
643 "segment didn't pick up powerline separator"
644 );
645 }
646 }
647
648 #[test]
649 fn layout_separator_space_is_passthrough() {
650 let cfg = config::Config::from_str(
654 r#"
655 [line]
656 segments = ["model"]
657 [layout_options]
658 separator = "space"
659 "#,
660 )
661 .expect("parse");
662 let segs = built(Some(&cfg));
663 assert_eq!(segs[0].defaults().default_separator, Separator::Space);
664 }
665
666 #[test]
667 fn layout_separator_capsule_warns_and_falls_back_to_space() {
668 let cfg = config::Config::from_str(
672 r#"
673 [line]
674 segments = ["model"]
675 [layout_options]
676 separator = "capsule"
677 "#,
678 )
679 .expect("parse");
680 let (segs, warns) = built_with_warns(Some(&cfg));
681 assert_eq!(segs[0].defaults().default_separator, Separator::Space);
682 assert!(
683 warns
684 .iter()
685 .any(|m| m.contains("capsule") && m.contains("v0.2+")),
686 "missing capsule deferral warning: {warns:?}"
687 );
688 }
689
690 #[test]
691 fn layout_separator_arbitrary_string_renders_as_literal() {
692 let cfg = config::Config::from_str(
698 r#"
699 [line]
700 segments = ["model"]
701 [layout_options]
702 separator = " | "
703 "#,
704 )
705 .expect("parse");
706 let (segs, warns) = built_with_warns(Some(&cfg));
707 assert_eq!(
708 segs[0].defaults().default_separator,
709 Separator::Literal(std::borrow::Cow::Owned(" | ".to_string()))
710 );
711 assert!(warns.is_empty(), "no warnings on literal: {warns:?}");
712 }
713
714 #[test]
715 fn layout_separator_empty_string_yields_none() {
716 let cfg = config::Config::from_str(
720 r#"
721 [line]
722 segments = ["model"]
723 [layout_options]
724 separator = ""
725 "#,
726 )
727 .expect("parse");
728 let (segs, warns) = built_with_warns(Some(&cfg));
729 assert_eq!(segs[0].defaults().default_separator, Separator::None);
730 assert!(
731 warns.is_empty(),
732 "empty string is a valid choice: {warns:?}"
733 );
734 }
735
736 #[test]
737 fn build_segments_empty_config_falls_back_to_defaults() {
738 let cfg = config::Config::default();
739 assert_eq!(built(Some(&cfg)).len(), DEFAULT_SEGMENT_IDS.len());
740 }
741
742 #[test]
743 fn layout_separator_preserves_segment_literal_default() {
744 struct PipeSeg;
749 impl segments::Segment for PipeSeg {
750 fn render(
751 &self,
752 _: &crate::data_context::DataContext,
753 _: &segments::RenderContext,
754 ) -> segments::RenderResult {
755 Ok(Some(segments::RenderedSegment::new("x")))
756 }
757 fn defaults(&self) -> segments::SegmentDefaults {
758 segments::SegmentDefaults::with_priority(0)
759 .with_default_separator(Separator::Literal(std::borrow::Cow::Borrowed(" | ")))
760 }
761 }
762 let wrapped = apply_layout_separator(Box::new(PipeSeg), &Separator::powerline());
763 assert_eq!(
764 wrapped.defaults().default_separator,
765 Separator::Literal(std::borrow::Cow::Borrowed(" | ")),
766 );
767 }
768
769 #[test]
770 fn layout_separator_preserves_segment_none_default() {
771 struct NoSepSeg;
776 impl segments::Segment for NoSepSeg {
777 fn render(
778 &self,
779 _: &crate::data_context::DataContext,
780 _: &segments::RenderContext,
781 ) -> segments::RenderResult {
782 Ok(Some(segments::RenderedSegment::new("x")))
783 }
784 fn defaults(&self) -> segments::SegmentDefaults {
785 segments::SegmentDefaults::with_priority(0).with_default_separator(Separator::None)
786 }
787 }
788 let wrapped = apply_layout_separator(Box::new(NoSepSeg), &Separator::powerline());
789 assert_eq!(wrapped.defaults().default_separator, Separator::None);
790 }
791
792 #[test]
793 fn layout_separator_does_not_double_wrap_when_default_already_powerline() {
794 struct PowerlineSeg;
799 impl segments::Segment for PowerlineSeg {
800 fn render(
801 &self,
802 _: &crate::data_context::DataContext,
803 _: &segments::RenderContext,
804 ) -> segments::RenderResult {
805 Ok(Some(segments::RenderedSegment::new("x")))
806 }
807 fn defaults(&self) -> segments::SegmentDefaults {
808 segments::SegmentDefaults::with_priority(0)
809 .with_default_separator(Separator::powerline())
810 }
811 }
812 let wrapped = apply_layout_separator(Box::new(PowerlineSeg), &Separator::powerline());
813 assert_eq!(wrapped.defaults().default_separator, Separator::powerline());
814 }
815
816 #[test]
817 fn layout_separator_handles_mixed_case_and_whitespace() {
818 let mut warns = Vec::new();
823 let mut warn = |m: &str| warns.push(m.to_string());
824 assert_eq!(
825 parse_layout_separator("Powerline", PowerlineWidth::One, &mut warn),
826 Separator::powerline()
827 );
828 assert_eq!(
829 parse_layout_separator(" POWERLINE ", PowerlineWidth::One, &mut warn),
830 Separator::powerline()
831 );
832 assert_eq!(
833 parse_layout_separator(" Space ", PowerlineWidth::One, &mut warn),
834 Separator::Space
835 );
836 assert!(
837 warns.is_empty(),
838 "no warnings on case/whitespace: {warns:?}"
839 );
840 }
841
842 #[test]
843 fn layout_separator_whitespace_only_renders_as_literal() {
844 let mut warns = Vec::new();
848 let mut warn = |m: &str| warns.push(m.to_string());
849 assert_eq!(
850 parse_layout_separator(" ", PowerlineWidth::One, &mut warn),
851 Separator::Literal(std::borrow::Cow::Owned(" ".to_string()))
852 );
853 assert_eq!(
855 parse_layout_separator("", PowerlineWidth::One, &mut warn),
856 Separator::None
857 );
858 assert!(
859 warns.is_empty(),
860 "no warns on whitespace literal: {warns:?}"
861 );
862 }
863
864 #[test]
865 fn layout_separator_typo_renders_as_literal_not_warn() {
866 let mut warns = Vec::new();
872 let mut warn = |m: &str| warns.push(m.to_string());
873 assert_eq!(
874 parse_layout_separator("powereline", PowerlineWidth::One, &mut warn),
875 Separator::Literal(std::borrow::Cow::Owned("powereline".to_string()))
876 );
877 assert!(warns.is_empty(), "typos don't warn: {warns:?}");
878 }
879
880 #[test]
881 fn layout_separator_powerline_overrides_runtime_right_separator() {
882 struct RuntimeSpaceSeg;
889 impl segments::Segment for RuntimeSpaceSeg {
890 fn render(
891 &self,
892 _: &crate::data_context::DataContext,
893 _: &segments::RenderContext,
894 ) -> segments::RenderResult {
895 Ok(Some(segments::RenderedSegment::with_separator(
896 "x",
897 Separator::Space,
898 )))
899 }
900 fn defaults(&self) -> segments::SegmentDefaults {
901 segments::SegmentDefaults::with_priority(0)
902 }
903 }
904 let layout_sep = parse_layout_separator("powerline", PowerlineWidth::One, &mut |_| {});
905 let wrapped = apply_layout_separator(Box::new(RuntimeSpaceSeg), &layout_sep);
906 let rendered = wrapped
907 .render(&stub_ctx(), &stub_rc())
908 .unwrap()
909 .expect("rendered");
910 assert_eq!(
911 rendered.right_separator(),
912 Some(&Separator::powerline()),
913 "layout-options separator must override runtime Space"
914 );
915 }
916
917 #[test]
918 fn plugin_runtime_space_emits_chevron_through_render_with_warn() {
919 struct RuntimeSpaceSeg(&'static str);
927 impl segments::Segment for RuntimeSpaceSeg {
928 fn render(
929 &self,
930 _: &crate::data_context::DataContext,
931 _: &segments::RenderContext,
932 ) -> segments::RenderResult {
933 Ok(Some(segments::RenderedSegment::with_separator(
934 self.0,
935 Separator::Space,
936 )))
937 }
938 fn defaults(&self) -> segments::SegmentDefaults {
939 segments::SegmentDefaults::with_priority(0)
940 }
941 }
942 let layout_sep = parse_layout_separator("powerline", PowerlineWidth::One, &mut |_| {});
943 let segs: Vec<Box<dyn segments::Segment>> = vec![
944 apply_layout_separator(Box::new(RuntimeSpaceSeg("a")), &layout_sep),
945 apply_layout_separator(Box::new(RuntimeSpaceSeg("b")), &layout_sep),
946 ];
947 let line = crate::layout::render_with_warn(
948 &segs,
949 &stub_ctx(),
950 100,
951 &mut |_| {},
952 theme::default_theme(),
953 theme::Capability::None,
954 false,
955 );
956 assert!(line.contains(" \u{E0B0} "), "chevron in output: {line:?}");
957 assert!(
958 !line.contains("a b"),
959 "Space should not survive between a and b: {line:?}"
960 );
961 }
962
963 #[test]
964 fn layout_separator_powerline_preserves_runtime_literal_right_separator() {
965 struct RuntimePipeSeg;
970 impl segments::Segment for RuntimePipeSeg {
971 fn render(
972 &self,
973 _: &crate::data_context::DataContext,
974 _: &segments::RenderContext,
975 ) -> segments::RenderResult {
976 Ok(Some(segments::RenderedSegment::with_separator(
977 "x",
978 Separator::Literal(std::borrow::Cow::Borrowed(" | ")),
979 )))
980 }
981 fn defaults(&self) -> segments::SegmentDefaults {
982 segments::SegmentDefaults::with_priority(0)
983 }
984 }
985 let layout_sep = parse_layout_separator("powerline", PowerlineWidth::One, &mut |_| {});
986 let wrapped = apply_layout_separator(Box::new(RuntimePipeSeg), &layout_sep);
987 let rendered = wrapped
988 .render(&stub_ctx(), &stub_rc())
989 .unwrap()
990 .expect("rendered");
991 assert_eq!(
992 rendered.right_separator(),
993 Some(&Separator::Literal(std::borrow::Cow::Borrowed(" | ")))
994 );
995 }
996
997 fn stub_ctx() -> crate::data_context::DataContext {
998 use crate::input::{ModelInfo, StatusContext, Tool, WorkspaceInfo};
999 use std::path::PathBuf;
1000 use std::sync::Arc;
1001 crate::data_context::DataContext::new(StatusContext {
1002 tool: Tool::ClaudeCode,
1003 model: Some(ModelInfo {
1004 display_name: "X".into(),
1005 }),
1006 workspace: Some(WorkspaceInfo {
1007 project_dir: PathBuf::from("/r"),
1008 git_worktree: None,
1009 }),
1010 context_window: None,
1011 cost: None,
1012 effort: None,
1013 vim: None,
1014 output_style: None,
1015 agent_name: None,
1016 version: None,
1017 raw: Arc::new(serde_json::Value::Null),
1018 })
1019 }
1020
1021 fn stub_rc() -> segments::RenderContext {
1022 segments::RenderContext::new(80)
1023 }
1024
1025 #[test]
1026 fn layout_separator_pipe_literal_no_warning() {
1027 let mut warns = Vec::new();
1032 let mut warn = |m: &str| warns.push(m.to_string());
1033 assert_eq!(
1034 parse_layout_separator("|", PowerlineWidth::One, &mut warn),
1035 Separator::Literal(std::borrow::Cow::Owned("|".to_string()))
1036 );
1037 assert!(warns.is_empty(), "no warning on literal: {warns:?}");
1038 }
1039
1040 #[test]
1041 fn layout_separator_single_space_renders_as_literal_not_keyword() {
1042 let mut warns = Vec::new();
1050 let mut warn = |m: &str| warns.push(m.to_string());
1051 assert_eq!(
1052 parse_layout_separator(" ", PowerlineWidth::One, &mut warn),
1053 Separator::Literal(std::borrow::Cow::Owned(" ".to_string()))
1054 );
1055 assert!(
1056 warns.is_empty(),
1057 "no warning on single-space literal: {warns:?}"
1058 );
1059 }
1060
1061 #[test]
1062 fn apply_layout_separator_wraps_when_configured_literal_replaces_space_default() {
1063 struct SpaceDefaultSeg;
1066 impl segments::Segment for SpaceDefaultSeg {
1067 fn render(
1068 &self,
1069 _: &crate::data_context::DataContext,
1070 _: &segments::RenderContext,
1071 ) -> segments::RenderResult {
1072 Ok(Some(segments::RenderedSegment::new("x")))
1073 }
1074 fn defaults(&self) -> segments::SegmentDefaults {
1075 segments::SegmentDefaults::with_priority(0)
1076 }
1077 }
1078 let sep = Separator::Literal(std::borrow::Cow::Owned(" | ".to_string()));
1079 let wrapped = apply_layout_separator(Box::new(SpaceDefaultSeg), &sep);
1080 assert_eq!(wrapped.defaults().default_separator, sep);
1081 }
1082
1083 #[test]
1084 fn apply_layout_separator_wraps_when_configured_none_replaces_space_default() {
1085 struct SpaceDefaultSeg;
1091 impl segments::Segment for SpaceDefaultSeg {
1092 fn render(
1093 &self,
1094 _: &crate::data_context::DataContext,
1095 _: &segments::RenderContext,
1096 ) -> segments::RenderResult {
1097 Ok(Some(segments::RenderedSegment::new("x")))
1098 }
1099 fn defaults(&self) -> segments::SegmentDefaults {
1100 segments::SegmentDefaults::with_priority(0)
1101 }
1102 }
1103 let wrapped = apply_layout_separator(Box::new(SpaceDefaultSeg), &Separator::None);
1104 assert_eq!(wrapped.defaults().default_separator, Separator::None);
1105 }
1106
1107 #[test]
1108 fn powerline_width_2_propagates_to_separator_variant() {
1109 let cfg = config::Config::from_str(
1115 r#"
1116 [line]
1117 segments = ["model"]
1118 [layout_options]
1119 separator = "powerline"
1120 powerline_width = 2
1121 "#,
1122 )
1123 .expect("parse");
1124 let segs = built(Some(&cfg));
1125 assert_eq!(
1126 segs[0].defaults().default_separator,
1127 Separator::Powerline {
1128 width: PowerlineWidth::Two,
1129 }
1130 );
1131 }
1132
1133 #[test]
1134 fn powerline_width_default_is_1_when_unset() {
1135 let cfg = config::Config::from_str(
1138 r#"
1139 [line]
1140 segments = ["model"]
1141 [layout_options]
1142 separator = "powerline"
1143 "#,
1144 )
1145 .expect("parse");
1146 let segs = built(Some(&cfg));
1147 assert_eq!(segs[0].defaults().default_separator, Separator::powerline(),);
1148 }
1149
1150 #[test]
1151 fn powerline_width_invalid_warns_and_falls_back_to_1() {
1152 let cfg = config::Config::from_str(
1156 r#"
1157 [line]
1158 segments = ["model"]
1159 [layout_options]
1160 separator = "powerline"
1161 powerline_width = 3
1162 "#,
1163 )
1164 .expect("parse");
1165 let (segs, warns) = built_with_warns(Some(&cfg));
1166 assert_eq!(segs[0].defaults().default_separator, Separator::powerline());
1167 assert!(
1168 warns
1169 .iter()
1170 .any(|m| m.contains("powerline_width") && m.contains("3")),
1171 "missing invalid-width warning: {warns:?}"
1172 );
1173 }
1174
1175 #[test]
1176 fn powerline_width_zero_warns_and_falls_back_to_1() {
1177 let mut warns = Vec::new();
1181 let mut warn = |m: &str| warns.push(m.to_string());
1182 assert_eq!(validate_powerline_width(0, &mut warn), PowerlineWidth::One);
1183 assert!(
1184 warns
1185 .iter()
1186 .any(|m| m.contains("powerline_width") && m.contains('0')),
1187 "missing zero warning: {warns:?}"
1188 );
1189 }
1190
1191 #[test]
1192 fn powerline_width_max_warns_and_falls_back_to_1() {
1193 let mut warns = Vec::new();
1198 let mut warn = |m: &str| warns.push(m.to_string());
1199 assert_eq!(
1200 validate_powerline_width(u16::MAX, &mut warn),
1201 PowerlineWidth::One
1202 );
1203 assert!(
1204 warns.iter().any(|m| m.contains("powerline_width")),
1205 "missing max-width warning: {warns:?}"
1206 );
1207 }
1208
1209 #[test]
1210 fn layout_separator_absent_section_resolves_to_space() {
1211 let cfg = config::Config::from_str(
1216 r#"
1217 [line]
1218 segments = ["model"]
1219 "#,
1220 )
1221 .expect("parse");
1222 let segs = built(Some(&cfg));
1223 assert_eq!(segs[0].defaults().default_separator, Separator::Space);
1224 }
1225
1226 #[test]
1227 fn build_segments_uses_configured_line_order() {
1228 let cfg = config::Config::from_str(
1229 r#"
1230 [line]
1231 segments = ["workspace", "model"]
1232 "#,
1233 )
1234 .expect("parse");
1235 let got = built(Some(&cfg));
1236 assert_eq!(got.len(), 2);
1239 assert_eq!(got[0].defaults().priority, 16); assert_eq!(got[1].defaults().priority, 64); }
1242
1243 #[test]
1244 fn build_segments_applies_priority_override() {
1245 let cfg = config::Config::from_str(
1246 r#"
1247 [line]
1248 segments = ["model"]
1249 [segments.model]
1250 priority = 0
1251 "#,
1252 )
1253 .expect("parse");
1254 let got = built(Some(&cfg));
1255 assert_eq!(got[0].defaults().priority, 0);
1256 }
1257
1258 #[test]
1259 fn build_segments_applies_width_override() {
1260 let cfg = config::Config::from_str(
1261 r#"
1262 [line]
1263 segments = ["workspace"]
1264 [segments.workspace.width]
1265 min = 5
1266 max = 30
1267 "#,
1268 )
1269 .expect("parse");
1270 let got = built(Some(&cfg));
1271 let bounds = got[0].defaults().width.expect("width set");
1272 assert_eq!(bounds.min(), 5);
1273 assert_eq!(bounds.max(), 30);
1274 }
1275
1276 #[test]
1277 fn build_segments_skips_unknown_ids_and_warns() {
1278 let cfg = config::Config::from_str(
1279 r#"
1280 [line]
1281 segments = ["model", "does_not_exist", "workspace"]
1282 "#,
1283 )
1284 .expect("parse");
1285 let mut warnings = Vec::new();
1286 let got = build_segments(Some(&cfg), None, |msg| warnings.push(msg.to_string()));
1287 assert_eq!(got.len(), 2);
1288 assert_eq!(warnings.len(), 1);
1289 assert!(warnings[0].contains("does_not_exist"));
1290 }
1291
1292 #[test]
1293 fn build_segments_dedupes_duplicates_with_warning() {
1294 let cfg = config::Config::from_str(
1295 r#"
1296 [line]
1297 segments = ["model", "model", "workspace"]
1298 "#,
1299 )
1300 .expect("parse");
1301 let mut warnings = Vec::new();
1302 let got = build_segments(Some(&cfg), None, |msg| warnings.push(msg.to_string()));
1303 assert_eq!(got.len(), 2); assert_eq!(warnings.len(), 1);
1305 assert!(warnings[0].contains("model"));
1306 assert!(warnings[0].contains("more than once"));
1307 }
1308
1309 #[test]
1310 fn build_segments_warns_on_explicitly_empty_segment_list() {
1311 let cfg = config::Config::from_str(
1312 r#"
1313 [line]
1314 segments = []
1315 "#,
1316 )
1317 .expect("parse");
1318 let mut warnings = Vec::new();
1319 let got = build_segments(Some(&cfg), None, |msg| warnings.push(msg.to_string()));
1320 assert!(got.is_empty());
1321 assert_eq!(warnings.len(), 1);
1322 assert!(warnings[0].contains("empty"));
1323 }
1324
1325 #[test]
1326 fn build_segments_warns_on_inverted_width_bounds() {
1327 let cfg = config::Config::from_str(
1328 r#"
1329 [line]
1330 segments = ["workspace"]
1331 [segments.workspace.width]
1332 min = 40
1333 max = 10
1334 "#,
1335 )
1336 .expect("parse");
1337 let mut warnings = Vec::new();
1338 let got = build_segments(Some(&cfg), None, |msg| warnings.push(msg.to_string()));
1339 assert_eq!(got.len(), 1);
1340 assert_eq!(got[0].defaults().width, None);
1341 assert_eq!(warnings.len(), 1);
1342 assert!(warnings[0].contains("min"));
1343 assert!(warnings[0].contains("max"));
1344 }
1345
1346 struct StubWithWidth;
1350
1351 impl Segment for StubWithWidth {
1352 fn render(
1353 &self,
1354 _: &crate::data_context::DataContext,
1355 _: &segments::RenderContext,
1356 ) -> segments::RenderResult {
1357 Ok(Some(segments::RenderedSegment::new("x")))
1358 }
1359 fn defaults(&self) -> segments::SegmentDefaults {
1360 segments::SegmentDefaults::with_priority(128)
1361 .with_width(WidthBounds::new(10, 50).expect("valid"))
1362 }
1363 }
1364
1365 fn merge_width(min: Option<u16>, max: Option<u16>) -> WidthBounds {
1366 let ov = config::SegmentOverride {
1367 priority: None,
1368 width: Some(config::WidthBoundsConfig { min, max }),
1369 style: None,
1370 extra: BTreeMap::new(),
1371 };
1372 let wrapped = apply_override("stub", Box::new(StubWithWidth), Some(&ov), &mut |_| {});
1373 wrapped.defaults().width.expect("width preserved")
1374 }
1375
1376 #[test]
1377 fn width_merge_min_only_inherits_max_from_inner_default() {
1378 let got = merge_width(Some(5), None);
1379 assert_eq!(got.min(), 5);
1380 assert_eq!(got.max(), 50);
1381 }
1382
1383 #[test]
1384 fn width_merge_max_only_inherits_min_from_inner_default() {
1385 let got = merge_width(None, Some(80));
1386 assert_eq!(got.min(), 10);
1387 assert_eq!(got.max(), 80);
1388 }
1389
1390 #[test]
1391 fn width_merge_both_sides_override_inner_default() {
1392 let got = merge_width(Some(3), Some(40));
1393 assert_eq!(got.min(), 3);
1394 assert_eq!(got.max(), 40);
1395 }
1396
1397 #[test]
1398 fn width_merge_empty_override_keeps_inner_default() {
1399 let got = merge_width(None, None);
1403 assert_eq!(got.min(), 10);
1404 assert_eq!(got.max(), 50);
1405 }
1406
1407 fn rc() -> crate::segments::RenderContext {
1408 crate::segments::RenderContext::new(80)
1409 }
1410
1411 fn model_ctx(display_name: &str) -> crate::data_context::DataContext {
1412 use crate::input::{ModelInfo, Tool, WorkspaceInfo};
1413 use std::path::PathBuf;
1414 use std::sync::Arc;
1415 crate::data_context::DataContext::new(input::StatusContext {
1416 tool: Tool::ClaudeCode,
1417 model: Some(ModelInfo {
1418 display_name: display_name.into(),
1419 }),
1420 workspace: Some(WorkspaceInfo {
1421 project_dir: PathBuf::from("/repo"),
1422 git_worktree: None,
1423 }),
1424 context_window: None,
1425 cost: None,
1426 effort: None,
1427 vim: None,
1428 output_style: None,
1429 agent_name: None,
1430 version: None,
1431 raw: Arc::new(serde_json::Value::Null),
1432 })
1433 }
1434
1435 #[test]
1436 fn style_override_replaces_segment_declared_style_at_render_time() {
1437 use crate::theme::{Color, Role};
1438 let cfg = config::Config::from_str(
1439 r#"
1440 [line]
1441 segments = ["model"]
1442 [segments.model]
1443 style = "role:accent bold italic"
1444 "#,
1445 )
1446 .expect("parse");
1447 let built = build_segments(Some(&cfg), None, |_| {});
1448 let rendered = built[0]
1449 .render(&model_ctx("Claude Sonnet 4.6"), &rc())
1450 .expect("render ok")
1451 .expect("visible");
1452 assert_eq!(rendered.style.role, Some(Role::Accent));
1453 assert_eq!(rendered.style.fg, None::<Color>);
1454 assert!(rendered.style.bold);
1455 assert!(rendered.style.italic);
1456 assert!(!rendered.style.underline);
1457 assert!(!rendered.style.dim);
1458 }
1459
1460 #[test]
1461 fn style_override_with_explicit_fg_populates_fg_slot() {
1462 use crate::theme::Color;
1463 let cfg = config::Config::from_str(
1464 r#"
1465 [line]
1466 segments = ["model"]
1467 [segments.model]
1468 style = "fg:#ff0000 underline"
1469 "#,
1470 )
1471 .expect("parse");
1472 let built = build_segments(Some(&cfg), None, |_| {});
1473 let rendered = built[0]
1474 .render(&model_ctx("Claude Sonnet 4.6"), &rc())
1475 .expect("render ok")
1476 .expect("visible");
1477 assert_eq!(
1478 rendered.style.fg,
1479 Some(Color::TrueColor { r: 255, g: 0, b: 0 })
1480 );
1481 assert!(rendered.style.underline);
1482 }
1483
1484 #[test]
1485 fn invalid_style_string_warns_and_leaves_segment_style_unchanged() {
1486 use crate::theme::Role;
1487 let cfg = config::Config::from_str(
1488 r#"
1489 [line]
1490 segments = ["model"]
1491 [segments.model]
1492 style = "role:mauve"
1493 "#,
1494 )
1495 .expect("parse");
1496 let mut warnings = Vec::new();
1497 let built = build_segments(Some(&cfg), None, |m| warnings.push(m.to_string()));
1498 let rendered = built[0]
1499 .render(&model_ctx("Claude Sonnet 4.6"), &rc())
1500 .expect("render ok")
1501 .expect("visible");
1502 assert_eq!(rendered.style.role, Some(Role::Primary));
1503 assert_eq!(warnings.len(), 1);
1504 assert!(warnings[0].contains("segments.model.style"));
1505 assert!(warnings[0].contains("mauve"));
1506 assert!(warnings[0].contains("ignoring"));
1507 }
1508
1509 #[test]
1510 fn empty_style_string_is_noop_and_preserves_segment_declared_style() {
1511 use crate::theme::Role;
1512 let cfg = config::Config::from_str(
1513 r#"
1514 [line]
1515 segments = ["model"]
1516 [segments.model]
1517 style = ""
1518 "#,
1519 )
1520 .expect("parse");
1521 let built = build_segments(Some(&cfg), None, |_| {});
1522 let rendered = built[0]
1523 .render(&model_ctx("Claude Sonnet 4.6"), &rc())
1524 .expect("render ok")
1525 .expect("visible");
1526 assert_eq!(rendered.style.role, Some(Role::Primary));
1527 }
1528
1529 #[test]
1530 fn whitespace_only_style_string_is_noop_and_preserves_segment_declared_style() {
1531 use crate::theme::Role;
1532 let cfg = config::Config::from_str(
1533 r#"
1534 [line]
1535 segments = ["model"]
1536 [segments.model]
1537 style = " "
1538 "#,
1539 )
1540 .expect("parse");
1541 let built = build_segments(Some(&cfg), None, |_| {});
1542 let rendered = built[0]
1543 .render(&model_ctx("Claude Sonnet 4.6"), &rc())
1544 .expect("render ok")
1545 .expect("visible");
1546 assert_eq!(rendered.style.role, Some(Role::Primary));
1547 }
1548
1549 fn write_plugin(dir: &std::path::Path, name: &str, src: &str) -> std::path::PathBuf {
1552 let p = dir.join(name);
1553 std::fs::write(&p, src).expect("write plugin");
1554 p
1555 }
1556
1557 #[test]
1558 fn plugin_id_resolves_through_build_segments() {
1559 let tmp = tempfile::TempDir::new().expect("tempdir");
1560 write_plugin(
1561 tmp.path(),
1562 "p.rhai",
1563 r#"
1564 const ID = "my_plugin";
1565 fn render(ctx) { #{ runs: [#{ text: "from-plugin" }] } }
1566 "#,
1567 );
1568 let engine = crate::plugins::build_engine();
1569 let registry = crate::plugins::PluginRegistry::load_with_xdg(
1570 &[tmp.path().to_path_buf()],
1571 None,
1572 &engine,
1573 BUILT_IN_SEGMENT_IDS,
1574 );
1575 assert!(
1576 registry.load_errors().is_empty(),
1577 "load errors: {:?}",
1578 registry.load_errors()
1579 );
1580
1581 let cfg = config::Config::from_str(
1582 r#"
1583 [line]
1584 segments = ["model", "my_plugin"]
1585 "#,
1586 )
1587 .expect("parse");
1588 let built = build_segments(Some(&cfg), Some((registry, engine)), |_| {});
1589 assert_eq!(built.len(), 2);
1590 assert_eq!(built[0].defaults().priority, 64);
1594 assert_eq!(built[1].defaults().priority, 128);
1595 let dc = model_ctx("Sonnet");
1598 let plugin_render = built[1]
1599 .render(&dc, &rc())
1600 .expect("plugin render ok")
1601 .expect("visible");
1602 assert_eq!(plugin_render.text(), "from-plugin");
1603 }
1604
1605 #[test]
1606 fn build_segments_falls_back_to_first_line_for_multi_line_configs() {
1607 let cfg = config::Config::from_str(
1613 r#"
1614 layout = "multi-line"
1615 [line.1]
1616 segments = ["model", "workspace"]
1617 [line.2]
1618 segments = ["context_window", "cost"]
1619 "#,
1620 )
1621 .expect("parse");
1622 let (segs, warns) = built_with_warns(Some(&cfg));
1623 assert_eq!(
1624 segs.len(),
1625 2,
1626 "expected line 1's two segments, got {} segs",
1627 segs.len()
1628 );
1629 let actual: Vec<u8> = segs.iter().map(|s| s.defaults().priority).collect();
1630 assert_eq!(actual, priorities_for(&["model", "workspace"]));
1631 assert!(
1632 warns
1633 .iter()
1634 .any(|w| w.contains("multi-line") && w.contains("build_lines")),
1635 "expected migration hint pointing at build_lines, got: {warns:?}"
1636 );
1637 }
1638
1639 #[test]
1640 fn build_lines_plugin_referenced_in_two_lines_warns_specifically_on_second() {
1641 let tmp = tempfile::TempDir::new().expect("tempdir");
1649 write_plugin(
1650 tmp.path(),
1651 "p.rhai",
1652 r#"
1653 const ID = "my_plugin";
1654 fn render(ctx) { #{ runs: [#{ text: "from-plugin" }] } }
1655 "#,
1656 );
1657 let engine = crate::plugins::build_engine();
1658 let registry = crate::plugins::PluginRegistry::load_with_xdg(
1659 &[tmp.path().to_path_buf()],
1660 None,
1661 &engine,
1662 BUILT_IN_SEGMENT_IDS,
1663 );
1664 assert!(registry.load_errors().is_empty());
1665
1666 let cfg = config::Config::from_str(
1667 r#"
1668 layout = "multi-line"
1669 [line.1]
1670 segments = ["my_plugin", "model"]
1671 [line.2]
1672 segments = ["my_plugin", "workspace"]
1673 "#,
1674 )
1675 .expect("parse");
1676 let mut warns: Vec<String> = Vec::new();
1677 let lines = build_lines(Some(&cfg), Some((registry, engine)), |m| {
1678 warns.push(m.to_string())
1679 });
1680
1681 assert_eq!(lines.len(), 2);
1684 assert_eq!(lines[0].len(), 2, "line 1 keeps plugin + model");
1685 assert_eq!(
1686 lines[1].len(),
1687 1,
1688 "line 2 drops the reused plugin, keeps workspace"
1689 );
1690 assert!(
1691 warns
1692 .iter()
1693 .any(|w| w.contains("'my_plugin'") && w.contains("rendered on an earlier line")),
1694 "expected specific cross-line plugin warning, got: {warns:?}"
1695 );
1696 assert!(
1697 !warns
1698 .iter()
1699 .any(|w| w.contains("unknown segment id 'my_plugin'")),
1700 "should NOT use the generic 'unknown segment id' text for cross-line reuse, got: {warns:?}"
1701 );
1702 }
1703
1704 #[test]
1705 fn unknown_id_with_plugin_registry_still_warns() {
1706 let tmp = tempfile::TempDir::new().expect("tempdir");
1707 write_plugin(
1708 tmp.path(),
1709 "p.rhai",
1710 r#"
1711 const ID = "loaded";
1712 fn render(ctx) { () }
1713 "#,
1714 );
1715 let engine = crate::plugins::build_engine();
1716 let registry = crate::plugins::PluginRegistry::load_with_xdg(
1717 &[tmp.path().to_path_buf()],
1718 None,
1719 &engine,
1720 BUILT_IN_SEGMENT_IDS,
1721 );
1722
1723 let cfg = config::Config::from_str(
1724 r#"
1725 [line]
1726 segments = ["loaded", "missing_plugin"]
1727 "#,
1728 )
1729 .expect("parse");
1730 let mut warnings = Vec::new();
1731 let built = build_segments(Some(&cfg), Some((registry, engine)), |m| {
1732 warnings.push(m.to_string())
1733 });
1734 assert_eq!(built.len(), 1);
1735 assert_eq!(warnings.len(), 1);
1736 assert!(warnings[0].contains("missing_plugin"));
1737 }
1738
1739 #[test]
1740 fn plugin_receives_extra_keys_from_segments_table_as_ctx_config() {
1741 let tmp = tempfile::TempDir::new().expect("tempdir");
1746 write_plugin(
1747 tmp.path(),
1748 "labelled.rhai",
1749 r#"
1750 const ID = "labelled";
1751 fn render(ctx) {
1752 #{ runs: [#{ text: ctx.config.label }] }
1753 }
1754 "#,
1755 );
1756 let engine = crate::plugins::build_engine();
1757 let registry = crate::plugins::PluginRegistry::load_with_xdg(
1758 &[tmp.path().to_path_buf()],
1759 None,
1760 &engine,
1761 BUILT_IN_SEGMENT_IDS,
1762 );
1763
1764 let cfg = config::Config::from_str(
1765 r#"
1766 [line]
1767 segments = ["labelled"]
1768 [segments.labelled]
1769 label = "from-toml"
1770 "#,
1771 )
1772 .expect("parse");
1773 let built = build_segments(Some(&cfg), Some((registry, engine)), |_| {});
1774 assert_eq!(built.len(), 1);
1775 let dc = model_ctx("Sonnet");
1776 let rendered = built[0]
1777 .render(&dc, &rc())
1778 .expect("render ok")
1779 .expect("visible");
1780 assert_eq!(rendered.text(), "from-toml");
1781 }
1782
1783 #[test]
1784 fn built_in_id_wins_over_plugin_with_same_id() {
1785 let tmp = tempfile::TempDir::new().expect("tempdir");
1793 write_plugin(
1794 tmp.path(),
1795 "ghost.rhai",
1796 r#"
1797 const ID = "model";
1798 fn render(_) { #{ runs: [#{ text: "from-plugin" }] } }
1799 "#,
1800 );
1801 let engine = crate::plugins::build_engine();
1802 let registry = crate::plugins::PluginRegistry::load_with_xdg(
1803 &[tmp.path().to_path_buf()],
1804 None,
1805 &engine,
1806 &[],
1807 );
1808
1809 let cfg = config::Config::from_str(
1810 r#"
1811 [line]
1812 segments = ["model"]
1813 "#,
1814 )
1815 .expect("parse");
1816 let built = build_segments(Some(&cfg), Some((registry, engine)), |_| {});
1817 assert_eq!(built.len(), 1);
1822 assert_eq!(built[0].defaults().priority, 64);
1823 }
1824
1825 #[test]
1826 fn build_segments_forward_compat_keys_dont_break_parsing() {
1827 let cfg = config::Config::from_str(
1828 r#"
1829 theme = "catppuccin-mocha"
1830 preset = "developer"
1831 layout = "single-line"
1832 [line]
1833 segments = ["model"]
1834 [layout_options]
1835 separator = "powerline"
1836 "#,
1837 )
1838 .expect("parse");
1839 assert_eq!(built(Some(&cfg)).len(), 1);
1840 }
1841
1842 fn lines(cfg: Option<&config::Config>) -> Vec<Vec<Box<dyn Segment>>> {
1845 build_lines(cfg, None, |_| {})
1846 }
1847
1848 fn lines_with_warns(cfg: Option<&config::Config>) -> (Vec<Vec<Box<dyn Segment>>>, Vec<String>) {
1849 let mut warns = Vec::new();
1850 let result = build_lines(cfg, None, |m| warns.push(m.to_string()));
1851 (result, warns)
1852 }
1853
1854 fn priorities_for(ids: &[&str]) -> Vec<u8> {
1861 ids.iter()
1862 .map(|id| {
1863 built_in_by_id(id, None, &mut |_| {})
1864 .unwrap_or_else(|| panic!("unknown built-in id in test fixture: {id}"))
1865 .defaults()
1866 .priority
1867 })
1868 .collect()
1869 }
1870
1871 fn priorities_per_line(built: &[Vec<Box<dyn Segment>>]) -> Vec<Vec<u8>> {
1872 built
1873 .iter()
1874 .map(|line| line.iter().map(|s| s.defaults().priority).collect())
1875 .collect()
1876 }
1877
1878 #[test]
1879 fn build_lines_single_line_default_returns_one_line_with_default_segments() {
1880 let result = lines(None);
1882 assert_eq!(result.len(), 1);
1883 assert_eq!(result[0].len(), DEFAULT_SEGMENT_IDS.len());
1884 }
1885
1886 #[test]
1887 fn build_lines_explicit_single_line_returns_one_line_from_segments() {
1888 let cfg = config::Config::from_str(
1889 r#"
1890 layout = "single-line"
1891 [line]
1892 segments = ["model", "workspace"]
1893 "#,
1894 )
1895 .expect("parse");
1896 let result = lines(Some(&cfg));
1897 assert_eq!(
1898 priorities_per_line(&result),
1899 vec![priorities_for(&["model", "workspace"])]
1900 );
1901 }
1902
1903 #[test]
1904 fn build_lines_multi_line_returns_one_inner_vec_per_numbered_table() {
1905 let cfg = config::Config::from_str(
1906 r#"
1907 layout = "multi-line"
1908 [line.1]
1909 segments = ["model", "context_window"]
1910 [line.2]
1911 segments = ["workspace", "cost"]
1912 "#,
1913 )
1914 .expect("parse");
1915 let result = lines(Some(&cfg));
1916 assert_eq!(
1917 priorities_per_line(&result),
1918 vec![
1919 priorities_for(&["model", "context_window"]),
1920 priorities_for(&["workspace", "cost"]),
1921 ]
1922 );
1923 }
1924
1925 #[test]
1926 fn build_lines_multi_line_sorts_by_parsed_integer_not_lexicographic() {
1927 let cfg = config::Config::from_str(
1932 r#"
1933 layout = "multi-line"
1934 [line.2]
1935 segments = ["workspace"]
1936 [line.10]
1937 segments = ["context_window"]
1938 [line.1]
1939 segments = ["model"]
1940 "#,
1941 )
1942 .expect("parse");
1943 let result = lines(Some(&cfg));
1944 assert_eq!(
1945 priorities_per_line(&result),
1946 vec![
1947 priorities_for(&["model"]),
1948 priorities_for(&["workspace"]),
1949 priorities_for(&["context_window"]),
1950 ]
1951 );
1952 }
1953
1954 #[test]
1955 fn build_lines_multi_line_with_no_numbered_tables_falls_back_to_single_line() {
1956 let cfg = config::Config::from_str(
1960 r#"
1961 layout = "multi-line"
1962 [line]
1963 segments = ["model", "workspace"]
1964 "#,
1965 )
1966 .expect("parse");
1967 let (result, warns) = lines_with_warns(Some(&cfg));
1968 assert_eq!(
1969 priorities_per_line(&result),
1970 vec![priorities_for(&["model", "workspace"])]
1971 );
1972 assert!(
1973 warns.iter().any(|w| w.contains("no usable [line.N]")),
1974 "expected fallback warning, got: {warns:?}"
1975 );
1976 }
1977
1978 #[test]
1979 fn build_lines_single_line_with_numbered_tables_warns_and_ignores_them() {
1980 let cfg = config::Config::from_str(
1985 r#"
1986 layout = "single-line"
1987 [line]
1988 segments = ["model"]
1989 [line.1]
1990 segments = ["workspace"]
1991 "#,
1992 )
1993 .expect("parse");
1994 let (result, warns) = lines_with_warns(Some(&cfg));
1995 assert_eq!(
1996 priorities_per_line(&result),
1997 vec![priorities_for(&["model"])]
1998 );
1999 assert!(
2000 warns
2001 .iter()
2002 .any(|w| w.contains("single-line") && w.contains("[line.N]")),
2003 "expected mode-mismatch warning, got: {warns:?}"
2004 );
2005 }
2006
2007 #[test]
2008 fn build_lines_default_layout_with_numbered_tables_warns_and_ignores_them() {
2009 let cfg = config::Config::from_str(
2012 r#"
2013 [line]
2014 segments = ["model"]
2015 [line.1]
2016 segments = ["workspace"]
2017 "#,
2018 )
2019 .expect("parse");
2020 let (result, warns) = lines_with_warns(Some(&cfg));
2021 assert_eq!(
2022 priorities_per_line(&result),
2023 vec![priorities_for(&["model"])]
2024 );
2025 assert!(
2026 warns.iter().any(|w| w.contains("[line.N]")),
2027 "expected mode-mismatch warning, got: {warns:?}"
2028 );
2029 }
2030
2031 #[test]
2032 fn build_lines_promotes_to_multi_line_when_layout_unset_and_segments_empty() {
2033 let cfg = config::Config::from_str(
2038 r#"
2039 [line.1]
2040 segments = ["model"]
2041 [line.2]
2042 segments = ["workspace"]
2043 "#,
2044 )
2045 .expect("parse");
2046 let (result, warns) = lines_with_warns(Some(&cfg));
2047 assert_eq!(
2048 priorities_per_line(&result),
2049 vec![priorities_for(&["model"]), priorities_for(&["workspace"]),],
2050 "must render both numbered lines, not a blank single-line"
2051 );
2052 assert!(
2053 warns
2054 .iter()
2055 .any(|w| w.contains("treating as multi-line") && w.contains("layout")),
2056 "expected auto-promote hint, got: {warns:?}"
2057 );
2058 }
2059
2060 #[test]
2061 fn build_lines_does_not_promote_when_segments_populated() {
2062 let cfg = config::Config::from_str(
2067 r#"
2068 [line]
2069 segments = ["model"]
2070 [line.1]
2071 segments = ["workspace"]
2072 "#,
2073 )
2074 .expect("parse");
2075 let (result, warns) = lines_with_warns(Some(&cfg));
2076 assert_eq!(
2077 priorities_per_line(&result),
2078 vec![priorities_for(&["model"])],
2079 "must render single-line `[line].segments`, not promote"
2080 );
2081 assert!(
2082 warns.iter().any(|w| w.contains("ignoring numbered tables")),
2083 "expected the existing 'ignoring' warning, not the promote hint, got: {warns:?}"
2084 );
2085 assert!(
2086 !warns.iter().any(|w| w.contains("treating as multi-line")),
2087 "must NOT auto-promote when segments is populated, got: {warns:?}"
2088 );
2089 }
2090
2091 #[test]
2092 fn build_lines_unknown_scalar_key_under_line_warns_and_drops() {
2093 let cfg = config::Config::from_str(
2098 r#"
2099 layout = "multi-line"
2100 [line]
2101 segmnts = ["model"]
2102 [line.1]
2103 segments = ["workspace"]
2104 "#,
2105 )
2106 .expect("parse");
2107 let (result, warns) = lines_with_warns(Some(&cfg));
2108 assert_eq!(
2109 priorities_per_line(&result),
2110 vec![priorities_for(&["workspace"])]
2111 );
2112 assert!(
2113 warns
2114 .iter()
2115 .any(|w| w.contains("unknown key 'segmnts'") && w.contains("array")),
2116 "expected unknown-key warning naming the key + type, got: {warns:?}"
2117 );
2118 }
2119
2120 #[test]
2121 fn build_lines_consumed_plugins_threads_across_three_or_more_lines() {
2122 let tmp = tempfile::TempDir::new().expect("tempdir");
2128 write_plugin(
2129 tmp.path(),
2130 "p.rhai",
2131 r#"
2132 const ID = "my_plugin";
2133 fn render(ctx) { #{ runs: [#{ text: "from-plugin" }] } }
2134 "#,
2135 );
2136 let engine = crate::plugins::build_engine();
2137 let registry = crate::plugins::PluginRegistry::load_with_xdg(
2138 &[tmp.path().to_path_buf()],
2139 None,
2140 &engine,
2141 BUILT_IN_SEGMENT_IDS,
2142 );
2143
2144 let cfg = config::Config::from_str(
2145 r#"
2146 layout = "multi-line"
2147 [line.1]
2148 segments = ["my_plugin", "model"]
2149 [line.2]
2150 segments = ["my_plugin", "workspace"]
2151 [line.3]
2152 segments = ["my_plugin", "context_window"]
2153 "#,
2154 )
2155 .expect("parse");
2156 let mut warns: Vec<String> = Vec::new();
2157 let lines = build_lines(Some(&cfg), Some((registry, engine)), |m| {
2158 warns.push(m.to_string())
2159 });
2160
2161 assert_eq!(lines.len(), 3);
2162 assert_eq!(lines[0].len(), 2, "line 1: plugin + model");
2163 assert_eq!(lines[1].len(), 1, "line 2: plugin dropped, only workspace");
2164 assert_eq!(
2165 lines[2].len(),
2166 1,
2167 "line 3: plugin dropped, only context_window"
2168 );
2169 let cross_line_warns = warns
2170 .iter()
2171 .filter(|w| w.contains("rendered on an earlier line"))
2172 .count();
2173 assert_eq!(
2174 cross_line_warns, 2,
2175 "expected exactly two cross-line warnings (lines 2 + 3), got {cross_line_warns}: {warns:?}"
2176 );
2177 }
2178
2179 #[test]
2180 fn build_segments_falls_back_to_line_one_even_when_top_segments_populated() {
2181 let cfg = config::Config::from_str(
2188 r#"
2189 layout = "multi-line"
2190 [line]
2191 segments = ["cost"]
2192 [line.1]
2193 segments = ["model"]
2194 "#,
2195 )
2196 .expect("parse");
2197 let (segs, _warns) = built_with_warns(Some(&cfg));
2198 let actual: Vec<u8> = segs.iter().map(|s| s.defaults().priority).collect();
2199 assert_eq!(
2200 actual,
2201 priorities_for(&["model"]),
2202 "fallback must use [line.1].segments, not the top-level [line].segments"
2203 );
2204 }
2205
2206 #[test]
2207 fn build_segments_multi_line_with_only_invalid_numbered_keys_falls_through_to_single_line() {
2208 let cfg = config::Config::from_str(
2215 r#"
2216 layout = "multi-line"
2217 [line.foo]
2218 segments = ["bogus"]
2219 "#,
2220 )
2221 .expect("parse");
2222 let (segs, warns) = built_with_warns(Some(&cfg));
2223 assert!(segs.is_empty(), "no usable line means no segments rendered");
2224 assert!(
2225 warns
2226 .iter()
2227 .any(|w| w.contains("[line.foo]") && w.contains("not a positive integer")),
2228 "must warn about the dropped non-numeric key, got: {warns:?}"
2229 );
2230 assert!(
2231 warns.iter().any(|w| w.contains("[line].segments is empty")),
2232 "must warn that the fallback finds nothing to render, got: {warns:?}"
2233 );
2234 }
2235
2236 #[test]
2237 fn build_lines_multi_line_drops_non_numeric_keys_with_warning() {
2238 let cfg = config::Config::from_str(
2242 r#"
2243 layout = "multi-line"
2244 [line.1]
2245 segments = ["model"]
2246 [line.foo]
2247 segments = ["bogus"]
2248 [line.2]
2249 segments = ["workspace"]
2250 "#,
2251 )
2252 .expect("parse");
2253 let (result, warns) = lines_with_warns(Some(&cfg));
2254 assert_eq!(
2255 priorities_per_line(&result),
2256 vec![priorities_for(&["model"]), priorities_for(&["workspace"])]
2257 );
2258 assert!(
2259 warns
2260 .iter()
2261 .any(|w| w.contains("[line.foo]") && w.contains("not a positive integer")),
2262 "expected non-numeric-key warning, got: {warns:?}"
2263 );
2264 }
2265
2266 #[test]
2267 fn build_lines_multi_line_drops_zero_and_negative_keys() {
2268 let cfg = config::Config::from_str(
2272 r#"
2273 layout = "multi-line"
2274 [line.0]
2275 segments = ["context_window"]
2276 [line.1]
2277 segments = ["model"]
2278 [line."-1"]
2279 segments = ["cost"]
2280 "#,
2281 )
2282 .expect("parse");
2283 let (result, warns) = lines_with_warns(Some(&cfg));
2284 assert_eq!(
2285 priorities_per_line(&result),
2286 vec![priorities_for(&["model"])]
2287 );
2288 assert!(warns.iter().any(|w| w.contains("[line.0]")));
2289 assert!(warns.iter().any(|w| w.contains("[line.-1]")));
2290 }
2291
2292 #[test]
2293 fn build_lines_multi_line_with_only_invalid_keys_falls_back_to_single_line() {
2294 let cfg = config::Config::from_str(
2299 r#"
2300 layout = "multi-line"
2301 [line]
2302 segments = ["model"]
2303 [line.foo]
2304 segments = ["bogus"]
2305 "#,
2306 )
2307 .expect("parse");
2308 let (result, warns) = lines_with_warns(Some(&cfg));
2309 assert_eq!(
2310 priorities_per_line(&result),
2311 vec![priorities_for(&["model"])]
2312 );
2313 assert!(warns.iter().any(|w| w.contains("[line.foo]")));
2314 assert!(warns.iter().any(|w| w.contains("no usable [line.N]")));
2315 }
2316
2317 #[test]
2318 fn build_lines_multi_line_warns_per_empty_numbered_segments() {
2319 let cfg = config::Config::from_str(
2320 r#"
2321 layout = "multi-line"
2322 [line.1]
2323 segments = ["model"]
2324 [line.2]
2325 segments = []
2326 "#,
2327 )
2328 .expect("parse");
2329 let (result, warns) = lines_with_warns(Some(&cfg));
2330 assert_eq!(
2331 priorities_per_line(&result),
2332 vec![priorities_for(&["model"]), Vec::<u8>::new()]
2333 );
2334 assert!(
2335 warns
2336 .iter()
2337 .any(|w| w.contains("[line.2].segments is empty")),
2338 "expected empty-segments warning for line 2, got: {warns:?}"
2339 );
2340 }
2341
2342 #[test]
2343 fn build_lines_multi_line_ignores_top_level_segments_when_numbered_present() {
2344 let cfg = config::Config::from_str(
2349 r#"
2350 layout = "multi-line"
2351 [line]
2352 segments = ["workspace"]
2353 [line.1]
2354 segments = ["model"]
2355 "#,
2356 )
2357 .expect("parse");
2358 let result = lines(Some(&cfg));
2359 assert_eq!(
2360 priorities_per_line(&result),
2361 vec![priorities_for(&["model"])]
2362 );
2363 }
2364
2365 #[test]
2366 fn build_lines_multi_line_dedupes_within_line_but_not_across_lines() {
2367 let cfg = config::Config::from_str(
2371 r#"
2372 layout = "multi-line"
2373 [line.1]
2374 segments = ["model", "model", "workspace"]
2375 [line.2]
2376 segments = ["model"]
2377 "#,
2378 )
2379 .expect("parse");
2380 let (result, warns) = lines_with_warns(Some(&cfg));
2381 assert_eq!(
2382 priorities_per_line(&result),
2383 vec![
2384 priorities_for(&["model", "workspace"]),
2385 priorities_for(&["model"]),
2386 ]
2387 );
2388 let dedup_warns: Vec<_> = warns
2390 .iter()
2391 .filter(|w| w.contains("listed more than once"))
2392 .collect();
2393 assert_eq!(
2394 dedup_warns.len(),
2395 1,
2396 "expected one dedup warning, got: {warns:?}"
2397 );
2398 }
2399}