tpnote_lib/content.rs
1//! Self referencing data structures to store the note's
2//! content as a raw string.
3use self_cell::self_cell;
4use std::fmt;
5use std::fmt::Debug;
6use std::fs::create_dir_all;
7use std::fs::read_to_string;
8use std::fs::OpenOptions;
9use std::io::Write;
10use std::path::Path;
11use substring::Substring;
12
13use crate::error::InputStreamError;
14
15/// As all text before the header marker `"---"` is ignored, this
16/// constant limits the maximum number of characters that are skipped
17/// before the header starts. In other words: the header
18/// must start within the first `BEFORE_HEADER_MAX_IGNORED_CHARS`.
19const BEFORE_HEADER_MAX_IGNORED_CHARS: usize = 1024;
20
21/// This trait represents Tp-Note content.
22/// The content is devided into header and body.
23/// The header is the YAML meta data describing the body.
24/// In some cases the header might be empty, e.g. when the data comes from
25/// the clipboard (the `txt_clipboard` data might come with a header).
26/// The body is flat UTF-8 markup formatted text, e.g. in
27/// Markdown or in ReStructuredText.
28/// A special case is HTML data in the body, originating from the HTML
29/// clipboard. Here, the body always starts with an HTML start tag
30/// (for details see the `html::HtmlStream` trait) and the header is always
31/// empty.
32///
33/// The trait provides cheap access to the header with `header()`, the body
34/// with `body()`, and the whole raw text with `as_str()`.
35/// Implementers should cache the `header()` and `body()` function results in
36/// oder to keep these as cheap as possible.
37///
38/// ```rust
39/// use tpnote_lib::content::Content;
40/// use tpnote_lib::content::ContentString;
41/// let input = "---\ntitle: \"My note\"\n---\nMy body";
42/// let c = ContentString::from(String::from(input));
43///
44/// assert_eq!(c.header(), r#"title: "My note""#);
45/// assert_eq!(c.body(), r#"My body"#);
46/// assert_eq!(c.as_str(), input);
47///
48/// // A test without front matter leads to an empty header:
49/// let c = ContentString::from(String::from("No header"));
50///
51/// assert_eq!(c.header(), "");
52/// assert_eq!(c.body(), "No header");
53/// assert_eq!(c.as_str(), "No header");
54/// ```
55///
56/// The `Content` trait allows to plug in your own storage back end if
57/// `ContentString` does not suit you. In addtion to the example shown below,
58/// you can overwrite `Content::open()` and `Content::save_as()` as well.
59///
60/// ```rust
61/// use tpnote_lib::content::Content;
62/// use std::string::String;
63///
64/// #[derive(Debug, Eq, PartialEq, Default)]
65/// struct MyString(String);
66/// impl Content for MyString {
67/// /// This sample implementation may be too expensive.
68/// /// Better precalculate this in `Self::from()`.
69/// fn header(&self) -> &str {
70/// Self::split(&self.as_str()).0
71/// }
72/// fn body(&self) -> &str {
73/// Self::split(&self.as_str()).1
74/// }
75/// }
76///
77/// impl From<String> for MyString {
78/// fn from(input: String) -> Self {
79/// Self(input)
80/// }
81/// }
82///
83/// impl AsRef<str> for MyString {
84/// fn as_ref(&self) -> &str {
85/// &self.0
86/// }
87/// }
88///
89/// let input = "---\ntitle: \"My note\"\n---\nMy body";
90/// let s = MyString::from(input.to_string());
91///
92/// assert_eq!(s.header(), r#"title: "My note""#);
93/// assert_eq!(s.body(), r#"My body"#);
94/// assert_eq!(s.as_str(), input);
95/// ```
96pub trait Content: AsRef<str> + Debug + Eq + PartialEq + Default + From<String> {
97 /// Reads the file at `path` and stores the content
98 /// `Content`. Possible `\r\n` are replaced by `\n`.
99 /// This trait has a default implementation, the empty content.
100 ///
101 /// ```rust
102 /// use tpnote_lib::content::Content;
103 /// use tpnote_lib::content::ContentString;
104 /// use std::env::temp_dir;
105 ///
106 /// // Prepare test.
107 /// let raw = "---\ntitle: \"My note\"\n---\nMy body";
108 /// let notefile = temp_dir().join("20221030-hello -- world.md");
109 /// let _ = std::fs::write(¬efile, raw.as_bytes());
110 ///
111 /// // Start test.
112 /// let c = ContentString::open(¬efile).unwrap();
113 ///
114 /// assert_eq!(c.header(), r#"title: "My note""#);
115 /// assert_eq!(c.body(), "My body");
116 /// ```
117 fn open(path: &Path) -> Result<Self, std::io::Error>
118 where
119 Self: Sized,
120 {
121 Ok(Self::from_string_with_cr(read_to_string(path)?))
122 }
123
124 /// Constructor that reads a structured document with a YAML header
125 /// and body. All `\r\n` are converted to `\n` if there are any.
126 /// If not, no memory allocation occurs and the buffer remains untouched.
127 ///
128 /// ```rust
129 /// use tpnote_lib::content::Content;
130 /// use tpnote_lib::content::ContentString;
131 /// let c = ContentString::from_string_with_cr(String::from(
132 /// "---\r\ntitle: \"My note\"\r\n---\r\nMy\nbody\r\n"));
133 ///
134 /// assert_eq!(c.header(), r#"title: "My note""#);
135 /// assert_eq!(c.body(), "My\nbody\n");
136 ///
137 /// // A test without front matter leads to an empty header:
138 /// let c = ContentString::from(String::from("No header"));
139 ///
140 /// assert_eq!(c.borrow_dependent().header, "");
141 /// assert_eq!(c.borrow_dependent().body, r#"No header"#);
142 /// ```
143 fn from_string_with_cr(input: String) -> Self {
144 // Avoid allocating when there is nothing to do.
145 let input = if input.find('\r').is_none() {
146 // Forward without allocating.
147 input
148 } else {
149 // We allocate here and do a lot of copying.
150 input.replace("\r\n", "\n")
151 };
152 Self::from(input)
153 }
154
155 /// Return a reference to theader part in between `---`
156 fn header(&self) -> &str;
157
158 /// Return the body below the second `---`.
159 fn body(&self) -> &str;
160
161 /// Constructor that accepts and store HTML imput in the body.
162 /// If the HTML input does not start with `<!DOCTYPE html...>` it is
163 /// automatically prepended.
164 /// If the input starts with another DOCTYPE than HTMl, return
165 /// `InputStreamError::NonHtmlDoctype`.
166 ///
167 /// ```rust
168 /// use tpnote_lib::content::Content;
169 /// use tpnote_lib::content::ContentString;
170 ///
171 /// let c = ContentString::from_html(String::from(
172 /// "Some HTML content")).unwrap();
173 /// assert_eq!(c.header(), "");
174 /// assert_eq!(c.body(), "<!DOCTYPE html>Some HTML content");
175 ///
176 /// let c = ContentString::from_html(String::from(
177 /// "<!DOCTYPE html>Some HTML content")).unwrap();
178 /// assert_eq!(c.header(), "");
179 /// assert_eq!(c.body(), "<!DOCTYPE html>Some HTML content");
180 ///
181 /// let c = ContentString::from_html(String::from(
182 /// "<!DOCTYPE xml>Some HTML content"));
183 /// assert!(c.is_err());
184 /// ```
185 fn from_html(input: String) -> Result<Self, InputStreamError> {
186 use crate::html::HtmlString;
187 let input = input.prepend_html_start_tag()?;
188 Ok(Self::from(input))
189 }
190
191 /// Writes the note to disk with `new_file_path` as filename.
192 /// If `new_file_path` contains missing directories, they will be
193 /// created on the fly.
194 ///
195 /// ```rust
196 /// use std::path::Path;
197 /// use std::env::temp_dir;
198 /// use std::fs;
199 /// use tpnote_lib::content::Content;
200 /// use tpnote_lib::content::ContentString;
201 /// let c = ContentString::from(
202 /// String::from("prelude\n\n---\ntitle: \"My note\"\n---\nMy body"));
203 /// let outfile = temp_dir().join("mynote.md");
204 /// #[cfg(not(target_family = "windows"))]
205 /// let expected = "\u{feff}prelude\n\n---\ntitle: \"My note\"\n---\nMy body\n";
206 /// #[cfg(target_family = "windows")]
207 /// let expected = "\u{feff}prelude\r\n\r\n---\r\ntitle: \"My note\"\r\n---\r\nMy body\r\n";
208 ///
209 /// c.save_as(&outfile).unwrap();
210 /// let result = fs::read_to_string(&outfile).unwrap();
211 ///
212 /// assert_eq!(result, expected);
213 /// fs::remove_file(&outfile);
214 /// ```
215 fn save_as(&self, new_file_path: &Path) -> Result<(), std::io::Error> {
216 // Create missing directories, if there are any.
217 create_dir_all(new_file_path.parent().unwrap_or_else(|| Path::new("")))?;
218
219 let mut outfile = OpenOptions::new()
220 .write(true)
221 .create(true)
222 .truncate(true)
223 .open(new_file_path)?;
224
225 log::trace!("Creating file: {:?}", new_file_path);
226 write!(outfile, "\u{feff}")?;
227
228 for l in self.as_str().lines() {
229 write!(outfile, "{}", l)?;
230 #[cfg(target_family = "windows")]
231 write!(outfile, "\r")?;
232 writeln!(outfile)?;
233 }
234
235 Ok(())
236 }
237
238 /// Accesses the whole content with all `---`.
239 /// Contract: The content does not contain any `\r\n`.
240 /// If your content contains `\r\n` use the
241 /// `from_string_with_cr()` constructor.
242 /// Possible BOM at the first position is not returned.
243 fn as_str(&self) -> &str {
244 self.as_ref().trim_start_matches('\u{feff}')
245 }
246
247 /// True if the header and body is empty.
248 ///
249 /// ```rust
250 /// use tpnote_lib::content::Content;
251 /// use tpnote_lib::content::ContentString;
252 ///
253 /// let c = ContentString::from(
254 /// "".to_string());
255 /// assert_eq!(c.header(), "");
256 /// assert_eq!(c.body(), "");
257 /// assert!(c.is_empty());
258 ///
259 /// let c = ContentString::from(
260 /// "Some content".to_string());
261 /// assert_eq!(c.header(), "");
262 /// assert_eq!(c.body(), "Some content");
263 /// assert!(!c.is_empty());
264 ///
265 /// let c = ContentString::from_html(
266 /// "".to_string()).unwrap();
267 /// assert_eq!(c.header(), "");
268 /// assert_eq!(c.body(), "<!DOCTYPE html>");
269 /// assert!(c.is_empty());
270 ///
271 /// let c = ContentString::from_html(
272 /// "Some HTML content".to_string()).unwrap();
273 /// assert_eq!(c.header(), "");
274 /// assert_eq!(c.body(), "<!DOCTYPE html>Some HTML content");
275 /// assert!(!c.is_empty());
276 ///
277 ///
278 /// let c = ContentString::from(
279 /// String::from("---\ntitle: My note\n---\n"));
280 /// assert_eq!(c.header(), "title: My note");
281 /// assert_eq!(c.body(), "");
282 /// assert!(!c.is_empty());
283 /// ```
284 fn is_empty(&self) -> bool {
285 // Bring methods into scope. Overwrites `is_empty()`.
286 use crate::html::HtmlStr;
287 // `.is_empty_html()` is true for `""` or only `<!DOCTYPE html...>`.
288 self.header().is_empty() && (self.body().is_empty_html())
289 }
290
291 /// Helper function that splits the content into header and body.
292 /// The header, if present, is trimmed (`trim()`), the body
293 /// is kept as it is.
294 /// Any BOM (byte order mark) at the beginning is ignored.
295 ///
296 /// 1. Ignore `\u{feff}` if present
297 /// 2. Ignore `---\n` or ignore all bytes until`\n\n---\n`,
298 /// 3. followed by header bytes,
299 /// 4. optionally followed by `\n`,
300 /// 5. followed by `\n---\n` or `\n...\n`,
301 /// 6. optionally followed by some `\t` and/or some ` `,
302 /// 7. optionally followed by `\n`.
303 ///
304 /// The remaining bytes are the "body".
305 ///
306 /// Alternatively, a YAML metadata block may occur anywhere in the document, but if it is not
307 /// at the beginning, it must be preceded by a blank line:
308 /// 1. skip all text (BEFORE_HEADER_MAX_IGNORED_CHARS) until you find `"\n\n---"`
309 /// 2. followed by header bytes,
310 /// 3. same as above ...
311 fn split(content: &str) -> (&str, &str) {
312 // Bring in scope `HtmlString`.
313 use crate::html::HtmlStr;
314 // Remove BOM
315 let content = content.trim_start_matches('\u{feff}');
316
317 if content.is_empty() {
318 return ("", "");
319 };
320
321 // If this is HTML content, leave the header empty.
322 // TODO: In the future the header might be constructed from
323 // the "meta" HTML fields. Though I am not sure if something meaningful
324 // can be found in HTML clipboard meta data.
325 if content.has_html_start_tag() {
326 return ("", content);
327 }
328
329 const HEADER_START_TAG: &str = "---";
330 let fm_start = if content.starts_with(HEADER_START_TAG) {
331 // Found at first byte.
332 HEADER_START_TAG.len()
333 } else {
334 const HEADER_START_TAG: &str = "\n\n---";
335 if let Some(start) = content
336 .substring(0, BEFORE_HEADER_MAX_IGNORED_CHARS)
337 .find(HEADER_START_TAG)
338 .map(|x| x + HEADER_START_TAG.len())
339 {
340 // Found just before `start`!
341 start
342 } else {
343 // Not found.
344 return ("", content);
345 }
346 };
347
348 // The first character after the document start marker
349 // must be a whitespace.
350 if !content[fm_start..]
351 .chars()
352 .next()
353 // If none, make test fail.
354 .unwrap_or('x')
355 .is_whitespace()
356 {
357 return ("", content);
358 };
359
360 // No need to search for an additional `\n` here, as we trim the
361 // header anyway.
362
363 const HEADER_END_TAG1: &str = "\n---";
364 // Contract: next pattern must have the same length!
365 const HEADER_END_TAG2: &str = "\n...";
366 debug_assert_eq!(HEADER_END_TAG1.len(), HEADER_END_TAG2.len());
367 const TAG_LEN: usize = HEADER_END_TAG1.len();
368
369 let fm_end = content[fm_start..]
370 .find(HEADER_END_TAG1)
371 .or_else(|| content[fm_start..].find(HEADER_END_TAG2))
372 .map(|x| x + fm_start);
373
374 let fm_end = if let Some(n) = fm_end {
375 n
376 } else {
377 return ("", content);
378 };
379
380 // We advance 4 because `"\n---"` has 4 bytes.
381 let mut body_start = fm_end + TAG_LEN;
382
383 // Skip spaces and tabs followed by one optional newline.
384 while let Some(c) = content[body_start..].chars().next() {
385 if c == ' ' || c == '\t' {
386 body_start += 1;
387 } else {
388 // Skip exactly one newline, if there is at least one.
389 if c == '\n' {
390 body_start += 1;
391 }
392 // Exit loop.
393 break;
394 };
395 }
396
397 (content[fm_start..fm_end].trim(), &content[body_start..])
398 }
399}
400
401#[derive(Debug, Eq, PartialEq)]
402/// Pointers belonging to the self referential struct `Content`.
403pub struct ContentRef<'a> {
404 /// Skip optional BOM and `"---" `in `s` until next `"---"`.
405 /// When no `---` is found, this is empty.
406 /// `header` is always trimmed.
407 pub header: &'a str,
408 /// Skip optional BOM and optional header and keep the rest.
409 pub body: &'a str,
410}
411
412self_cell!(
413/// Holds the notes content in a string and two string slices
414/// `header` and `body`.
415/// This struct is self referential.
416/// It deals with operating system specific handling of newlines.
417/// The content of a note is stored as UTF-8 string with
418/// one `\n` character as newline. If present, a Byte Order Mark
419/// BOM is removed while reading with `new()`.
420 pub struct ContentString {
421 owner: String,
422
423 #[covariant]
424 dependent: ContentRef,
425 }
426
427 impl {Debug, Eq, PartialEq}
428);
429
430/// Constructor that parses a _Tp-Note_ document.
431/// A valid document is UTF-8 encoded and starts with an optional
432/// BOM (byte order mark) followed by `---`. When the start marker
433/// `---` does not follow directly the BOM, it must be prepended
434/// by an empty line. In this case all text before is ignored:
435/// BOM + ignored text + empty line + `---`.
436/// Contract: the input string does not contain `\r\n`. If
437/// it may, use `Content::from_string_with_cr()` instead.
438///
439/// ```rust
440/// use tpnote_lib::content::Content;
441/// use tpnote_lib::content::ContentString;
442/// let input = "---\ntitle: \"My note\"\n---\nMy body";
443/// let c = ContentString::from(input.to_string());
444///
445/// assert_eq!(c.header(), r#"title: "My note""#);
446/// assert_eq!(c.body(), "My body");
447///
448/// // A test without front matter leads to an empty header:
449/// let c = ContentString::from("No header".to_string());
450///
451/// assert_eq!(c.header(), "");
452/// assert_eq!(c.body(), "No header");
453/// ```
454impl From<String> for ContentString {
455 /// Self referential. The constructor splits the content
456 /// in header and body.
457 fn from(input: String) -> Self {
458 ContentString::new(input, |owner: &String| {
459 let (header, body) = ContentString::split(owner);
460 ContentRef { header, body }
461 })
462 }
463}
464
465/// Add `header()` and `body()` implementation.
466impl Content for ContentString {
467 /// Cheap access to the note's header.
468 fn header(&self) -> &str {
469 self.borrow_dependent().header
470 }
471
472 /// Cheap access to the note's body.
473 fn body(&self) -> &str {
474 self.borrow_dependent().body
475 }
476}
477
478/// Default is the empty string.
479impl Default for ContentString {
480 fn default() -> Self {
481 Self::from(String::new())
482 }
483}
484
485/// Returns the whole raw content with header and body.
486/// Possible `\r\n` in the input are replaced by `\n`.
487impl AsRef<str> for ContentString {
488 fn as_ref(&self) -> &str {
489 self.borrow_owner()
490 }
491}
492
493/// Concatenates the header and the body and prints the content.
494/// This function is expensive as it involves copying the
495/// whole content.
496impl<'a> fmt::Display for ContentRef<'a> {
497 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
498 let s = if self.header.is_empty() {
499 self.body.to_string()
500 } else {
501 format!("\u{feff}---\n{}\n---\n{}", &self.header, &self.body)
502 };
503 write!(f, "{}", s)
504 }
505}
506
507/// Delegates the printing to `Display for ContentRef`.
508impl fmt::Display for ContentString {
509 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
510 std::fmt::Display::fmt(&self.borrow_dependent(), f)
511 }
512}
513
514#[cfg(test)]
515mod tests {
516 use super::*;
517
518 #[test]
519 fn test_from_string_with_cr() {
520 // Test windows string.
521 let content = ContentString::from_string_with_cr("first\r\nsecond\r\nthird".to_string());
522 assert_eq!(content.borrow_dependent().body, "first\nsecond\nthird");
523
524 // Test Unix string.
525 let content = ContentString::from_string_with_cr("first\nsecond\nthird".to_string());
526 assert_eq!(content.borrow_dependent().body, "first\nsecond\nthird");
527
528 // Test BOM removal.
529 let content =
530 ContentString::from_string_with_cr("\u{feff}first\nsecond\nthird".to_string());
531 assert_eq!(content.borrow_dependent().body, "first\nsecond\nthird");
532 }
533
534 #[test]
535 fn test_new() {
536 // Test Unix string.
537 let content = ContentString::from("first\nsecond\nthird".to_string());
538 assert_eq!(content.borrow_dependent().body, "first\nsecond\nthird");
539
540 // Test BOM removal.
541 let content = ContentString::from("\u{feff}first\nsecond\nthird".to_string());
542 assert_eq!(content.borrow_dependent().body, "first\nsecond\nthird");
543
544 // Test header extraction.
545 let content = ContentString::from("\u{feff}---\nfirst\n---\nsecond\nthird".to_string());
546 assert_eq!(content.borrow_dependent().header, "first");
547 assert_eq!(content.borrow_dependent().body, "second\nthird");
548
549 // Test header extraction without `\n` at the end.
550 let content = ContentString::from("\u{feff}---\nfirst\n---".to_string());
551 assert_eq!(content.borrow_dependent().header, "first");
552 assert_eq!(content.borrow_dependent().body, "");
553
554 // Some skipped bytes.
555 let content = ContentString::from("\u{feff}ignored\n\n---\nfirst\n---".to_string());
556 assert_eq!(content.borrow_dependent().header, "first");
557 assert_eq!(content.borrow_dependent().body, "");
558
559 // This fails to find the header because the `---` comes to late.
560 let mut s = "\u{feff}".to_string();
561 s.push_str(&String::from_utf8(vec![b'X'; BEFORE_HEADER_MAX_IGNORED_CHARS]).unwrap());
562 s.push_str("\n\n---\nfirst\n---\nsecond");
563 let s_ = s.clone();
564 let content = ContentString::from(s);
565 assert_eq!(content.borrow_dependent().header, "");
566 assert_eq!(content.borrow_dependent().body, &s_[3..]);
567
568 // This finds the header.
569 let mut s = "\u{feff}".to_string();
570 s.push_str(
571 &String::from_utf8(vec![
572 b'X';
573 BEFORE_HEADER_MAX_IGNORED_CHARS - "\n\n---".len()
574 ])
575 .unwrap(),
576 );
577 s.push_str("\n\n---\nfirst\n---\nsecond");
578 let content = ContentString::from(s);
579 assert_eq!(content.borrow_dependent().header, "first");
580 assert_eq!(content.borrow_dependent().body, "second");
581 }
582
583 #[test]
584 fn test_split() {
585 // Document start marker is not followed by whitespace.
586 let input_stream = String::from("---first\n---\nsecond\nthird");
587 let expected = ("", "---first\n---\nsecond\nthird");
588 let result = ContentString::split(&input_stream);
589 assert_eq!(result, expected);
590
591 // Document start marker is followed by whitespace.
592 let input_stream = String::from("---\nfirst\n---\nsecond\nthird");
593 let expected = ("first", "second\nthird");
594 let result = ContentString::split(&input_stream);
595 assert_eq!(result, expected);
596
597 // Document start marker is followed by whitespace.
598 let input_stream = String::from("---\tfirst\n---\nsecond\nthird");
599 let expected = ("first", "second\nthird");
600 let result = ContentString::split(&input_stream);
601 assert_eq!(result, expected);
602
603 // Document start marker is followed by whitespace.
604 let input_stream = String::from("--- first\n---\nsecond\nthird");
605 let expected = ("first", "second\nthird");
606 let result = ContentString::split(&input_stream);
607 assert_eq!(result, expected);
608
609 // Header is trimmed.
610 let input_stream = String::from("---\n\nfirst\n\n---\nsecond\nthird");
611 let expected = ("first", "second\nthird");
612 let result = ContentString::split(&input_stream);
613 assert_eq!(result, expected);
614
615 // Body is kept as it is (not trimmed).
616 let input_stream = String::from("---\nfirst\n---\n\nsecond\nthird\n");
617 let expected = ("first", "\nsecond\nthird\n");
618 let result = ContentString::split(&input_stream);
619 assert_eq!(result, expected);
620
621 // Header end marker line is trimmed right.
622 let input_stream = String::from("---\nfirst\n--- \t \n\nsecond\nthird\n");
623 let expected = ("first", "\nsecond\nthird\n");
624 let result = ContentString::split(&input_stream);
625 assert_eq!(result, expected);
626
627 let input_stream = String::from("\nsecond\nthird");
628 let expected = ("", "\nsecond\nthird");
629 let result = ContentString::split(&input_stream);
630 assert_eq!(result, expected);
631
632 let input_stream = String::from("");
633 let expected = ("", "");
634 let result = ContentString::split(&input_stream);
635 assert_eq!(result, expected);
636
637 let input_stream = String::from("\u{feff}\nsecond\nthird");
638 let expected = ("", "\nsecond\nthird");
639 let result = ContentString::split(&input_stream);
640 assert_eq!(result, expected);
641
642 let input_stream = String::from("\u{feff}");
643 let expected = ("", "");
644 let result = ContentString::split(&input_stream);
645 assert_eq!(result, expected);
646
647 let input_stream = String::from("[📽 2 videos]");
648 let expected = ("", "[📽 2 videos]");
649 let result = ContentString::split(&input_stream);
650 assert_eq!(result, expected);
651
652 let input_stream = "my prelude\n\n---\nmy header\n--- \nmy body\n";
653 let expected = ("my header", "my body\n");
654 let result = ContentString::split(input_stream);
655 assert_eq!(result, expected);
656 }
657
658 #[test]
659 fn test_display_for_content() {
660 let expected = "\u{feff}---\nfirst\n---\n\nsecond\nthird\n".to_string();
661 let input = ContentString::from(expected.clone());
662 assert_eq!(input.to_string(), expected);
663
664 let expected = "\nsecond\nthird\n".to_string();
665 let input = ContentString::from(expected.clone());
666 assert_eq!(input.to_string(), expected);
667
668 let expected = "".to_string();
669 let input = ContentString::from(expected.clone());
670 assert_eq!(input.to_string(), expected);
671
672 let expected = "\u{feff}---\nfirst\n---\n\nsecond\nthird\n".to_string();
673 let input = ContentString::from(
674 "\u{feff}ignored\n\n---\nfirst\n---\n\nsecond\nthird\n".to_string(),
675 );
676 assert_eq!(input.to_string(), expected);
677 }
678}