content_line_writer/
lib.rs

1// Copyright 2024-2025 Hugo Osvaldo Barrera
2//
3// SPDX-License-Identifier: ISC
4#![deny(clippy::pedantic)]
5#![deny(clippy::unwrap_used)]
6#![warn(missing_docs)]
7
8//! This library provides [`ContentLineWriter`], which writes content lines for
9//! [iCalendar] or [vCard] files.
10//!
11//! [iCalendar]: https://www.rfc-editor.org/rfc/rfc5545
12//! [vCard]: https://www.rfc-editor.org/rfc/rfc6350
13//!
14//! The type state pattern is used to ensure that lines are not incomplete; a reference to the
15//! writer can only be recovered by writing a content line to completion.
16//!
17//! # Example
18//!
19//! The following example serialises into a [`Vec`]. Any type implementing [`std::io::Write`] is
20//! usable as an output.
21//!
22//! ```
23//! use content_line_writer::ContentLineWriter;
24//!
25//! let buffer = Vec::<u8>::new();
26//! let mut writer = ContentLineWriter::new(buffer);
27//!
28//! writer = writer.start_line("BEGIN")
29//!     .unwrap()
30//!     .value("VEVENT")
31//!     .unwrap();
32//!
33//! let buffer = writer.into_inner();
34//! let s = String::from_utf8(buffer).unwrap();
35//!
36//! assert_eq!("BEGIN:VEVENT\r\n", s);
37//! ```
38use std::io::Write;
39
40use validate::{is_valid_name, is_valid_param_value, is_valid_value};
41
42pub mod error;
43mod validate;
44
45/// Writer which encodes content lines into a iCalendar or vCard files.
46///
47/// This writer takes care of folding lines and the lower-level details of formatting content
48/// lines. Optionally, it can validate the syntax of the input data. See
49/// [`ContentLineWriter::start_line`].
50///
51/// This writer makes frequent and small calls to the output's [`std::io::Write::write`]. It is
52/// generally advised to use [`std::io::BufWriter`] or a similar buffering writer.
53#[derive(Debug)]
54pub struct ContentLineWriter<W: Write> {
55    output: W,
56    /// Length of the current raw output line.
57    line: usize,
58}
59
60impl<W: Write> ContentLineWriter<W> {
61    /// Create a new instance which will write processed data into `output`.
62    pub fn new(output: W) -> ContentLineWriter<W> {
63        ContentLineWriter { output, line: 0 }
64    }
65
66    /// Start a new line, with the given `name`.
67    ///
68    /// The returned handle allows adding parameters and a value to this line before returning this
69    /// writer instance.
70    ///
71    /// # Errors
72    ///
73    /// Returns an error if either the is invalid or if an IO error occurs.
74    pub fn start_line(mut self, name: &str) -> std::io::Result<LineWithName<W>> {
75        is_valid_name(name)?;
76
77        self.write_folding(name)?;
78
79        Ok(LineWithName(self))
80    }
81
82    /// Write a new value for the current line.
83    ///
84    /// Must only be called after having written a name or full param.
85    fn write_value(mut self, value: &str) -> std::io::Result<Self> {
86        is_valid_value(value)?;
87
88        self.write_folding(":")?;
89        self.write_folding(value)?;
90        self.output.write_all(b"\r\n")?;
91
92        self.line = 0;
93        Ok(self)
94    }
95
96    fn write_param(&mut self, param_name: &str, param_value: &str) -> std::io::Result<()> {
97        is_valid_name(param_name)?;
98        is_valid_param_value(param_value)?;
99
100        self.write_folding(";")?;
101        self.write_folding(param_name)?;
102        self.write_folding("=")?;
103        // TODO: quote if applicable
104        self.write_folding(param_value)?;
105        Ok(())
106    }
107
108    /// Write continuous text, folding as necessary.
109    #[inline]
110    fn write_folding(&mut self, data: &str) -> std::io::Result<()> {
111        for c in data.chars() {
112            self.write_folding_char(c)?;
113            self.line += 1;
114        }
115        Ok(())
116    }
117
118    /// Write a single character, folding the line if necessary.
119    fn write_folding_char(&mut self, c: char) -> std::io::Result<()> {
120        if self.line + c.len_utf8() >= 75 {
121            // Start a continuation line.
122            self.output.write_all(b"\r\n ")?;
123            self.line = 1;
124        }
125        let mut buf = [0u8; 4];
126        let encoded = c.encode_utf8(&mut buf);
127        self.output.write_all(encoded.as_bytes())?;
128        Ok(())
129    }
130
131    /// Return the inner, wrapper `output` instance.
132    pub fn into_inner(self) -> W {
133        self.output
134    }
135}
136
137impl<W: std::io::Write> From<W> for ContentLineWriter<W> {
138    fn from(output: W) -> Self {
139        ContentLineWriter { output, line: 0 }
140    }
141}
142
143/// An incomplete line which only has a name.
144///
145/// See: [`ContentLineWriter::start_line`].
146#[derive(Debug)]
147pub struct LineWithName<W: std::io::Write>(ContentLineWriter<W>);
148
149impl<W: std::io::Write> LineWithName<W> {
150    /// Add a parameter to the current content line.
151    ///
152    /// # Errors
153    ///
154    /// Returns an error if either the name or value is invalid or if an IO error occurs.
155    pub fn with_param(
156        mut self,
157        param_name: &str,
158        param_value: &str,
159    ) -> std::io::Result<LineWithParam<W>> {
160        self.0.write_param(param_name, param_value)?;
161        Ok(LineWithParam(self.0))
162    }
163
164    /// Add multiple parameters to the current content line.
165    ///
166    /// # Errors
167    ///
168    /// Returns an error if a name or value is invalid or if an IO error occurs.
169    pub fn with_params<'p>(
170        mut self,
171        params: impl Iterator<Item = (&'p str, &'p str)>,
172    ) -> std::io::Result<LineWithParam<W>> {
173        for (name, value) in params {
174            self.0.write_param(name, value)?;
175        }
176        Ok(LineWithParam(self.0))
177    }
178
179    /// Write a vale for the current content line.
180    ///
181    /// Returns the original [`ContentLineWriter`] instance.
182    ///
183    /// # Errors
184    ///
185    /// Returns an error if the value is invalid or if an IO error occurs.
186    #[inline]
187    pub fn value(self, value: &str) -> std::io::Result<ContentLineWriter<W>> {
188        self.0.write_value(value)
189    }
190
191    /// Continue writing this line without any parameters.
192    ///
193    /// This is useful for scenarios where parameters are conditional.
194    ///
195    /// # Example
196    ///
197    /// ```rust
198    /// # use content_line_writer::ContentLineWriter;
199    /// # let buffer = Vec::<u8>::new();
200    /// # let condition = false;
201    /// # let mut writer = ContentLineWriter::new(buffer);
202    ///
203    /// let line = writer.start_line("DTSTART").unwrap();
204    ///
205    /// let want_value = if (condition) {
206    ///     line.with_param("TZID", "Atlantic/Reykjavik").unwrap().end_params()
207    /// } else {
208    ///     line.without_params()
209    /// };
210    /// writer = want_value.value("20250106T180000").unwrap()
211    /// ```
212    ///
213    /// # See also
214    ///
215    /// [`LineWithParam::end_params`]
216    #[inline]
217    pub fn without_params(self) -> LineWantValue<W> {
218        LineWantValue(self.0)
219    }
220}
221
222/// An incomplete line which has a name and at least one parameter.
223#[derive(Debug)]
224pub struct LineWithParam<W: std::io::Write>(ContentLineWriter<W>);
225
226impl<W: std::io::Write> LineWithParam<W> {
227    /// Append an additional value to the last parameter.
228    ///
229    /// Returns the same handle.
230    ///
231    /// # Errors
232    ///
233    /// Returns an error if the parameter value is invalid or if an IO error occurs.
234    pub fn add_param_value(mut self, param_value: &str) -> std::io::Result<LineWithParam<W>> {
235        is_valid_param_value(param_value)?;
236        self.0.write_folding(",")?;
237        self.0.write_folding(param_value)?;
238        Ok(self)
239    }
240
241    /// Add another parameter to the current content line.
242    ///
243    /// Returns a handle to add further values to the new parameter.
244    ///
245    /// # Errors
246    ///
247    /// Returns an error if the parameter name or value are invalid or if an IO error occurs.
248    pub fn with_param(
249        mut self,
250        param_name: &str,
251        param_value: &str,
252    ) -> std::io::Result<LineWithParam<W>> {
253        self.0.write_param(param_name, param_value)?;
254        Ok(self)
255    }
256
257    /// Write a vale for the current content line.
258    ///
259    /// Returns the original [`ContentLineWriter`] instance.
260    ///
261    /// # Errors
262    ///
263    /// Returns an error if the value is invalid or if an IO error occurs.
264    #[inline]
265    pub fn value(self, value: &str) -> std::io::Result<ContentLineWriter<W>> {
266        self.0.write_value(value)
267    }
268
269    /// End parameters for this line
270    ///
271    /// This is useful for scenarios where parameters are conditional.
272    ///
273    /// See [`LineWithName::without_params`] for a usage example.
274    #[inline]
275    pub fn end_params(self) -> LineWantValue<W> {
276        LineWantValue(self.0)
277    }
278}
279
280/// An incomplete line which has a name and is missing a value.
281#[derive(Debug)]
282pub struct LineWantValue<W: std::io::Write>(ContentLineWriter<W>);
283
284impl<W: std::io::Write> LineWantValue<W> {
285    /// Write a vale for the current content line.
286    ///
287    /// Returns the original [`ContentLineWriter`] instance.
288    ///
289    /// # Errors
290    ///
291    /// Returns an error if the value is invalid or if an IO error occurs.
292    #[inline]
293    pub fn value(self, value: &str) -> std::io::Result<ContentLineWriter<W>> {
294        self.0.write_value(value)
295    }
296}
297
298// TODO: write some tests with examples from vparser
299// TODO: write round-trip tests with vparser
300
301#[cfg(test)]
302mod tests {
303    use crate::{
304        error::{NameError, ParamValueError},
305        ContentLineWriter,
306    };
307
308    #[test]
309    fn test_simple_case() {
310        let mut output = Vec::<u8>::new();
311        ContentLineWriter::new(&mut output)
312            .start_line("BEGIN")
313            .unwrap()
314            .value("VEVENT")
315            .unwrap();
316        let s = String::from_utf8(output).unwrap();
317        assert_eq!(s, "BEGIN:VEVENT\r\n");
318    }
319
320    #[test]
321    fn test_write_line_empty() {
322        let mut output = Vec::new();
323        let err = ContentLineWriter::new(&mut output)
324            .start_line("")
325            .unwrap_err();
326        assert_eq!(err.downcast::<NameError>().unwrap(), NameError::InvalidName);
327    }
328
329    #[test]
330    fn test_write_empty_value() {
331        let mut output = Vec::new();
332        ContentLineWriter::new(&mut output)
333            .start_line("NAME")
334            .unwrap()
335            .value("")
336            .unwrap();
337        let s = String::from_utf8(output).unwrap();
338        assert_eq!(s, "NAME:\r\n");
339    }
340
341    #[test]
342    fn test_write_invalid_vendor_id() {
343        let mut output = Vec::new();
344        let err = ContentLineWriter::new(&mut output)
345            .start_line("X-h-u")
346            .unwrap_err();
347        assert_eq!(
348            err.downcast::<NameError>().unwrap(),
349            NameError::InvalidVendorIdLength(1)
350        );
351    }
352
353    #[test]
354    fn test_write_valid_vendor_id() {
355        let mut output = Vec::new();
356        ContentLineWriter::new(&mut output)
357            .start_line("X-pimutils-test")
358            .unwrap()
359            .value("something")
360            .unwrap();
361        let s = String::from_utf8(output).unwrap();
362        assert_eq!(s, "X-pimutils-test:something\r\n");
363    }
364
365    #[test]
366    fn test_write_line_single_param_empty_value() {
367        let mut output = Vec::new();
368        ContentLineWriter::new(&mut output)
369            .start_line("name")
370            .unwrap()
371            .with_param("key", "")
372            .unwrap()
373            .value("value")
374            .unwrap();
375        dbg!(std::str::from_utf8(&output).unwrap());
376        assert_eq!(output, b"name;key=:value\r\n");
377    }
378
379    #[test]
380    fn test_write_line_single_param() {
381        let mut output = Vec::new();
382        ContentLineWriter::new(&mut output)
383            .start_line("name")
384            .unwrap()
385            .with_param("key", "value")
386            .unwrap()
387            .value("value")
388            .unwrap();
389        assert_eq!(output, b"name;key=value:value\r\n");
390    }
391
392    #[test]
393    fn test_write_line_multiple_params() {
394        let mut output = Vec::new();
395        ContentLineWriter::new(&mut output)
396            .start_line("name")
397            .unwrap()
398            .with_param("key1", "value1")
399            .unwrap()
400            .with_param("key2", "value2")
401            .unwrap()
402            .value("value")
403            .unwrap();
404        let s = String::from_utf8(output).unwrap();
405        assert_eq!(s, "name;key1=value1;key2=value2:value\r\n");
406    }
407
408    #[test]
409    fn test_write_line_with_parms() {
410        let params = vec![("key1", "value1"), ("key2", "value2")];
411        let mut output = Vec::new();
412        ContentLineWriter::new(&mut output)
413            .start_line("name")
414            .unwrap()
415            .with_params(params.into_iter())
416            .unwrap()
417            .value("value")
418            .unwrap();
419        let s = String::from_utf8(output).unwrap();
420        assert_eq!(s, "name;key1=value1;key2=value2:value\r\n");
421    }
422
423    #[test]
424    fn test_write_line_single_param_multiple_values() {
425        let mut output = Vec::new();
426        ContentLineWriter::new(&mut output)
427            .start_line("name")
428            .unwrap()
429            .with_param("key1", "value1")
430            .unwrap()
431            .add_param_value("value2")
432            .unwrap()
433            .value("value")
434            .unwrap();
435        let s = String::from_utf8(output).unwrap();
436        assert_eq!(s, "name;key1=value1,value2:value\r\n");
437    }
438
439    #[test]
440    fn test_write_line_quoted_value() {
441        let mut output = Vec::new();
442        let generator = ContentLineWriter::new(&mut output);
443        generator
444            .start_line("name")
445            .unwrap()
446            .value("\"quoted value\"")
447            .unwrap();
448        assert_eq!(output, b"name:\"quoted value\"\r\n");
449    }
450
451    #[test]
452    fn test_write_line_single_param_quoted() {
453        let mut output = Vec::new();
454        let generator = ContentLineWriter::new(&mut output);
455        generator
456            .start_line("name")
457            .unwrap()
458            .with_param("key", "\"quoted value\"")
459            .unwrap()
460            .value("value")
461            .unwrap();
462        assert_eq!(output, b"name;key=\"quoted value\":value\r\n");
463    }
464
465    #[test]
466    fn test_write_line_single_param_invalid_quoted() {
467        let mut output = Vec::new();
468        let generator = ContentLineWriter::new(&mut output);
469        let err = generator
470            .start_line("name")
471            .unwrap()
472            .with_param("key", "\"invalid quoted value")
473            .unwrap_err();
474        assert_eq!(
475            err.downcast::<ParamValueError>().unwrap(),
476            ParamValueError::MissingClosingQuote
477        );
478    }
479
480    #[test]
481    fn test_write_line_folding() {
482        let mut output = Vec::new();
483        let generator = ContentLineWriter::new(&mut output);
484        let value =
485            "This is a very long line, which in fact has over 75 characters and needs to be folded";
486        generator
487            .start_line("DESCRIPTION")
488            .unwrap()
489            .value(value)
490            .unwrap();
491        let s = String::from_utf8(output).unwrap();
492        let expected = concat!(
493            "DESCRIPTION:This is a very long line, which in fact has over 75 characters\r\n",
494            "  and needs to be folded\r\n"
495        );
496        assert_eq!(s, expected);
497    }
498
499    #[test]
500    fn test_write_line_escaped_newline() {
501        let mut output = Vec::new();
502        let generator = ContentLineWriter::new(&mut output);
503        generator
504            .start_line("DESCRIPTION")
505            .unwrap()
506            .value("Test with\\nnewline")
507            .unwrap();
508        let s = String::from_utf8(output).unwrap();
509        assert_eq!(s, "DESCRIPTION:Test with\\nnewline\r\n");
510    }
511
512    #[test]
513    #[ignore = "We don't handle escaping, this needs an 'unchecked' API."]
514    fn test_write_line_escape_at_eol() {
515        let mut output = Vec::new();
516        let generator = ContentLineWriter::new(&mut output);
517        let value = format!("{}\\nyy", "x".repeat(67));
518        generator
519            .start_line("BEGIN")
520            .unwrap()
521            .value(&value)
522            .unwrap();
523        let s = String::from_utf8(output).unwrap();
524        let expected = concat!(
525            "BEGIN:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n\r\n",
526            " yy\r\n"
527        );
528        assert_eq!(s, expected);
529    }
530}