Skip to main content

cpdb_sys/
printer.rs

1//! Safe wrapper around `cpdb_printer_obj_t`.
2//!
3//! # Ownership and lifetimes
4//!
5//! Printers come in two flavours:
6//!
7//! - **Borrowed** — returned by [`crate::Frontend::find_printer`],
8//!   [`crate::Frontend::get_printer`], [`crate::Frontend::get_printers`],
9//!   and the default-printer accessors. The underlying C object is owned by
10//!   the frontend's hash table; the Rust binding carries the frontend's
11//!   lifetime so the borrow checker enforces that the printer cannot outlive
12//!   its frontend.
13//! - **Owned** — returned by [`Printer::load_from_file`]. The C object was
14//!   allocated independently; Rust frees it via `cpdbDeletePrinterObj` on
15//!   drop. Owned printers have a `'static` lifetime.
16//!
17//! `Printer` deliberately does not implement [`Send`] or [`Sync`]. Most
18//! cpdb-libs methods on a printer object mutate shared state (the printer's
19//! settings table is shared with the frontend), and the C library does not
20//! lock internally. If you need to dispatch printer operations from
21//! multiple threads, wrap a single printer in a [`std::sync::Mutex`].
22
23use super::bindings as ffi;
24use super::callbacks::{self, AcquireCompletion};
25use super::frontend::Frontend;
26use super::util;
27use crate::error::{CpdbError, Result};
28use crate::options::OptionsCollection;
29use libc::c_char;
30use std::collections::HashMap;
31use std::ffi::{CStr, CString};
32use std::marker::PhantomData;
33use std::mem::MaybeUninit;
34use std::os::fd::{FromRawFd, OwnedFd};
35use std::ptr::NonNull;
36
37/// Page margins in hundredths of a millimetre.
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub struct Margin {
40    /// Top margin.
41    pub top: i32,
42    /// Bottom margin.
43    pub bottom: i32,
44    /// Left margin.
45    pub left: i32,
46    /// Right margin.
47    pub right: i32,
48}
49
50/// One or more [`Margin`] entries returned by the backend for a media type.
51#[derive(Debug, Clone, Default, PartialEq, Eq)]
52pub struct Margins {
53    /// Every margin set the backend reports for the queried media.
54    pub entries: Vec<Margin>,
55}
56
57/// Media dimensions in hundredths of a millimetre.
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub struct MediaSize {
60    /// Width.
61    pub width: i32,
62    /// Length.
63    pub length: i32,
64}
65
66/// Handle returned by [`Printer::print_fd`].
67///
68/// The caller writes the document data to [`PrintFdHandle::fd`] and then
69/// drops the handle to close it.
70#[derive(Debug)]
71pub struct PrintFdHandle {
72    /// A writable file descriptor that consumes the print job data.
73    pub fd: OwnedFd,
74    /// The backend-assigned job ID, or an empty string when not provided.
75    pub job_id: String,
76    /// Optional auxiliary socket path the backend may return.
77    pub socket_path: Option<String>,
78}
79
80/// Handle returned by [`Printer::print_socket`].
81#[derive(Debug, Clone, PartialEq, Eq)]
82pub struct PrintSocketHandle {
83    /// Path to a Unix-domain socket the caller writes the job data to.
84    pub socket_path: String,
85    /// The backend-assigned job ID, or an empty string when not provided.
86    pub job_id: String,
87}
88
89/// An owned snapshot of a printer's translation table.
90///
91/// Built by walking `cpdb_printer_obj.translations` once and copying every
92/// `(key, value)` pair into Rust-owned `String`s. After construction the
93/// map holds no raw pointers and can be freely stored or sent across
94/// threads.
95#[derive(Debug, Clone, Default, PartialEq, Eq)]
96pub struct TranslationMap {
97    /// The locale this map was captured for, when available.
98    pub locale: Option<String>,
99    /// Translation entries: source string → localised string.
100    pub entries: HashMap<String, String>,
101}
102
103impl TranslationMap {
104    /// `true` when no entries are present.
105    pub fn is_empty(&self) -> bool {
106        self.entries.is_empty()
107    }
108
109    /// Number of entries in the map.
110    pub fn len(&self) -> usize {
111        self.entries.len()
112    }
113
114    /// Looks up the translation for `key`.
115    pub fn get(&self, key: &str) -> Option<&str> {
116        self.entries.get(key).map(String::as_str)
117    }
118}
119
120/// A safe handle to a cpdb printer object.
121///
122/// See [the module docs](self) for the ownership and lifetime model.
123#[derive(Debug)]
124pub struct Printer<'frontend> {
125    raw: NonNull<ffi::cpdb_printer_obj_t>,
126    owned: bool,
127    // Borrowed printers borrow from a `Frontend`; using a non-`Send`/`Sync`
128    // marker also keeps owned printers off other threads, which matches
129    // cpdb-libs' lack of internal locking.
130    _marker: PhantomData<&'frontend Frontend>,
131}
132
133impl<'frontend> Printer<'frontend> {
134    // ─── Constructors ────────────────────────────────────────────────────────
135
136    /// Wraps a printer object that is owned by a frontend's hash table.
137    ///
138    /// The returned printer borrows from the frontend and will NOT be freed
139    /// by Rust on drop.
140    pub(crate) fn from_raw_borrowed(raw: *mut ffi::cpdb_printer_obj_t) -> Result<Self> {
141        let raw = NonNull::new(raw).ok_or(CpdbError::NullPointer)?;
142        Ok(Self {
143            raw,
144            owned: false,
145            _marker: PhantomData,
146        })
147    }
148
149    /// Wraps a printer object that the binding will free on drop.
150    ///
151    /// Used for printers loaded from a pickle file.
152    pub(crate) fn from_raw_owned(raw: *mut ffi::cpdb_printer_obj_t) -> Result<Self> {
153        let raw = NonNull::new(raw).ok_or(CpdbError::NullPointer)?;
154        Ok(Self {
155            raw,
156            owned: true,
157            _marker: PhantomData,
158        })
159    }
160
161    /// Returns the raw pointer for use within this crate.
162    #[doc(hidden)]
163    pub fn as_raw(&self) -> *mut ffi::cpdb_printer_obj_t {
164        self.raw.as_ptr()
165    }
166
167    /// `true` when this binding will free the underlying C object on drop.
168    #[doc(hidden)]
169    pub(crate) fn is_owned(&self) -> bool {
170        self.owned
171    }
172
173    // ─── Field accessors ─────────────────────────────────────────────────────
174
175    /// The backend-assigned printer ID (stable across sessions).
176    pub fn id(&self) -> Result<String> {
177        self.read_str_field(|p| unsafe { (*p).id })
178    }
179
180    /// The human-readable printer name.
181    pub fn name(&self) -> Result<String> {
182        self.read_str_field(|p| unsafe { (*p).name })
183    }
184
185    /// The physical location string supplied by the backend.
186    pub fn location(&self) -> Result<String> {
187        self.read_str_field(|p| unsafe { (*p).location })
188    }
189
190    /// A free-form description (`info` in the C struct).
191    pub fn description(&self) -> Result<String> {
192        self.read_str_field(|p| unsafe { (*p).info })
193    }
194
195    /// The printer's make and model.
196    pub fn make_and_model(&self) -> Result<String> {
197        self.read_str_field(|p| unsafe { (*p).make_and_model })
198    }
199
200    /// The backend name this printer belongs to (e.g. `"CUPS"`).
201    pub fn backend_name(&self) -> Result<String> {
202        self.read_str_field(|p| unsafe { (*p).backend_name })
203    }
204
205    /// The cached state string (`idle`, `processing`, ...).
206    ///
207    /// For an authoritative answer use [`Printer::get_updated_state`].
208    pub fn cached_state(&self) -> Result<String> {
209        self.read_str_field(|p| unsafe { (*p).state })
210    }
211
212    /// Reads an optional NUL-terminated string field from the printer struct.
213    fn read_str_field<F>(&self, accessor: F) -> Result<String>
214    where
215        F: FnOnce(*mut ffi::cpdb_printer_obj_t) -> *const c_char,
216    {
217        let ptr = accessor(self.raw.as_ptr());
218        // SAFETY: `ptr` is borrowed from the printer object; the read above
219        // is just a field deref. `cstr_to_string` is null-tolerant.
220        match unsafe { util::cstr_to_string(ptr) } {
221            Ok(s) => Ok(s),
222            Err(CpdbError::NullPointer) => Ok(String::new()),
223            Err(e) => Err(e),
224        }
225    }
226
227    // ─── State ───────────────────────────────────────────────────────────────
228
229    /// Queries the current state from the backend.
230    ///
231    /// The returned string is owned by Rust; the cpdb-libs allocation is
232    /// freed inside this call.
233    pub fn get_updated_state(&self) -> Result<String> {
234        // SAFETY: cpdbGetState returns a borrowed string owned by the printer object.
235        unsafe {
236            let raw = ffi::cpdbGetState(self.raw.as_ptr());
237            util::cstr_to_string(raw)
238        }
239    }
240
241    /// `true` when the printer is accepting jobs.
242    pub fn is_accepting_jobs(&self) -> Result<bool> {
243        // SAFETY: pointer is non-null.
244        Ok(unsafe { ffi::cpdbIsAcceptingJobs(self.raw.as_ptr()) } != 0)
245    }
246
247    // ─── Defaults ────────────────────────────────────────────────────────────
248
249    /// Marks this printer as the user's default. Returns `true` on success.
250    pub fn set_user_default(&self) -> Result<bool> {
251        // SAFETY: pointer is non-null.
252        Ok(unsafe { ffi::cpdbSetUserDefaultPrinter(self.raw.as_ptr()) } != 0)
253    }
254
255    /// Marks this printer as the system-wide default. Returns `true` on success.
256    pub fn set_system_default(&self) -> Result<bool> {
257        // SAFETY: pointer is non-null.
258        Ok(unsafe { ffi::cpdbSetSystemDefaultPrinter(self.raw.as_ptr()) } != 0)
259    }
260
261    // ─── Job submission ──────────────────────────────────────────────────────
262
263    /// Submits a file as a print job with no extra options.
264    ///
265    /// Returns the backend-assigned job ID string.
266    pub fn print_file(&self, file_path: &str) -> Result<String> {
267        let c_path = CString::new(file_path)?;
268        // SAFETY: cpdbPrintFile returns a `g_strdup`'d job ID we own.
269        unsafe {
270            let id = ffi::cpdbPrintFile(self.raw.as_ptr(), c_path.as_ptr());
271            util::cstr_to_string_and_g_free(id)
272                .map_err(|_| CpdbError::JobFailed("cpdbPrintFile returned null".into()))
273        }
274    }
275
276    /// Streams a print job over a file descriptor.
277    ///
278    /// cpdb-libs hands back a writable file descriptor; the caller writes
279    /// the job's data to it and drops the returned handle to close it.
280    /// The backend job ID and an optional auxiliary socket path are also
281    /// returned.
282    pub fn print_fd(&self, title: &str) -> Result<PrintFdHandle> {
283        let c_title = CString::new(title)?;
284        let mut jobid_ptr: *mut c_char = std::ptr::null_mut();
285        let mut socket_ptr: *mut c_char = std::ptr::null_mut();
286        // SAFETY: pointers are non-null; output params receive
287        // `g_strdup`'d strings we own.
288        let fd = unsafe {
289            ffi::cpdbPrintFD(
290                self.raw.as_ptr(),
291                &mut jobid_ptr,
292                c_title.as_ptr(),
293                &mut socket_ptr,
294            )
295        };
296        if fd < 0 {
297            // Defensive cleanup in case cpdb-libs allocated before failing.
298            unsafe {
299                if !jobid_ptr.is_null() {
300                    glib_sys::g_free(jobid_ptr as glib_sys::gpointer);
301                }
302                if !socket_ptr.is_null() {
303                    glib_sys::g_free(socket_ptr as glib_sys::gpointer);
304                }
305            }
306            return Err(CpdbError::JobFailed(
307                "cpdbPrintFD returned an invalid fd".into(),
308            ));
309        }
310        let job_id = if jobid_ptr.is_null() {
311            String::new()
312        } else {
313            unsafe { util::cstr_to_string_and_g_free(jobid_ptr) }.unwrap_or_default()
314        };
315        let socket_path = if socket_ptr.is_null() {
316            None
317        } else {
318            unsafe { util::cstr_to_string_and_g_free(socket_ptr) }.ok()
319        };
320        // SAFETY: cpdb-libs returned a valid fd we now own.
321        let fd = unsafe { OwnedFd::from_raw_fd(fd) };
322        Ok(PrintFdHandle {
323            fd,
324            job_id,
325            socket_path,
326        })
327    }
328
329    /// Streams a print job over a Unix-domain socket.
330    ///
331    /// cpdb-libs returns a socket path the caller connects to and writes
332    /// the job data through, plus the backend-assigned job ID.
333    pub fn print_socket(&self, title: &str) -> Result<PrintSocketHandle> {
334        let c_title = CString::new(title)?;
335        let mut jobid_ptr: *mut c_char = std::ptr::null_mut();
336        // SAFETY: cpdb returns a `g_strdup`'d socket path we own.
337        let socket_ptr =
338            unsafe { ffi::cpdbPrintSocket(self.raw.as_ptr(), &mut jobid_ptr, c_title.as_ptr()) };
339        if socket_ptr.is_null() {
340            if !jobid_ptr.is_null() {
341                unsafe { glib_sys::g_free(jobid_ptr as glib_sys::gpointer) };
342            }
343            return Err(CpdbError::JobFailed(
344                "cpdbPrintSocket returned a null socket path".into(),
345            ));
346        }
347        let socket_path = unsafe { util::cstr_to_string_and_g_free(socket_ptr) }?;
348        let job_id = if jobid_ptr.is_null() {
349            String::new()
350        } else {
351            unsafe { util::cstr_to_string_and_g_free(jobid_ptr) }.unwrap_or_default()
352        };
353        Ok(PrintSocketHandle {
354            socket_path,
355            job_id,
356        })
357    }
358
359    /// Submits a job with a per-call set of options and an explicit title.
360    ///
361    /// Options are applied to the printer's settings table via
362    /// `cpdbAddSettingToPrinter` before submission, so they take effect for
363    /// this job and persist for subsequent jobs on the same printer until
364    /// cleared via [`Printer::clear_setting`].
365    ///
366    /// Returns the backend-assigned job ID string.
367    pub fn submit_job(
368        &self,
369        file_path: &str,
370        options: &[(&str, &str)],
371        title: &str,
372    ) -> Result<String> {
373        let c_path = CString::new(file_path)?;
374        let c_title = CString::new(title)?;
375        for (key, value) in options {
376            let k = CString::new(*key)?;
377            let v = CString::new(*value)?;
378            // SAFETY: pointers are non-null; CStrings live until end of loop body.
379            unsafe { ffi::cpdbAddSettingToPrinter(self.raw.as_ptr(), k.as_ptr(), v.as_ptr()) };
380        }
381        // SAFETY: cpdbPrintFileWithJobTitle returns a `g_strdup`'d job ID we own.
382        unsafe {
383            let id = ffi::cpdbPrintFileWithJobTitle(
384                self.raw.as_ptr(),
385                c_path.as_ptr(),
386                c_title.as_ptr(),
387            );
388            util::cstr_to_string_and_g_free(id)
389                .map_err(|_| CpdbError::JobFailed("cpdbPrintFileWithJobTitle returned null".into()))
390        }
391    }
392
393    // ─── Options ─────────────────────────────────────────────────────────────
394
395    /// Returns the default value for a named option, if the option exists.
396    pub fn get_option(&self, option_name: &str) -> Result<Option<String>> {
397        let c_name = CString::new(option_name)?;
398        // SAFETY: `cpdbGetOption` returns a borrowed pointer into the printer's
399        // option table; we must NOT free it. Reading `default_value` is a
400        // simple field deref.
401        unsafe {
402            let opt = ffi::cpdbGetOption(self.raw.as_ptr(), c_name.as_ptr());
403            if opt.is_null() {
404                return Ok(None);
405            }
406            let dv = (*opt).default_value;
407            if dv.is_null() {
408                Ok(None)
409            } else {
410                util::cstr_to_string(dv).map(Some)
411            }
412        }
413    }
414
415    /// Returns the default value for a named option, freeing the GLib string.
416    pub fn get_default(&self, option_name: &str) -> Result<String> {
417        let c_name = CString::new(option_name)?;
418        // SAFETY: `cpdbGetDefault` returns a `g_strdup`'d string we own.
419        unsafe {
420            let v = ffi::cpdbGetDefault(self.raw.as_ptr(), c_name.as_ptr());
421            util::cstr_to_string_and_g_free(v)
422        }
423    }
424
425    /// Returns the *current* (setting-or-default) value for a named option.
426    pub fn get_current(&self, option_name: &str) -> Result<String> {
427        let c_name = CString::new(option_name)?;
428        // SAFETY: `cpdbGetCurrent` returns a `g_strdup`'d string we own.
429        unsafe {
430            let v = ffi::cpdbGetCurrent(self.raw.as_ptr(), c_name.as_ptr());
431            util::cstr_to_string_and_g_free(v)
432        }
433    }
434
435    /// Asynchronously asks the backend to populate the printer's full
436    /// option table. Returns immediately; no completion notification.
437    ///
438    /// Use [`Printer::acquire_details_with`] if you need to be told when
439    /// the operation finishes.
440    pub fn acquire_details(&self) {
441        // SAFETY: passing a null callback is documented as valid.
442        unsafe {
443            ffi::cpdbAcquireDetails(self.raw.as_ptr(), None, std::ptr::null_mut());
444        }
445    }
446
447    /// Asynchronously populates the option table, firing `completion` when
448    /// the backend responds.
449    ///
450    /// `completion` is invoked from cpdb-libs' D-Bus listener thread with
451    /// `success = true` when the backend reported success. Panics inside
452    /// the closure are caught and absorbed.
453    pub fn acquire_details_with<F>(&self, completion: F)
454    where
455        F: FnOnce(&Printer<'_>, bool) + Send + 'static,
456    {
457        let boxed: Box<AcquireCompletion> = Box::new(completion);
458        let user_data = callbacks::into_completion_user_data(boxed);
459        // SAFETY: `user_data` was produced for this exact callback firing;
460        // the trampoline reclaims and frees it.
461        unsafe {
462            ffi::cpdbAcquireDetails(
463                self.raw.as_ptr(),
464                Some(callbacks::acquire_trampoline),
465                user_data,
466            );
467        }
468    }
469
470    /// Returns an owned snapshot of every option on this printer.
471    ///
472    /// Call [`Printer::acquire_details`] before this so the option table is
473    /// populated by the backend.
474    pub fn get_options_collection(&self) -> Result<OptionsCollection> {
475        // SAFETY: `cpdbGetAllOptions` returns a borrowed pointer to the
476        // printer's `options` field; the collection copies all data before
477        // returning, so we never retain the pointer.
478        unsafe {
479            let opts = ffi::cpdbGetAllOptions(self.raw.as_ptr());
480            let opts = NonNull::new(opts).ok_or_else(|| {
481                CpdbError::BackendError(
482                    "cpdbGetAllOptions returned null — call acquire_details() first".into(),
483                )
484            })?;
485            OptionsCollection::from_raw(opts.as_ptr())
486        }
487    }
488
489    // ─── Per-printer settings ────────────────────────────────────────────────
490
491    /// Reads a per-printer setting, or returns `None` when unset.
492    pub fn get_setting(&self, name: &str) -> Result<Option<String>> {
493        let c_name = CString::new(name)?;
494        // SAFETY: `cpdbGetSetting` returns a borrowed pointer into the
495        // printer's settings table; we must NOT free it.
496        unsafe {
497            let v = ffi::cpdbGetSetting(self.raw.as_ptr(), c_name.as_ptr());
498            if v.is_null() {
499                Ok(None)
500            } else {
501                util::cstr_to_string(v).map(Some)
502            }
503        }
504    }
505
506    /// Inserts or overwrites a per-printer setting.
507    pub fn add_setting(&self, name: &str, value: &str) -> Result<()> {
508        let c_name = CString::new(name)?;
509        let c_val = CString::new(value)?;
510        // SAFETY: pointers are non-null; the CStrings outlive the call.
511        unsafe { ffi::cpdbAddSettingToPrinter(self.raw.as_ptr(), c_name.as_ptr(), c_val.as_ptr()) };
512        Ok(())
513    }
514
515    /// Removes a per-printer setting. Returns `Ok(true)` when it existed.
516    pub fn clear_setting(&self, name: &str) -> Result<bool> {
517        let c_name = CString::new(name)?;
518        // SAFETY: pointers are non-null; the CString outlives the call.
519        let existed =
520            unsafe { ffi::cpdbClearSettingFromPrinter(self.raw.as_ptr(), c_name.as_ptr()) };
521        Ok(existed != 0)
522    }
523
524    // ─── Media ───────────────────────────────────────────────────────────────
525
526    /// Returns the descriptive name of a media type, if known.
527    pub fn get_media(&self, media_name: &str) -> Result<Option<String>> {
528        let c_name = CString::new(media_name)?;
529        // SAFETY: `cpdbGetMedia` returns a borrowed pointer into the
530        // printer's media table.
531        unsafe {
532            let m = ffi::cpdbGetMedia(self.raw.as_ptr(), c_name.as_ptr());
533            if m.is_null() {
534                return Ok(None);
535            }
536            let name_ptr = (*m).name;
537            if name_ptr.is_null() {
538                Ok(None)
539            } else {
540                util::cstr_to_string(name_ptr).map(Some)
541            }
542        }
543    }
544
545    /// Returns the media size for a named media type.
546    ///
547    /// Both dimensions are in hundredths of a millimetre.
548    pub fn get_media_size(&self, media_name: &str) -> Result<MediaSize> {
549        let c_name = CString::new(media_name)?;
550        let (mut width, mut length): (i32, i32) = (0, 0);
551        // SAFETY: passing valid pointers to two stack-allocated `i32`s.
552        let rc = unsafe {
553            ffi::cpdbGetMediaSize(self.raw.as_ptr(), c_name.as_ptr(), &mut width, &mut length)
554        };
555        if rc == 0 {
556            Ok(MediaSize { width, length })
557        } else {
558            Err(CpdbError::NotFound(format!("media size '{media_name}'")))
559        }
560    }
561
562    /// Returns every margin entry the backend reports for a named media type.
563    pub fn get_media_margins(&self, media_name: &str) -> Result<Margins> {
564        let c_name = CString::new(media_name)?;
565        let mut raw_margins: *mut ffi::cpdb_margin_t = std::ptr::null_mut();
566        // SAFETY: passing valid pointers; cpdb-libs writes to `raw_margins`.
567        let count = unsafe {
568            ffi::cpdbGetMediaMargins(self.raw.as_ptr(), c_name.as_ptr(), &mut raw_margins)
569        };
570        if count <= 0 || raw_margins.is_null() {
571            return Err(CpdbError::NotFound(format!("media margins '{media_name}'")));
572        }
573        let mut out = Vec::with_capacity(count as usize);
574        // SAFETY: cpdb-libs guarantees `count` valid entries at `raw_margins`.
575        for i in 0..(count as isize) {
576            let m = unsafe { &*raw_margins.offset(i) };
577            out.push(Margin {
578                top: m.top,
579                bottom: m.bottom,
580                left: m.left,
581                right: m.right,
582            });
583        }
584        Ok(Margins { entries: out })
585    }
586
587    // ─── Translations ────────────────────────────────────────────────────────
588
589    /// Asynchronously fetches translations for the given locale.
590    ///
591    /// Use [`Printer::acquire_translations_with`] if you need to be told
592    /// when the operation finishes.
593    pub fn acquire_translations(&self, locale: &str) -> Result<()> {
594        let c_locale = CString::new(locale)?;
595        // SAFETY: passing a null callback is documented as valid.
596        unsafe {
597            ffi::cpdbAcquireTranslations(
598                self.raw.as_ptr(),
599                c_locale.as_ptr(),
600                None,
601                std::ptr::null_mut(),
602            );
603        }
604        Ok(())
605    }
606
607    /// Asynchronously fetches translations, firing `completion` on success/failure.
608    ///
609    /// `completion` is invoked from cpdb-libs' D-Bus listener thread.
610    /// Panics inside the closure are caught and absorbed.
611    pub fn acquire_translations_with<F>(&self, locale: &str, completion: F) -> Result<()>
612    where
613        F: FnOnce(&Printer<'_>, bool) + Send + 'static,
614    {
615        let c_locale = CString::new(locale)?;
616        let boxed: Box<AcquireCompletion> = Box::new(completion);
617        let user_data = callbacks::into_completion_user_data(boxed);
618        // SAFETY: `user_data` was produced for this exact callback firing.
619        unsafe {
620            ffi::cpdbAcquireTranslations(
621                self.raw.as_ptr(),
622                c_locale.as_ptr(),
623                Some(callbacks::acquire_trampoline),
624                user_data,
625            );
626        }
627        Ok(())
628    }
629
630    /// Returns the human-readable label for an option in the given locale.
631    pub fn get_option_translation(&self, option: &str, locale: &str) -> Result<Option<String>> {
632        let c_opt = CString::new(option)?;
633        let c_locale = CString::new(locale)?;
634        // SAFETY: cpdb returns a `g_strdup`'d translation string we own.
635        unsafe {
636            let t =
637                ffi::cpdbGetOptionTranslation(self.raw.as_ptr(), c_opt.as_ptr(), c_locale.as_ptr());
638            translation_to_option(t)
639        }
640    }
641
642    /// Returns the human-readable label for a specific option choice.
643    pub fn get_choice_translation(
644        &self,
645        option: &str,
646        choice: &str,
647        locale: &str,
648    ) -> Result<Option<String>> {
649        let c_opt = CString::new(option)?;
650        let c_choice = CString::new(choice)?;
651        let c_locale = CString::new(locale)?;
652        // SAFETY: cpdb returns a `g_strdup`'d translation string we own.
653        unsafe {
654            let t = ffi::cpdbGetChoiceTranslation(
655                self.raw.as_ptr(),
656                c_opt.as_ptr(),
657                c_choice.as_ptr(),
658                c_locale.as_ptr(),
659            );
660            translation_to_option(t)
661        }
662    }
663
664    /// Like [`get_option_translation`], but only consults the in-memory
665    /// translation table — never falls through to the backend over D-Bus.
666    ///
667    /// Returns `None` if the option label has not been loaded yet. Call
668    /// [`Printer::get_all_translations`] (or
669    /// [`Printer::acquire_translations_with`]) first to populate the table.
670    ///
671    /// [`get_option_translation`]: Self::get_option_translation
672    pub fn get_option_translation_from_table(
673        &self,
674        option: &str,
675        locale: &str,
676    ) -> Result<Option<String>> {
677        let c_opt = CString::new(option)?;
678        let c_locale = CString::new(locale)?;
679        // SAFETY: cpdb returns a `g_strdup`'d translation string we own.
680        unsafe {
681            let t = ffi::cpdbGetOptionTranslationFromTable(
682                self.raw.as_ptr(),
683                c_opt.as_ptr(),
684                c_locale.as_ptr(),
685            );
686            translation_to_option(t)
687        }
688    }
689
690    /// Like [`get_choice_translation`], but only consults the in-memory
691    /// translation table — never falls through to the backend over D-Bus.
692    ///
693    /// [`get_choice_translation`]: Self::get_choice_translation
694    pub fn get_choice_translation_from_table(
695        &self,
696        option: &str,
697        choice: &str,
698        locale: &str,
699    ) -> Result<Option<String>> {
700        let c_opt = CString::new(option)?;
701        let c_choice = CString::new(choice)?;
702        let c_locale = CString::new(locale)?;
703        // SAFETY: cpdb returns a `g_strdup`'d translation string we own.
704        unsafe {
705            let t = ffi::cpdbGetChoiceTranslationFromTable(
706                self.raw.as_ptr(),
707                c_opt.as_ptr(),
708                c_choice.as_ptr(),
709                c_locale.as_ptr(),
710            );
711            translation_to_option(t)
712        }
713    }
714
715    /// Returns the human-readable label for an option group.
716    pub fn get_group_translation(&self, group: &str, locale: &str) -> Result<Option<String>> {
717        let c_group = CString::new(group)?;
718        let c_locale = CString::new(locale)?;
719        // SAFETY: cpdb returns a `g_strdup`'d translation string we own.
720        unsafe {
721            let t = ffi::cpdbGetGroupTranslation(
722                self.raw.as_ptr(),
723                c_group.as_ptr(),
724                c_locale.as_ptr(),
725            );
726            translation_to_option(t)
727        }
728    }
729
730    /// Synchronously populates every translation for the given locale.
731    pub fn get_all_translations(&self, locale: &str) -> Result<()> {
732        let c_locale = CString::new(locale)?;
733        // SAFETY: pointers are non-null.
734        unsafe { ffi::cpdbGetAllTranslations(self.raw.as_ptr(), c_locale.as_ptr()) };
735        Ok(())
736    }
737
738    /// Returns an owned snapshot of the printer's cached translation table.
739    ///
740    /// Call [`Printer::get_all_translations`] (or
741    /// [`Printer::acquire_translations_with`]) first to populate the table.
742    /// Returns an empty map when no translations have been loaded.
743    pub fn translations(&self) -> TranslationMap {
744        // SAFETY: dereferencing the printer struct's `locale` and
745        // `translations` fields is sound; we only read borrowed pointers.
746        let raw = self.raw.as_ptr();
747        let locale_ptr = unsafe { (*raw).locale };
748        let table = unsafe { (*raw).translations } as *mut glib_sys::GHashTable;
749
750        let locale = if locale_ptr.is_null() {
751            None
752        } else {
753            unsafe { util::cstr_to_string(locale_ptr) }.ok()
754        };
755
756        if table.is_null() {
757            return TranslationMap {
758                locale,
759                entries: HashMap::new(),
760            };
761        }
762
763        let mut entries: HashMap<String, String> = HashMap::new();
764        // SAFETY: iterator is initialised on the stack and iterated
765        // synchronously; we copy all data into owned Strings before
766        // returning. The table is not mutated during this loop.
767        unsafe {
768            let mut iter = MaybeUninit::<glib_sys::GHashTableIter>::uninit();
769            glib_sys::g_hash_table_iter_init(iter.as_mut_ptr(), table);
770            let mut iter = iter.assume_init();
771
772            let mut key: glib_sys::gpointer = std::ptr::null_mut();
773            let mut value: glib_sys::gpointer = std::ptr::null_mut();
774            while glib_sys::g_hash_table_iter_next(&mut iter, &mut key, &mut value) != 0 {
775                if key.is_null() || value.is_null() {
776                    continue;
777                }
778                let k = CStr::from_ptr(key as *const c_char)
779                    .to_string_lossy()
780                    .into_owned();
781                let v = CStr::from_ptr(value as *const c_char)
782                    .to_string_lossy()
783                    .into_owned();
784                entries.insert(k, v);
785            }
786        }
787        TranslationMap { locale, entries }
788    }
789
790    // ─── Debug helpers ───────────────────────────────────────────────────────
791
792    /// Dumps a human-readable description of this printer to stderr via
793    /// `cpdbDebugPrinter`. Useful for diagnostics; production code should
794    /// inspect the typed accessors instead.
795    pub fn debug_dump(&self) {
796        // SAFETY: pointer is non-null.
797        unsafe { ffi::cpdbDebugPrinter(self.raw.as_ptr()) };
798    }
799
800    /// Dumps the printer's basic option set to stdout via
801    /// `cpdbPrintBasicOptions`. Diagnostics-only.
802    pub fn dump_basic_options(&self) {
803        // SAFETY: pointer is non-null.
804        unsafe { ffi::cpdbPrintBasicOptions(self.raw.as_ptr()) };
805    }
806
807    // ─── Persistence ─────────────────────────────────────────────────────────
808
809    /// Serialises this printer to a file (`cpdbPicklePrinterToFile`).
810    pub fn pickle_to_file(&self, path: &str, frontend: &Frontend) -> Result<()> {
811        let c_path = CString::new(path)?;
812        // SAFETY: both pointers are non-null and live for the duration of the call.
813        unsafe {
814            ffi::cpdbPicklePrinterToFile(self.raw.as_ptr(), c_path.as_ptr(), frontend.as_raw());
815        }
816        Ok(())
817    }
818
819    /// Loads a printer that was previously serialised via [`pickle_to_file`].
820    ///
821    /// The returned printer is *owned* — it is freed when dropped.
822    ///
823    /// [`pickle_to_file`]: Self::pickle_to_file
824    pub fn load_from_file(path: &str) -> Result<Printer<'static>> {
825        let c_path = CString::new(path)?;
826        // SAFETY: `cpdbResurrectPrinterFromFile` returns an owned printer.
827        let raw = unsafe { ffi::cpdbResurrectPrinterFromFile(c_path.as_ptr()) };
828        if raw.is_null() {
829            return Err(CpdbError::NotFound(format!("pickled printer at {path}")));
830        }
831        Printer::<'static>::from_raw_owned(raw)
832    }
833}
834
835/// Converts a cpdb-libs-allocated translation string into `Option<String>`,
836/// freeing the underlying buffer.
837///
838/// # Safety
839/// `ptr` must be null or a GLib-allocated NUL-terminated string we own.
840unsafe fn translation_to_option(ptr: *mut c_char) -> Result<Option<String>> {
841    if ptr.is_null() {
842        Ok(None)
843    } else {
844        unsafe { util::cstr_to_string_and_g_free(ptr) }.map(Some)
845    }
846}
847
848impl Drop for Printer<'_> {
849    fn drop(&mut self) {
850        if self.owned {
851            // SAFETY: we own the pointer and have not aliased it.
852            unsafe { ffi::cpdbDeletePrinterObj(self.raw.as_ptr()) };
853        }
854    }
855}
856
857#[cfg(test)]
858mod tests {
859    use super::*;
860
861    #[test]
862    fn from_raw_borrowed_rejects_null() {
863        let r = Printer::from_raw_borrowed(std::ptr::null_mut());
864        assert!(matches!(r, Err(CpdbError::NullPointer)));
865    }
866
867    #[test]
868    fn from_raw_owned_rejects_null() {
869        let r = Printer::from_raw_owned(std::ptr::null_mut());
870        assert!(matches!(r, Err(CpdbError::NullPointer)));
871    }
872
873    // `load_from_file` calls `cpdbResurrectPrinterFromFile` — real FFI.
874    // Miri cannot interpret it, so skip there.
875    #[test]
876    #[cfg_attr(miri, ignore)]
877    fn load_from_nonexistent_file_returns_error() {
878        let r = Printer::load_from_file("/tmp/cpdb-rs-nonexistent-pickle-file");
879        assert!(r.is_err());
880    }
881}