Skip to main content

taglogwriter/
lib.rs

1//! Writes tag SQLite logs through the C++ writer.
2//!
3//! [`LogWriter`] is a safe wrapper over the C interface in
4//! `host/libraries/tagcore-c/taglogwriter_c.h`, which wraps the C++
5//! `SqliteTagLogWriter` that `tag-dwnld`, `tag-log-import` and the Qt
6//! applications use. Each message crosses as its protobuf wire encoding, so
7//! the database is exactly the one the C++ tools write: one writer, one
8//! schema, and no log format interpreted on the Rust side.
9//!
10//! The sequence follows a download:
11//!
12//! ```no_run
13//! use taglogwriter::{LogHeader, LogWriter};
14//! # fn read_page(index: usize) -> tagcore::proto::Ack { unimplemented!() }
15//! # let header: LogHeader = unimplemented!();
16//! let mut writer = LogWriter::create("tag.db3".as_ref(), &header.config, true)?;
17//! writer.write_header(&header)?;
18//! writer.begin_log()?;
19//! let mut index = 0;
20//! loop {
21//!     // The writer says how far each page advances the download, and
22//!     // where it ends: both depend on the tag type.
23//!     let consumed = writer.write_log(&read_page(index))?;
24//!     if consumed == 0 {
25//!         break;
26//!     }
27//!     index += consumed;
28//! }
29//! writer.end_log()?;
30//! # Ok::<(), taglogwriter::WriterError>(())
31//! ```
32//!
33//! # Linking
34//!
35//! The crate links `libtagcore_c`, which CMake builds with
36//! `-DBUILD_TAGCORE_C=ON`, through `tagcore-c-sys`, whose build script finds it
37//! through `TAGCORE_C_LIB_DIR` and which the other wrappers over the library
38//! share, so a program may use them together.
39//! On macOS it links the static archive (`tagcore_c_static`), since each
40//! application is its own bundle, and the executable needs nothing at run
41//! time. On Linux and Windows it links the shared library, one copy of which
42//! serves every tool in the install directory, and a binary must find it at
43//! run time: on Linux its package's build script sets an rpath from
44//! `DEP_TAGCORE_C_LIB_DIR`, which a direct dependent of `tagcore-c-sys` reads
45//! (see `commandline-rs/build.rs`); on Windows the
46//! DLL must sit beside the executable or on `PATH`. `TAGCORE_C_LINK=dylib`
47//! forces the shared library on macOS too.
48//!
49//! # Safety model
50//!
51//! All `unsafe` is in this file, and every call states why it is sound. Three
52//! rules carry the whole argument:
53//!
54//! - **Ownership.** A [`LogWriter`] owns exactly one C handle, never null,
55//!   closed exactly once in `Drop`. Nothing else holds the pointer.
56//! - **Borrowed buffers.** Every pointer passed in (a path, an encoded
57//!   message, an info string) points at Rust memory that lives until the call
58//!   returns. The library copies what it keeps and holds no pointer after the
59//!   call.
60//! - **Returned strings.** `tlw_last_error()` returns memory owned by the
61//!   handle, valid only until the next call on it, so it is copied out at
62//!   once, by the private `LogWriter::last_error`, which is why it is not
63//!   public.
64//!
65//! A handle may move between threads but must not be used from two at once:
66//! `LogWriter` is `Send` and not `Sync`, and every writing method takes
67//! `&mut self`.
68//!
69//! # Maintaining
70//!
71//! The declarations, imported here as `ffi`, are `tagcore_c_sys::tlw`
72//! (`host/libraries/tagcore-c-sys/src/tlw.rs`), written by hand from
73//! `host/libraries/tagcore-c/taglogwriter_c.h`; with a dozen functions,
74//! `bindgen` would cost more than it saves. When the header changes:
75//!
76//! 1. Change the matching declaration in `tlw.rs`, and the safe method over it.
77//! 2. If a function's signature or meaning changed, the header raises
78//!    `TLW_ABI_VERSION`; raise `ffi::TLW_ABI_VERSION` to match.
79//!    [`LogWriter::create`] refuses a library whose version differs, so a
80//!    stale library fails with a message instead of misbehaving.
81//! 3. Keep `ffi::TLW_CONFIG` ... `TLW_STATE` equal to the header's
82//!    `tlw_header_kind` values.
83//!
84//! The messages themselves need no such care: both sides compile them from
85//! the same `proto/` files (prost here, protoc for the library), and protobuf
86//! wire encoding is the contract between them. A field added to `proto/`
87//! reaches the C++ writer once both are rebuilt.
88//!
89//! The design, including how the library is built and why, is in
90//! `host/libraries/tagcore-c/design/architecture.md`.
91
92use std::ffi::{CStr, CString, c_int};
93use std::fmt;
94use std::path::Path;
95use std::ptr::NonNull;
96
97use prost::Message;
98use tagcore::proto::{Ack, CalibrationConstants, Config, State, TagInfo};
99
100/// The raw C interface, in `tagcore-c-sys`, which links the library.
101use tagcore_c_sys::tlw as ffi;
102
103
104/// Why a writer call failed: the C++ writer's own message, or the
105/// interface's (a message that did not parse, a version mismatch).
106#[derive(Debug, Clone, PartialEq, Eq)]
107pub struct WriterError(pub String);
108
109impl fmt::Display for WriterError {
110    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
111        f.write_str(&self.0)
112    }
113}
114
115impl std::error::Error for WriterError {}
116
117/// Sets how much the C++ writer logs to stderr: everything at DEBUG when
118/// `enabled`, errors only otherwise, as the C++ tools' `-d` does. It logs at
119/// DEBUG until this is called. Process-wide.
120pub fn set_debug(enabled: bool) {
121    // SAFETY: no preconditions; it sets one integer in the library.
122    unsafe { ffi::tlw_set_debug(c_int::from(enabled)) }
123}
124
125/// Everything a log records about the tag before its data: the C++
126/// `TagLogHeader`.
127///
128/// A live download fills it with the same calls, in the same order, as the
129/// C++ `readTagLogHeader()`: Config, TagInfo, every stored set of
130/// calibration constants from index 0, then the state log in chunks.
131#[derive(Debug, Clone, Default, PartialEq)]
132pub struct LogHeader {
133    /// The tag's configuration. Its tag type chooses the schema.
134    pub config: Config,
135    /// The tag's identity; its UUID is written to the info table.
136    pub info: TagInfo,
137    /// Stored calibration constants, oldest first.
138    pub calibration: Vec<CalibrationConstants>,
139    /// State history, in the order the tag returned it.
140    pub states: Vec<State>,
141}
142
143/// One SQLite log being written by the C++ writer.
144///
145/// Create it, write the header, then the pages between
146/// [`begin_log`](Self::begin_log) and [`end_log`](Self::end_log). Dropping
147/// it closes the database; pages written since `begin_log` without an
148/// `end_log` are rolled back.
149pub struct LogWriter {
150    /// The C handle; never null, and owned: closed exactly once, in `Drop`.
151    raw: NonNull<ffi::TlwWriter>,
152}
153
154// SAFETY: the C handle carries no thread affinity (no thread-local state, no
155// thread-bound SQLite connection mode); it requires only that one thread use
156// it at a time, which `&mut self` on every writing call guarantees. It is
157// not `Sync`.
158unsafe impl Send for LogWriter {}
159
160impl LogWriter {
161    /// Creates a log at `path` for a tag with `config`, replacing an
162    /// existing file when `replace` is true.
163    ///
164    /// # Errors
165    ///
166    /// When the library was built from a different interface version, the
167    /// path is not UTF-8 or contains a NUL, or the database cannot be
168    /// created (the writer's message says why).
169    pub fn create(path: &Path, config: &Config, replace: bool) -> Result<Self, WriterError> {
170        // SAFETY: no arguments, no preconditions.
171        let version = unsafe { ffi::tlw_abi_version() };
172        if version != ffi::TLW_ABI_VERSION {
173            return Err(WriterError(format!(
174                "libtagcore_c has interface version {version}; this program needs {}",
175                ffi::TLW_ABI_VERSION
176            )));
177        }
178        let path = path
179            .to_str()
180            .ok_or_else(|| WriterError(format!("Path is not UTF-8: {}", path.display())))?;
181        let path = CString::new(path)
182            .map_err(|_| WriterError(format!("Path contains a NUL byte: {path}")))?;
183        let config = config.encode_to_vec();
184        // SAFETY: `path` is a NUL-terminated string and `config` a buffer of
185        // `config.len()` bytes, both live for the call; the library copies
186        // what it keeps.
187        let raw = unsafe {
188            ffi::tlw_open(
189                path.as_ptr(),
190                config.as_ptr(),
191                config.len(),
192                c_int::from(replace),
193            )
194        };
195        let raw = NonNull::new(raw).ok_or_else(|| WriterError("Out of memory".into()))?;
196        let writer = Self { raw };
197        // SAFETY: `raw` is a live handle from tlw_open().
198        if unsafe { ffi::tlw_is_open(writer.raw.as_ptr()) } == 0 {
199            return Err(writer.last_error());
200        }
201        Ok(writer)
202    }
203
204    /// Writes `header`: its parts, then the info, calibration and state
205    /// tables and the tag's data tables. Call once, first.
206    ///
207    /// The parts are passed one at a time (`tlw_header_add`) and written
208    /// together (`tlw_write_header`), so the C side never needs a header
209    /// message of its own and `proto/` gains nothing for this interface. The
210    /// parts accumulate in the handle: a second call would pass them again
211    /// and the writer would fail creating tables that exist.
212    ///
213    /// # Errors
214    ///
215    /// When a part is rejected or the writer fails, for example because the
216    /// header's tag type differs from the one given to
217    /// [`create`](Self::create).
218    pub fn write_header(&mut self, header: &LogHeader) -> Result<(), WriterError> {
219        self.add_part(ffi::TLW_CONFIG, &header.config)?;
220        self.add_part(ffi::TLW_INFO, &header.info)?;
221        for constants in &header.calibration {
222            self.add_part(ffi::TLW_CALIBRATION, constants)?;
223        }
224        for state in &header.states {
225            self.add_part(ffi::TLW_STATE, state)?;
226        }
227        // SAFETY: `raw` is a live handle, used from this thread only.
228        self.check(unsafe { ffi::tlw_write_header(self.raw.as_ptr()) })
229    }
230
231    /// Adds a row to the info table, after the header.
232    ///
233    /// # Errors
234    ///
235    /// When a string contains a NUL byte, or the writer fails.
236    pub fn write_info(&mut self, field: &str, value: &str) -> Result<(), WriterError> {
237        let field = CString::new(field)
238            .map_err(|_| WriterError(format!("Info field contains a NUL byte: {field}")))?;
239        let value = CString::new(value)
240            .map_err(|_| WriterError("Info value contains a NUL byte".into()))?;
241        // SAFETY: both strings are NUL-terminated and live for the call.
242        self.check(unsafe {
243            ffi::tlw_write_info(self.raw.as_ptr(), field.as_ptr(), value.as_ptr())
244        })
245    }
246
247    /// Starts the data log: the pages that follow are written in one
248    /// transaction, committed by [`end_log`](Self::end_log).
249    ///
250    /// # Errors
251    ///
252    /// When the transaction cannot be started.
253    pub fn begin_log(&mut self) -> Result<(), WriterError> {
254        // SAFETY: `raw` is a live handle, used from this thread only.
255        self.check(unsafe { ffi::tlw_begin_log(self.raw.as_ptr()) })
256    }
257
258    /// Decodes and writes one data-log page, the Ack as the tag returned it.
259    ///
260    /// Returns how many log records the page consumed, which advances the
261    /// index of the next page to request, or 0 at the end of the log. Both
262    /// depend on the tag type, which is why the writer decides them, and why
263    /// a download must call this synchronously, page by page: the next
264    /// request cannot be formed until it returns.
265    ///
266    /// # Errors
267    ///
268    /// When the writer rejects the page or the database write fails.
269    pub fn write_log(&mut self, ack: &Ack) -> Result<usize, WriterError> {
270        let bytes = ack.encode_to_vec();
271        // SAFETY: `bytes` is a buffer of `bytes.len()` bytes, live for the
272        // call; `raw` is a live handle, used from this thread only.
273        let consumed =
274            unsafe { ffi::tlw_write_log(self.raw.as_ptr(), bytes.as_ptr(), bytes.len()) };
275        usize::try_from(consumed).map_err(|_| self.last_error())
276    }
277
278    /// Commits the data log.
279    ///
280    /// # Errors
281    ///
282    /// When the commit fails.
283    pub fn end_log(&mut self) -> Result<(), WriterError> {
284        // SAFETY: `raw` is a live handle, used from this thread only.
285        self.check(unsafe { ffi::tlw_end_log(self.raw.as_ptr()) })
286    }
287
288    /// Passes one header part to the writer.
289    ///
290    /// # Errors
291    ///
292    /// When the library cannot parse the encoding (a version mismatch).
293    fn add_part(&mut self, kind: c_int, message: &impl Message) -> Result<(), WriterError> {
294        let bytes = message.encode_to_vec();
295        // SAFETY: `bytes` is a buffer of `bytes.len()` bytes, live for the
296        // call; `raw` is a live handle, used from this thread only.
297        self.check(unsafe {
298            ffi::tlw_header_add(self.raw.as_ptr(), kind, bytes.as_ptr(), bytes.len())
299        })
300    }
301
302    /// Turns a C status (1 success, 0 failure) into a `Result`.
303    ///
304    /// # Errors
305    ///
306    /// When `status` is not 1, with the handle's last error.
307    fn check(&self, status: c_int) -> Result<(), WriterError> {
308        if status == 1 {
309            Ok(())
310        } else {
311            Err(self.last_error())
312        }
313    }
314
315    /// The handle's last error, copied out of the library.
316    fn last_error(&self) -> WriterError {
317        // SAFETY: `raw` is a live handle; the returned string is
318        // NUL-terminated and valid until the next call on the handle, and is
319        // copied before then.
320        let text = unsafe { CStr::from_ptr(ffi::tlw_last_error(self.raw.as_ptr())) };
321        let text = text.to_string_lossy();
322        WriterError(if text.is_empty() {
323            "The log writer failed without saying why".into()
324        } else {
325            text.into_owned()
326        })
327    }
328}
329
330impl Drop for LogWriter {
331    fn drop(&mut self) {
332        // SAFETY: `raw` came from tlw_open() and is closed only here.
333        unsafe { ffi::tlw_close(self.raw.as_ptr()) }
334    }
335}