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