Skip to main content

vtcode_ghostty_vt_sys/
lib.rs

1#![allow(clippy::map_err_ignore)]
2use anyhow::{Context, Result, anyhow};
3use libloading::Library;
4use std::ffi::c_void;
5use std::os::raw::{c_int, c_uint};
6use std::path::{Path, PathBuf};
7use std::sync::OnceLock;
8
9#[derive(Debug, Clone, Copy)]
10pub struct GhosttyRenderRequest {
11    pub cols: u16,
12    pub rows: u16,
13    pub scrollback_lines: usize,
14}
15
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct GhosttyRenderOutput {
18    pub screen_contents: String,
19    pub scrollback: String,
20}
21
22pub fn render_terminal_snapshot(
23    request: GhosttyRenderRequest,
24    vt_stream: &[u8],
25) -> Result<GhosttyRenderOutput> {
26    if vt_stream.is_empty() {
27        return Ok(GhosttyRenderOutput {
28            screen_contents: String::new(),
29            scrollback: String::new(),
30        });
31    }
32
33    platform::render_terminal_snapshot(request, vt_stream)
34}
35
36fn unavailable_error() -> anyhow::Error {
37    anyhow!("Ghostty VT library is unavailable; falling back to legacy_vt100")
38}
39
40#[cfg(any(target_os = "linux", target_os = "macos"))]
41#[expect(
42    unsafe_code,
43    reason = "This module is the deliberate FFI boundary for the packaged Ghostty runtime and wraps its unsafe calls in a safe rendering API."
44)]
45mod platform {
46    use super::{
47        Context, GhosttyRenderOutput, GhosttyRenderRequest, Library, OnceLock, Path, PathBuf,
48        Result, anyhow, c_int, c_uint, c_void, unavailable_error,
49    };
50    use std::cmp;
51    use std::mem::size_of;
52
53    const GHOSTTY_SUCCESS: c_int = 0;
54    const GHOSTTY_OUT_OF_SPACE: c_int = -3;
55
56    const GHOSTTY_CELL_DATA_CODEPOINT: c_uint = 1;
57    const GHOSTTY_CELL_DATA_WIDE: c_uint = 3;
58    const GHOSTTY_CELL_DATA_HAS_TEXT: c_uint = 4;
59    const GHOSTTY_CELL_WIDE_SPACER_TAIL: c_uint = 2;
60    const GHOSTTY_CELL_WIDE_SPACER_HEAD: c_uint = 3;
61    const GHOSTTY_ROW_DATA_WRAP: c_uint = 1;
62    const GHOSTTY_POINT_TAG_ACTIVE: c_uint = 0;
63    const GHOSTTY_POINT_TAG_SCREEN: c_uint = 2;
64    const GHOSTTY_TERMINAL_DATA_SCROLLBAR: c_uint = 9;
65
66    type GhosttyTerminal = *mut c_void;
67    type GhosttyCell = u64;
68    type GhosttyRow = u64;
69    type GhosttyResult = c_int;
70    type GhosttyTerminalNew = unsafe extern "C" fn(
71        allocator: *const GhosttyAllocator,
72        terminal: *mut GhosttyTerminal,
73        options: GhosttyTerminalOptions,
74    ) -> GhosttyResult;
75    type GhosttyTerminalFree = unsafe extern "C" fn(terminal: GhosttyTerminal);
76    type GhosttyTerminalGet = unsafe extern "C" fn(
77        terminal: GhosttyTerminal,
78        data: c_uint,
79        out: *mut c_void,
80    ) -> GhosttyResult;
81    type GhosttyTerminalGridRef = unsafe extern "C" fn(
82        terminal: GhosttyTerminal,
83        point: GhosttyPoint,
84        out_ref: *mut GhosttyGridRef,
85    ) -> GhosttyResult;
86    type GhosttyTerminalVtWrite =
87        unsafe extern "C" fn(terminal: GhosttyTerminal, data: *const u8, len: usize);
88    type GhosttyGridRefCell = unsafe extern "C" fn(
89        ref_: *const GhosttyGridRef,
90        out_cell: *mut GhosttyCell,
91    ) -> GhosttyResult;
92    type GhosttyGridRefRow = unsafe extern "C" fn(
93        ref_: *const GhosttyGridRef,
94        out_row: *mut GhosttyRow,
95    ) -> GhosttyResult;
96    type GhosttyGridRefGraphemes = unsafe extern "C" fn(
97        ref_: *const GhosttyGridRef,
98        buf: *mut u32,
99        buf_len: usize,
100        out_len: *mut usize,
101    ) -> GhosttyResult;
102    type GhosttyCellGet =
103        unsafe extern "C" fn(cell: GhosttyCell, data: c_uint, out: *mut c_void) -> GhosttyResult;
104    type GhosttyRowGet =
105        unsafe extern "C" fn(row: GhosttyRow, data: c_uint, out: *mut c_void) -> GhosttyResult;
106
107    #[repr(C)]
108    struct GhosttyAllocator {
109        _unused: [u8; 0],
110    }
111
112    #[repr(C)]
113    #[derive(Clone, Copy)]
114    struct GhosttyTerminalOptions {
115        cols: u16,
116        rows: u16,
117        max_scrollback: usize,
118    }
119
120    #[repr(C)]
121    #[derive(Default, Clone, Copy)]
122    struct GhosttyGridRef {
123        size: usize,
124        node: *mut c_void,
125        x: u16,
126        y: u16,
127    }
128
129    #[repr(C)]
130    #[derive(Default, Clone, Copy)]
131    struct GhosttyPointCoordinate {
132        x: u16,
133        y: u32,
134    }
135
136    #[repr(C)]
137    #[derive(Clone, Copy)]
138    union GhosttyPointValue {
139        coordinate: GhosttyPointCoordinate,
140        padding: [u64; 2],
141    }
142
143    impl Default for GhosttyPointValue {
144        fn default() -> Self {
145            Self { padding: [0; 2] }
146        }
147    }
148
149    #[repr(C)]
150    #[derive(Default, Clone, Copy)]
151    struct GhosttyPoint {
152        tag: c_uint,
153        value: GhosttyPointValue,
154    }
155
156    #[repr(C)]
157    #[derive(Default, Clone, Copy)]
158    struct GhosttyTerminalScrollbar {
159        total: u64,
160        offset: u64,
161        len: u64,
162    }
163
164    #[derive(Debug)]
165    struct GhosttyApi {
166        _library: Library,
167        terminal_new: GhosttyTerminalNew,
168        terminal_free: GhosttyTerminalFree,
169        terminal_get: GhosttyTerminalGet,
170        terminal_grid_ref: GhosttyTerminalGridRef,
171        terminal_vt_write: GhosttyTerminalVtWrite,
172        grid_ref_cell: GhosttyGridRefCell,
173        grid_ref_row: GhosttyGridRefRow,
174        grid_ref_graphemes: GhosttyGridRefGraphemes,
175        cell_get: GhosttyCellGet,
176        row_get: GhosttyRowGet,
177    }
178
179    struct TerminalHandle<'a> {
180        api: &'a GhosttyApi,
181        raw: GhosttyTerminal,
182    }
183
184    impl Drop for TerminalHandle<'_> {
185        fn drop(&mut self) {
186            if self.raw.is_null() {
187                return;
188            }
189            // SAFETY: `raw` is owned by this handle and was returned by `ghostty_terminal_new`.
190            unsafe { (self.api.terminal_free)(self.raw) };
191        }
192    }
193
194    pub(super) fn render_terminal_snapshot(
195        request: GhosttyRenderRequest,
196        vt_stream: &[u8],
197    ) -> Result<GhosttyRenderOutput> {
198        let api = GhosttyApi::load()?;
199        render_snapshot_with_api(api, request, vt_stream)
200    }
201
202    impl GhosttyApi {
203        fn load() -> Result<&'static Self> {
204            static API: OnceLock<std::result::Result<GhosttyApi, String>> = OnceLock::new();
205
206            match API.get_or_init(|| {
207                Self::load_from_dirs(runtime_library_dirs()).map_err(|error| error.to_string())
208            }) {
209                Ok(api) => Ok(api),
210                Err(error) => Err(anyhow!(error.clone())),
211            }
212        }
213
214        fn load_from_dirs(dirs: Vec<PathBuf>) -> Result<Self> {
215            let candidates = candidate_library_paths_from_dirs(dirs);
216            if candidates.is_empty() {
217                return Err(unavailable_error());
218            }
219
220            let mut errors = Vec::new();
221            for candidate in candidates {
222                match Self::load_from_path(&candidate) {
223                    Ok(api) => return Ok(api),
224                    Err(error) => errors.push(format!("{}: {error}", candidate.display())),
225                }
226            }
227
228            Err(anyhow!("{} ({})", unavailable_error(), errors.join("; ")))
229        }
230
231        fn load_from_path(path: &Path) -> Result<Self> {
232            // SAFETY: Loading the packaged Ghostty runtime library is inherently unsafe. The path
233            // comes from VT Code-controlled package locations and the symbols are validated below.
234            let library = unsafe { Library::new(path) }
235                .with_context(|| format!("failed to load {}", path.display()))?;
236
237            Ok(Self {
238                terminal_new: load_symbol(&library, b"ghostty_terminal_new\0")?,
239                terminal_free: load_symbol(&library, b"ghostty_terminal_free\0")?,
240                terminal_get: load_symbol(&library, b"ghostty_terminal_get\0")?,
241                terminal_grid_ref: load_symbol(&library, b"ghostty_terminal_grid_ref\0")?,
242                terminal_vt_write: load_symbol(&library, b"ghostty_terminal_vt_write\0")?,
243                grid_ref_cell: load_symbol(&library, b"ghostty_grid_ref_cell\0")?,
244                grid_ref_row: load_symbol(&library, b"ghostty_grid_ref_row\0")?,
245                grid_ref_graphemes: load_symbol(&library, b"ghostty_grid_ref_graphemes\0")?,
246                cell_get: load_symbol(&library, b"ghostty_cell_get\0")?,
247                row_get: load_symbol(&library, b"ghostty_row_get\0")?,
248                _library: library,
249            })
250        }
251    }
252
253    impl TerminalHandle<'_> {
254        fn new(api: &GhosttyApi, request: GhosttyRenderRequest) -> Result<TerminalHandle<'_>> {
255            let mut raw = std::ptr::null_mut();
256            let options = GhosttyTerminalOptions {
257                cols: request.cols,
258                rows: request.rows,
259                max_scrollback: request.scrollback_lines,
260            };
261
262            let create_result = {
263                // SAFETY: `raw` points to writable storage and `options` matches the upstream
264                // layout expected by the Ghostty runtime.
265                unsafe { (api.terminal_new)(std::ptr::null(), &mut raw, options) }
266            };
267            ensure_success(create_result, "failed to create Ghostty terminal")?;
268            if raw.is_null() {
269                return Err(anyhow!(
270                    "failed to create Ghostty terminal: Ghostty returned null"
271                ));
272            }
273
274            Ok(TerminalHandle { api, raw })
275        }
276    }
277
278    fn render_region(
279        api: &GhosttyApi,
280        terminal: GhosttyTerminal,
281        tag: c_uint,
282        row_count: u32,
283        cols: u16,
284    ) -> Result<String> {
285        let row_count_hint = usize::try_from(row_count).unwrap_or(usize::MAX);
286        let col_count_hint = usize::from(cols);
287        let capacity = row_count_hint.saturating_mul(col_count_hint.saturating_add(1));
288        let mut output = String::with_capacity(capacity);
289
290        for row in 0..row_count {
291            let row_start = output.len();
292
293            for col in 0..cols {
294                let point = grid_point(tag, col, row);
295                let mut grid_ref = sized::<GhosttyGridRef>();
296
297                // SAFETY: `terminal` is valid, `point` is initialized, and `grid_ref` points to
298                // writable storage for the returned opaque reference.
299                let ref_result = unsafe { (api.terminal_grid_ref)(terminal, point, &mut grid_ref) };
300                if ref_result != GHOSTTY_SUCCESS {
301                    output.push(' ');
302                    continue;
303                }
304
305                let mut cell = 0;
306                // SAFETY: `grid_ref` is initialized and `cell` points to writable storage.
307                let cell_result = unsafe { (api.grid_ref_cell)(&grid_ref, &mut cell) };
308                if cell_result != GHOSTTY_SUCCESS {
309                    output.push(' ');
310                    continue;
311                }
312
313                append_cell_text(api, &mut output, &grid_ref, cell)?;
314            }
315
316            let wrap = row_wraps(api, terminal, tag, row)?;
317            trim_trailing_spaces(&mut output, row_start);
318            if !wrap && row + 1 < row_count {
319                output.push('\n');
320            }
321        }
322
323        Ok(output)
324    }
325
326    fn append_cell_text(
327        api: &GhosttyApi,
328        output: &mut String,
329        grid_ref: &GhosttyGridRef,
330        cell: GhosttyCell,
331    ) -> Result<()> {
332        let mut wide = 0u32;
333        let wide_result = {
334            // SAFETY: `wide` points to writable storage for the requested cell width field.
335            unsafe { (api.cell_get)(cell, GHOSTTY_CELL_DATA_WIDE, (&mut wide as *mut u32).cast()) }
336        };
337        ensure_success(wide_result, "failed to read Ghostty cell width")?;
338
339        if wide == GHOSTTY_CELL_WIDE_SPACER_TAIL || wide == GHOSTTY_CELL_WIDE_SPACER_HEAD {
340            return Ok(());
341        }
342
343        let mut has_text = false;
344        let has_text_result = {
345            // SAFETY: `has_text` points to writable storage for the requested boolean field.
346            unsafe {
347                (api.cell_get)(
348                    cell,
349                    GHOSTTY_CELL_DATA_HAS_TEXT,
350                    (&mut has_text as *mut bool).cast(),
351                )
352            }
353        };
354        ensure_success(has_text_result, "failed to read Ghostty cell text flag")?;
355
356        if !has_text {
357            output.push(' ');
358            return Ok(());
359        }
360
361        let mut grapheme_len = 0usize;
362        // SAFETY: Passing a null buffer is the documented way to query grapheme length.
363        let grapheme_result = unsafe {
364            (api.grid_ref_graphemes)(grid_ref, std::ptr::null_mut(), 0, &mut grapheme_len)
365        };
366        if grapheme_result == GHOSTTY_OUT_OF_SPACE && grapheme_len > 0 {
367            let mut codepoints = vec![0u32; grapheme_len];
368            let grapheme_fill_result = {
369                // SAFETY: `codepoints` provides writable storage for the reported grapheme length.
370                unsafe {
371                    (api.grid_ref_graphemes)(
372                        grid_ref,
373                        codepoints.as_mut_ptr(),
374                        codepoints.len(),
375                        &mut grapheme_len,
376                    )
377                }
378            };
379            ensure_success(
380                grapheme_fill_result,
381                "failed to read Ghostty grapheme cluster",
382            )?;
383
384            for codepoint in codepoints.into_iter().take(grapheme_len) {
385                push_codepoint(output, codepoint);
386            }
387            return Ok(());
388        }
389
390        let mut codepoint = 0u32;
391        let codepoint_result = {
392            // SAFETY: `codepoint` points to writable storage for the requested codepoint field.
393            unsafe {
394                (api.cell_get)(
395                    cell,
396                    GHOSTTY_CELL_DATA_CODEPOINT,
397                    (&mut codepoint as *mut u32).cast(),
398                )
399            }
400        };
401        ensure_success(codepoint_result, "failed to read Ghostty codepoint")?;
402
403        push_codepoint(output, codepoint);
404        Ok(())
405    }
406
407    fn row_wraps(
408        api: &GhosttyApi,
409        terminal: GhosttyTerminal,
410        tag: c_uint,
411        row: u32,
412    ) -> Result<bool> {
413        let mut grid_ref = sized::<GhosttyGridRef>();
414        // SAFETY: `grid_ref` points to writable storage for the requested row reference.
415        let ref_result =
416            unsafe { (api.terminal_grid_ref)(terminal, grid_point(tag, 0, row), &mut grid_ref) };
417        if ref_result != GHOSTTY_SUCCESS {
418            return Ok(false);
419        }
420
421        let mut grid_row = 0;
422        // SAFETY: `grid_row` points to writable storage for the row handle.
423        let row_result = unsafe { (api.grid_ref_row)(&grid_ref, &mut grid_row) };
424        if row_result != GHOSTTY_SUCCESS {
425            return Ok(false);
426        }
427
428        let mut wrap = false;
429        // SAFETY: `wrap` points to writable storage for the requested row field.
430        let wrap_result = unsafe {
431            (api.row_get)(
432                grid_row,
433                GHOSTTY_ROW_DATA_WRAP,
434                (&mut wrap as *mut bool).cast(),
435            )
436        };
437        if wrap_result != GHOSTTY_SUCCESS {
438            return Ok(false);
439        }
440
441        Ok(wrap)
442    }
443
444    fn runtime_library_dirs() -> Vec<PathBuf> {
445        let mut roots = Vec::new();
446        if let Ok(current_exe) = std::env::current_exe()
447            && let Some(exe_dir) = current_exe.parent()
448        {
449            push_unique(&mut roots, exe_dir.join("ghostty-vt"));
450            push_unique(&mut roots, exe_dir.to_path_buf());
451        }
452        roots
453    }
454
455    fn render_snapshot_with_api(
456        api: &GhosttyApi,
457        request: GhosttyRenderRequest,
458        vt_stream: &[u8],
459    ) -> Result<GhosttyRenderOutput> {
460        let terminal = TerminalHandle::new(api, request)?;
461
462        // SAFETY: `terminal.raw` is valid for the duration of the call and the slice pointer/len
463        // pair remains valid across the FFI call.
464        unsafe { (api.terminal_vt_write)(terminal.raw, vt_stream.as_ptr(), vt_stream.len()) };
465
466        let screen_contents = render_region(
467            api,
468            terminal.raw,
469            GHOSTTY_POINT_TAG_ACTIVE,
470            u32::from(request.rows),
471            request.cols,
472        )?;
473
474        let total_rows = query_total_rows(api, terminal.raw, request.rows)?;
475        let scrollback = render_region(
476            api,
477            terminal.raw,
478            GHOSTTY_POINT_TAG_SCREEN,
479            total_rows,
480            request.cols,
481        )?;
482
483        Ok(GhosttyRenderOutput {
484            screen_contents,
485            scrollback,
486        })
487    }
488
489    fn query_total_rows(api: &GhosttyApi, terminal: GhosttyTerminal, rows: u16) -> Result<u32> {
490        let mut scrollbar = GhosttyTerminalScrollbar::default();
491        let scrollbar_result = {
492            // SAFETY: `scrollbar` points to writable storage for the requested result type.
493            unsafe {
494                (api.terminal_get)(
495                    terminal,
496                    GHOSTTY_TERMINAL_DATA_SCROLLBAR,
497                    (&mut scrollbar as *mut GhosttyTerminalScrollbar).cast(),
498                )
499            }
500        };
501        ensure_success(scrollbar_result, "failed to query Ghostty scrollbar state")?;
502
503        let total_rows = cmp::max(scrollbar.total, u64::from(rows));
504        u32::try_from(total_rows)
505            .map_err(|_| anyhow!("Ghostty screen too large to render: {total_rows}"))
506    }
507
508    fn candidate_library_paths_from_dirs(dirs: Vec<PathBuf>) -> Vec<PathBuf> {
509        let mut paths = Vec::new();
510
511        for dir in dirs {
512            let Ok(entries) = std::fs::read_dir(&dir) else {
513                continue;
514            };
515
516            let mut preferred = Vec::new();
517            let mut versioned = Vec::new();
518
519            for entry in entries.flatten() {
520                let path = entry.path();
521                if !path.is_file() {
522                    continue;
523                }
524
525                let Some(name) = path.file_name().and_then(|value| value.to_str()) else {
526                    continue;
527                };
528                if !is_runtime_library_name(name) {
529                    continue;
530                }
531
532                if is_preferred_runtime_library_name(name) {
533                    preferred.push(path);
534                } else {
535                    versioned.push(path);
536                }
537            }
538
539            preferred.sort();
540            versioned.sort();
541            paths.extend(preferred);
542            paths.extend(versioned);
543        }
544
545        paths
546    }
547
548    fn is_runtime_library_name(name: &str) -> bool {
549        if cfg!(target_os = "macos") {
550            name.starts_with("libghostty-vt") && name.ends_with(".dylib")
551        } else {
552            name.starts_with("libghostty-vt") && name.contains(".so")
553        }
554    }
555
556    fn is_preferred_runtime_library_name(name: &str) -> bool {
557        if cfg!(target_os = "macos") {
558            name == "libghostty-vt.dylib"
559        } else {
560            name == "libghostty-vt.so"
561        }
562    }
563
564    fn grid_point(tag: c_uint, x: u16, y: u32) -> GhosttyPoint {
565        GhosttyPoint {
566            tag,
567            value: GhosttyPointValue {
568                coordinate: GhosttyPointCoordinate { x, y },
569            },
570        }
571    }
572
573    fn ensure_success(result: GhosttyResult, context: &str) -> Result<()> {
574        if result == GHOSTTY_SUCCESS {
575            Ok(())
576        } else {
577            Err(anyhow!("{context}: Ghostty returned {result}"))
578        }
579    }
580
581    fn push_codepoint(output: &mut String, codepoint: u32) {
582        if let Some(ch) = char::from_u32(codepoint) {
583            output.push(ch);
584        } else {
585            output.push(char::REPLACEMENT_CHARACTER);
586        }
587    }
588
589    fn trim_trailing_spaces(output: &mut String, floor: usize) {
590        while output.len() > floor && output.as_bytes().last().copied() == Some(b' ') {
591            let _ = output.pop();
592        }
593    }
594
595    fn push_unique(values: &mut Vec<PathBuf>, value: PathBuf) {
596        if !values.iter().any(|existing| existing == &value) {
597            values.push(value);
598        }
599    }
600
601    fn sized<T: Default>() -> T {
602        let mut value = T::default();
603        // SAFETY: All callers use FFI structs whose first field is the `size` field expected by
604        // Ghostty. This mirrors the upstream `sized!` helper from `libghostty-rs`.
605        unsafe {
606            let size_ptr = (&mut value as *mut T).cast::<usize>();
607            *size_ptr = size_of::<T>();
608        }
609        value
610    }
611
612    fn load_symbol<T: Copy>(library: &Library, name: &[u8]) -> Result<T> {
613        // SAFETY: The symbol names and types match the upstream `libghostty-rs` bindings.
614        let symbol = unsafe { library.get::<T>(name) }
615            .with_context(|| format!("missing symbol {}", String::from_utf8_lossy(name)))?;
616        Ok(*symbol)
617    }
618
619    #[cfg(test)]
620    fn test_asset_dirs() -> Vec<PathBuf> {
621        let mut dirs = Vec::new();
622        if let Some(asset_dir) = option_env!("VTCODE_GHOSTTY_VT_TEST_ASSET_DIR")
623            .filter(|value| !value.is_empty())
624            .map(PathBuf::from)
625        {
626            push_unique(&mut dirs, asset_dir.join("lib"));
627            push_unique(&mut dirs, asset_dir);
628        }
629        dirs
630    }
631
632    #[cfg(test)]
633    fn real_ghostty_available() -> bool {
634        !test_asset_dirs().is_empty() && GhosttyApi::load_from_dirs(test_asset_dirs()).is_ok()
635    }
636
637    #[cfg(test)]
638    fn render_with_test_assets(
639        request: GhosttyRenderRequest,
640        vt_stream: &[u8],
641    ) -> Result<GhosttyRenderOutput> {
642        let api = GhosttyApi::load_from_dirs(test_asset_dirs())?;
643        render_snapshot_with_api(&api, request, vt_stream)
644    }
645
646    #[cfg(test)]
647    mod tests {
648        use super::{GhosttyApi, candidate_library_paths_from_dirs, real_ghostty_available};
649        use crate::{GhosttyRenderOutput, GhosttyRenderRequest};
650
651        #[test]
652        fn empty_dirs_report_unavailable() {
653            let error = GhosttyApi::load_from_dirs(Vec::new()).expect_err("missing dirs must fail");
654            assert!(error.to_string().contains("legacy_vt100"));
655        }
656
657        #[test]
658        fn candidate_library_paths_prioritize_unversioned_names() {
659            let temp = tempfile::tempdir().expect("tempdir");
660            let root = temp.path();
661            let preferred = if cfg!(target_os = "macos") {
662                root.join("libghostty-vt.dylib")
663            } else {
664                root.join("libghostty-vt.so")
665            };
666            let versioned = if cfg!(target_os = "macos") {
667                root.join("libghostty-vt.0.1.0.dylib")
668            } else {
669                root.join("libghostty-vt.so.0.1.0")
670            };
671            std::fs::write(&preferred, b"").expect("preferred");
672            std::fs::write(&versioned, b"").expect("versioned");
673
674            let candidates = candidate_library_paths_from_dirs(vec![root.to_path_buf()]);
675            assert_eq!(candidates.first(), Some(&preferred));
676            assert_eq!(candidates.get(1), Some(&versioned));
677        }
678
679        #[test]
680        fn renders_plain_text_when_test_assets_are_available() {
681            if !real_ghostty_available() {
682                return;
683            }
684
685            let output = super::render_with_test_assets(
686                GhosttyRenderRequest {
687                    cols: 5,
688                    rows: 1,
689                    scrollback_lines: 16,
690                },
691                b"hello",
692            )
693            .expect("plain text should render");
694
695            assert_eq!(
696                output,
697                GhosttyRenderOutput {
698                    screen_contents: "hello".to_string(),
699                    scrollback: "hello".to_string(),
700                }
701            );
702        }
703
704        #[test]
705        fn wrapped_rows_do_not_insert_newlines_when_test_assets_are_available() {
706            if !real_ghostty_available() {
707                return;
708            }
709
710            let output = super::render_with_test_assets(
711                GhosttyRenderRequest {
712                    cols: 5,
713                    rows: 2,
714                    scrollback_lines: 16,
715                },
716                b"helloworld",
717            )
718            .expect("wrapped text should render");
719
720            assert_eq!(output.screen_contents, "helloworld");
721        }
722
723        #[test]
724        fn trims_trailing_spaces_when_test_assets_are_available() {
725            if !real_ghostty_available() {
726                return;
727            }
728
729            let output = super::render_with_test_assets(
730                GhosttyRenderRequest {
731                    cols: 6,
732                    rows: 1,
733                    scrollback_lines: 16,
734                },
735                b"hi   ",
736            )
737            .expect("trailing spaces should trim");
738
739            assert_eq!(output.screen_contents, "hi");
740        }
741
742        #[test]
743        fn wide_cells_skip_spacers_when_test_assets_are_available() {
744            if !real_ghostty_available() {
745                return;
746            }
747
748            let output = super::render_with_test_assets(
749                GhosttyRenderRequest {
750                    cols: 4,
751                    rows: 1,
752                    scrollback_lines: 16,
753                },
754                "你a".as_bytes(),
755            )
756            .expect("wide glyphs should render");
757
758            assert_eq!(output.screen_contents, "你a");
759        }
760
761        #[test]
762        fn scrollback_renders_full_screen_when_test_assets_are_available() {
763            if !real_ghostty_available() {
764                return;
765            }
766
767            let output = super::render_with_test_assets(
768                GhosttyRenderRequest {
769                    cols: 5,
770                    rows: 1,
771                    scrollback_lines: 16,
772                },
773                b"one\r\ntwo",
774            )
775            .expect("scrollback should render");
776
777            assert_eq!(output.screen_contents, "two");
778            assert_eq!(output.scrollback, "one\ntwo");
779        }
780    }
781}
782
783#[cfg(not(any(target_os = "linux", target_os = "macos")))]
784mod platform {
785    use super::{GhosttyRenderOutput, GhosttyRenderRequest, Result, unavailable_error};
786
787    pub(super) fn render_terminal_snapshot(
788        _request: GhosttyRenderRequest,
789        _vt_stream: &[u8],
790    ) -> Result<GhosttyRenderOutput> {
791        Err(unavailable_error())
792    }
793}
794
795#[cfg(test)]
796mod tests {
797    use super::{GhosttyRenderOutput, GhosttyRenderRequest, render_terminal_snapshot};
798
799    #[test]
800    fn empty_vt_stream_returns_empty_snapshot() {
801        let output = render_terminal_snapshot(
802            GhosttyRenderRequest {
803                cols: 80,
804                rows: 24,
805                scrollback_lines: 1000,
806            },
807            &[],
808        )
809        .expect("empty VT stream should not require Ghostty");
810
811        assert_eq!(
812            output,
813            GhosttyRenderOutput {
814                screen_contents: String::new(),
815                scrollback: String::new(),
816            }
817        );
818    }
819}