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