Skip to main content

vtcode_ghostty_vt_sys/
lib.rs

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