Skip to main content

linesmith_core/segments/
git_branch.rs

1//! `git_branch` segment: branch name + dirty indicator.
2//!
3//! Canonical definition: `docs/specs/git-segments.md`.
4//!
5//! Hidden when cwd is outside a git repo, when the repo is bare, or
6//! when gix rejects the repo. Detached HEAD renders a short SHA;
7//! unborn HEAD renders the symbolic-ref target (e.g. `main`).
8
9use std::collections::BTreeMap;
10
11use super::extras::parse_bool;
12use super::{RenderContext, RenderResult, RenderedSegment, Segment, SegmentDefaults};
13use crate::data_context::{DataContext, DataDep, GitContext, Head, RepoKind};
14use crate::theme::Role;
15
16#[derive(Default)]
17pub struct GitBranchSegment {
18    cfg: Config,
19}
20
21/// Between workspace (16) and model (64): branch identity is more
22/// valuable than cost/effort under width pressure but less than
23/// model identity when both are set.
24const PRIORITY: u8 = 48;
25
26const ID: &str = "git_branch";
27const DEFAULT_DIRTY_INDICATOR: &str = "*";
28const DEFAULT_TRUNCATION_MARKER: &str = "…";
29const DEFAULT_SHORT_SHA_LEN: u8 = 7;
30const DEFAULT_MAX_BRANCH_LEN: u16 = 40;
31const DEFAULT_AHEAD_FORMAT: &str = "↑{n}";
32const DEFAULT_BEHIND_FORMAT: &str = "↓{n}";
33const NO_UPSTREAM_MARKER: &str = "?";
34
35/// Resolved runtime config. Defaults match `git-segments.md`
36/// §Config schema.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub(crate) struct Config {
39    pub(crate) icon: String,
40    pub(crate) label: String,
41    pub(crate) max_length: u16,
42    pub(crate) truncation_marker: String,
43    pub(crate) short_sha_length: u8,
44    pub(crate) dirty_enabled: bool,
45    pub(crate) dirty_indicator: String,
46    pub(crate) clean_indicator: String,
47    /// Hide the dirty marker when `rc.terminal_width` is below this
48    /// threshold. `0` = never auto-hide.
49    pub(crate) dirty_hide_below_cells: u16,
50    pub(crate) ahead_behind: AheadBehindConfig,
51}
52
53#[derive(Debug, Clone, PartialEq, Eq)]
54#[non_exhaustive]
55pub(crate) struct AheadBehindConfig {
56    pub(crate) enabled: bool,
57    pub(crate) ahead_format: FormatTemplate,
58    pub(crate) behind_format: FormatTemplate,
59    pub(crate) hide_when_zero: bool,
60    pub(crate) hide_when_no_upstream: bool,
61    /// Hide the ahead/behind marker when `rc.terminal_width` is below
62    /// this threshold. `0` = never auto-hide.
63    pub(crate) hide_below_cells: u16,
64}
65
66impl Default for AheadBehindConfig {
67    fn default() -> Self {
68        Self {
69            enabled: true,
70            ahead_format: FormatTemplate::parse(DEFAULT_AHEAD_FORMAT)
71                .expect("DEFAULT_AHEAD_FORMAT must contain FormatTemplate::PLACEHOLDER"),
72            behind_format: FormatTemplate::parse(DEFAULT_BEHIND_FORMAT)
73                .expect("DEFAULT_BEHIND_FORMAT must contain FormatTemplate::PLACEHOLDER"),
74            hide_when_zero: true,
75            hide_when_no_upstream: true,
76            hide_below_cells: 0,
77        }
78    }
79}
80
81/// Template string for ahead/behind rendering. Constructor guarantees
82/// [`Self::PLACEHOLDER`] is present, so a typo like `↑{count}`
83/// surfaces at config-parse time rather than silently rendering with
84/// no count.
85#[derive(Debug, Clone, PartialEq, Eq)]
86pub(crate) struct FormatTemplate(String);
87
88impl FormatTemplate {
89    /// The count placeholder every template must contain.
90    pub(crate) const PLACEHOLDER: &'static str = "{n}";
91
92    /// Parse a user-supplied template. Returns `None` when the
93    /// placeholder is missing.
94    pub(crate) fn parse(s: &str) -> Option<Self> {
95        if s.contains(Self::PLACEHOLDER) {
96            Some(Self(s.to_string()))
97        } else {
98            None
99        }
100    }
101
102    pub(crate) fn render(&self, n: u32) -> String {
103        self.0.replace(Self::PLACEHOLDER, &n.to_string())
104    }
105}
106
107impl Default for Config {
108    fn default() -> Self {
109        Self {
110            icon: String::new(),
111            label: String::new(),
112            max_length: DEFAULT_MAX_BRANCH_LEN,
113            truncation_marker: DEFAULT_TRUNCATION_MARKER.into(),
114            short_sha_length: DEFAULT_SHORT_SHA_LEN,
115            dirty_enabled: true,
116            dirty_indicator: DEFAULT_DIRTY_INDICATOR.into(),
117            clean_indicator: String::new(),
118            dirty_hide_below_cells: 0,
119            ahead_behind: AheadBehindConfig::default(),
120        }
121    }
122}
123
124impl GitBranchSegment {
125    /// Parse the `[segments.git_branch]` extras bag. Unknown values
126    /// warn and fall back to defaults. `dirty.format = "counts"`
127    /// warns and falls back to "indicator".
128    pub fn from_extras(
129        extras: &BTreeMap<String, toml::Value>,
130        warn: &mut impl FnMut(&str),
131    ) -> Self {
132        let mut cfg = Config::default();
133
134        if let Some(v) = extras.get("icon").and_then(|v| v.as_str()) {
135            cfg.icon = v.to_string();
136        }
137        if let Some(v) = extras.get("label").and_then(|v| v.as_str()) {
138            cfg.label = v.to_string();
139        }
140        if let Some(v) = extras.get("max_length") {
141            match v.as_integer().and_then(|n| u16::try_from(n).ok()) {
142                // Spec min is 1; 0 would render nothing useful.
143                Some(n) if n >= 1 => cfg.max_length = n,
144                _ => warn(&format!(
145                    "segments.{ID}.max_length: expected 1..=65535; ignoring"
146                )),
147            }
148        }
149        if let Some(v) = extras.get("truncation_marker").and_then(|v| v.as_str()) {
150            cfg.truncation_marker = v.to_string();
151        }
152        if let Some(v) = extras.get("short_sha_length").and_then(|v| v.as_integer()) {
153            match u8::try_from(v) {
154                // Spec allows 1..=40; clamp to u8 and cap at 40.
155                Ok(n) if (1..=40).contains(&n) => cfg.short_sha_length = n,
156                _ => warn(&format!(
157                    "segments.{ID}.short_sha_length: expected 1..=40; ignoring"
158                )),
159            }
160        }
161
162        if let Some(dirty) = extras.get("dirty").and_then(|v| v.as_table()) {
163            let dirty_map: BTreeMap<String, toml::Value> =
164                dirty.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
165            if let Some(v) = parse_bool(&dirty_map, "enabled", "git_branch.dirty", warn) {
166                cfg.dirty_enabled = v;
167            }
168            if let Some(fmt) = dirty_map.get("format").and_then(|v| v.as_str()) {
169                match fmt {
170                    "indicator" | "hidden" => {
171                        if fmt == "hidden" {
172                            cfg.dirty_enabled = false;
173                        }
174                    }
175                    "counts" => {
176                        warn("segments.git_branch.dirty.format=\"counts\" is not yet implemented; falling back to \"indicator\" (follow-up: lsm-kjj counts mode)");
177                    }
178                    _ => warn(
179                        "segments.git_branch.dirty.format: expected \"indicator\"|\"counts\"|\"hidden\"; ignoring",
180                    ),
181                }
182            }
183            if let Some(v) = dirty_map.get("indicator").and_then(|v| v.as_str()) {
184                cfg.dirty_indicator = v.to_string();
185            }
186            if let Some(v) = dirty_map.get("clean_indicator").and_then(|v| v.as_str()) {
187                cfg.clean_indicator = v.to_string();
188            }
189            if let Some(v) = parse_hide_below_cells(&dirty_map, "git_branch.dirty", warn) {
190                cfg.dirty_hide_below_cells = v;
191            }
192        }
193
194        if let Some(ab) = extras.get("ahead_behind").and_then(|v| v.as_table()) {
195            let ab_map: BTreeMap<String, toml::Value> =
196                ab.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
197            if let Some(v) = parse_bool(&ab_map, "enabled", "git_branch.ahead_behind", warn) {
198                cfg.ahead_behind.enabled = v;
199            }
200            let placeholder = FormatTemplate::PLACEHOLDER;
201            if let Some(v) = ab_map.get("ahead_format").and_then(|v| v.as_str()) {
202                match FormatTemplate::parse(v) {
203                    Some(tpl) => cfg.ahead_behind.ahead_format = tpl,
204                    None => warn(&format!(
205                        "segments.{ID}.ahead_behind.ahead_format: missing `{placeholder}` placeholder in {v:?}; ignoring"
206                    )),
207                }
208            }
209            if let Some(v) = ab_map.get("behind_format").and_then(|v| v.as_str()) {
210                match FormatTemplate::parse(v) {
211                    Some(tpl) => cfg.ahead_behind.behind_format = tpl,
212                    None => warn(&format!(
213                        "segments.{ID}.ahead_behind.behind_format: missing `{placeholder}` placeholder in {v:?}; ignoring"
214                    )),
215                }
216            }
217            if let Some(v) = parse_bool(&ab_map, "hide_when_zero", "git_branch.ahead_behind", warn)
218            {
219                cfg.ahead_behind.hide_when_zero = v;
220            }
221            if let Some(v) = parse_bool(
222                &ab_map,
223                "hide_when_no_upstream",
224                "git_branch.ahead_behind",
225                warn,
226            ) {
227                cfg.ahead_behind.hide_when_no_upstream = v;
228            }
229            if let Some(v) = parse_hide_below_cells(&ab_map, "git_branch.ahead_behind", warn) {
230                cfg.ahead_behind.hide_below_cells = v;
231            }
232        }
233
234        Self { cfg }
235    }
236}
237
238/// `true` when the configured threshold is set and the current
239/// terminal width is below it. Threshold `0` is the sentinel for
240/// "never auto-hide" — the marker shows at every terminal width.
241fn is_below_threshold(rc: &RenderContext, threshold: u16) -> bool {
242    threshold > 0 && rc.terminal_width < threshold
243}
244
245/// Parse a `hide_below_cells` field as a `u16` cell threshold. Returns
246/// `Some(n)` for a valid `u16`, `None` on missing key (silent), and
247/// `None` plus a warning on a malformed value — leaving the caller's
248/// existing threshold untouched rather than clearing it on a typo.
249fn parse_hide_below_cells(
250    table: &BTreeMap<String, toml::Value>,
251    scope: &str,
252    warn: &mut impl FnMut(&str),
253) -> Option<u16> {
254    let v = table.get("hide_below_cells")?;
255    match v.as_integer().and_then(|n| u16::try_from(n).ok()) {
256        Some(n) => Some(n),
257        None => {
258            warn(&format!(
259                "segments.{scope}.hide_below_cells: expected 0..=65535; ignoring"
260            ));
261            None
262        }
263    }
264}
265
266impl Segment for GitBranchSegment {
267    fn data_deps(&self) -> &'static [DataDep] {
268        &[DataDep::Git]
269    }
270
271    fn defaults(&self) -> SegmentDefaults {
272        SegmentDefaults::with_priority(PRIORITY)
273    }
274
275    fn render(&self, ctx: &DataContext, rc: &RenderContext) -> RenderResult {
276        let arc = ctx.git();
277        match &*arc {
278            Err(_) | Ok(None) => Ok(None),
279            // Bare repos have no working tree, so branch / dirty
280            // state is meaningless. Submodules, linked worktrees, and
281            // main checkouts all render normally.
282            Ok(Some(gc)) if matches!(gc.repo_kind, RepoKind::Bare) => {
283                crate::lsm_debug!("git_branch: bare repo; hiding");
284                Ok(None)
285            }
286            Ok(Some(gc)) => {
287                let text = self.assemble(gc, rc);
288                if text.is_empty() {
289                    return Ok(None);
290                }
291                Ok(Some(RenderedSegment::new(text).with_role(Role::Accent)))
292            }
293        }
294    }
295
296    fn shrink_to_fit(
297        &self,
298        ctx: &DataContext,
299        _rc: &RenderContext,
300        target: u16,
301    ) -> Option<RenderedSegment> {
302        // Same hide-rules as `render`: outside a repo or in a bare
303        // repo, there's nothing structured to shed and no shorter
304        // form to offer.
305        let arc = ctx.git();
306        let gc = match &*arc {
307            Err(_) | Ok(None) => return None,
308            Ok(Some(gc)) if matches!(gc.repo_kind, RepoKind::Bare) => return None,
309            Ok(Some(gc)) => gc,
310        };
311        let text = self.assemble_compact(gc);
312        if text.is_empty() {
313            return None;
314        }
315        let rendered = RenderedSegment::new(text).with_role(Role::Accent);
316        (rendered.width <= target).then_some(rendered)
317    }
318}
319
320impl GitBranchSegment {
321    fn assemble(&self, gc: &GitContext, rc: &RenderContext) -> String {
322        let mut parts: Vec<String> = Vec::new();
323        if !self.cfg.icon.is_empty() {
324            parts.push(self.cfg.icon.clone());
325        }
326        if !self.cfg.label.is_empty() {
327            parts.push(self.cfg.label.clone());
328        }
329
330        let head = self.render_head(&gc.head);
331        if !head.is_empty() {
332            parts.push(head);
333        }
334
335        if self.cfg.dirty_enabled && !is_below_threshold(rc, self.cfg.dirty_hide_below_cells) {
336            if let Some(marker) = self.render_dirty(gc) {
337                parts.push(marker);
338            }
339        }
340
341        if self.cfg.ahead_behind.enabled
342            && !is_below_threshold(rc, self.cfg.ahead_behind.hide_below_cells)
343        {
344            if let Some(marker) = self.render_ahead_behind(gc) {
345                parts.push(marker);
346            }
347        }
348
349        parts.join(" ")
350    }
351
352    /// `assemble` with both structured-tail markers (dirty,
353    /// ahead/behind) suppressed regardless of config. The compact
354    /// fallback the engine asks for via `shrink_to_fit` under layout
355    /// pressure: shed decoration, keep the signal-bearing prefix
356    /// (icon + label + head).
357    fn assemble_compact(&self, gc: &GitContext) -> String {
358        let mut parts: Vec<String> = Vec::new();
359        if !self.cfg.icon.is_empty() {
360            parts.push(self.cfg.icon.clone());
361        }
362        if !self.cfg.label.is_empty() {
363            parts.push(self.cfg.label.clone());
364        }
365        let head = self.render_head(&gc.head);
366        if !head.is_empty() {
367            parts.push(head);
368        }
369        parts.join(" ")
370    }
371
372    fn render_ahead_behind(&self, gc: &GitContext) -> Option<String> {
373        // Ahead/behind only applies to local branches. Detached /
374        // Unborn / OtherRef skip the marker entirely — the `?` that
375        // `hide_when_no_upstream = false` emits is reserved for a
376        // branch whose tracking remote is unconfigured.
377        if !matches!(gc.head, Head::Branch(_)) {
378            return None;
379        }
380        match &*gc.upstream() {
381            None => {
382                if self.cfg.ahead_behind.hide_when_no_upstream {
383                    None
384                } else {
385                    Some(NO_UPSTREAM_MARKER.to_string())
386                }
387            }
388            Some(state) => {
389                if state.ahead == 0 && state.behind == 0 && self.cfg.ahead_behind.hide_when_zero {
390                    return None;
391                }
392                let mut out = String::new();
393                if state.ahead > 0 || !self.cfg.ahead_behind.hide_when_zero {
394                    out.push_str(&self.cfg.ahead_behind.ahead_format.render(state.ahead));
395                }
396                if state.behind > 0 || !self.cfg.ahead_behind.hide_when_zero {
397                    if !out.is_empty() {
398                        out.push(' ');
399                    }
400                    out.push_str(&self.cfg.ahead_behind.behind_format.render(state.behind));
401                }
402                if out.is_empty() {
403                    None
404                } else {
405                    Some(out)
406                }
407            }
408        }
409    }
410
411    fn render_head(&self, head: &Head) -> String {
412        match head {
413            Head::Branch(name) => {
414                truncate_middle(name, self.cfg.max_length, &self.cfg.truncation_marker)
415            }
416            Head::Detached(oid) => {
417                let s = oid.to_string();
418                let n = usize::from(self.cfg.short_sha_length).min(s.len());
419                format!("({})", &s[..n])
420            }
421            Head::Unborn { symbolic_ref } => truncate_middle(
422                symbolic_ref,
423                self.cfg.max_length,
424                &self.cfg.truncation_marker,
425            ),
426            Head::OtherRef { full_name } => {
427                truncate_middle(full_name, self.cfg.max_length, &self.cfg.truncation_marker)
428            }
429        }
430    }
431
432    fn render_dirty(&self, gc: &GitContext) -> Option<String> {
433        if gc.dirty().is_dirty() {
434            if self.cfg.dirty_indicator.is_empty() {
435                None
436            } else {
437                Some(self.cfg.dirty_indicator.clone())
438            }
439        } else if self.cfg.clean_indicator.is_empty() {
440            None
441        } else {
442            Some(self.cfg.clean_indicator.clone())
443        }
444    }
445}
446
447/// Middle-truncate `s` so its cell width fits `max`, inserting
448/// `marker` between the kept prefix and suffix. Grapheme-aware per
449/// segment-system.md §Layout intent. Falls through for short inputs.
450fn truncate_middle(s: &str, max: u16, marker: &str) -> String {
451    use unicode_segmentation::UnicodeSegmentation;
452    use unicode_width::UnicodeWidthStr;
453
454    let max_usize = usize::from(max);
455    let cur_width = UnicodeWidthStr::width(s);
456    if max == 0 || cur_width <= max_usize {
457        return s.to_string();
458    }
459    let marker_width = UnicodeWidthStr::width(marker);
460    if marker_width >= max_usize {
461        // Pathological: marker alone exceeds the budget. Keep the
462        // first `max` graphemes of the source — degraded but stable.
463        let mut out = String::new();
464        let mut w = 0usize;
465        for g in s.graphemes(true) {
466            let gw = UnicodeWidthStr::width(g);
467            if w + gw > max_usize {
468                break;
469            }
470            out.push_str(g);
471            w += gw;
472        }
473        return out;
474    }
475    let budget = max_usize - marker_width;
476    let head_budget = budget.div_ceil(2);
477    let tail_budget = budget - head_budget;
478
479    let mut head = String::new();
480    let mut head_w = 0usize;
481    for g in s.graphemes(true) {
482        let gw = UnicodeWidthStr::width(g);
483        if head_w + gw > head_budget {
484            break;
485        }
486        head.push_str(g);
487        head_w += gw;
488    }
489    let mut tail_graphemes: Vec<&str> = Vec::new();
490    let mut tail_w = 0usize;
491    for g in s.graphemes(true).rev() {
492        let gw = UnicodeWidthStr::width(g);
493        if tail_w + gw > tail_budget {
494            break;
495        }
496        tail_graphemes.push(g);
497        tail_w += gw;
498    }
499    tail_graphemes.reverse();
500    let mut out = head;
501    out.push_str(marker);
502    for g in tail_graphemes {
503        out.push_str(g);
504    }
505    out
506}
507
508#[cfg(test)]
509mod tests {
510    use super::*;
511    use crate::data_context::{DirtyState, GitContext, Head, RepoKind, UpstreamState};
512    use crate::input::{ModelInfo, StatusContext, Tool, WorkspaceInfo};
513    use std::path::PathBuf;
514    use std::sync::Arc;
515
516    fn minimal_status() -> StatusContext {
517        StatusContext {
518            tool: Tool::ClaudeCode,
519            model: Some(ModelInfo {
520                display_name: "Claude".into(),
521            }),
522            workspace: Some(WorkspaceInfo {
523                project_dir: PathBuf::from("/repo"),
524                git_worktree: None,
525            }),
526            context_window: None,
527            cost: None,
528            effort: None,
529            vim: None,
530            output_style: None,
531            agent_name: None,
532            version: None,
533            raw: Arc::new(serde_json::Value::Null),
534        }
535    }
536
537    fn rc() -> RenderContext {
538        RenderContext::new(80)
539    }
540
541    fn ctx_with_git(
542        result: Result<Option<GitContext>, crate::data_context::GitError>,
543    ) -> DataContext {
544        let dc = DataContext::with_cwd(minimal_status(), None);
545        dc.preseed_git(result).expect("seed");
546        dc
547    }
548
549    #[test]
550    fn hides_when_not_in_repo() {
551        assert!(GitBranchSegment::default()
552            .render(&ctx_with_git(Ok(None)), &rc())
553            .unwrap()
554            .is_none());
555    }
556
557    #[test]
558    fn hides_on_gix_error() {
559        let err = crate::data_context::GitError::CorruptRepo {
560            path: PathBuf::from("/x"),
561            message: "synthetic".into(),
562        };
563        assert!(GitBranchSegment::default()
564            .render(&ctx_with_git(Err(err)), &rc())
565            .unwrap()
566            .is_none());
567    }
568
569    #[test]
570    fn hides_on_bare_repo() {
571        let gc = GitContext::new(
572            RepoKind::Bare,
573            PathBuf::from("/tmp/bare.git"),
574            Head::Unborn {
575                symbolic_ref: "main".into(),
576            },
577        );
578        assert!(GitBranchSegment::default()
579            .render(&ctx_with_git(Ok(Some(gc))), &rc())
580            .unwrap()
581            .is_none());
582    }
583
584    #[test]
585    fn renders_branch_name() {
586        let gc = GitContext::new(
587            RepoKind::Main,
588            PathBuf::from("/repo/.git"),
589            Head::Branch("main".into()),
590        );
591        let rendered = GitBranchSegment::default()
592            .render(&ctx_with_git(Ok(Some(gc))), &rc())
593            .unwrap()
594            .expect("rendered");
595        assert_eq!(rendered.text(), "main");
596        assert_eq!(rendered.style().role, Some(Role::Accent));
597    }
598
599    #[test]
600    fn renders_detached_as_short_sha_in_parens() {
601        let gc = GitContext::new(
602            RepoKind::Main,
603            PathBuf::from("/repo/.git"),
604            Head::Detached(gix::ObjectId::empty_tree(gix::hash::Kind::Sha1)),
605        );
606        let rendered = GitBranchSegment::default()
607            .render(&ctx_with_git(Ok(Some(gc))), &rc())
608            .unwrap()
609            .expect("rendered");
610        // gix's canonical empty-tree SHA starts with "4b825dc6" — we
611        // assert the shape (parens + configured length) rather than
612        // the exact bytes so the test survives gix's hash changes.
613        assert!(rendered.text().starts_with('('));
614        assert!(rendered.text().ends_with(')'));
615        // `(` + 7 hex + `)` = 9 cells.
616        assert_eq!(rendered.text().chars().count(), 9);
617    }
618
619    #[test]
620    fn renders_other_ref_full_name() {
621        let gc = GitContext::new(
622            RepoKind::Main,
623            PathBuf::from("/repo/.git"),
624            Head::OtherRef {
625                full_name: "refs/remotes/origin/feature".into(),
626            },
627        );
628        let rendered = GitBranchSegment::default()
629            .render(&ctx_with_git(Ok(Some(gc))), &rc())
630            .unwrap()
631            .expect("rendered");
632        assert_eq!(rendered.text(), "refs/remotes/origin/feature");
633    }
634
635    // --- Ahead/behind rendering ---
636
637    fn ctx_with_upstream(head: Head, upstream: Option<UpstreamState>) -> DataContext {
638        let gc = GitContext::new(RepoKind::Main, PathBuf::from("/repo/.git"), head);
639        gc.preseed_upstream(upstream).expect("fresh onceCell");
640        let dc = DataContext::with_cwd(minimal_status(), None);
641        dc.preseed_git(Ok(Some(gc))).expect("seed");
642        dc
643    }
644
645    #[test]
646    fn renders_ahead_when_local_leads() {
647        let rendered = GitBranchSegment::default()
648            .render(
649                &ctx_with_upstream(
650                    Head::Branch("main".into()),
651                    Some(UpstreamState {
652                        ahead: 2,
653                        behind: 0,
654                        upstream_branch: "origin/main".into(),
655                    }),
656                ),
657                &rc(),
658            )
659            .unwrap()
660            .expect("rendered");
661        assert_eq!(rendered.text(), "main ↑2");
662    }
663
664    #[test]
665    fn renders_behind_when_remote_leads() {
666        let rendered = GitBranchSegment::default()
667            .render(
668                &ctx_with_upstream(
669                    Head::Branch("main".into()),
670                    Some(UpstreamState {
671                        ahead: 0,
672                        behind: 3,
673                        upstream_branch: "origin/main".into(),
674                    }),
675                ),
676                &rc(),
677            )
678            .unwrap()
679            .expect("rendered");
680        assert_eq!(rendered.text(), "main ↓3");
681    }
682
683    #[test]
684    fn renders_both_when_diverged() {
685        let rendered = GitBranchSegment::default()
686            .render(
687                &ctx_with_upstream(
688                    Head::Branch("main".into()),
689                    Some(UpstreamState {
690                        ahead: 2,
691                        behind: 3,
692                        upstream_branch: "origin/main".into(),
693                    }),
694                ),
695                &rc(),
696            )
697            .unwrap()
698            .expect("rendered");
699        assert_eq!(rendered.text(), "main ↑2 ↓3");
700    }
701
702    #[test]
703    fn hides_ahead_behind_when_zero_by_default() {
704        let rendered = GitBranchSegment::default()
705            .render(
706                &ctx_with_upstream(
707                    Head::Branch("main".into()),
708                    Some(UpstreamState {
709                        ahead: 0,
710                        behind: 0,
711                        upstream_branch: "origin/main".into(),
712                    }),
713                ),
714                &rc(),
715            )
716            .unwrap()
717            .expect("rendered");
718        assert_eq!(rendered.text(), "main");
719    }
720
721    #[test]
722    fn shows_zeros_when_configured() {
723        let mut seg = GitBranchSegment::default();
724        seg.cfg.ahead_behind.hide_when_zero = false;
725        let rendered = seg
726            .render(
727                &ctx_with_upstream(
728                    Head::Branch("main".into()),
729                    Some(UpstreamState {
730                        ahead: 0,
731                        behind: 0,
732                        upstream_branch: "origin/main".into(),
733                    }),
734                ),
735                &rc(),
736            )
737            .unwrap()
738            .expect("rendered");
739        assert_eq!(rendered.text(), "main ↑0 ↓0");
740    }
741
742    #[test]
743    fn hides_ahead_behind_when_no_upstream_by_default() {
744        let rendered = GitBranchSegment::default()
745            .render(&ctx_with_upstream(Head::Branch("main".into()), None), &rc())
746            .unwrap()
747            .expect("rendered");
748        assert_eq!(rendered.text(), "main");
749    }
750
751    #[test]
752    fn renders_question_mark_when_no_upstream_opted_in() {
753        let mut seg = GitBranchSegment::default();
754        seg.cfg.ahead_behind.hide_when_no_upstream = false;
755        let rendered = seg
756            .render(&ctx_with_upstream(Head::Branch("main".into()), None), &rc())
757            .unwrap()
758            .expect("rendered");
759        assert_eq!(rendered.text(), "main ?");
760    }
761
762    #[test]
763    fn skips_ahead_behind_on_detached_head() {
764        let gc = GitContext::new(
765            RepoKind::Main,
766            PathBuf::from("/repo/.git"),
767            Head::Detached(gix::ObjectId::empty_tree(gix::hash::Kind::Sha1)),
768        );
769        let dc = DataContext::with_cwd(minimal_status(), None);
770        dc.preseed_git(Ok(Some(gc))).expect("seed");
771        let rendered = GitBranchSegment::default()
772            .render(&dc, &rc())
773            .unwrap()
774            .expect("rendered");
775        assert!(
776            !rendered.text().contains('↑') && !rendered.text().contains('↓'),
777            "expected no ahead/behind on detached HEAD, got {:?}",
778            rendered.text()
779        );
780    }
781
782    #[test]
783    fn from_extras_warns_on_ahead_format_missing_placeholder() {
784        let mut extras = BTreeMap::new();
785        let mut ab = toml::value::Table::new();
786        ab.insert(
787            "ahead_format".into(),
788            toml::Value::String("↑{count}".into()),
789        );
790        extras.insert("ahead_behind".into(), toml::Value::Table(ab));
791        let mut warnings = Vec::<String>::new();
792        let seg = GitBranchSegment::from_extras(&extras, &mut |m| warnings.push(m.to_string()));
793        assert_eq!(warnings.len(), 1);
794        assert!(warnings[0].contains("ahead_format"));
795        assert!(warnings[0].contains("{n}"));
796        assert_eq!(seg.cfg.ahead_behind.ahead_format.render(2), "↑2");
797    }
798
799    #[test]
800    fn from_extras_warns_on_behind_format_missing_placeholder() {
801        let mut extras = BTreeMap::new();
802        let mut ab = toml::value::Table::new();
803        ab.insert(
804            "behind_format".into(),
805            toml::Value::String("↓{count}".into()),
806        );
807        extras.insert("ahead_behind".into(), toml::Value::Table(ab));
808        let mut warnings = Vec::<String>::new();
809        let seg = GitBranchSegment::from_extras(&extras, &mut |m| warnings.push(m.to_string()));
810        assert_eq!(warnings.len(), 1);
811        assert!(warnings[0].contains("behind_format"));
812        assert!(warnings[0].contains("{n}"));
813        assert_eq!(seg.cfg.ahead_behind.behind_format.render(3), "↓3");
814    }
815
816    #[test]
817    fn format_template_parse_rejects_missing_placeholder() {
818        assert!(FormatTemplate::parse("no placeholder").is_none());
819        assert!(FormatTemplate::parse("↑{count}").is_none());
820        assert!(FormatTemplate::parse("↑{n}").is_some());
821    }
822
823    #[test]
824    fn format_template_render_substitutes_placeholder() {
825        let tpl = FormatTemplate::parse("↑{n} commits").expect("valid");
826        assert_eq!(tpl.render(7), "↑7 commits");
827    }
828
829    #[test]
830    fn default_templates_contain_placeholder() {
831        // AheadBehindConfig::default() panics if the module-private
832        // DEFAULT_*_FORMAT consts drift away from FormatTemplate's
833        // PLACEHOLDER contract. Pin the default build in CI so the
834        // expect() in Default is proven at least once.
835        let default = AheadBehindConfig::default();
836        assert_eq!(default.ahead_format.render(2), "↑2");
837        assert_eq!(default.behind_format.render(3), "↓3");
838    }
839
840    #[test]
841    fn skips_ahead_behind_on_unborn_head() {
842        let rendered = GitBranchSegment::default()
843            .render(
844                &ctx_with_upstream(
845                    Head::Unborn {
846                        symbolic_ref: "main".into(),
847                    },
848                    None,
849                ),
850                &rc(),
851            )
852            .unwrap()
853            .expect("rendered");
854        assert!(
855            !rendered.text().contains('↑')
856                && !rendered.text().contains('↓')
857                && !rendered.text().contains('?'),
858            "expected no ahead/behind marker on Unborn HEAD, got {:?}",
859            rendered.text()
860        );
861    }
862
863    #[test]
864    fn skips_ahead_behind_on_other_ref_head() {
865        let rendered = GitBranchSegment::default()
866            .render(
867                &ctx_with_upstream(
868                    Head::OtherRef {
869                        full_name: "refs/remotes/origin/feature".into(),
870                    },
871                    None,
872                ),
873                &rc(),
874            )
875            .unwrap()
876            .expect("rendered");
877        assert!(
878            !rendered.text().contains('↑')
879                && !rendered.text().contains('↓')
880                && !rendered.text().contains('?'),
881            "expected no ahead/behind marker on OtherRef HEAD, got {:?}",
882            rendered.text()
883        );
884    }
885
886    #[test]
887    fn skips_ahead_behind_on_unborn_head_even_with_hide_when_no_upstream_false() {
888        // The '?' marker is reserved for branches with no configured
889        // tracking remote. Unborn HEAD isn't a branch, so it must not
890        // render '?' regardless of hide_when_no_upstream.
891        let mut seg = GitBranchSegment::default();
892        seg.cfg.ahead_behind.hide_when_no_upstream = false;
893        let rendered = seg
894            .render(
895                &ctx_with_upstream(
896                    Head::Unborn {
897                        symbolic_ref: "main".into(),
898                    },
899                    None,
900                ),
901                &rc(),
902            )
903            .unwrap()
904            .expect("rendered");
905        assert!(
906            !rendered.text().contains('?'),
907            "Unborn HEAD should not render '?' even with hide_when_no_upstream=false; got {:?}",
908            rendered.text()
909        );
910    }
911
912    #[test]
913    fn renders_ahead_with_custom_format() {
914        let mut seg = GitBranchSegment::default();
915        seg.cfg.ahead_behind.ahead_format = FormatTemplate::parse(">>{n}").expect("valid");
916        let rendered = seg
917            .render(
918                &ctx_with_upstream(
919                    Head::Branch("main".into()),
920                    Some(UpstreamState {
921                        ahead: 5,
922                        behind: 0,
923                        upstream_branch: "origin/main".into(),
924                    }),
925                ),
926                &rc(),
927            )
928            .unwrap()
929            .expect("rendered");
930        assert_eq!(rendered.text(), "main >>5");
931    }
932
933    #[test]
934    fn from_extras_reads_ahead_behind_knobs() {
935        let mut extras = BTreeMap::new();
936        let mut ab = toml::value::Table::new();
937        ab.insert("enabled".into(), toml::Value::Boolean(true));
938        ab.insert("ahead_format".into(), toml::Value::String(">>{n}".into()));
939        ab.insert("behind_format".into(), toml::Value::String("<<{n}".into()));
940        ab.insert("hide_when_zero".into(), toml::Value::Boolean(false));
941        ab.insert("hide_when_no_upstream".into(), toml::Value::Boolean(false));
942        extras.insert("ahead_behind".into(), toml::Value::Table(ab));
943        let mut warnings = Vec::<String>::new();
944        let seg = GitBranchSegment::from_extras(&extras, &mut |m| warnings.push(m.to_string()));
945        assert!(warnings.is_empty(), "{warnings:?}");
946        assert!(seg.cfg.ahead_behind.enabled);
947        assert_eq!(seg.cfg.ahead_behind.ahead_format.render(3), ">>3");
948        assert_eq!(seg.cfg.ahead_behind.behind_format.render(5), "<<5");
949        assert!(!seg.cfg.ahead_behind.hide_when_zero);
950        assert!(!seg.cfg.ahead_behind.hide_when_no_upstream);
951    }
952
953    #[test]
954    fn renders_submodule_like_main() {
955        let gc = GitContext::new(
956            RepoKind::Submodule,
957            PathBuf::from("/parent/.git/modules/child"),
958            Head::Branch("main".into()),
959        );
960        let rendered = GitBranchSegment::default()
961            .render(&ctx_with_git(Ok(Some(gc))), &rc())
962            .unwrap()
963            .expect("rendered");
964        assert_eq!(rendered.text(), "main");
965    }
966
967    #[test]
968    fn from_extras_rejects_max_length_zero() {
969        let mut extras = BTreeMap::new();
970        extras.insert("max_length".into(), toml::Value::Integer(0));
971        let mut warnings = Vec::<String>::new();
972        let seg = GitBranchSegment::from_extras(&extras, &mut |m| warnings.push(m.to_string()));
973        assert_eq!(warnings.len(), 1);
974        assert!(warnings[0].contains("max_length"));
975        assert_eq!(seg.cfg.max_length, DEFAULT_MAX_BRANCH_LEN);
976    }
977
978    #[test]
979    fn from_extras_rejects_max_length_wrong_type() {
980        let mut extras = BTreeMap::new();
981        extras.insert("max_length".into(), toml::Value::String("wide".into()));
982        let mut warnings = Vec::<String>::new();
983        let seg = GitBranchSegment::from_extras(&extras, &mut |m| warnings.push(m.to_string()));
984        assert_eq!(warnings.len(), 1);
985        assert!(warnings[0].contains("max_length"));
986        assert_eq!(seg.cfg.max_length, DEFAULT_MAX_BRANCH_LEN);
987    }
988
989    #[test]
990    fn renders_unborn_as_symbolic_ref_name() {
991        let gc = GitContext::new(
992            RepoKind::Main,
993            PathBuf::from("/repo/.git"),
994            Head::Unborn {
995                symbolic_ref: "master".into(),
996            },
997        );
998        let rendered = GitBranchSegment::default()
999            .render(&ctx_with_git(Ok(Some(gc))), &rc())
1000            .unwrap()
1001            .expect("rendered");
1002        assert_eq!(rendered.text(), "master");
1003    }
1004
1005    #[test]
1006    fn applies_icon_and_label_when_configured() {
1007        let mut seg = GitBranchSegment::default();
1008        seg.cfg.icon = ">>".into();
1009        seg.cfg.label = "branch:".into();
1010        let gc = GitContext::new(
1011            RepoKind::Main,
1012            PathBuf::from("/repo/.git"),
1013            Head::Branch("main".into()),
1014        );
1015        let rendered = seg
1016            .render(&ctx_with_git(Ok(Some(gc))), &rc())
1017            .unwrap()
1018            .expect("rendered");
1019        assert_eq!(rendered.text(), ">> branch: main");
1020    }
1021
1022    #[test]
1023    fn defaults_use_expected_priority() {
1024        assert_eq!(GitBranchSegment::default().defaults().priority, PRIORITY);
1025    }
1026
1027    #[test]
1028    fn declares_git_data_dep() {
1029        assert_eq!(GitBranchSegment::default().data_deps(), &[DataDep::Git]);
1030    }
1031
1032    #[test]
1033    fn from_extras_reads_icon_label_and_dirty_knobs() {
1034        let mut extras = BTreeMap::new();
1035        extras.insert("icon".into(), toml::Value::String("".into()));
1036        extras.insert("label".into(), toml::Value::String("br".into()));
1037        extras.insert("max_length".into(), toml::Value::Integer(10));
1038        extras.insert("truncation_marker".into(), toml::Value::String("..".into()));
1039        extras.insert("short_sha_length".into(), toml::Value::Integer(12));
1040
1041        let mut dirty = toml::value::Table::new();
1042        dirty.insert("enabled".into(), toml::Value::Boolean(true));
1043        dirty.insert("format".into(), toml::Value::String("indicator".into()));
1044        dirty.insert("indicator".into(), toml::Value::String("●".into()));
1045        dirty.insert("clean_indicator".into(), toml::Value::String("✓".into()));
1046        extras.insert("dirty".into(), toml::Value::Table(dirty));
1047
1048        let mut warnings = Vec::<String>::new();
1049        let seg = GitBranchSegment::from_extras(&extras, &mut |m| warnings.push(m.to_string()));
1050        assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
1051        assert_eq!(seg.cfg.icon, "");
1052        assert_eq!(seg.cfg.label, "br");
1053        assert_eq!(seg.cfg.max_length, 10);
1054        assert_eq!(seg.cfg.truncation_marker, "..");
1055        assert_eq!(seg.cfg.short_sha_length, 12);
1056        assert!(seg.cfg.dirty_enabled);
1057        assert_eq!(seg.cfg.dirty_indicator, "●");
1058        assert_eq!(seg.cfg.clean_indicator, "✓");
1059    }
1060
1061    #[test]
1062    fn from_extras_counts_mode_warns_and_falls_back_to_indicator() {
1063        let mut extras = BTreeMap::new();
1064        let mut dirty = toml::value::Table::new();
1065        dirty.insert("format".into(), toml::Value::String("counts".into()));
1066        extras.insert("dirty".into(), toml::Value::Table(dirty));
1067
1068        let mut warnings = Vec::<String>::new();
1069        let seg = GitBranchSegment::from_extras(&extras, &mut |m| warnings.push(m.to_string()));
1070        assert_eq!(warnings.len(), 1);
1071        assert!(warnings[0].contains("counts"));
1072        assert!(seg.cfg.dirty_enabled);
1073    }
1074
1075    #[test]
1076    fn from_extras_hidden_format_turns_dirty_off() {
1077        let mut extras = BTreeMap::new();
1078        let mut dirty = toml::value::Table::new();
1079        dirty.insert("format".into(), toml::Value::String("hidden".into()));
1080        extras.insert("dirty".into(), toml::Value::Table(dirty));
1081
1082        let mut warnings = Vec::<String>::new();
1083        let seg = GitBranchSegment::from_extras(&extras, &mut |m| warnings.push(m.to_string()));
1084        assert!(warnings.is_empty());
1085        assert!(!seg.cfg.dirty_enabled);
1086    }
1087
1088    #[test]
1089    fn from_extras_rejects_short_sha_length_out_of_range() {
1090        for bad in [0i64, 41, -5, 999] {
1091            let mut extras = BTreeMap::new();
1092            extras.insert("short_sha_length".into(), toml::Value::Integer(bad));
1093            let mut warnings = Vec::<String>::new();
1094            let seg = GitBranchSegment::from_extras(&extras, &mut |m| warnings.push(m.to_string()));
1095            assert_eq!(warnings.len(), 1, "{bad}: {warnings:?}");
1096            assert_eq!(seg.cfg.short_sha_length, DEFAULT_SHORT_SHA_LEN);
1097        }
1098    }
1099
1100    // --- truncate_middle ---
1101
1102    #[test]
1103    fn truncate_middle_keeps_short_strings_verbatim() {
1104        assert_eq!(truncate_middle("main", 10, "…"), "main");
1105        assert_eq!(truncate_middle("feature/x", 9, "…"), "feature/x");
1106    }
1107
1108    #[test]
1109    fn truncate_middle_preserves_prefix_and_suffix() {
1110        // "feature/authentication-v3" is 25 cells; budget 10 → 9
1111        // available chars → 5-head + marker + 4-tail (approx).
1112        let out = truncate_middle("feature/authentication-v3", 10, "…");
1113        assert!(out.contains('…'));
1114        assert!(out.len() <= 25);
1115        assert!(out.starts_with("feat"), "expected prefix kept, got {out}");
1116        assert!(out.ends_with("-v3") || out.ends_with("v3"));
1117    }
1118
1119    #[test]
1120    fn truncate_middle_handles_zero_budget() {
1121        assert_eq!(truncate_middle("main", 0, "…"), "main");
1122    }
1123
1124    #[test]
1125    fn truncate_middle_degrades_when_marker_exceeds_budget() {
1126        // marker "[truncated]" is wider than max=3 cells. Falls back
1127        // to keeping the first `max` graphemes.
1128        assert_eq!(truncate_middle("hello-world", 3, "[truncated]"), "hel");
1129    }
1130
1131    // --- Width-aware threshold ---
1132
1133    /// Build a `DataContext` with a dirty working tree and an
1134    /// ahead/behind upstream so both markers fire at full width.
1135    fn ctx_with_dirty_and_upstream(ahead: u32, behind: u32) -> DataContext {
1136        let gc = GitContext::new(
1137            RepoKind::Main,
1138            PathBuf::from("/repo/.git"),
1139            Head::Branch("main".into()),
1140        );
1141        gc.preseed_dirty_state(DirtyState::Dirty(None))
1142            .expect("fresh dirty cell");
1143        gc.preseed_upstream(Some(UpstreamState {
1144            ahead,
1145            behind,
1146            upstream_branch: "origin/main".into(),
1147        }))
1148        .expect("fresh upstream cell");
1149        let dc = DataContext::with_cwd(minimal_status(), None);
1150        dc.preseed_git(Ok(Some(gc))).expect("seed");
1151        dc
1152    }
1153
1154    fn render_at(seg: &GitBranchSegment, terminal_width: u16, dc: &DataContext) -> String {
1155        let rendered = seg
1156            .render(dc, &RenderContext::new(terminal_width))
1157            .unwrap()
1158            .expect("rendered");
1159        rendered.text().to_string()
1160    }
1161
1162    #[test]
1163    fn dirty_hide_below_cells_default_zero_keeps_existing_behavior() {
1164        // Default config: threshold 0 means "never auto-hide". The
1165        // dirty marker shows even at terminal_width=1.
1166        let seg = GitBranchSegment::default();
1167        let dc = ctx_with_dirty_and_upstream(0, 0);
1168        assert_eq!(render_at(&seg, 1, &dc), "main *");
1169        assert_eq!(render_at(&seg, 200, &dc), "main *");
1170    }
1171
1172    #[test]
1173    fn dirty_marker_hidden_when_terminal_width_below_threshold() {
1174        let mut seg = GitBranchSegment::default();
1175        seg.cfg.dirty_hide_below_cells = 50;
1176        let dc = ctx_with_dirty_and_upstream(0, 0);
1177        // Width 49: below threshold → marker hidden.
1178        assert_eq!(render_at(&seg, 49, &dc), "main");
1179        // Width 50 and above: not-below → marker shown.
1180        assert_eq!(render_at(&seg, 50, &dc), "main *");
1181        assert_eq!(render_at(&seg, 100, &dc), "main *");
1182    }
1183
1184    #[test]
1185    fn ahead_behind_hidden_when_terminal_width_below_threshold() {
1186        let mut seg = GitBranchSegment::default();
1187        seg.cfg.ahead_behind.hide_below_cells = 80;
1188        let dc = ctx_with_dirty_and_upstream(2, 1);
1189        // Width 79: below ahead/behind threshold → ahead/behind
1190        // hidden. Dirty still shows (its threshold defaults to 0).
1191        assert_eq!(render_at(&seg, 79, &dc), "main *");
1192        // Width 80 and above: not-below → ahead/behind shown.
1193        assert_eq!(render_at(&seg, 80, &dc), "main * ↑2 ↓1");
1194    }
1195
1196    #[test]
1197    fn per_marker_thresholds_compose_independently() {
1198        // dirty.hide_below_cells = 50, ahead_behind.hide_below_cells = 80.
1199        // Three regimes: full / no-tracking / branch-only.
1200        let mut seg = GitBranchSegment::default();
1201        seg.cfg.dirty_hide_below_cells = 50;
1202        seg.cfg.ahead_behind.hide_below_cells = 80;
1203        let dc = ctx_with_dirty_and_upstream(2, 1);
1204        assert_eq!(render_at(&seg, 100, &dc), "main * ↑2 ↓1"); // full
1205        assert_eq!(render_at(&seg, 60, &dc), "main *"); // no tracking
1206        assert_eq!(render_at(&seg, 40, &dc), "main"); // branch only
1207    }
1208
1209    #[test]
1210    fn enabled_false_overrides_hide_below_cells() {
1211        // `dirty.enabled = false` always hides, regardless of threshold.
1212        let mut seg = GitBranchSegment::default();
1213        seg.cfg.dirty_enabled = false;
1214        seg.cfg.dirty_hide_below_cells = 50;
1215        let dc = ctx_with_dirty_and_upstream(0, 0);
1216        // Wide terminal, threshold satisfied — but still hidden.
1217        assert_eq!(render_at(&seg, 200, &dc), "main");
1218    }
1219
1220    #[test]
1221    fn from_extras_reads_dirty_hide_below_cells() {
1222        let mut dirty = toml::value::Table::new();
1223        dirty.insert("hide_below_cells".to_string(), toml::Value::Integer(60));
1224        let extras = BTreeMap::from([("dirty".to_string(), toml::Value::Table(dirty))]);
1225        let seg = GitBranchSegment::from_extras(&extras, &mut |_| {});
1226        assert_eq!(seg.cfg.dirty_hide_below_cells, 60);
1227    }
1228
1229    #[test]
1230    fn from_extras_reads_ahead_behind_hide_below_cells() {
1231        let mut ab = toml::value::Table::new();
1232        ab.insert("hide_below_cells".to_string(), toml::Value::Integer(90));
1233        let extras = BTreeMap::from([("ahead_behind".to_string(), toml::Value::Table(ab))]);
1234        let seg = GitBranchSegment::from_extras(&extras, &mut |_| {});
1235        assert_eq!(seg.cfg.ahead_behind.hide_below_cells, 90);
1236    }
1237
1238    #[test]
1239    fn ahead_behind_hide_when_zero_and_hide_below_cells_compose_multiplicatively() {
1240        // The two gates live in different layers: `hide_below_cells`
1241        // short-circuits in `assemble`, while `hide_when_zero` checks
1242        // counts inside `render_ahead_behind`. Either gate firing
1243        // hides the marker. Pin both branches so a refactor that
1244        // collapses them can't silently drop one path.
1245        let mut seg = GitBranchSegment::default();
1246        seg.cfg.ahead_behind.hide_below_cells = 80;
1247        // Width gate fires first (counts non-zero, but width < 80).
1248        let dc_diverged = ctx_with_dirty_and_upstream(2, 1);
1249        assert_eq!(render_at(&seg, 79, &dc_diverged), "main *");
1250        // Counts gate fires (width >= 80, but ahead == 0 && behind == 0
1251        // and hide_when_zero defaults to true).
1252        let dc_zero = ctx_with_dirty_and_upstream(0, 0);
1253        assert_eq!(render_at(&seg, 100, &dc_zero), "main *");
1254    }
1255
1256    #[test]
1257    fn from_extras_warns_on_negative_hide_below_cells_and_keeps_default() {
1258        let mut dirty = toml::value::Table::new();
1259        dirty.insert("hide_below_cells".to_string(), toml::Value::Integer(-5));
1260        let extras = BTreeMap::from([("dirty".to_string(), toml::Value::Table(dirty))]);
1261        let mut warnings = vec![];
1262        let seg = GitBranchSegment::from_extras(&extras, &mut |m| warnings.push(m.to_string()));
1263        assert_eq!(seg.cfg.dirty_hide_below_cells, 0);
1264        assert!(warnings
1265            .iter()
1266            .any(|w| w.contains("segments.git_branch.dirty.hide_below_cells")));
1267    }
1268
1269    // --- shrink_to_fit (layout-pressure-aware compaction) ---
1270
1271    #[test]
1272    fn shrink_to_fit_returns_compact_form_when_target_fits() {
1273        // Full assembly is "main * ↑2 ↓1" (12 cells). Compact form
1274        // is "main" (4 cells). Target ≥ 4 → engine gets the compact
1275        // form; the segment sheds dirty + ahead/behind.
1276        let seg = GitBranchSegment::default();
1277        let dc = ctx_with_dirty_and_upstream(2, 1);
1278        let dummy_rc = RenderContext::new(80);
1279        let shrunk = seg
1280            .shrink_to_fit(&dc, &dummy_rc, 4)
1281            .expect("compact form fits");
1282        assert_eq!(shrunk.text(), "main");
1283        assert_eq!(shrunk.style().role, Some(Role::Accent));
1284    }
1285
1286    #[test]
1287    fn shrink_to_fit_returns_none_when_even_compact_form_overflows() {
1288        // Compact form is "main" (4 cells). Target 3 is below that,
1289        // so `shrink_to_fit` declines (returns `None`) rather than
1290        // emit a too-wide render. The engine's drop-on-decline
1291        // behavior is covered separately by the layout-side test.
1292        let seg = GitBranchSegment::default();
1293        let dc = ctx_with_dirty_and_upstream(2, 1);
1294        let dummy_rc = RenderContext::new(80);
1295        assert!(seg.shrink_to_fit(&dc, &dummy_rc, 3).is_none());
1296    }
1297
1298    #[test]
1299    fn shrink_to_fit_returns_none_outside_repo() {
1300        let seg = GitBranchSegment::default();
1301        let dc = ctx_with_git(Ok(None));
1302        let dummy_rc = RenderContext::new(80);
1303        assert!(seg.shrink_to_fit(&dc, &dummy_rc, 100).is_none());
1304    }
1305
1306    #[test]
1307    fn shrink_to_fit_returns_none_in_bare_repo() {
1308        let seg = GitBranchSegment::default();
1309        let gc = GitContext::new(
1310            RepoKind::Bare,
1311            PathBuf::from("/tmp/bare.git"),
1312            Head::Unborn {
1313                symbolic_ref: "main".into(),
1314            },
1315        );
1316        let dc = ctx_with_git(Ok(Some(gc)));
1317        let dummy_rc = RenderContext::new(80);
1318        assert!(seg.shrink_to_fit(&dc, &dummy_rc, 100).is_none());
1319    }
1320
1321    #[test]
1322    fn shrink_to_fit_keeps_configured_icon_and_label_in_compact_form() {
1323        // The compact form is `icon + label + head` (the
1324        // signal-bearing prefix). Default config leaves icon and
1325        // label empty, so the existing tests don't exercise the
1326        // `if !cfg.icon.is_empty()` / `if !cfg.label.is_empty()`
1327        // branches in `assemble_compact`. Configure both and confirm
1328        // the prefix survives shedding the structured tail.
1329        let mut seg = GitBranchSegment::default();
1330        seg.cfg.icon = "@".into();
1331        seg.cfg.label = "br:".into();
1332        let dc = ctx_with_dirty_and_upstream(2, 1);
1333        let dummy_rc = RenderContext::new(80);
1334        let shrunk = seg
1335            .shrink_to_fit(&dc, &dummy_rc, 50)
1336            .expect("compact form fits");
1337        assert_eq!(shrunk.text(), "@ br: main");
1338    }
1339
1340    #[test]
1341    fn shrink_to_fit_strips_markers_even_when_thresholds_would_keep_them() {
1342        // Reflow path: a wide terminal (rc=200) with both
1343        // hide_below_cells thresholds at 0 means render() emits the
1344        // full assembly. shrink_to_fit is a separate engine-driven
1345        // gate that must still produce the compact form when the
1346        // engine asks, regardless of the user's threshold preferences.
1347        let seg = GitBranchSegment::default();
1348        let dc = ctx_with_dirty_and_upstream(2, 1);
1349        let wide_rc = RenderContext::new(200);
1350        let shrunk = seg
1351            .shrink_to_fit(&dc, &wide_rc, 50)
1352            .expect("compact form fits 50 cells");
1353        assert_eq!(shrunk.text(), "main");
1354    }
1355}