tpnote_lib/context.rs
1//! Extends the built-in Tera filters.
2use crate::config::Assertion;
3use crate::config::FILENAME_ROOT_PATH_MARKER;
4use crate::config::LIB_CFG;
5#[cfg(feature = "viewer")]
6use crate::config::TMPL_HTML_VAR_DOC_ERROR;
7#[cfg(feature = "viewer")]
8use crate::config::TMPL_HTML_VAR_DOC_TEXT;
9use crate::config::TMPL_HTML_VAR_EXPORTER_DOC_CSS;
10use crate::config::TMPL_HTML_VAR_EXPORTER_HIGHLIGHTING_CSS;
11use crate::config::TMPL_HTML_VAR_VIEWER_DOC_CSS_PATH;
12use crate::config::TMPL_HTML_VAR_VIEWER_DOC_CSS_PATH_VALUE;
13use crate::config::TMPL_HTML_VAR_VIEWER_DOC_JS;
14use crate::config::TMPL_HTML_VAR_VIEWER_HIGHLIGHTING_CSS_PATH;
15use crate::config::TMPL_HTML_VAR_VIEWER_HIGHLIGHTING_CSS_PATH_VALUE;
16use crate::config::TMPL_VAR_BODY;
17use crate::config::TMPL_VAR_CURRENT_SCHEME;
18use crate::config::TMPL_VAR_DIR_PATH;
19use crate::config::TMPL_VAR_DOC_FILE_DATE;
20use crate::config::TMPL_VAR_EXTENSION_DEFAULT;
21use crate::config::TMPL_VAR_FM_;
22use crate::config::TMPL_VAR_FM_ALL;
23use crate::config::TMPL_VAR_FM_SCHEME;
24use crate::config::TMPL_VAR_FORCE_LANG;
25use crate::config::TMPL_VAR_HEADER;
26use crate::config::TMPL_VAR_LANG;
27use crate::config::TMPL_VAR_PATH;
28use crate::config::TMPL_VAR_ROOT_PATH;
29use crate::config::TMPL_VAR_SCHEME_SYNC_DEFAULT;
30use crate::config::TMPL_VAR_USERNAME;
31use crate::content::Content;
32use crate::error::FileError;
33use crate::error::LibCfgError;
34use crate::error::NoteError;
35use crate::filename::Extension;
36use crate::filename::NotePath;
37use crate::filename::NotePathStr;
38use crate::filter::name;
39use crate::front_matter::FrontMatter;
40use crate::settings::SETTINGS;
41use std::borrow::Cow;
42use std::fs::File;
43use std::marker::PhantomData;
44use std::ops::Deref;
45use std::path::Path;
46use std::path::PathBuf;
47use std::time::SystemTime;
48
49/// At trait setting up a state machine as described below.
50/// Its implementors represent one specific state defining the amount and the
51/// type of data the `Context` type holds at that moment.
52pub trait ContextState {}
53
54#[derive(Debug, PartialEq, Clone)]
55/// See description in the `ContextState` implementor list.
56pub struct Invalid;
57
58#[derive(Debug, PartialEq, Clone)]
59/// See description in the `ContextState` implementor list.
60pub struct HasSettings;
61
62#[derive(Debug, PartialEq, Clone)]
63/// See description in the `ContextState` implementor list.
64pub(crate) struct ReadyForFilenameTemplate;
65
66#[derive(Debug, PartialEq, Clone)]
67/// See description in the `ContextState` implementor list.
68pub(crate) struct HasExistingContent;
69
70#[derive(Debug, PartialEq, Clone)]
71/// See description in the `ContextState` implementor list.
72pub(crate) struct ReadyForContentTemplate;
73
74#[derive(Debug, PartialEq, Clone)]
75/// See description in the `ContextState` implementor list.
76pub(crate) struct ReadyForHtmlTemplate;
77
78#[cfg(feature = "viewer")]
79#[derive(Debug, PartialEq, Clone)]
80/// See description in the `ContextState` implementor list.
81pub(crate) struct ReadyForHtmlErrorTemplate;
82
83/// The `Context` object is in an invalid state. Either it was not initialized
84/// or its data does not correspond any more to the `Content` it represents.
85///
86/// | State order | |
87/// |----------------|---------------------------------------|
88/// | Previous state | none |
89/// | Current state | `Invalid` |
90/// | Next state | `HasSettings` |
91///
92impl ContextState for Invalid {}
93
94/// The `Context` has the following initialized and valid fields: `path`,
95/// `dir_path`, `root_path` and `ct`. The context `ct` contains data from
96/// `insert_config_vars()` and `insert_settings()`.
97/// `Context<HasSettings>` has the following variables set:
98///
99/// * `TMPL_VAR_CURRENT_SCHEME`
100/// * `TMPL_VAR_DIR_PATH` in sync with `self.dir_path` and
101/// * `TMPL_VAR_DOC_FILE_DATE` in sync with `self.doc_file_date` (only if
102/// available).
103/// * `TMPL_VAR_EXTENSION_DEFAULT`
104/// * `TMPL_VAR_LANG`
105/// * `TMPL_VAR_PATH` in sync with `self.path`,
106/// * `TMPL_VAR_ROOT_PATH` in sync with `self.root_path`.
107/// * `TMPL_VAR_SCHEME_SYNC_DEFAULT`.
108/// * `TMPL_VAR_USERNAME`
109///
110/// The variables are inserted by the following methods: `self.from()`,
111/// `self.insert_config_vars()` and `self.insert_settings()`.
112/// Once this state is achieved, `Context` is constant and write protected until
113/// the next state transition.
114///
115/// | State order | |
116/// |----------------|---------------------------------------|
117/// | Previous state | `Invalid` |
118/// | Current state | `HasSettings` |
119/// | Next state | `ReadyForFilenameTemplate` or `HasExistingContent` |
120///
121impl ContextState for HasSettings {}
122
123/// In addition to `HasSettings`, the `context.ct` contains template variables
124/// deserialized from some note's front matter. E.g. a field named `title:`
125/// appears in the context as `fm.fm_title` template variable.
126/// In `Note` objects the `Content` is always associated with a
127/// `Context<ReadyForFilenameTemplate>`.
128/// Once this state is achieved, `Context` is constant and write protected until
129/// the next state transition.
130///
131/// | State order | |
132/// |----------------|---------------------------------------|
133/// | Previous state | `HasSettings` |
134/// | Current state | `ReadyForFilenameTemplate ` |
135/// | Next state | none or `ReadyForHtmlTemplate` |
136///
137impl ContextState for ReadyForFilenameTemplate {}
138
139/// In addition to the `HasSettings` the YAML headers of all clipboard
140/// `Content` objects are registered as front matter variables `fm.fm*` in the
141/// `Context`.
142/// This stage is also used for the `TemplateKind::FromTextFile` template.
143/// In this case the last inserted `Content` comes from the text file
144/// the command line parameter `<path>` points to. This adds the following key:
145///
146/// * `TMPL_VAR_DOC`
147///
148/// This state can evolve as the
149/// `insert_front_matter_and_raw_text_from_existing_content()` function can be
150/// called several times.
151///
152/// | State order | |
153/// |----------------|---------------------------------------|
154/// | Previous state | `HasSettings` or `HasExistingContent` |
155/// | Current state | `HasExistingContent` |
156/// | Next state | `ReadyForContentTemplate` |
157///
158impl ContextState for HasExistingContent {}
159
160/// This marker state means that enough information have been collected
161/// in the `HasExistingContent` state to be passed to a
162/// content template renderer.
163/// Once this state is achieved, `Context` is constant and write protected until
164/// the next state transition.
165///
166/// | State order | |
167/// |----------------|---------------------------------------|
168/// | Previous state | `HasExistingContent` |
169/// | Current state | `ReadyForContentTemplate` |
170/// | Next state | none |
171///
172impl ContextState for ReadyForContentTemplate {}
173
174/// In addition to the `ReadyForFilenameTemplate` state this state has the
175/// following variables set:
176///
177/// * `TMPL_HTML_VAR_EXPORTER_DOC_CSS`
178/// * `TMPL_HTML_VAR_EXPORTER_HIGHLIGHTING_CSS`
179/// * `TMPL_HTML_VAR_EXPORTER_HIGHLIGHTING_CSS`
180/// * `TMPL_HTML_VAR_VIEWER_DOC_CSS_PATH`
181/// * `TMPL_HTML_VAR_VIEWER_DOC_CSS_PATH_VALUE`
182/// * `TMPL_HTML_VAR_VIEWER_DOC_JS` from `viewer_doc_js`
183/// * `TMPL_HTML_VAR_VIEWER_HIGHLIGHTING_CSS_PATH`
184/// * `TMPL_HTML_VAR_VIEWER_HIGHLIGHTING_CSS_PATH_VALUE`
185/// * `TMPL_VAR_DOC`
186///
187/// Once this state is achieved, `Context` is constant and write protected until
188/// the next state transition.
189///
190/// | State order | |
191/// |----------------|---------------------------------------|
192/// | Previous state | `ReadyForFilenameTemplate` |
193/// | Current state | `ReadyForHtmlTemplate` |
194/// | Next state | none |
195///
196impl ContextState for ReadyForHtmlTemplate {}
197
198/// The `Context` has all data for the intended template.
199///
200/// * `TMPL_HTML_VAR_DOC_ERROR` from `error_message`
201/// * `TMPL_HTML_VAR_DOC_TEXT` from `note_erroneous_content`
202/// * `TMPL_HTML_VAR_VIEWER_DOC_JS` from `viewer_doc_js`
203///
204/// Once this state is achieved, `Context` is constant and write protected until
205/// the next state transition.
206///
207/// | State order | |
208/// |----------------|---------------------------------------|
209/// | Previous state | `HasSettings` |
210/// | Current state | `ReadyForHtmlErrorTemplate` |
211/// | Next state | none |
212///
213#[cfg(feature = "viewer")]
214impl ContextState for ReadyForHtmlErrorTemplate {}
215
216/// Recursively checks that all leaf values of a `tera::Value` tree satisfy `f`.
217/// Arrays and maps are traversed; other values are passed to `f`.
218fn tera_all_leaves(val: &tera::Value, f: &dyn Fn(&tera::Value) -> bool) -> bool {
219 if let Some(a) = val.as_array() {
220 a.iter().all(|i| tera_all_leaves(i, f))
221 } else if let Some(map) = val.as_map() {
222 map.values().all(|v| tera_all_leaves(v, f))
223 } else {
224 f(val)
225 }
226}
227
228/// Tiny wrapper around "Tera context" with some additional information.
229#[derive(Clone, Debug, PartialEq)]
230pub struct Context<S: ContextState + ?Sized> {
231 /// Collection of substitution variables.
232 ct: tera::Context,
233 /// First positional command line argument.
234 path: PathBuf,
235 /// The directory (only) path corresponding to the first positional
236 /// command line argument. The is our working directory and
237 /// the directory where the note file is (will be) located.
238 dir_path: PathBuf,
239 /// `dir_path` is a subdirectory of `root_path`. `root_path` is the
240 /// first directory, that upwards from `dir_path`, contains a file named
241 /// `FILENAME_ROOT_PATH_MARKER` (or `/` if no marker file can be found).
242 /// The root directory is interpreted by Tp-Note's viewer as its base
243 /// directory: only files within this directory are served.
244 root_path: PathBuf,
245 /// If `path` points to a file, we store its creation date here.
246 doc_file_date: Option<SystemTime>,
247 /// Rust requires usage of generic parameters, here `S`.
248 _marker: PhantomData<S>,
249}
250
251/// The methods below are available in all `ContentState` states.
252impl<S: ContextState> Context<S> {
253 /// Getter for `self.path`.
254 /// See `from()` method for details.
255 pub fn get_path(&self) -> &Path {
256 self.path.as_path()
257 }
258
259 /// Getter for `self.dir_path`.
260 /// See `from()` method for details.
261 pub fn get_dir_path(&self) -> &Path {
262 self.dir_path.as_path()
263 }
264
265 /// Getter for `self.root_path`.
266 /// See `from()` method for details.
267 pub fn get_root_path(&self) -> &Path {
268 self.root_path.as_path()
269 }
270
271 /// Getter for `self.doc_file_date`.
272 /// See `from()` method for details.
273 pub fn get_doc_file_date(&self) -> Option<SystemTime> {
274 self.doc_file_date
275 }
276
277 /// Constructor. Unlike `from()` this constructor does not access
278 /// the filesystem in order to detect `dir_path`, `root_path` and
279 /// `doc_file_date`. It copies these values from the passed `context`.
280 /// Use this constructor when you are sure that the above date has
281 /// not changed since you instantiated `context`. In this case you
282 /// can avoid repeated file access.
283 pub fn from_context_path(context: &Context<S>) -> Context<HasSettings> {
284 let mut new_context = Context {
285 ct: tera::Context::new(),
286 path: context.path.clone(),
287 dir_path: context.dir_path.clone(),
288 root_path: context.root_path.clone(),
289 doc_file_date: context.doc_file_date,
290 _marker: PhantomData,
291 };
292
293 new_context.sync_paths_to_map();
294 new_context.insert_config_vars();
295 new_context.insert_settings();
296 new_context
297 }
298
299 /// Helper function that keeps the values with the `self.ct` key
300 ///
301 /// * `TMPL_VAR_PATH` in sync with `self.path`,
302 /// * `TMPL_VAR_DIR_PATH` in sync with `self.dir_path` and
303 /// * `TMPL_VAR_ROOT_PATH` in sync with `self.root_path`.
304 /// * `TMPL_VAR_DOC_FILE_DATE` in sync with `self.doc_file_date` (only if
305 ///
306 /// available).
307 /// Synchronization is performed by copying the latter to the former.
308 fn sync_paths_to_map(&mut self) {
309 self.ct.insert(TMPL_VAR_PATH, &self.path);
310 self.ct.insert(TMPL_VAR_DIR_PATH, &self.dir_path);
311 self.ct.insert(TMPL_VAR_ROOT_PATH, &self.root_path);
312 if let Some(time) = self.doc_file_date {
313 self.ct.insert(
314 TMPL_VAR_DOC_FILE_DATE,
315 &time
316 .duration_since(SystemTime::UNIX_EPOCH)
317 .unwrap_or_default()
318 .as_secs(),
319 )
320 } else {
321 self.ct.remove(TMPL_VAR_DOC_FILE_DATE);
322 };
323 }
324
325 /// Insert some configuration variables into the context so that they
326 /// can be used in the templates.
327 ///
328 /// This function adds the key:
329 ///
330 /// * `TMPL_VAR_SCHEME_SYNC_DEFAULT`.
331 ///
332 /// ```
333 /// use std::path::Path;
334 /// use tpnote_lib::config::TMPL_VAR_SCHEME_SYNC_DEFAULT;
335 /// use tpnote_lib::settings::set_test_default_settings;
336 /// use tpnote_lib::context::Context;
337 /// set_test_default_settings().unwrap();
338 ///
339 /// // The constructor calls `context.insert_settings()` before returning.
340 /// let mut context = Context::from(&Path::new("/path/to/mynote.md")).unwrap();
341 ///
342 /// // When the note's YAML header does not contain a `scheme:` field,
343 /// // the `default` scheme is used.
344 /// assert_eq!(&context.get(TMPL_VAR_SCHEME_SYNC_DEFAULT).unwrap().to_string(),
345 /// "default");
346 /// ```
347 fn insert_config_vars(&mut self) {
348 let lib_cfg = LIB_CFG.read_recursive();
349
350 // Default extension for new notes as defined in the configuration file.
351 self.ct.insert(
352 TMPL_VAR_SCHEME_SYNC_DEFAULT,
353 lib_cfg.scheme_sync_default.as_str(),
354 );
355 }
356
357 /// Captures Tp-Note's environment and stores it as variables in a
358 /// `context` collection. The variables are needed later to populate
359 /// a context template and a filename template.
360 ///
361 /// This function adds the keys:
362 ///
363 /// * `TMPL_VAR_EXTENSION_DEFAULT`
364 /// * `TMPL_VAR_USERNAME`
365 /// * `TMPL_VAR_LANG`
366 /// * `TMPL_VAR_CURRENT_SCHEME`
367 ///
368 /// ```
369 /// use std::path::Path;
370 /// use tpnote_lib::config::TMPL_VAR_EXTENSION_DEFAULT;
371 /// use tpnote_lib::config::TMPL_VAR_CURRENT_SCHEME;
372 /// use tpnote_lib::settings::set_test_default_settings;
373 /// use tpnote_lib::context::Context;
374 /// set_test_default_settings().unwrap();
375 ///
376 /// // The constructor calls `context.insert_settings()` before returning.
377 /// let mut context = Context::from(&Path::new("/path/to/mynote.md")).unwrap();
378 ///
379 /// // For most platforms `context.get("extension_default")` is `md`
380 /// assert_eq!(&context.get(TMPL_VAR_EXTENSION_DEFAULT).unwrap().to_string(),
381 /// "md");
382 /// // `Settings.current_scheme` is by default the `default` scheme.
383 /// assert_eq!(&context.get(TMPL_VAR_CURRENT_SCHEME).unwrap().to_string(),
384 /// "default");
385 /// ```
386 fn insert_settings(&mut self) {
387 let settings = SETTINGS.read_recursive();
388
389 // Default extension for new notes as defined in the configuration file.
390 self.ct.insert(
391 TMPL_VAR_EXTENSION_DEFAULT,
392 settings.extension_default.as_str(),
393 );
394
395 {
396 let lib_cfg = LIB_CFG.read_recursive();
397 self.ct.insert(
398 TMPL_VAR_CURRENT_SCHEME,
399 &lib_cfg.scheme[settings.current_scheme].name,
400 );
401 } // Release `lib_cfg` here.
402
403 // Search for UNIX, Windows, and MacOS user-names.
404 self.ct.insert(TMPL_VAR_USERNAME, &settings.author);
405
406 // Get the user's language tag.
407 self.ct.insert(TMPL_VAR_LANG, &settings.lang);
408
409 // Store `force_lang`.
410 self.ct.insert(TMPL_VAR_FORCE_LANG, &settings.force_lang);
411 }
412
413 /// Inserts the YAML front header variables into the context for later use
414 /// with templates.
415 ///
416 fn insert_front_matter2(&mut self, fm: &FrontMatter) {
417 let mut fm_all_map: serde_json::Map<String, serde_json::Value> = self
418 .ct
419 .remove(TMPL_VAR_FM_ALL)
420 .and_then(|v| {
421 serde_json::to_value(&v).ok().and_then(|sj| match sj {
422 serde_json::Value::Object(m) => Some(m),
423 _ => None,
424 })
425 })
426 .unwrap_or_default();
427
428 // Collect all localized scheme field names.
429 // Example: `["scheme", "scheme", "Schema"]`
430 let localized_scheme_names: Vec<String> = LIB_CFG
431 .read_recursive()
432 .scheme
433 .iter()
434 .map(|s| {
435 s.tmpl
436 .fm_var
437 .localization
438 .iter()
439 .find_map(|(k, v)| (k == TMPL_VAR_FM_SCHEME).then_some(v.to_owned()))
440 })
441 .collect::<Option<Vec<String>>>()
442 .unwrap_or_default();
443
444 // Search for localized scheme names in front matter.
445 // `(scheme_idx, field_value)`. Example: `(2, "Deutsch")`
446 let localized_scheme: Option<(usize, &str)> = localized_scheme_names
447 .iter()
448 .enumerate()
449 .find_map(|(i, k)| fm.0.get(k).and_then(|s| s.as_str()).map(|s| (i, s)));
450
451 let scheme = if let Some((scheme_idx, scheme_name)) = localized_scheme {
452 {
453 log::trace!(
454 "Found `scheme: {}` with index=={} in front matter",
455 scheme_name,
456 scheme_idx,
457 );
458 scheme_idx
459 }
460 } else {
461 SETTINGS.read_recursive().current_scheme
462 };
463 let scheme = &LIB_CFG.read_recursive().scheme[scheme];
464
465 let vars = &scheme.tmpl.fm_var.localization;
466 for (key, value) in fm.iter() {
467 // This delocalizes the variable name and prepends `fm_` to its name.
468 // NB: We also insert `Value::Array` and `Value::Object`
469 // variants, No flattening occurs here.
470 let fm_key = vars.iter().find(|&l| &l.1 == key).map_or_else(
471 || {
472 let mut s = TMPL_VAR_FM_.to_string();
473 s.push_str(key);
474 Cow::Owned(s)
475 },
476 |l| Cow::Borrowed(&l.0),
477 );
478
479 // Store a copy in `fm`.
480 fm_all_map.insert(fm_key.to_string(), value.clone());
481 }
482 // Register the collection as `Object(Map<String, Value>)`.
483 self.ct.insert(TMPL_VAR_FM_ALL, &fm_all_map);
484 }
485
486 /// Insert a key/val pair directly. Only available in tests.
487 #[cfg(test)]
488 pub(crate) fn insert(&mut self, key: &str, val: &impl serde::Serialize) {
489 self.ct.insert(key.to_owned(), val);
490 }
491
492 /// Inserts a `Content` in `Context`. The content appears as key in
493 /// `context.ct` with its name taken from `content.name()`.
494 /// Its value is a `tera::Map` with two keys `TMPL_VAR_HEADER` and
495 /// `TMPL_VAR_BODY`. The corresponding values are copied from
496 /// `conten.header()` and `content.body()`.
497 fn insert_raw_text_from_existing_content(&mut self, content: &impl Content) {
498 //
499 // Register input.
500 let mut map = serde_json::Map::new();
501 map.insert(
502 TMPL_VAR_HEADER.to_string(),
503 serde_json::Value::String(content.header().to_string()),
504 );
505 map.insert(
506 TMPL_VAR_BODY.to_string(),
507 serde_json::Value::String(content.body().to_string()),
508 );
509
510 self.ct
511 .insert(content.name().to_owned(), &serde_json::Value::Object(map));
512 }
513
514 /// See function of the same name in `impl Context<HasSettings>`.
515 fn insert_front_matter_and_raw_text_from_existing_content2(
516 &mut self,
517 clipboards: &Vec<&impl Content>,
518 ) -> Result<(), NoteError> {
519 //
520 for &clip in clipboards {
521 // Register input.
522 self.insert_raw_text_from_existing_content(clip);
523
524 // Can we find a front matter in the input stream? If yes, the
525 // unmodified input stream is our new note content.
526 if !clip.header().is_empty() {
527 let input_fm = FrontMatter::try_from(clip.header());
528 match input_fm {
529 Ok(ref fm) => {
530 log::trace!(
531 "Input stream \"{}\" generates the front matter variables:\n{:#?}",
532 clip.name(),
533 &fm
534 )
535 }
536 Err(ref e) => {
537 log::warn!(
538 "Invalid YAML header in \"{}\" input stream, \
539 treating entire content as body:\n{}",
540 clip.name(),
541 e
542 );
543 // Re-register with empty header and full raw content as body.
544 let mut map = serde_json::Map::new();
545 map.insert(
546 TMPL_VAR_HEADER.to_string(),
547 serde_json::Value::String(String::new()),
548 );
549 map.insert(
550 TMPL_VAR_BODY.to_string(),
551 serde_json::Value::String(clip.as_str().to_string()),
552 );
553 self.ct
554 .insert(clip.name().to_owned(), &serde_json::Value::Object(map));
555 }
556 };
557
558 // Register front matter.
559 // The variables registered here can be overwrite the ones from the clipboard.
560 if let Ok(fm) = input_fm {
561 self.insert_front_matter2(&fm);
562 }
563 }
564 }
565
566 // Tera v2 raises an error when a template accesses an undefined variable
567 // (e.g. `fm.fm_title`). Ensure `fm` is always present so templates using
568 // `fm.x | default(value='')` work even when no front matter was found.
569 if self.ct.get(TMPL_VAR_FM_ALL).is_none() {
570 let empty: serde_json::Map<String, serde_json::Value> = serde_json::Map::new();
571 self.ct.insert(TMPL_VAR_FM_ALL.to_owned(), &empty);
572 }
573
574 Ok(())
575 }
576}
577
578/// The start state of all `Context` objects.
579///
580impl Context<Invalid> {
581 /// Constructor: `path` is Tp-Notes first positional command line parameter
582 /// `<path>` (see man page). `path` must point to a directory or
583 /// a file.
584 ///
585 /// A copy of `path` is stored in `self.ct` as key `TMPL_VAR_PATH`. It
586 /// directory path as key `TMPL_VAR_DIR_PATH`. The root directory, where
587 /// the marker file `tpnote.toml` was found, is stored with the key
588 /// `TMPL_VAR_ROOT_PATH`. If `path` points to a file, its file creation
589 /// date is stored with the key `TMPL_VAR_DOC_FILE_DATE`.
590 ///
591 /// ```rust
592 /// use std::path::Path;
593 /// use tpnote_lib::settings::set_test_default_settings;
594 /// use tpnote_lib::config::TMPL_VAR_DIR_PATH;
595 /// use tpnote_lib::config::TMPL_VAR_PATH;
596 /// use tpnote_lib::context::Context;
597 /// set_test_default_settings().unwrap();
598 ///
599 /// let mut context = Context::from(&Path::new("/path/to/mynote.md")).unwrap();
600 ///
601 /// assert_eq!(context.get_path(), Path::new("/path/to/mynote.md"));
602 /// assert_eq!(context.get_dir_path(), Path::new("/path/to/"));
603 /// assert_eq!(&context.get(TMPL_VAR_PATH).unwrap().to_string(),
604 /// "/path/to/mynote.md");
605 /// assert_eq!(&context.get(TMPL_VAR_DIR_PATH).unwrap().to_string(),
606 /// "/path/to");
607 /// ```
608 pub fn from(path: &Path) -> Result<Context<HasSettings>, FileError> {
609 let path = path.to_path_buf();
610
611 // `dir_path` is a directory as fully qualified path, ending
612 // by a separator.
613 let dir_path = if path.is_dir() {
614 path.clone()
615 } else {
616 path.parent()
617 .unwrap_or_else(|| Path::new("./"))
618 .to_path_buf()
619 };
620
621 // Get the root directory.
622 let mut root_path = Path::new("");
623
624 for anc in dir_path.ancestors() {
625 root_path = anc;
626 let mut p = anc.to_owned();
627 p.push(Path::new(FILENAME_ROOT_PATH_MARKER));
628 if p.is_file() {
629 break;
630 }
631 }
632 let root_path = root_path.to_owned();
633 debug_assert!(dir_path.starts_with(&root_path));
634
635 // Get the file's creation date. Fail silently.
636 let file_creation_date = if let Ok(file) = File::open(&path) {
637 let metadata = file.metadata()?;
638 metadata.created().or_else(|_| metadata.modified()).ok()
639 } else {
640 None
641 };
642
643 // Insert environment.
644 let mut context = Context {
645 ct: tera::Context::new(),
646 path,
647 dir_path,
648 root_path,
649 doc_file_date: file_creation_date,
650 _marker: PhantomData,
651 };
652
653 context.sync_paths_to_map();
654 context.insert_config_vars();
655 context.insert_settings();
656 Ok(context)
657 }
658}
659
660impl Context<HasSettings> {
661 /// Merges `fm` into `self.ct`.
662 pub(crate) fn insert_front_matter(
663 mut self,
664 fm: &FrontMatter,
665 ) -> Context<ReadyForFilenameTemplate> {
666 Context::insert_front_matter2(&mut self, fm);
667 Context {
668 ct: self.ct,
669 path: self.path,
670 dir_path: self.dir_path,
671 root_path: self.root_path,
672 doc_file_date: self.doc_file_date,
673 _marker: PhantomData,
674 }
675 }
676
677 /// Inserts clipboard data, standard input data and/or existing note file
678 /// content into the context. The data may contain some copied text with
679 /// or without a YAML header. The latter usually carries front matter
680 /// variables. The `input` data below is registered with the key name given
681 /// by `tmpl_var_body_name`. Typical names are `"clipboard"` or `"stdin"`.
682 /// If the below `input` contains a valid YAML header, it will be registered
683 /// in the context with the key name given by `tmpl_var_header_name`. The
684 /// templates expect the key names `clipboard_header` or `std_header`. The
685 /// raw header text will be inserted with this key name.
686 ///
687 pub(crate) fn insert_front_matter_and_raw_text_from_existing_content(
688 mut self,
689 clipboards: &Vec<&impl Content>,
690 ) -> Result<Context<HasExistingContent>, NoteError> {
691 //
692 self.insert_front_matter_and_raw_text_from_existing_content2(clipboards)?;
693
694 Ok(Context {
695 ct: self.ct,
696 path: self.path,
697 dir_path: self.dir_path,
698 root_path: self.root_path,
699 doc_file_date: self.doc_file_date,
700 _marker: PhantomData,
701 })
702 }
703
704 /// This adds the following variables to `self`:
705 ///
706 /// * `TMPL_HTML_VAR_VIEWER_DOC_JS` from `viewer_doc_js`
707 /// * `TMPL_HTML_VAR_DOC_ERROR` from `error_message`
708 /// * `TMPL_HTML_VAR_DOC_TEXT` from `note_erroneous_content`
709 ///
710 #[cfg(feature = "viewer")]
711 pub(crate) fn insert_error_content(
712 mut self,
713 note_erroneous_content: &impl Content,
714 error_message: &str,
715 // Java Script live updater inject code. Will be inserted into
716 // `tmpl_html.viewer`.
717 viewer_doc_js: &str,
718 ) -> Context<ReadyForHtmlErrorTemplate> {
719 //
720 self.ct.insert(TMPL_HTML_VAR_VIEWER_DOC_JS, viewer_doc_js);
721
722 self.ct.insert(TMPL_HTML_VAR_DOC_ERROR, error_message);
723 self.ct
724 .insert(TMPL_HTML_VAR_DOC_TEXT, ¬e_erroneous_content.as_str());
725
726 Context {
727 ct: self.ct,
728 path: self.path,
729 dir_path: self.dir_path,
730 root_path: self.root_path,
731 doc_file_date: self.doc_file_date,
732 _marker: PhantomData,
733 }
734 }
735}
736
737impl Context<HasExistingContent> {
738 /// See same method in `Context<HasSettings>`.
739 pub(crate) fn insert_front_matter_and_raw_text_from_existing_content(
740 mut self,
741 clipboards: &Vec<&impl Content>,
742 ) -> Result<Context<HasExistingContent>, NoteError> {
743 //
744 self.insert_front_matter_and_raw_text_from_existing_content2(clipboards)?;
745
746 Ok(Context {
747 ct: self.ct,
748 path: self.path,
749 dir_path: self.dir_path,
750 root_path: self.root_path,
751 doc_file_date: self.doc_file_date,
752 _marker: PhantomData,
753 })
754 }
755
756 /// Mark this as ready for a content template.
757 pub(crate) fn set_state_ready_for_content_template(self) -> Context<ReadyForContentTemplate> {
758 Context {
759 ct: self.ct,
760 path: self.path,
761 dir_path: self.dir_path,
762 root_path: self.root_path,
763 doc_file_date: self.doc_file_date,
764 _marker: PhantomData,
765 }
766 }
767}
768
769impl Context<ReadyForFilenameTemplate> {
770 /// Checks if the front matter variables satisfy preconditions.
771 /// `self.path` is the path to the current document.
772 #[inline]
773 pub(crate) fn assert_precoditions(&self) -> Result<(), NoteError> {
774 let path = &self.path;
775 let lib_cfg = &LIB_CFG.read_recursive();
776
777 // Get front matter scheme if there is any.
778 let fm_all = self.get(TMPL_VAR_FM_ALL);
779 if fm_all.is_none() {
780 return Ok(());
781 }
782 let fm_all = fm_all.unwrap();
783 let fm_scheme = fm_all
784 .get_from_path(TMPL_VAR_FM_SCHEME)
785 .and_then(|v| v.as_str());
786 let scheme_idx = fm_scheme.and_then(|scheme_name| {
787 lib_cfg
788 .scheme
789 .iter()
790 .enumerate()
791 .find_map(|(i, s)| (s.name == scheme_name).then_some(i))
792 });
793 // If not use `current_scheme` from `SETTINGS`
794 let scheme_idx = scheme_idx.unwrap_or_else(|| SETTINGS.read_recursive().current_scheme);
795 let scheme = &lib_cfg.scheme[scheme_idx];
796
797 for (key, conditions) in scheme.tmpl.fm_var.assertions.iter() {
798 if let Some(value) = fm_all.get_from_path(key) {
799 for cond in conditions {
800 match cond {
801 Assertion::IsDefined => {}
802
803 Assertion::IsString => {
804 if !tera_all_leaves(value, &|v| v.is_string()) {
805 return Err(NoteError::FrontMatterFieldIsNotString {
806 field_name: name(scheme, key).to_string(),
807 });
808 }
809 }
810
811 Assertion::IsNotEmptyString => {
812 if !tera_all_leaves(value, &|v| v.is_string() && v.as_str() != Some(""))
813 {
814 return Err(NoteError::FrontMatterFieldIsEmptyString {
815 field_name: name(scheme, key).to_string(),
816 });
817 }
818 }
819
820 Assertion::IsNumber => {
821 if !tera_all_leaves(value, &|v| v.is_number()) {
822 return Err(NoteError::FrontMatterFieldIsNotNumber {
823 field_name: name(scheme, key).to_string(),
824 });
825 }
826 }
827
828 Assertion::IsBool => {
829 if !tera_all_leaves(value, &|v| v.is_bool()) {
830 return Err(NoteError::FrontMatterFieldIsNotBool {
831 field_name: name(scheme, key).to_string(),
832 });
833 }
834 }
835
836 Assertion::IsNotCompound => {
837 if value.is_array() || value.is_map() {
838 return Err(NoteError::FrontMatterFieldIsCompound {
839 field_name: name(scheme, key).to_string(),
840 });
841 }
842 }
843
844 Assertion::IsValidSortTag => {
845 let fm_sort_tag = value.as_str().unwrap_or_default();
846 if !fm_sort_tag.is_empty() {
847 // Check for forbidden characters.
848 let (_, rest, is_sequential) = fm_sort_tag.split_sort_tag(true);
849 if !rest.is_empty() {
850 return Err(NoteError::FrontMatterFieldIsInvalidSortTag {
851 sort_tag: fm_sort_tag.to_owned(),
852 sort_tag_extra_chars: scheme
853 .filename
854 .sort_tag
855 .extra_chars
856 .escape_default()
857 .to_string(),
858 filename_sort_tag_letters_in_succession_max: scheme
859 .filename
860 .sort_tag
861 .letters_in_succession_max,
862 });
863 }
864
865 // Check for duplicate sequential sort-tags.
866 if !is_sequential {
867 // No further checks.
868 return Ok(());
869 }
870 let docpath = path.to_str().unwrap_or_default();
871
872 let (dirpath, filename) =
873 docpath.rsplit_once(['/', '\\']).unwrap_or(("", docpath));
874 let sort_tag = filename.split_sort_tag(false).0;
875 // No further check if filename(path) has no sort-tag
876 // or if sort-tags are identical.
877 if sort_tag.is_empty() || sort_tag == fm_sort_tag {
878 return Ok(());
879 }
880 let dirpath = Path::new(dirpath);
881
882 if let Some(other_file) =
883 dirpath.has_file_with_sort_tag(fm_sort_tag)
884 {
885 return Err(NoteError::FrontMatterFieldIsDuplicateSortTag {
886 sort_tag: fm_sort_tag.to_string(),
887 existing_file: other_file,
888 });
889 }
890 }
891 }
892
893 Assertion::IsTpnoteExtension => {
894 let file_ext = value.as_str().unwrap_or_default();
895
896 if !file_ext.is_empty() && !(*file_ext).is_tpnote_ext() {
897 return Err(NoteError::FrontMatterFieldIsNotTpnoteExtension {
898 extension: file_ext.to_string(),
899 extensions: {
900 use std::fmt::Write;
901 let mut errstr = scheme.filename.extensions.iter().fold(
902 String::new(),
903 |mut output, (k, _v1, _v2)| {
904 let _ = write!(output, "{k}, ");
905 output
906 },
907 );
908 errstr.truncate(errstr.len().saturating_sub(2));
909 errstr
910 },
911 });
912 }
913 }
914
915 Assertion::IsConfiguredScheme => {
916 let fm_scheme = value.as_str().unwrap_or_default();
917 match lib_cfg.scheme_idx(fm_scheme) {
918 Ok(_) => {}
919 Err(LibCfgError::SchemeNotFound {
920 scheme_name,
921 schemes,
922 }) => {
923 return Err(NoteError::SchemeNotFound {
924 scheme_val: scheme_name,
925 scheme_key: key.to_string(),
926 schemes,
927 });
928 }
929 Err(e) => return Err(e.into()),
930 };
931 }
932
933 Assertion::NoOperation => {}
934 } //
935 }
936 //
937 } else if conditions.contains(&Assertion::IsDefined) {
938 return Err(NoteError::FrontMatterFieldMissing {
939 field_name: name(scheme, key).to_string(),
940 });
941 }
942 }
943 Ok(())
944 }
945
946 /// Indicates that this context contains all we need for the content
947 /// template.
948 #[cfg(test)]
949 pub(crate) fn set_state_ready_for_content_template(self) -> Context<ReadyForContentTemplate> {
950 //
951 Context {
952 ct: self.ct,
953 path: self.path,
954 dir_path: self.dir_path,
955 root_path: self.root_path,
956 doc_file_date: self.doc_file_date,
957 _marker: PhantomData,
958 }
959 }
960
961 /// Inserts the following variables into `self`:
962 ///
963 /// * `TMPL_HTML_VAR_VIEWER_DOC_JS` from `viewer_doc_js`
964 /// * `TMPL_VAR_DOC` from `content.header()` and `content.body()`
965 /// * `TMPL_HTML_VAR_EXPORTER_DOC_CSS`
966 /// * `TMPL_HTML_VAR_EXPORTER_HIGHLIGHTING_CSS`
967 /// * `TMPL_HTML_VAR_EXPORTER_HIGHLIGHTING_CSS`
968 /// * `TMPL_HTML_VAR_VIEWER_DOC_CSS_PATH`
969 /// * `TMPL_HTML_VAR_VIEWER_DOC_CSS_PATH_VALUE`
970 /// * `TMPL_HTML_VAR_VIEWER_HIGHLIGHTING_CSS_PATH`
971 /// * `TMPL_HTML_VAR_VIEWER_HIGHLIGHTING_CSS_PATH_VALUE`
972 ///
973 pub(crate) fn insert_raw_content_and_css(
974 mut self,
975 content: &impl Content,
976 viewer_doc_js: &str,
977 ) -> Context<ReadyForHtmlTemplate> {
978 //
979 self.ct.insert(TMPL_HTML_VAR_VIEWER_DOC_JS, viewer_doc_js);
980
981 self.insert_raw_text_from_existing_content(content);
982
983 {
984 let lib_cfg = &LIB_CFG.read_recursive();
985
986 // Insert the raw CSS
987 self.ct.insert(
988 TMPL_HTML_VAR_EXPORTER_DOC_CSS,
989 &(lib_cfg.tmpl_html.exporter_doc_css),
990 );
991
992 // Insert the raw CSS
993 #[cfg(feature = "renderer")]
994 self.ct.insert(
995 TMPL_HTML_VAR_EXPORTER_HIGHLIGHTING_CSS,
996 &(lib_cfg.tmpl_html.exporter_highlighting_css),
997 );
998 } // Drop `lib_cfg`.
999
1000 // Insert the raw CSS
1001 #[cfg(not(feature = "renderer"))]
1002 self.ct.insert(TMPL_HTML_VAR_EXPORTER_HIGHLIGHTING_CSS, "");
1003
1004 // Insert the web server path to get the Tp-Note's CSS loaded.
1005 self.ct.insert(
1006 TMPL_HTML_VAR_VIEWER_DOC_CSS_PATH,
1007 TMPL_HTML_VAR_VIEWER_DOC_CSS_PATH_VALUE,
1008 );
1009
1010 // Insert the web server path to get the highlighting CSS loaded.
1011 self.ct.insert(
1012 TMPL_HTML_VAR_VIEWER_HIGHLIGHTING_CSS_PATH,
1013 TMPL_HTML_VAR_VIEWER_HIGHLIGHTING_CSS_PATH_VALUE,
1014 );
1015 Context {
1016 ct: self.ct,
1017 path: self.path,
1018 dir_path: self.dir_path,
1019 root_path: self.root_path,
1020 doc_file_date: self.doc_file_date,
1021 _marker: PhantomData,
1022 }
1023 }
1024}
1025
1026/// Auto dereferences for convenient access to `tera::Context`.
1027impl<S: ContextState> Deref for Context<S> {
1028 type Target = tera::Context;
1029
1030 fn deref(&self) -> &Self::Target {
1031 &self.ct
1032 }
1033}
1034
1035#[cfg(test)]
1036mod tests {
1037
1038 use crate::{config::TMPL_VAR_FM_ALL, error::NoteError};
1039
1040 use std::path::Path;
1041
1042 #[test]
1043 fn test_insert_front_matter() {
1044 use crate::context::Context;
1045 use crate::front_matter::FrontMatter;
1046 use std::path::Path;
1047 let context = Context::from(Path::new("/path/to/mynote.md")).unwrap();
1048 let context = context
1049 .insert_front_matter(&FrontMatter::try_from("title: My Stdin.\nsome: text").unwrap());
1050
1051 assert_eq!(
1052 &context
1053 .get(TMPL_VAR_FM_ALL)
1054 .unwrap()
1055 .get_from_path("fm_title")
1056 .unwrap()
1057 .to_string(),
1058 "My Stdin."
1059 );
1060 assert_eq!(
1061 &context
1062 .get(TMPL_VAR_FM_ALL)
1063 .unwrap()
1064 .get_from_path("fm_some")
1065 .unwrap()
1066 .to_string(),
1067 "text"
1068 );
1069 }
1070
1071 #[test]
1072 fn test_insert_front_matter2() {
1073 use crate::context::Context;
1074 use crate::front_matter::FrontMatter;
1075 use std::path::Path;
1076 let context = Context::from(Path::new("/path/to/mynote.md")).unwrap();
1077 let context = context
1078 .insert_front_matter(&FrontMatter::try_from("title: My Stdin.\nsome: text").unwrap());
1079 let context = context.set_state_ready_for_content_template();
1080
1081 assert_eq!(
1082 &context
1083 .get(TMPL_VAR_FM_ALL)
1084 .unwrap()
1085 .get_from_path("fm_title")
1086 .unwrap()
1087 .to_string(),
1088 "My Stdin."
1089 );
1090 assert_eq!(
1091 &context
1092 .get(TMPL_VAR_FM_ALL)
1093 .unwrap()
1094 .get_from_path("fm_some")
1095 .unwrap()
1096 .to_string(),
1097 "text"
1098 );
1099 }
1100
1101 #[test]
1102 fn test_insert_front_matter_and_raw_text_from_existing_content() {
1103 use crate::content::Content;
1104 use crate::content::ContentString;
1105 use crate::context::Context;
1106 use crate::settings::set_test_default_settings;
1107 use std::path::Path;
1108 set_test_default_settings().unwrap();
1109 let context = Context::from(Path::new("/path/to/mynote.md")).unwrap();
1110 let c1 = ContentString::from_string(
1111 String::from("Data from clipboard."),
1112 "txt_clipboard".to_string(),
1113 );
1114 let c2 = ContentString::from_string(
1115 "---\ntitle: My Stdin.\n---\nbody".to_string(),
1116 "stdin".to_string(),
1117 );
1118 let c = vec![&c1, &c2];
1119 let context = context
1120 .insert_front_matter_and_raw_text_from_existing_content(&c)
1121 .unwrap();
1122 assert_eq!(
1123 &context
1124 .get("txt_clipboard")
1125 .unwrap()
1126 .get_from_path("body")
1127 .unwrap()
1128 .to_string(),
1129 "Data from clipboard."
1130 );
1131 assert_eq!(
1132 &context
1133 .get("stdin")
1134 .unwrap()
1135 .get_from_path("body")
1136 .unwrap()
1137 .to_string(),
1138 "body"
1139 );
1140 assert_eq!(
1141 &context
1142 .get("stdin")
1143 .unwrap()
1144 .get_from_path("header")
1145 .unwrap()
1146 .to_string(),
1147 "title: My Stdin."
1148 );
1149 // "fm_title" is dynamically generated from the header variable "title".
1150 assert_eq!(
1151 &context
1152 .get("fm")
1153 .unwrap()
1154 .get_from_path("fm_title")
1155 .unwrap()
1156 .to_string(),
1157 "My Stdin."
1158 );
1159 }
1160
1161 #[test]
1162 fn test_assert_preconditions() {
1163 // Check `tmpl.filter.assert_preconditions` in
1164 // `tpnote_lib/src/config_default.toml` to understand these tests.
1165 use crate::context::Context;
1166 use crate::front_matter::FrontMatter;
1167 use serde_json::json;
1168 //
1169 // Is empty.
1170 let input = "";
1171 let fm = FrontMatter::try_from(input).unwrap();
1172 let cx = Context::from(Path::new("does not matter")).unwrap();
1173 let cx = cx.insert_front_matter(&fm);
1174
1175 assert!(matches!(
1176 cx.assert_precoditions().unwrap_err(),
1177 NoteError::FrontMatterFieldMissing { .. }
1178 ));
1179
1180 //
1181 // Ok as long as no other file with that sort-tag exists.
1182 let input = "# document start
1183 title: The book
1184 sort_tag: 123b";
1185 let fm = FrontMatter::try_from(input).unwrap();
1186 let cx = Context::from(Path::new("./03b-test.md")).unwrap();
1187 let cx = cx.insert_front_matter(&fm);
1188
1189 assert!(matches!(cx.assert_precoditions(), Ok(())));
1190
1191 //
1192 // Should not be a compound type.
1193 let input = "# document start
1194 title: The book
1195 sort_tag:
1196 - 1234
1197 - 456";
1198 let fm = FrontMatter::try_from(input).unwrap();
1199 let cx = Context::from(Path::new("does not matter")).unwrap();
1200 let cx = cx.insert_front_matter(&fm);
1201
1202 assert!(matches!(
1203 cx.assert_precoditions().unwrap_err(),
1204 NoteError::FrontMatterFieldIsCompound { .. }
1205 ));
1206
1207 //
1208 // Should not be a compound type.
1209 let input = "# document start
1210 title: The book
1211 sort_tag:
1212 first: 1234
1213 second: 456";
1214 let fm = FrontMatter::try_from(input).unwrap();
1215 let cx = Context::from(Path::new("does not matter")).unwrap();
1216 let cx = cx.insert_front_matter(&fm);
1217
1218 assert!(matches!(
1219 cx.assert_precoditions().unwrap_err(),
1220 NoteError::FrontMatterFieldIsCompound { .. }
1221 ));
1222
1223 //
1224 // Not registered file extension.
1225 let input = "# document start
1226 title: The book
1227 file_ext: xyz";
1228 let fm = FrontMatter::try_from(input).unwrap();
1229 let cx = Context::from(Path::new("does not matter")).unwrap();
1230 let cx = cx.insert_front_matter(&fm);
1231
1232 assert!(matches!(
1233 cx.assert_precoditions().unwrap_err(),
1234 NoteError::FrontMatterFieldIsNotTpnoteExtension { .. }
1235 ));
1236
1237 //
1238 // Check `bool`
1239 let input = "# document start
1240 title: The book
1241 filename_sync: error, here should be a bool";
1242 let fm = FrontMatter::try_from(input).unwrap();
1243 let cx = Context::from(Path::new("does not matter")).unwrap();
1244 let cx = cx.insert_front_matter(&fm);
1245
1246 assert!(matches!(
1247 cx.assert_precoditions().unwrap_err(),
1248 NoteError::FrontMatterFieldIsNotBool { .. }
1249 ));
1250
1251 //
1252 let input = "# document start
1253 title: my title
1254 subtitle: my subtitle
1255 ";
1256 let expected = json!({"fm_title": "my title", "fm_subtitle": "my subtitle"});
1257
1258 let fm = FrontMatter::try_from(input).unwrap();
1259 let cx = Context::from(Path::new("does not matter")).unwrap();
1260 let cx = cx.insert_front_matter(&fm);
1261 let fm_all_sj = serde_json::to_value(cx.get(TMPL_VAR_FM_ALL).unwrap()).unwrap();
1262 assert_eq!(fm_all_sj, expected);
1263
1264 //
1265 let input = "# document start
1266 title: my title
1267 file_ext: ''
1268 ";
1269 let expected = json!({"fm_title": "my title", "fm_file_ext": ""});
1270
1271 let fm = FrontMatter::try_from(input).unwrap();
1272 let cx = Context::from(Path::new("does not matter")).unwrap();
1273 let cx = cx.insert_front_matter(&fm);
1274 let fm_all_sj = serde_json::to_value(cx.get(TMPL_VAR_FM_ALL).unwrap()).unwrap();
1275 assert_eq!(fm_all_sj, expected);
1276
1277 //
1278 let input = "# document start
1279 title: ''
1280 subtitle: my subtitle
1281 ";
1282 let fm = FrontMatter::try_from(input).unwrap();
1283 let cx = Context::from(Path::new("does not matter")).unwrap();
1284 let cx = cx.insert_front_matter(&fm);
1285
1286 assert!(matches!(
1287 cx.assert_precoditions().unwrap_err(),
1288 NoteError::FrontMatterFieldIsEmptyString { .. }
1289 ));
1290
1291 //
1292 let input = "# document start
1293 title: My doc
1294 author:
1295 - First author
1296 - Second author
1297 ";
1298 let fm = FrontMatter::try_from(input).unwrap();
1299 let cx = Context::from(Path::new("does not matter")).unwrap();
1300 let cx = cx.insert_front_matter(&fm);
1301
1302 assert!(cx.assert_precoditions().is_ok());
1303
1304 //
1305 let input = "# document start
1306 title: My doc
1307 subtitle: my subtitle
1308 author:
1309 - First title
1310 - 1234
1311 ";
1312 let fm = FrontMatter::try_from(input).unwrap();
1313 let cx = Context::from(Path::new("does not matter")).unwrap();
1314 let cx = cx.insert_front_matter(&fm);
1315
1316 assert!(matches!(
1317 cx.assert_precoditions().unwrap_err(),
1318 NoteError::FrontMatterFieldIsNotString { .. }
1319 ));
1320 }
1321
1322 /// When an input stream has syntactically invalid YAML between `---`
1323 /// delimiters, `insert_front_matter_and_raw_text_from_existing_content`
1324 /// must succeed and expose the entire raw content as body with an empty
1325 /// header, instead of returning `InvalidInputYaml`.
1326 #[test]
1327 fn test_invalid_yaml_header_treated_as_body() {
1328 use crate::content::Content;
1329 use crate::content::ContentString;
1330 use crate::context::Context;
1331 use crate::settings::set_test_default_settings;
1332 use std::path::Path;
1333 set_test_default_settings().unwrap();
1334
1335 let raw = "---\n<invalid\n---\nhallo";
1336 let c = ContentString::from_string(raw.to_string(), "stdin".to_string());
1337 // Sanity check: the split logic does recognise a header here.
1338 assert!(!c.header().is_empty());
1339
1340 let context = Context::from(Path::new("/path/to/mynote.md")).unwrap();
1341 let result = context
1342 .insert_front_matter_and_raw_text_from_existing_content(&vec![&c]);
1343
1344 assert!(result.is_ok(), "expected Ok, got: {:?}", result.err());
1345
1346 let context = result.unwrap();
1347 // Header must be empty — the invalid YAML was discarded.
1348 assert_eq!(
1349 context
1350 .get("stdin")
1351 .unwrap()
1352 .get_from_path("header")
1353 .unwrap()
1354 .as_str()
1355 .unwrap(),
1356 ""
1357 );
1358 // Body must be the full raw input.
1359 assert_eq!(
1360 context
1361 .get("stdin")
1362 .unwrap()
1363 .get_from_path("body")
1364 .unwrap()
1365 .as_str()
1366 .unwrap(),
1367 raw
1368 );
1369 }
1370}