Skip to main content

forge_workspace/
output_tail.rs

1//! A command's own output: the tail of the file the CLI streams it to.
2//!
3//! The CLI's local-bash tasks write the command's stdout to a file on disk
4//! rather than sending it over the wire (confirmed in
5//! `~/Projects/forge/.claude/skills/claude-cli-upgrade/reference-captures/monitor.jsonl`),
6//! so the tail is a file read. The readers are the transport - which
7//! resolves the path from its held conversation and answers a view's
8//! `ReadCallOutput` with the result - and the views that hold a path already
9//! (a view tails a Monitor's file on its own tick).
10//!
11//! Two ways a path is found, and the ending frame's own word wins when both
12//! exist: `task_notification.output_file` names the file when the task ends,
13//! and until then the path is composed from the task id the wire carries
14//! (`compose_task_output_path`, under `task_output_base` - both crate-internal,
15//! so named rather than linked). Neither reads the launch sentence: forge
16//! resolves the file structurally, never from prose.
17
18use std::collections::VecDeque;
19use std::ffi::OsString;
20use std::fs::File;
21use std::io::{BufRead, BufReader, Seek, SeekFrom};
22use std::path::{Path, PathBuf};
23
24/// Largest slice of the file read at a time. Output files grow without bound
25/// (cargo build, npm install) and this runs inline on a view's own tick, so
26/// the read seeks to within this window of the end rather than reading the
27/// whole file each time. 64 KiB comfortably holds the last few lines at any
28/// realistic line length.
29pub const TAIL_WINDOW_BYTES: u64 = 64 * 1024;
30
31/// The base directory the CLI keeps its per-session task files under:
32/// `realpath($CLAUDE_CODE_TMPDIR ?? /tmp)`, read from the same environment
33/// the spawned child runs with (forge sets none of its own). Rule 14: never
34/// forge's own cwd.
35pub(crate) fn task_output_base() -> PathBuf {
36    base_of(std::env::var_os("CLAUDE_CODE_TMPDIR"))
37}
38
39/// [`task_output_base`]'s own rule, split out so it can be tested without
40/// writing the process's environment - edition 2024 makes that unsafe and
41/// this workspace forbids unsafe code.
42fn base_of(tmpdir: Option<OsString>) -> PathBuf {
43    let raw = tmpdir.map_or_else(|| PathBuf::from("/tmp"), PathBuf::from);
44    // realpath, the way the CLI resolves it: on macOS `/tmp` is a symlink to
45    // `/private/tmp`, and the path the frames name is the resolved one.
46    std::fs::canonicalize(&raw).unwrap_or(raw)
47}
48
49/// The path a running task's own output file sits at, composed from the
50/// fields the wire carries rather than parsed out of prose:
51/// `<base>/claude-<uid>/<cwd-slug>/<session-id>/tasks/<task-id>.output`.
52///
53/// **The session id anchors the lookup and the cwd-slug is a wildcard**, so a
54/// change to the CLI's own slug rule cannot starve the read. `None` unless
55/// exactly one directory under this user's tree holds the file: more than one
56/// match is a miss rather than a guess, and the caller draws that as no-path.
57pub(crate) fn compose_task_output_path(
58    base: &Path,
59    session_id: &str,
60    task_id: &str,
61) -> Option<PathBuf> {
62    let mine = base.join(format!("claude-{}", rustix::process::getuid().as_raw()));
63    let mut found: Option<PathBuf> = None;
64    for entry in std::fs::read_dir(&mine).ok()?.flatten() {
65        let candidate =
66            entry.path().join(session_id).join("tasks").join(format!("{task_id}.output"));
67        if candidate.is_file() {
68            if found.is_some() {
69                return None;
70            }
71            found = Some(candidate);
72        }
73    }
74    found
75}
76
77/// One check per process that `<base>/claude-<uid>` is where this build
78/// expects it, so a wholesale CLI layout change announces itself once rather
79/// than starving every tail in silence.
80///
81/// Forge's own health, not a session's - there is no slot to name (rule 20) -
82/// and it fires only when a running call's output is actually looked up.
83///
84/// **The bound is the directory's existence, and that is the whole check.** A
85/// base that MOVES leaves its old directory behind, so the probe stays quiet
86/// while every composed lookup under the new one misses; what it catches is a
87/// CLI that stops making the tree where this build looks at all. D7's shape:
88/// one announcement per process, not a guarantee that a silent probe means
89/// the reads are landing.
90pub(crate) fn probe_task_output_layout(base: &Path) {
91    LAYOUT_PROBED.call_once(|| {
92        let mine = base.join(format!("claude-{}", rustix::process::getuid().as_raw()));
93        if !mine.is_dir() {
94            tracing::warn!(
95                event_name = "task_output_layout_missing",
96                outcome = "no_layout",
97                base = %base.display(),
98                "the CLI's background-task directory is not where this build expects it; a \
99                 running call's output tail will answer no-path until the layout matches",
100            );
101        }
102    });
103}
104
105/// The one check [`probe_task_output_layout`] guards.
106static LAYOUT_PROBED: std::sync::Once = std::sync::Once::new();
107
108/// Read the last `max_lines` lines of `path` into a `Vec` ordered
109/// oldest-first. Returns `None` on any read error (file missing,
110/// permission denied, a read that failed) so the caller can distinguish
111/// "could not read, keep the tail already held" from "the file is
112/// genuinely empty". The empty-file case returns `Some(vec![])`.
113///
114/// The lines come back as the command wrote them, escape sequences and all.
115/// What a renderer has to drop to draw them is the renderer's business:
116/// different renderers drop different bytes, so neither rule belongs on a
117/// read that only hands the text over.
118///
119/// Only the final [`TAIL_WINDOW_BYTES`] are read: for a larger file the
120/// read seeks to `len - TAIL_WINDOW_BYTES` and drops the first (probably
121/// partial) line after the seek. Files under the window are read whole.
122/// A file the watched command is still writing is read as it stands: an
123/// unterminated final line comes back as `Ok` and is kept. The one line
124/// that is dropped for good is one that is not valid UTF-8, which is
125/// skipped on every read rather than once.
126pub fn read_output_file_tail(path: &Path, max_lines: usize) -> Option<Vec<String>> {
127    if max_lines == 0 {
128        return Some(Vec::new());
129    }
130    let mut file = match File::open(path) {
131        Ok(f) => f,
132        Err(err) => {
133            // A file that will not open is the session's own condition, and
134            // a view is answered for it by name (`FileGone`): it is not
135            // forge's health, so it stays out of WARN's signal - the level a
136            // reader acts on (rule 20).
137            tracing::debug!(
138                event_name = "output_tail_open_failed",
139                message = "could not open an output file; tail unavailable",
140                outcome = "failure",
141                path = %path.display(),
142                error_kind = ?err.kind(),
143                error_message = %err,
144            );
145            return None;
146        }
147    };
148    // Seek to within TAIL_WINDOW_BYTES of the end so per-tick cost is
149    // bounded by the window, not the (growing) full file size. After a
150    // mid-file seek the first line is probably partial, so drop it.
151    let file_len = file.metadata().map_or(0, |m| m.len());
152    let drop_partial_first = file_len > TAIL_WINDOW_BYTES
153        && file.seek(SeekFrom::Start(file_len - TAIL_WINDOW_BYTES)).is_ok();
154    let mut ring: VecDeque<String> = VecDeque::with_capacity(max_lines);
155    for (idx, line) in BufReader::new(file).lines().enumerate() {
156        match line {
157            Ok(text) => {
158                if idx == 0 && drop_partial_first {
159                    continue;
160                }
161                if ring.len() == max_lines {
162                    ring.pop_front();
163                }
164                ring.push_back(text);
165            }
166            Err(err) => {
167                // A line that is not valid UTF-8, or a read that failed.
168                // An unterminated final line does not reach here: `lines`
169                // hands that back as `Ok` and it is kept. The line is
170                // skipped and the others stand, which is forge's own
171                // reading of the session's own output rather than a problem
172                // with forge, so it is not a warning. The decodable case is
173                // not transient either: those bytes are skipped on every
174                // read, while a failed read may well come right at the
175                // next one.
176                tracing::debug!(
177                    event_name = "output_tail_line_unreadable",
178                    message = "an output file holds a line that could not be read; skipping it",
179                    outcome = "skipped",
180                    path = %path.display(),
181                    error_kind = ?err.kind(),
182                );
183            }
184        }
185    }
186    Some(ring.into_iter().collect())
187}
188
189/// How many lines of a call's output a `ReadCallOutput` answers with.
190pub const CALL_OUTPUT_MAX_LINES: usize = 12;
191
192/// The file a call's output went to, from the frames a conversation carries.
193///
194/// **Two ways to find it, and the ending frame's own word wins when both
195/// exist**: `task_notification.output_file` names the file when the task
196/// ends, and until then the path is composed from the task id the launch
197/// frame carries, under `task_output_base`. An empty `output_file` is a
198/// path the frame does not carry, not a path that is gone.
199pub fn resolve_call_output_path(
200    history: &[forge_primitives::Message],
201    call_id: &str,
202) -> Option<PathBuf> {
203    resolve_call_output_path_at(history, call_id, task_output_base)
204}
205
206/// [`resolve_call_output_path`] with the base named, so the composition can
207/// be exercised against a scratch tree without writing the process env.
208pub(crate) fn resolve_call_output_path_at(
209    history: &[forge_primitives::Message],
210    call_id: &str,
211    base: impl FnOnce() -> PathBuf,
212) -> Option<PathBuf> {
213    let named = history.iter().find_map(|message| match message {
214        forge_primitives::Message::TaskNotification {
215            tool_use_id: Some(id), output_file, ..
216        } if id == call_id && !output_file.is_empty() => Some(PathBuf::from(output_file)),
217        _ => None,
218    });
219    if named.is_some() {
220        return named;
221    }
222    let (session_id, task_id) = history.iter().find_map(|message| match message {
223        forge_primitives::Message::TaskStarted {
224            task_id,
225            tool_use_id: Some(id),
226            session_id,
227            ..
228        } if id == call_id => Some((session_id.clone(), task_id.clone())),
229        _ => None,
230    })?;
231    let base = base();
232    probe_task_output_layout(&base);
233    compose_task_output_path(&base, &session_id, &task_id)
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239    use std::io::Write;
240
241    /// A scratch base for a composed lookup, under the real temp dir: empty
242    /// to start, so one test cannot read another's tree.
243    fn scratch_base(tag: &str) -> std::path::PathBuf {
244        let base = std::env::temp_dir()
245            .join(format!("forge-task-output-compose-{}-{tag}", std::process::id()));
246        let _ = std::fs::remove_dir_all(&base);
247        std::fs::create_dir_all(&base).expect("create the scratch base");
248        base
249    }
250
251    fn plant_file(base: &Path, slug: &str, session_id: &str, task_id: &str, contents: &str) {
252        let dir = base
253            .join(format!("claude-{}", rustix::process::getuid().as_raw()))
254            .join(slug)
255            .join(session_id)
256            .join("tasks");
257        std::fs::create_dir_all(&dir).expect("create the scratch tasks tree");
258        std::fs::write(dir.join(format!("{task_id}.output")), contents).expect("plant the file");
259    }
260
261    /// A `task_started` frame naming `call` as the launch of `task_id`.
262    fn task_started(task_id: &str, call: &str, session_id: &str) -> forge_primitives::Message {
263        serde_json::from_value(serde_json::json!({
264            "type": "system",
265            "subtype": "task_started",
266            "task_id": task_id,
267            "description": "watch the run",
268            "uuid": format!("u-{task_id}"),
269            "session_id": session_id,
270            "tool_use_id": call,
271        }))
272        .expect("a task_started frame")
273    }
274
275    /// A `task_notification` frame naming the file a call's output went to.
276    fn task_notification(
277        task_id: &str,
278        call: &str,
279        session_id: &str,
280        output_file: &str,
281    ) -> forge_primitives::Message {
282        serde_json::from_value(serde_json::json!({
283            "type": "system",
284            "subtype": "task_notification",
285            "task_id": task_id,
286            "status": "completed",
287            "output_file": output_file,
288            "summary": "Background command \"true\" completed",
289            "uuid": format!("u-{task_id}"),
290            "session_id": session_id,
291            "tool_use_id": call,
292        }))
293        .expect("a task_notification frame")
294    }
295
296    /// The ending frame's own path WINS over the composed one when both are
297    /// reachable: `task_notification` is the CLI's word for where the output
298    /// went, and the composed path is this build's reading of a layout the
299    /// CLI never promised.
300    #[test]
301    fn the_ending_frames_path_wins_over_the_composed_one() {
302        let base = scratch_base("resolve-wins");
303        plant_file(&base, "slug-any", "sess-live", "t-live", "composed\n");
304        let named = base.join("named-by-the-ending-frame.output");
305        std::fs::write(&named, "named\n").expect("write the named file");
306
307        let started = task_started("t-live", "tu-live", "sess-live");
308        let ended =
309            task_notification("t-live", "tu-live", "sess-live", &named.display().to_string());
310
311        assert_eq!(
312            resolve_call_output_path_at(std::slice::from_ref(&started), "tu-live", || base.clone()),
313            compose_task_output_path(&base, "sess-live", "t-live"),
314            "while it runs, the composed path is the one the read uses",
315        );
316        assert_eq!(
317            resolve_call_output_path_at(&[started, ended], "tu-live", || base.clone()),
318            Some(named),
319            "and once the ending frame lands, its own path overrides the composition",
320        );
321        let _ = std::fs::remove_dir_all(&base);
322    }
323
324    /// **Nothing reads the launch sentence.** The launch result says where the
325    /// output will be written in prose, and a resolver that took that path
326    /// would be reading text the CLI is free to reword - so the pin carries
327    /// the real sentence naming file A beside a task id that composes to file
328    /// B, and the answer must be B's path. The second half catches the other
329    /// mistake: with B gone the answer is no-path, never A.
330    #[test]
331    fn nothing_reads_the_launch_sentence_for_the_path() {
332        let base = scratch_base("resolve-prose");
333        plant_file(&base, "slug-any", "sess-prose", "t-prose", "from the composition\n");
334        let composed = compose_task_output_path(&base, "sess-prose", "t-prose")
335            .expect("the composition finds its file");
336        // File A, which only the sentence names, with bytes nothing else has.
337        let named_by_prose = base.join("named-by-the-launch-sentence.output");
338        std::fs::write(&named_by_prose, "from the launch sentence\n").expect("write file A");
339
340        let launch: forge_primitives::Message = serde_json::from_value(serde_json::json!({
341            "type": "user",
342            "message": {
343                "role": "user",
344                "content": [{
345                    "type": "tool_result",
346                    "tool_use_id": "tu-prose",
347                    "is_error": false,
348                    "content": format!(
349                        "Command running in background with ID: t-prose. Output is being \
350                         written to: {}. You will be notified when it completes. To check \
351                         interim output, use Read on that file path.",
352                        named_by_prose.display(),
353                    ),
354                }],
355            },
356            "tool_use_result": { "backgroundTaskId": "t-prose" },
357            "uuid": "u-prose",
358            "session_id": "sess-prose",
359        }))
360        .expect("a launch result frame");
361        let started = task_started("t-prose", "tu-prose", "sess-prose");
362
363        assert_eq!(
364            resolve_call_output_path_at(&[launch.clone(), started.clone()], "tu-prose", || base
365                .clone()),
366            Some(composed.clone()),
367            "the composed path answers, not the one the launch sentence names",
368        );
369        std::fs::remove_file(&composed).expect("unplant the composed file");
370        assert_eq!(
371            resolve_call_output_path_at(&[launch, started], "tu-prose", || base.clone()),
372            None,
373            "and with the composed file gone the answer is no-path, never the sentence's file",
374        );
375        let _ = std::fs::remove_dir_all(&base);
376    }
377
378    /// **Each task reads its own file.** Two backgrounded calls in one session
379    /// carry two `task_started` frames, and the link is the tool-use id:
380    /// without that guard, row B tails row A's file - wrong output shown as if
381    /// it were B's, which is worse than nothing.
382    #[test]
383    fn two_running_calls_each_read_their_own_task() {
384        let base = scratch_base("resolve-two");
385        plant_file(&base, "slug-any", "sess-two", "t-a", "task a\n");
386        plant_file(&base, "slug-any", "sess-two", "t-b", "task b\n");
387        let history =
388            vec![task_started("t-a", "tu-a", "sess-two"), task_started("t-b", "tu-b", "sess-two")];
389
390        assert_eq!(
391            resolve_call_output_path_at(&history, "tu-a", || base.clone()),
392            compose_task_output_path(&base, "sess-two", "t-a"),
393            "the first call reads its own task's file",
394        );
395        assert_eq!(
396            resolve_call_output_path_at(&history, "tu-b", || base.clone()),
397            compose_task_output_path(&base, "sess-two", "t-b"),
398            "and the second reads its own, not the first's",
399        );
400        let _ = std::fs::remove_dir_all(&base);
401    }
402
403    /// The read never answers blank: a call the conversation does not name is
404    /// the no-path reason, and an empty `output_file` is a path the frame does
405    /// not carry - not a path that is gone.
406    #[test]
407    fn a_call_with_no_frame_and_one_with_an_empty_path_are_both_no_path() {
408        assert_eq!(
409            resolve_call_output_path(&[], "tu-unknown"),
410            None,
411            "a call no frame names is no path at all",
412        );
413        let ended = task_notification("t-empty", "tu-empty", "sess-empty", "");
414        assert_eq!(
415            resolve_call_output_path(&[ended], "tu-empty"),
416            None,
417            "and an empty output_file is a path the frame does not carry, not one that is gone",
418        );
419    }
420
421    fn write_tmp(contents: &str) -> std::path::PathBuf {
422        // Hash the contents so concurrent tests get unique paths.
423        use std::hash::{Hash, Hasher};
424        let dir = std::env::temp_dir();
425        let nonce = std::process::id();
426        let mut hasher = std::collections::hash_map::DefaultHasher::new();
427        contents.hash(&mut hasher);
428        let id = hasher.finish();
429        let path = dir.join(format!("forge_monitor_tail_test_{nonce}_{id}.log"));
430        let mut f = File::create(&path).expect("create tmp");
431        f.write_all(contents.as_bytes()).expect("write tmp");
432        path
433    }
434
435    /// The composition's own shape: the session id and the task id anchor the
436    /// lookup while the slug between them is any directory the CLI made - a
437    /// slug-rule change must not starve a running call's read.
438    #[test]
439    fn compose_finds_the_task_under_any_slug_the_cli_made() {
440        let base = scratch_base("slug");
441        plant_file(&base, "some-moved-slug", "sess-1", "t-1", "hello\n");
442        assert_eq!(
443            compose_task_output_path(&base, "sess-1", "t-1"),
444            Some(
445                base.join(format!("claude-{}", rustix::process::getuid().as_raw()))
446                    .join("some-moved-slug")
447                    .join("sess-1")
448                    .join("tasks")
449                    .join("t-1.output"),
450            ),
451            "the file is found with the slug component wildcarded",
452        );
453        let _ = std::fs::remove_dir_all(&base);
454    }
455
456    /// The uid is this process's own: another user's tree under the same base
457    /// is not read.
458    #[test]
459    fn compose_ignores_a_tree_under_another_uid() {
460        let base = scratch_base("uid");
461        let other = base
462            .join(format!("claude-{}", rustix::process::getuid().as_raw() + 1))
463            .join("slug")
464            .join("sess-1")
465            .join("tasks");
466        std::fs::create_dir_all(&other).expect("create the other user's tree");
467        std::fs::write(other.join("t-1.output"), "not ours\n").expect("plant it");
468        assert_eq!(
469            compose_task_output_path(&base, "sess-1", "t-1"),
470            None,
471            "a tree under another uid is not this process's to read",
472        );
473        let _ = std::fs::remove_dir_all(&base);
474    }
475
476    /// Absence is a miss rather than an error: the file is written when the
477    /// task is, so a call read the instant it starts has no file yet.
478    #[test]
479    fn compose_answers_none_while_no_file_is_there_yet() {
480        let base = scratch_base("absent");
481        assert_eq!(compose_task_output_path(&base, "sess-1", "t-1"), None);
482        let _ = std::fs::remove_dir_all(&base);
483    }
484
485    /// Two matches is a miss, never a guess: one call's output is the one
486    /// file the CLI wrote, and picking between two would be inventing which.
487    #[test]
488    fn compose_answers_none_when_two_slugs_hold_the_file() {
489        let base = scratch_base("two");
490        plant_file(&base, "slug-a", "sess-1", "t-1", "first\n");
491        plant_file(&base, "slug-b", "sess-1", "t-1", "second\n");
492        assert_eq!(
493            compose_task_output_path(&base, "sess-1", "t-1"),
494            None,
495            "two candidates is a miss the caller draws as no-path",
496        );
497        let _ = std::fs::remove_dir_all(&base);
498    }
499
500    /// The base mirrors the child's own rule: `realpath(CLAUDE_CODE_TMPDIR ?? /tmp)`.
501    #[test]
502    fn the_base_is_the_tmpdir_the_child_runs_with() {
503        assert_eq!(
504            base_of(None),
505            std::fs::canonicalize("/tmp").expect("the real temp dir"),
506            "no CLAUDE_CODE_TMPDIR in the child's env means /tmp, realpath'd",
507        );
508        let scratch = scratch_base("base");
509        assert_eq!(
510            base_of(Some(scratch.clone().into_os_string())),
511            std::fs::canonicalize(&scratch).expect("the scratch base canonicalizes"),
512            "a set CLAUDE_CODE_TMPDIR is the base, and it is realpath'd the way the CLI does",
513        );
514        let _ = std::fs::remove_dir_all(&scratch);
515    }
516
517    #[test]
518    fn tail_returns_last_n_lines_in_order() {
519        let path = write_tmp(
520            "alpha\nbravo\ncharlie\ndelta\necho\nfoxtrot\ngolf\nhotel\nindia\njuliet\nkilo\nlima\nmike\nnovember\noscar\n",
521        );
522        let lines = read_output_file_tail(&path, 12).expect("read ok");
523        assert_eq!(lines.len(), 12);
524        // Last 12 of 15 -> drops alpha, bravo, charlie.
525        assert_eq!(lines.first().map(String::as_str), Some("delta"));
526        assert_eq!(lines.last().map(String::as_str), Some("oscar"));
527        let _ = std::fs::remove_file(&path);
528    }
529
530    #[test]
531    fn tail_under_cap_returns_all_lines() {
532        let path = write_tmp("one\ntwo\nthree\n");
533        let lines = read_output_file_tail(&path, 12).expect("read ok");
534        assert_eq!(lines, vec!["one".to_owned(), "two".to_owned(), "three".to_owned()]);
535        let _ = std::fs::remove_file(&path);
536    }
537
538    #[test]
539    fn empty_file_returns_empty_vec() {
540        let path = write_tmp("");
541        let lines = read_output_file_tail(&path, 12).expect("read ok");
542        assert!(lines.is_empty());
543        let _ = std::fs::remove_file(&path);
544    }
545
546    #[test]
547    fn missing_file_returns_none() {
548        let path = std::path::PathBuf::from("/nonexistent/forge_monitor_output_file_test.log");
549        let result = read_output_file_tail(&path, 12);
550        assert!(result.is_none(), "missing file must return None so caller skips replace");
551    }
552
553    #[test]
554    fn zero_max_lines_returns_empty_vec_without_reading() {
555        // Pass a clearly-nonexistent path; with max_lines=0 we must
556        // never even touch it.
557        let path = std::path::PathBuf::from("/nonexistent/zero_lines.log");
558        let result = read_output_file_tail(&path, 0).expect("zero max yields Ok(empty)");
559        assert!(result.is_empty());
560    }
561
562    #[test]
563    fn read_output_file_tail_hands_over_the_bytes_the_command_wrote() {
564        // Real-world Monitor commands (cargo build, npm install, anything
565        // with progress bars) emit ANSI colour codes + carriage returns for
566        // in-place line updates + the occasional BEL/backspace. The read
567        // hands them over as written: dropping them is the renderer's rule,
568        // and different renderers drop different bytes.
569        let raw = "\
570\x1b[32mline 1 green\x1b[0m\n\
571line 2 with \rcarriage return\n\
572\x1b[1;31mline 3 bold red\x1b[0m\x07\n\
573line 4 with \x08\x08backspace\n";
574        let path = write_tmp(raw);
575        let tail = read_output_file_tail(&path, 12).expect("read ok");
576
577        assert!(
578            tail.iter().any(|line| line.contains("\u{1b}[32m")),
579            "the colour the command wrote is still in the tail: {tail:?}",
580        );
581        assert!(
582            tail.iter().any(|line| line.contains('\r')),
583            "the carriage return the command wrote is still in the tail: {tail:?}",
584        );
585        assert!(
586            tail.iter().any(|line| line.contains("line 1 green")),
587            "and the words it wrote are there beside them: {tail:?}",
588        );
589        let _ = std::fs::remove_file(&path);
590    }
591
592    #[test]
593    fn handles_trailing_partial_line_without_newline() {
594        // File with unterminated trailing line: BufRead::lines yields
595        // it as a final Ok([whatever]) entry, included in the tail.
596        let path = write_tmp("one\ntwo\npartial-without-newline");
597        let lines = read_output_file_tail(&path, 12).expect("read ok");
598        assert_eq!(
599            lines,
600            vec!["one".to_owned(), "two".to_owned(), "partial-without-newline".to_owned()]
601        );
602        let _ = std::fs::remove_file(&path);
603    }
604
605    #[test]
606    fn large_file_reads_only_the_tail_window() {
607        // A file well past TAIL_WINDOW_BYTES must still return the last
608        // max_lines complete lines (proves the seek path + partial-first
609        // drop keep the real tail).
610        use std::fmt::Write as _;
611        let filler = "x".repeat(100);
612        let mut contents = String::new();
613        for i in 0..1200 {
614            let _ = writeln!(contents, "filler-{i}-{filler}");
615        }
616        for tag in ["tail-a", "tail-b", "tail-c"] {
617            contents.push_str(tag);
618            contents.push('\n');
619        }
620        assert!(contents.len() as u64 > TAIL_WINDOW_BYTES, "fixture must exceed the window");
621        let path = write_tmp(&contents);
622        let lines = read_output_file_tail(&path, 3).expect("read ok");
623        assert_eq!(lines, vec!["tail-a".to_owned(), "tail-b".to_owned(), "tail-c".to_owned()]);
624        let _ = std::fs::remove_file(&path);
625    }
626}