Skip to main content

recutils_rs/
lib.rs

1//! Safe(r) wrapper around GNU recutils' `librec`.
2//!
3//! The raw bindings are still available under [`ffi`] for anything the safe
4//! layer doesn't yet cover.
5
6#![allow(non_upper_case_globals)]
7#![allow(non_camel_case_types)]
8#![allow(non_snake_case)]
9#![cfg_attr(docsrs, feature(doc_cfg))]
10
11pub mod ffi {
12    #![allow(dead_code)]
13    include!(concat!(env!("OUT_DIR"), "/bindings.rs"));
14}
15
16mod db;
17mod record;
18mod rset;
19mod selection_expression;
20
21#[cfg(feature = "arrow")]
22#[cfg_attr(docsrs, doc(cfg(feature = "arrow")))]
23pub mod arrow;
24
25pub use db::Db;
26pub use record::{FieldRef, Fields, Record, RecordRef};
27pub use rset::{OwnedRset, Records, Rset};
28pub use selection_expression::SelectionExpression;
29
30use std::ffi::CString;
31use std::fmt;
32use std::sync::Once;
33
34#[derive(Debug)]
35pub struct Error(String);
36
37impl Error {
38    pub(crate) fn new(msg: impl Into<String>) -> Self {
39        Error(msg.into())
40    }
41}
42
43impl fmt::Display for Error {
44    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
45        f.write_str(&self.0)
46    }
47}
48
49impl std::error::Error for Error {}
50
51pub(crate) fn cstring(s: &str, what: &str) -> Result<CString, Error> {
52    CString::new(s).map_err(|_| Error::new(format!("{what} contains an interior NUL byte")))
53}
54
55pub(crate) fn ensure_init() {
56    static ONCE: Once = Once::new();
57    ONCE.call_once(|| unsafe { ffi::rec_init() });
58}
59
60#[cfg(test)]
61mod tests {
62    use super::*;
63
64    const SAMPLE: &str = "\
65%rec: Book
66
67Title: Refactoring
68Author: Martin Fowler
69
70Title: Domain-Driven Design
71Author: Eric Evans
72";
73
74    #[test]
75    fn parse_and_count() {
76        let mut db = Db::parse_str(SAMPLE).unwrap();
77        assert_eq!(db.num_rsets(), 1);
78        let rset = db.rset_by_type("Book").unwrap();
79        assert_eq!(rset.num_records(), 2);
80    }
81
82    #[test]
83    fn selection_expression_filters() {
84        let mut db = Db::parse_str(SAMPLE).unwrap();
85        let rset = db.rset_by_type("Book").unwrap();
86        let selection_expression = SelectionExpression::compile("Author = 'Eric Evans'", false).unwrap();
87        let hits = rset.records().filter(|r| selection_expression.matches(r)).count();
88        assert_eq!(hits, 1);
89    }
90
91    #[test]
92    fn set_field_updates_matching() {
93        let mut db = Db::parse_str(SAMPLE).unwrap();
94        let selection_expression = SelectionExpression::compile("Author = 'Eric Evans'", false).unwrap();
95        let rset = db.rset_by_type("Book").unwrap();
96        let mut updated = 0;
97        for mut r in rset.records().filter(|r| selection_expression.matches(r)) {
98            assert!(r.set_field("Author", "Evans, Eric").unwrap());
99            updated += 1;
100        }
101        assert_eq!(updated, 1);
102        let s = db.to_rec_string().unwrap();
103        assert!(s.contains("Evans, Eric"));
104        assert!(!s.contains("Author: Eric Evans"));
105    }
106
107    #[test]
108    fn remove_matching_drops_records() {
109        let mut db = Db::parse_str(SAMPLE).unwrap();
110        let selection_expression = SelectionExpression::compile("Author = 'Martin Fowler'", false).unwrap();
111        let removed = {
112            let mut rset = db.rset_by_type("Book").unwrap();
113            rset.remove_matching(|r| selection_expression.matches(r))
114        };
115        assert_eq!(removed, 1);
116        assert_eq!(db.rset_by_type("Book").unwrap().num_records(), 1);
117    }
118
119    #[test]
120    fn build_db_from_scratch() {
121        let mut db = Db::new();
122        let mut rset = OwnedRset::new();
123
124        let mut desc = Record::new();
125        desc.append_field("%rec", "Book").unwrap();
126        desc.append_field("%type", "Year int").unwrap();
127        desc.append_field("%mandatory", "Title").unwrap();
128        rset.set_descriptor(desc);
129
130        let mut r = Record::new();
131        r.append_field("Title", "TDD").unwrap();
132        r.append_field("Year", "2002").unwrap();
133        rset.append_record(r).unwrap();
134
135        db.append_rset(rset).unwrap();
136        let text = db.to_rec_string().unwrap();
137        assert!(text.contains("%rec: Book"));
138        assert!(text.contains("%type: Year int"));
139        assert!(text.contains("%mandatory: Title"));
140        assert!(text.contains("Title: TDD"));
141
142        // Round-trip: librec parses what librec wrote.
143        let mut db2 = Db::parse_str(&text).unwrap();
144        let rset2 = db2.rset_by_type("Book").unwrap();
145        assert_eq!(rset2.num_records(), 1);
146    }
147
148    #[test]
149    fn append_round_trip() {
150        let mut db = Db::parse_str(SAMPLE).unwrap();
151        {
152            let mut rset = db.rset_by_type("Book").unwrap();
153            let mut rec = Record::new();
154            rec.append_field("Title", "TDD").unwrap();
155            rec.append_field("Author", "Kent Beck").unwrap();
156            rset.append_record(rec).unwrap();
157        }
158        let serialized = db.to_rec_string().unwrap();
159        assert!(serialized.contains("Kent Beck"));
160        let mut db2 = Db::parse_str(&serialized).unwrap();
161        assert_eq!(db2.rset_by_type("Book").unwrap().num_records(), 3);
162    }
163}