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}