Skip to main content

git_stk/commands/
guide.rs

1use std::ffi::OsStr;
2use std::io::{IsTerminal, Write};
3use std::path::{Path, PathBuf};
4use std::process::{Command, Output, Stdio};
5use std::{env, fs};
6
7use anstyle::Style;
8use anyhow::{Context, Result, bail};
9use console::{Alignment, Key, Term, pad_str, truncate_str};
10use dialoguer::theme::ColorfulTheme;
11use dialoguer::{Confirm, Select};
12
13use crate::commands::Run;
14use crate::style;
15
16type Walk = fn(&mut Tour) -> Result<()>;
17
18/// The available tours: (topic, menu description, runner).
19const TOPICS: &[(&str, &str, Walk)] = &[
20    ("intro", "create, submit, restack, and land a stack", intro),
21    (
22        "conflicts",
23        "when a restack stops: resolve, continue, abort",
24        conflicts,
25    ),
26    ("repair", "rebuild lost stack metadata", repair),
27    (
28        "absorb",
29        "fold review fixes back into the commits they belong to",
30        absorb,
31    ),
32    (
33        "adopt",
34        "adopt a branch into a stack, or move it to a new parent",
35        adopt,
36    ),
37];
38
39/// Walk the stacked workflow in a disposable sandbox repository.
40#[derive(Debug, clap::Args)]
41pub struct Guide {
42    /// Which tour to run; omit for a menu.
43    #[arg(value_parser = clap::builder::PossibleValuesParser::new(["intro", "conflicts", "repair", "absorb", "adopt"]))]
44    topic: Option<String>,
45}
46
47impl Run for Guide {
48    fn run(self) -> Result<()> {
49        guide(self.topic.as_deref())
50    }
51}
52
53fn guide(topic: Option<&str>) -> Result<()> {
54    if !std::io::stdin().is_terminal() || !std::io::stdout().is_terminal() {
55        bail!("the guide is interactive; run it from a terminal");
56    }
57
58    banner("git stk guide");
59    say("Short interactive tours. Everything happens in a disposable sandbox");
60    say("repository - your real work is never touched, and a built-in demo");
61    say("provider stands in for GitHub: same commands, no network.");
62    say("Each step opens full-screen; scroll with j/k or the arrows, Enter to");
63    say("move on, q to quit.");
64    println!();
65
66    let chosen = match topic {
67        Some(topic) => TOPICS
68            .iter()
69            .find(|(name, _, _)| *name == topic)
70            .context("unknown guide topic")?,
71        None => {
72            let items: Vec<String> = TOPICS
73                .iter()
74                .map(|(name, blurb, _)| format!("{name} - {blurb}"))
75                .collect();
76            let index = Select::with_theme(&ColorfulTheme::default())
77                .with_prompt("which tour?")
78                .items(&items)
79                .default(0)
80                .interact()
81                .context("nothing chosen")?;
82            &TOPICS[index]
83        }
84    };
85    println!();
86
87    let sandbox = env::temp_dir().join(format!("git-stk-guide-{}", std::process::id()));
88    if sandbox.exists() {
89        fs::remove_dir_all(&sandbox).context("failed to clear an old sandbox")?;
90    }
91    say(&format!("sandbox: {}", sandbox.display()));
92    println!();
93    setup_sandbox(&sandbox)?;
94
95    let mut tour = Tour::new(&sandbox, chosen.0);
96    let finished = (chosen.2)(&mut tour);
97
98    // Hand the sandbox over or clean it up, whether or not the tour ran dry.
99    let delete = Confirm::with_theme(&ColorfulTheme::default())
100        .with_prompt("delete the sandbox?")
101        .default(true)
102        .interact()
103        .unwrap_or(true);
104    if delete {
105        fs::remove_dir_all(&sandbox).context("failed to remove the sandbox")?;
106        say("sandbox removed");
107    } else {
108        say(&format!("kept: cd {}", sandbox.display()));
109        say("it uses `git config stk.provider demo`, so every command works offline");
110    }
111
112    finished
113}
114
115fn intro(tour: &mut Tour) -> Result<()> {
116    tour.banner("1/5 - a stack is just branches");
117    tour.say("Each branch carries one reviewable change and knows its parent.");
118    tour.say("`new` creates a child of wherever you stand:");
119    tour.stk(&["new", "feature/login"])?;
120    tour.commit("login.txt", "username + password form\n", "add login form")?;
121    tour.stk(&["new", "feature/avatar"])?;
122    tour.commit("avatar.txt", "round avatars\n", "add avatars")?;
123    tour.say("Two branches, stacked. `list` draws the pile, trunk at the bottom:");
124    tour.stk(&["list"])?;
125    if tour.pause()?.stop() {
126        return Ok(());
127    }
128
129    tour.banner("2/5 - submit the whole stack");
130    tour.say("One command opens (or updates) a review per branch, parent-first,");
131    tour.say("and writes a live stack overview into every description:");
132    tour.stk(&["submit", "--stack"])?;
133    tour.stk(&["status"])?;
134    if tour.pause()?.stop() {
135        return Ok(());
136    }
137
138    tour.banner("3/5 - parents move; restack follows");
139    tour.say("Review feedback lands on the bottom branch:");
140    tour.stk(&["down"])?;
141    tour.commit(
142        "login.txt",
143        "username + password form\nremember me\n",
144        "add remember me",
145    )?;
146    tour.say("The child is now behind its parent - `list` notices:");
147    tour.stk(&["list"])?;
148    tour.say("`restack` rebases every descendant back onto its parent:");
149    tour.stk(&["restack"])?;
150    tour.stk(&["top"])?;
151    if tour.pause()?.stop() {
152        return Ok(());
153    }
154
155    tour.banner("4/5 - land the stack");
156    tour.say("`merge --all` repeats merge-bottom-then-sync until the stack is");
157    tour.say("complete: children retarget, merged branches vanish, the overview");
158    tour.say("in every review restyles as history accumulates:");
159    tour.stk(&["merge", "--all", "-y"])?;
160    if tour.pause()?.stop() {
161        return Ok(());
162    }
163
164    tour.banner("5/5 - nothing left but trunk");
165    tour.stk(&["list"])?;
166    tour.say("That is the whole loop: new -> commit -> submit -> merge.");
167    tour.say("On a real repo the provider is detected from your remote; day to day");
168    tour.say("you mostly run `git stk new`, `git stk submit --stack`, and");
169    tour.say("`git stk merge --all`. `git stk status` and the hints fill the gaps.");
170    tour.finish()
171}
172
173fn conflicts(tour: &mut Tour) -> Result<()> {
174    tour.banner("1/3 - set up a collision");
175    tour.say("A two-branch stack where both branches touch the same line:");
176    tour.stk(&["new", "feature/payment"])?;
177    tour.commit("notes.txt", "use stripe\n", "choose payment provider")?;
178    tour.stk(&["new", "feature/receipts"])?;
179    tour.commit("notes.txt", "use stripe with receipts\n", "email receipts")?;
180    tour.say("Now the parent changes its mind about that very line:");
181    tour.stk(&["down"])?;
182    tour.commit("notes.txt", "use paypal\n", "switch to paypal")?;
183    if tour.pause()?.stop() {
184        return Ok(());
185    }
186
187    tour.banner("2/3 - the restack stops, with context");
188    tour.say("Replaying the child onto the rewritten parent cannot succeed; the");
189    tour.say("restack stops, shows git's conflict output, and says what to do:");
190    tour.stk_fails(&["restack"])?;
191    if tour.pause()?.stop() {
192        return Ok(());
193    }
194
195    tour.banner("3/3 - resolve, then continue");
196    tour.say("Fix the file and stage it, exactly like any rebase conflict:");
197    tour.edit_and_add("notes.txt", "use paypal with receipts\n")?;
198    tour.say("`continue` picks the restack back up where it stopped");
199    tour.say("(`git stk abort` would have unwound it instead):");
200    tour.stk(&["continue"])?;
201    tour.stk(&["list"])?;
202    tour.say("Conflicts interrupt the restack, never break it: resolve, continue,");
203    tour.say("and the rest of the stack follows.");
204    tour.finish()
205}
206
207fn repair(tour: &mut Tour) -> Result<()> {
208    tour.banner("1/3 - a healthy stack");
209    tour.stk(&["new", "feature/api"])?;
210    tour.commit("api.txt", "endpoints\n", "add api")?;
211    tour.stk(&["new", "feature/ui"])?;
212    tour.commit("ui.txt", "buttons\n", "add ui")?;
213    tour.stk(&["submit", "--stack"])?;
214    if tour.pause()?.stop() {
215        return Ok(());
216    }
217
218    tour.banner("2/3 - the metadata vanishes");
219    tour.say("Stack parents are plain `branch.<name>.stkParent` entries in");
220    tour.say(".git/config - annotations, not state. Suppose one gets lost:");
221    tour.note("git config --unset branch.feature/ui.stkParent");
222    run_git(
223        tour.sandbox,
224        &["config", "--unset", "branch.feature/ui.stkParent"],
225    )?;
226    tour.say("The stack no longer knows feature/ui belongs to it:");
227    tour.stk(&["list"])?;
228    if tour.pause()?.stop() {
229        return Ok(());
230    }
231
232    tour.banner("3/3 - repair rebuilds it");
233    tour.say("`repair` re-derives parents from review bases (when a provider is");
234    tour.say("reachable) and branch ancestry, and verifies recorded fork points:");
235    tour.stk(&["repair", "--dry-run"])?;
236    tour.stk(&["repair"])?;
237    tour.stk(&["list"])?;
238    tour.say("Branches are the real state; metadata is always recoverable.");
239    tour.say("Anything repair cannot resolve safely, it reports for a manual");
240    tour.say("`git stk adopt`.");
241    tour.finish()
242}
243
244fn absorb(tour: &mut Tour) -> Result<()> {
245    tour.banner("1/3 - fixes scattered across the stack");
246    tour.say("A two-branch stack, each branch owning one file:");
247    tour.stk(&["new", "feature/login"])?;
248    tour.commit("login.txt", "username + password form\n", "add login form")?;
249    tour.stk(&["new", "feature/avatar"])?;
250    tour.commit("avatar.txt", "round avatars\n", "add avatars")?;
251    tour.say("Review comes back: two small fixes, one on each branch's file.");
252    tour.say("You make both edits from the top and stage them, as usual:");
253    tour.edit_and_add("login.txt", "username + password form, with 2FA\n")?;
254    tour.edit_and_add("avatar.txt", "round avatars, lazy-loaded\n")?;
255    tour.say("Both fixes sit staged together, but each belongs to a different commit");
256    tour.say("further down the stack:");
257    tour.stk(&["status"])?;
258    if tour.pause()?.stop() {
259        return Ok(());
260    }
261
262    tour.banner("2/3 - preview where each hunk lands");
263    tour.say("`absorb` blames every staged hunk and routes it to the commit that");
264    tour.say("introduced the lines it touches. `--dry-run` shows the plan first:");
265    tour.stk(&["absorb", "--dry-run"])?;
266    if tour.pause()?.stop() {
267        return Ok(());
268    }
269
270    tour.banner("3/3 - fold them in");
271    tour.say("Run it for real: each fix becomes a `fixup!` of its owning commit, an");
272    tour.say("autosquash rebase folds them in, and every branch ref rides along:");
273    tour.stk(&["absorb"])?;
274    tour.say("The history reads as if the fixes were always there - no extra commits:");
275    tour.show_git(
276        "git log --oneline main..feature/avatar",
277        &[
278            "--no-pager",
279            "-c",
280            "color.ui=always",
281            "log",
282            "--oneline",
283            "main..feature/avatar",
284        ],
285    )?;
286    tour.say("Hunks that cannot be attributed - brand-new lines, trunk-owned lines, a");
287    tour.say("hunk spanning two commits - are left staged and reported, never guessed.");
288    tour.finish()
289}
290
291fn adopt(tour: &mut Tour) -> Result<()> {
292    tour.banner("1/3 - adopt a hand-made branch");
293    tour.say("Not every branch begins with `git stk new`. Suppose you branched off");
294    tour.say("the trunk by hand and did some work:");
295    tour.note("git switch -c feature/logging");
296    run_git(tour.sandbox, &["switch", "-c", "feature/logging"])?;
297    tour.commit("logging.txt", "structured logs\n", "add logging")?;
298    tour.say("git-stk has no metadata for it yet. `adopt` records its parent -");
299    tour.say("metadata only, nothing is rewritten - folding it into a stack:");
300    tour.stk(&["adopt", "--parent", "main"])?;
301    tour.stk(&["list"])?;
302    if tour.pause()?.stop() {
303        return Ok(());
304    }
305
306    tour.banner("2/3 - move a branch onto another");
307    tour.say("Two branches, each started independently off the trunk:");
308    tour.note("git switch main");
309    run_git(tour.sandbox, &["switch", "main"])?;
310    tour.stk(&["new", "feature/api"])?;
311    tour.commit("api.txt", "endpoints\n", "add api")?;
312    tour.note("git switch main");
313    run_git(tour.sandbox, &["switch", "main"])?;
314    tour.stk(&["new", "feature/web"])?;
315    tour.commit("web.txt", "pages\n", "add web")?;
316    tour.say("`list` shows them as siblings on the trunk:");
317    tour.stk(&["list", "--all"])?;
318    tour.say("But feature/web really belongs on top of feature/api. Re-point its");
319    tour.say("parent with `adopt`, then `restack` replays its commits onto the new");
320    tour.say("base (only its own commits move; the parent's are already there):");
321    tour.stk(&["adopt", "--parent", "feature/api"])?;
322    tour.stk(&["restack"])?;
323    tour.stk(&["list"])?;
324    if tour.pause()?.stop() {
325        return Ok(());
326    }
327
328    tour.banner("3/3 - detach: the inverse");
329    tour.say("`detach` drops a branch's stack metadata, leaving the branch and its");
330    tour.say("commits untouched - handy when something was adopted by mistake:");
331    tour.stk(&["detach", "feature/web"])?;
332    tour.stk(&["list", "--all"])?;
333    tour.say("feature/web still exists; git-stk just no longer tracks it. Re-`adopt`");
334    tour.say("it onto any parent whenever you want it back in a stack.");
335    tour.finish()
336}
337
338/// One full-screen step: a pinned title, a scrollable body of narration and
339/// captured command output, and a footer of scroll hints. The tour functions
340/// build a screen with `banner`/`say`/`stk`/..., then `pause` (or `finish`)
341/// renders it and waits for the reader.
342struct Tour<'a> {
343    sandbox: &'a Path,
344    topic: &'a str,
345    term: Term,
346    title: String,
347    lines: Vec<String>,
348}
349
350/// What the reader chose at a `pause`: move on, or quit the tour.
351enum Flow {
352    Continue,
353    Stop,
354}
355
356impl Flow {
357    fn stop(&self) -> bool {
358        matches!(self, Self::Stop)
359    }
360}
361
362impl<'a> Tour<'a> {
363    fn new(sandbox: &'a Path, topic: &'a str) -> Self {
364        Self {
365            sandbox,
366            topic,
367            term: Term::stdout(),
368            title: String::new(),
369            lines: Vec::new(),
370        }
371    }
372
373    /// Start a fresh screen with `title`. Does not render: content accrues
374    /// until the next `pause`/`finish`.
375    fn banner(&mut self, title: &str) {
376        self.title = title.to_owned();
377        self.lines.clear();
378    }
379
380    /// A line of narration.
381    fn say(&mut self, line: &str) {
382        self.lines.push(style::dim(line));
383    }
384
385    /// A shell-prompt line for a step we narrate but do not capture output
386    /// from (e.g. a manual `git config --unset`).
387    fn note(&mut self, command: &str) {
388        self.lines.push(format!("{} {command}", style::dim("$")));
389    }
390
391    /// Run `git stk <args>` in the sandbox, showing the command and its
392    /// output. Fails if the command does.
393    fn stk(&mut self, args: &[&str]) -> Result<()> {
394        let output = self.run_stk(args)?;
395        if !output.status.success() {
396            bail!("`git stk {}` failed in the sandbox", args.join(" "));
397        }
398        Ok(())
399    }
400
401    /// Like `stk`, for the step that is supposed to stop (the conflict).
402    fn stk_fails(&mut self, args: &[&str]) -> Result<()> {
403        let output = self.run_stk(args)?;
404        if output.status.success() {
405            bail!(
406                "`git stk {}` was expected to stop on the conflict",
407                args.join(" ")
408            );
409        }
410        Ok(())
411    }
412
413    fn run_stk(&mut self, args: &[&str]) -> Result<Output> {
414        self.note(&format!("git stk {}", args.join(" ")));
415        let binary = env::current_exe().context("failed to locate the running binary")?;
416        let output = capture(self.sandbox, &binary, args)?;
417        self.absorb_output(&output);
418        Ok(output)
419    }
420
421    /// Run a raw `git` command and show it under `display` with its output.
422    fn show_git(&mut self, display: &str, args: &[&str]) -> Result<()> {
423        self.note(display);
424        let output = capture(self.sandbox, OsStr::new("git"), args)?;
425        self.absorb_output(&output);
426        if !output.status.success() {
427            bail!("`{display}` failed in the sandbox");
428        }
429        Ok(())
430    }
431
432    /// Write `contents` to `file` and commit it, narrating the edit.
433    fn commit(&mut self, file: &str, contents: &str, message: &str) -> Result<()> {
434        self.note(&format!("edit {file}, then git commit -m {message:?}"));
435        fs::write(self.sandbox.join(file), contents).context("failed to write sandbox file")?;
436        run_git(self.sandbox, &["add", file])?;
437        run_git(self.sandbox, &["commit", "-q", "-m", message])
438    }
439
440    /// Write `contents` to `file` and stage it without committing - a review
441    /// fix, or a resolved conflict.
442    fn edit_and_add(&mut self, file: &str, contents: &str) -> Result<()> {
443        self.note(&format!("edit {file}, then git add {file}"));
444        fs::write(self.sandbox.join(file), contents).context("failed to write sandbox file")?;
445        run_git(self.sandbox, &["add", file])
446    }
447
448    /// Append a captured command's output, then a blank separator line.
449    fn absorb_output(&mut self, output: &Output) {
450        for stream in [&output.stdout, &output.stderr] {
451            let text = String::from_utf8_lossy(stream);
452            let text = text.trim_end_matches(['\n', '\r']);
453            if text.is_empty() {
454                continue;
455            }
456            for line in text.split('\n') {
457                self.lines.push(line.trim_end_matches('\r').to_owned());
458            }
459        }
460        self.lines.push(String::new());
461    }
462
463    /// Render the current screen and wait for the reader to move on or quit.
464    fn pause(&mut self) -> Result<Flow> {
465        self.present("j/k/up/down scroll - space/pgdn page - enter continue - q quit")
466    }
467
468    /// Render the final screen; enter or q both end the tour.
469    fn finish(&mut self) -> Result<()> {
470        self.present("j/k/up/down scroll - enter/q to finish")?;
471        Ok(())
472    }
473
474    /// The pager: draw the framed screen and scroll it until the reader
475    /// presses enter (continue) or q/esc (stop).
476    fn present(&mut self, hint: &str) -> Result<Flow> {
477        self.term.hide_cursor().ok();
478        self.term.clear_screen().ok();
479
480        let mut scroll = 0usize;
481        let flow = loop {
482            let (rows, cols) = self.term.size();
483            let (rows, cols) = (rows as usize, cols as usize);
484            let body = rows.saturating_sub(2).max(1);
485            let max_scroll = self.lines.len().saturating_sub(body);
486            scroll = scroll.min(max_scroll);
487            self.draw(scroll, cols, body, hint)?;
488
489            match self.term.read_key() {
490                Ok(Key::ArrowDown | Key::Char('j')) => scroll = (scroll + 1).min(max_scroll),
491                Ok(Key::ArrowUp | Key::Char('k')) => scroll = scroll.saturating_sub(1),
492                Ok(Key::PageDown | Key::Char(' ')) => scroll = (scroll + body).min(max_scroll),
493                Ok(Key::PageUp) => scroll = scroll.saturating_sub(body),
494                Ok(Key::Home | Key::Char('g')) => scroll = 0,
495                Ok(Key::End | Key::Char('G')) => scroll = max_scroll,
496                Ok(Key::Enter) => break Flow::Continue,
497                Ok(Key::Char('q') | Key::Escape | Key::CtrlC) => break Flow::Stop,
498                Ok(_) => {}
499                Err(_) => break Flow::Stop,
500            }
501        };
502
503        self.term.show_cursor().ok();
504        self.term.clear_screen().ok();
505        Ok(flow)
506    }
507
508    /// Compose and paint one frame: header bar, `body` rows of content from
509    /// `scroll`, and a footer bar. Every row is exactly `cols` wide so each
510    /// frame fully overwrites the last.
511    fn draw(&self, scroll: usize, cols: usize, body: usize, hint: &str) -> Result<()> {
512        let bar = Style::new().invert();
513        let header = format!("{} - {}", self.topic, self.title);
514        let mut frame = style::paint(bar, &fit(&format!(" {header}"), cols));
515
516        for row in 0..body {
517            frame.push('\n');
518            let line = self.lines.get(scroll + row).map_or("", String::as_str);
519            frame.push_str(&fit(line, cols));
520        }
521
522        let scrollable = self.lines.len() > body;
523        let footer = if scrollable {
524            format!(
525                " {hint}   [{}/{}]",
526                (scroll + body).min(self.lines.len()),
527                self.lines.len()
528            )
529        } else {
530            format!(" {hint}")
531        };
532        frame.push('\n');
533        frame.push_str(&style::paint(bar, &fit(&footer, cols)));
534
535        self.term.move_cursor_to(0, 0)?;
536        print!("{frame}");
537        std::io::stdout()
538            .flush()
539            .context("failed to draw the guide")?;
540        Ok(())
541    }
542}
543
544/// Truncate (ANSI-aware) to `width`, then pad with spaces to exactly `width`.
545fn fit(line: &str, width: usize) -> String {
546    let truncated = truncate_str(line, width, "…");
547    pad_str(&truncated, width, Alignment::Left, None).into_owned()
548}
549
550fn setup_sandbox(sandbox: &Path) -> Result<()> {
551    fs::create_dir_all(sandbox).context("failed to create the sandbox")?;
552    run_git(sandbox, &["init", "-q", "-b", "main"])?;
553    run_git(sandbox, &["config", "user.email", "guide@git-stk.dev"])?;
554    run_git(sandbox, &["config", "user.name", "git-stk guide"])?;
555    run_git(sandbox, &["config", "stk.provider", "demo"])?;
556    run_git(sandbox, &["config", "stk.noUpdateCheck", "true"])?;
557    fs::write(sandbox.join("README.md"), "# guide sandbox\n").context("failed to seed sandbox")?;
558    run_git(sandbox, &["add", "README.md"])?;
559    run_git(sandbox, &["commit", "-q", "-m", "initial commit"])?;
560    Ok(())
561}
562
563/// Run a command in the sandbox and capture its output, forcing color on so
564/// the captured lines look like a real terminal session.
565fn capture(sandbox: &Path, program: impl AsRef<OsStr>, args: &[&str]) -> Result<Output> {
566    let program = program.as_ref();
567    isolated(Command::new(program).args(args).current_dir(sandbox))
568        .env("CLICOLOR_FORCE", "1")
569        .stdin(Stdio::null())
570        .output()
571        .with_context(|| format!("failed to run {} in the sandbox", program.to_string_lossy()))
572}
573
574/// Run a `git` command in the sandbox for its effect, discarding output.
575fn run_git(sandbox: &Path, args: &[&str]) -> Result<()> {
576    let status = isolated(Command::new("git").args(args).current_dir(sandbox))
577        .status()
578        .context("failed to run git in the sandbox")?;
579    if !status.success() {
580        bail!("`git {}` failed in the sandbox", args.join(" "));
581    }
582    Ok(())
583}
584
585/// The user's global git config (e.g. stk.pushOnSubmit) must not leak into
586/// the tour.
587fn isolated(command: &mut Command) -> &mut Command {
588    command
589        .env("GIT_CONFIG_GLOBAL", nul_device())
590        .env("GIT_CONFIG_NOSYSTEM", "1")
591        .env("GIT_EDITOR", "true")
592}
593
594fn nul_device() -> PathBuf {
595    if cfg!(windows) {
596        PathBuf::from("NUL")
597    } else {
598        PathBuf::from("/dev/null")
599    }
600}
601
602fn banner(title: &str) {
603    anstream::println!("{}", style::paint(style::CURRENT, title));
604}
605
606fn say(line: &str) {
607    anstream::println!("{}", style::paint(style::DIM, line));
608}
609
610#[cfg(test)]
611mod tests {
612    use super::fit;
613    use console::measure_text_width;
614
615    #[test]
616    fn fit_pads_short_lines_to_exact_width() {
617        let fitted = fit("ab", 5);
618        assert_eq!(fitted, "ab   ");
619        assert_eq!(measure_text_width(&fitted), 5);
620    }
621
622    #[test]
623    fn fit_truncates_long_lines_to_exact_width() {
624        let fitted = fit("abcdefghij", 4);
625        assert_eq!(measure_text_width(&fitted), 4);
626        assert!(fitted.ends_with('…'));
627    }
628
629    #[test]
630    fn fit_measures_width_ignoring_ansi() {
631        // Three visible chars wrapped in color codes, padded to width 6.
632        let fitted = fit("\x1b[31mred\x1b[0m", 6);
633        assert_eq!(measure_text_width(&fitted), 6);
634        assert!(fitted.contains("\x1b[31m"));
635    }
636}