tpnote_lib/context.rs
1//! Extends the built-in Tera filters.
2use crate::config::Assertion;
3use crate::config::LIB_CFG;
4#[cfg(feature = "viewer")]
5use crate::config::TMPL_HTML_VAR_DOC_ERROR;
6#[cfg(feature = "viewer")]
7use crate::config::TMPL_HTML_VAR_DOC_TEXT;
8use crate::config::TMPL_HTML_VAR_EXPORTER_DOC_CSS;
9use crate::config::TMPL_HTML_VAR_EXPORTER_HIGHLIGHTING_CSS;
10use crate::config::TMPL_HTML_VAR_VIEWER_DOC_CSS_PATH;
11use crate::config::TMPL_HTML_VAR_VIEWER_DOC_CSS_PATH_VALUE;
12use crate::config::TMPL_HTML_VAR_VIEWER_DOC_JS;
13use crate::config::TMPL_HTML_VAR_VIEWER_HIGHLIGHTING_CSS_PATH;
14use crate::config::TMPL_HTML_VAR_VIEWER_HIGHLIGHTING_CSS_PATH_VALUE;
15use crate::config::TMPL_VAR_BODY;
16use crate::config::TMPL_VAR_CURRENT_SCHEME;
17use crate::config::TMPL_VAR_DIR_PATH;
18use crate::config::TMPL_VAR_DOC_FILE_DATE;
19use crate::config::TMPL_VAR_EXTENSION_DEFAULT;
20use crate::config::TMPL_VAR_FM_;
21use crate::config::TMPL_VAR_FM_ALL;
22use crate::config::TMPL_VAR_FM_SCHEME;
23use crate::config::TMPL_VAR_FORCE_LANG;
24use crate::config::TMPL_VAR_HEADER;
25use crate::config::TMPL_VAR_LANG;
26use crate::config::TMPL_VAR_PATH;
27use crate::config::TMPL_VAR_ROOT_PATH;
28use crate::config::TMPL_VAR_SCHEME_SYNC_DEFAULT;
29use crate::config::TMPL_VAR_USERNAME;
30use crate::content::Content;
31use crate::error::FileError;
32use crate::error::LibCfgError;
33use crate::error::NoteError;
34use crate::filename::Extension;
35use crate::filename::NotePath;
36use crate::filename::NotePathStr;
37use crate::filter::name;
38use crate::front_matter::FrontMatter;
39use crate::settings::SETTINGS;
40use std::borrow::Cow;
41use std::fs::File;
42use std::marker::PhantomData;
43use std::ops::Deref;
44use std::path::Path;
45use std::path::PathBuf;
46use std::time::SystemTime;
47
48/// At trait setting up a state machine as described below.
49/// Its implementors represent one specific state defining the amount and the
50/// type of data the `Context` type holds at that moment.
51pub trait ContextState {}
52
53#[derive(Debug, PartialEq, Clone)]
54/// See description in the `ContextState` implementor list.
55pub struct Invalid;
56
57#[derive(Debug, PartialEq, Clone)]
58/// See description in the `ContextState` implementor list.
59pub struct HasSettings;
60
61#[derive(Debug, PartialEq, Clone)]
62/// See description in the `ContextState` implementor list.
63pub(crate) struct ReadyForFilenameTemplate;
64
65#[derive(Debug, PartialEq, Clone)]
66/// See description in the `ContextState` implementor list.
67pub(crate) struct HasExistingContent;
68
69#[derive(Debug, PartialEq, Clone)]
70/// See description in the `ContextState` implementor list.
71pub(crate) struct ReadyForContentTemplate;
72
73#[derive(Debug, PartialEq, Clone)]
74/// See description in the `ContextState` implementor list.
75pub(crate) struct ReadyForHtmlTemplate;
76
77#[cfg(feature = "viewer")]
78#[derive(Debug, PartialEq, Clone)]
79/// See description in the `ContextState` implementor list.
80pub(crate) struct ReadyForHtmlErrorTemplate;
81
82/// The `Context` object is in an invalid state. Either it was not initialized
83/// or its data does not correspond any more to the `Content` it represents.
84///
85/// | State order | |
86/// |----------------|---------------------------------------|
87/// | Previous state | none |
88/// | Current state | `Invalid` |
89/// | Next state | `HasSettings` |
90///
91impl ContextState for Invalid {}
92
93/// The `Context` has the following initialized and valid fields: `path`,
94/// `dir_path`, `root_path` and `ct`. The context `ct` contains data from
95/// `insert_config_vars()` and `insert_settings()`.
96/// `Context<HasSettings>` has the following variables set:
97///
98/// * `TMPL_VAR_CURRENT_SCHEME`
99/// * `TMPL_VAR_DIR_PATH` in sync with `self.dir_path` and
100/// * `TMPL_VAR_DOC_FILE_DATE` in sync with `self.doc_file_date` (only if
101/// available).
102/// * `TMPL_VAR_EXTENSION_DEFAULT`
103/// * `TMPL_VAR_LANG`
104/// * `TMPL_VAR_PATH` in sync with `self.path`,
105/// * `TMPL_VAR_ROOT_PATH` in sync with `self.root_path`.
106/// * `TMPL_VAR_SCHEME_SYNC_DEFAULT`.
107/// * `TMPL_VAR_USERNAME`
108///
109/// The variables are inserted by the following methods: `self.from()`,
110/// `self.insert_config_vars()` and `self.insert_settings()`.
111/// Once this state is achieved, `Context` is constant and write protected until
112/// the next state transition.
113///
114/// | State order | |
115/// |----------------|---------------------------------------|
116/// | Previous state | `Invalid` |
117/// | Current state | `HasSettings` |
118/// | Next state | `ReadyForFilenameTemplate` or `HasExistingContent` |
119///
120impl ContextState for HasSettings {}
121
122/// In addition to `HasSettings`, the `context.ct` contains template variables
123/// deserialized from some note's front matter. E.g. a field named `title:`
124/// appears in the context as `fm.fm_title` template variable.
125/// In `Note` objects the `Content` is always associated with a
126/// `Context<ReadyForFilenameTemplate>`.
127/// Once this state is achieved, `Context` is constant and write protected until
128/// the next state transition.
129///
130/// | State order | |
131/// |----------------|---------------------------------------|
132/// | Previous state | `HasSettings` |
133/// | Current state | `ReadyForFilenameTemplate ` |
134/// | Next state | none or `ReadyForHtmlTemplate` |
135///
136impl ContextState for ReadyForFilenameTemplate {}
137
138/// In addition to the `HasSettings` the YAML headers of all clipboard
139/// `Content` objects are registered as front matter variables `fm.fm*` in the
140/// `Context`.
141/// This stage is also used for the `TemplateKind::FromTextFile` template.
142/// In this case the last inserted `Content` comes from the text file
143/// the command line parameter `<path>` points to. This adds the following key:
144///
145/// * `TMPL_VAR_DOC`
146///
147/// This state can evolve as the
148/// `insert_front_matter_and_raw_text_from_existing_content()` function can be
149/// called several times.
150///
151/// | State order | |
152/// |----------------|---------------------------------------|
153/// | Previous state | `HasSettings` or `HasExistingContent` |
154/// | Current state | `HasExistingContent` |
155/// | Next state | `ReadyForContentTemplate` |
156///
157impl ContextState for HasExistingContent {}
158
159/// This marker state means that enough information have been collected
160/// in the `HasExistingContent` state to be passed to a
161/// content template renderer.
162/// Once this state is achieved, `Context` is constant and write protected until
163/// the next state transition.
164///
165/// | State order | |
166/// |----------------|---------------------------------------|
167/// | Previous state | `HasExistingContent` |
168/// | Current state | `ReadyForContentTemplate` |
169/// | Next state | none |
170///
171impl ContextState for ReadyForContentTemplate {}
172
173/// In addition to the `ReadyForFilenameTemplate` state this state has the
174/// following variables set:
175///
176/// * `TMPL_HTML_VAR_EXPORTER_DOC_CSS`
177/// * `TMPL_HTML_VAR_EXPORTER_HIGHLIGHTING_CSS`
178/// * `TMPL_HTML_VAR_EXPORTER_HIGHLIGHTING_CSS`
179/// * `TMPL_HTML_VAR_VIEWER_DOC_CSS_PATH`
180/// * `TMPL_HTML_VAR_VIEWER_DOC_CSS_PATH_VALUE`
181/// * `TMPL_HTML_VAR_VIEWER_DOC_JS` from `viewer_doc_js`
182/// * `TMPL_HTML_VAR_VIEWER_HIGHLIGHTING_CSS_PATH`
183/// * `TMPL_HTML_VAR_VIEWER_HIGHLIGHTING_CSS_PATH_VALUE`
184/// * `TMPL_VAR_DOC`
185///
186/// Once this state is achieved, `Context` is constant and write protected until
187/// the next state transition.
188///
189/// | State order | |
190/// |----------------|---------------------------------------|
191/// | Previous state | `ReadyForFilenameTemplate` |
192/// | Current state | `ReadyForHtmlTemplate` |
193/// | Next state | none |
194///
195impl ContextState for ReadyForHtmlTemplate {}
196
197/// The `Context` has all data for the intended template.
198///
199/// * `TMPL_HTML_VAR_DOC_ERROR` from `error_message`
200/// * `TMPL_HTML_VAR_DOC_TEXT` from `note_erroneous_content`
201/// * `TMPL_HTML_VAR_VIEWER_DOC_JS` from `viewer_doc_js`
202///
203/// Once this state is achieved, `Context` is constant and write protected until
204/// the next state transition.
205///
206/// | State order | |
207/// |----------------|---------------------------------------|
208/// | Previous state | `HasSettings` |
209/// | Current state | `ReadyForHtmlErrorTemplate` |
210/// | Next state | none |
211///
212#[cfg(feature = "viewer")]
213impl ContextState for ReadyForHtmlErrorTemplate {}
214
215/// Recursively checks that all leaf values of a `tera::Value` tree satisfy `f`.
216/// Arrays and maps are traversed; other values are passed to `f`.
217fn tera_all_leaves(val: &tera::Value, f: &dyn Fn(&tera::Value) -> bool) -> bool {
218 if let Some(a) = val.as_array() {
219 a.iter().all(|i| tera_all_leaves(i, f))
220 } else if let Some(map) = val.as_map() {
221 map.values().all(|v| tera_all_leaves(v, f))
222 } else {
223 f(val)
224 }
225}
226
227/// Tiny wrapper around "Tera context" with some additional information.
228#[derive(Clone, Debug, PartialEq)]
229pub struct Context<S: ContextState + ?Sized> {
230 /// Collection of substitution variables.
231 ct: tera::Context,
232 /// First positional command line argument.
233 path: PathBuf,
234 /// The directory (only) path corresponding to the first positional
235 /// command line argument. The is our working directory and
236 /// the directory where the note file is (will be) located.
237 dir_path: PathBuf,
238 /// `dir_path` is a subdirectory of `root_path`. `root_path` is the
239 /// first directory, that upwards from `dir_path`, contains a file named
240 /// `FILENAME_ROOT_PATH_MARKER` (or `/` if no project configuration
241 /// 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"), Path::new("/path/to").to_path_buf()).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"), Path::new("/path/to").to_path_buf()).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 /// `root_path` is the document root: the directory Tp-Note's viewer
586 /// treats as its security boundary for rewriting local links, and the
587 /// value stored under the `TMPL_VAR_ROOT_PATH` template key. This
588 /// constructor does not discover it: the caller must supply it, e.g. by
589 /// searching upward for a `tpnote.toml` project configuration file (a
590 /// convention the `tpnote` binary implements; cf. its CUSTOMIZATION man
591 /// page section) or by any other means appropriate to the embedding
592 /// application.
593 ///
594 /// A copy of `path` is stored in `self.ct` as key `TMPL_VAR_PATH`. Its
595 /// directory path as key `TMPL_VAR_DIR_PATH`. If `path` points to a
596 /// file, its file creation date is stored with the key
597 /// `TMPL_VAR_DOC_FILE_DATE`.
598 ///
599 /// ```rust
600 /// use std::path::Path;
601 /// use tpnote_lib::settings::set_test_default_settings;
602 /// use tpnote_lib::config::TMPL_VAR_DIR_PATH;
603 /// use tpnote_lib::config::TMPL_VAR_PATH;
604 /// use tpnote_lib::context::Context;
605 /// set_test_default_settings().unwrap();
606 ///
607 /// let mut context = Context::from(&Path::new("/path/to/mynote.md"), Path::new("/path/to").to_path_buf()).unwrap();
608 ///
609 /// assert_eq!(context.get_path(), Path::new("/path/to/mynote.md"));
610 /// assert_eq!(context.get_dir_path(), Path::new("/path/to/"));
611 /// assert_eq!(&context.get(TMPL_VAR_PATH).unwrap().to_string(),
612 /// "/path/to/mynote.md");
613 /// assert_eq!(&context.get(TMPL_VAR_DIR_PATH).unwrap().to_string(),
614 /// "/path/to");
615 /// ```
616 ///
617 /// # Panics
618 ///
619 /// Debug builds assert that `path`'s directory is a subdirectory of
620 /// `root_path`, catching a caller that passes a `root_path` computed
621 /// for a different directory.
622 pub fn from(path: &Path, root_path: PathBuf) -> Result<Context<HasSettings>, FileError> {
623 // `dir_path` is a directory as fully qualified path, ending
624 // by a separator.
625 let dir_path = if path.is_dir() {
626 path.to_path_buf()
627 } else {
628 path.parent()
629 .unwrap_or_else(|| Path::new("./"))
630 .to_path_buf()
631 };
632 let path = path.to_path_buf();
633
634 debug_assert!(dir_path.starts_with(&root_path));
635
636 // Get the file's creation date. Fail silently.
637 let file_creation_date = if let Ok(file) = File::open(&path) {
638 let metadata = file.metadata()?;
639 metadata.created().or_else(|_| metadata.modified()).ok()
640 } else {
641 None
642 };
643
644 // Insert environment.
645 let mut context = Context {
646 ct: tera::Context::new(),
647 path,
648 dir_path,
649 root_path,
650 doc_file_date: file_creation_date,
651 _marker: PhantomData,
652 };
653
654 context.sync_paths_to_map();
655 context.insert_config_vars();
656 context.insert_settings();
657 Ok(context)
658 }
659}
660
661impl Context<HasSettings> {
662 /// Merges `fm` into `self.ct`.
663 pub(crate) fn insert_front_matter(
664 mut self,
665 fm: &FrontMatter,
666 ) -> Context<ReadyForFilenameTemplate> {
667 Context::insert_front_matter2(&mut self, fm);
668 Context {
669 ct: self.ct,
670 path: self.path,
671 dir_path: self.dir_path,
672 root_path: self.root_path,
673 doc_file_date: self.doc_file_date,
674 _marker: PhantomData,
675 }
676 }
677
678 /// Inserts clipboard data, standard input data and/or existing note file
679 /// content into the context. The data may contain some copied text with
680 /// or without a YAML header. The latter usually carries front matter
681 /// variables. The `input` data below is registered with the key name given
682 /// by `tmpl_var_body_name`. Typical names are `"clipboard"` or `"stdin"`.
683 /// If the below `input` contains a valid YAML header, it will be registered
684 /// in the context with the key name given by `tmpl_var_header_name`. The
685 /// templates expect the key names `clipboard_header` or `std_header`. The
686 /// raw header text will be inserted with this key name.
687 ///
688 pub(crate) fn insert_front_matter_and_raw_text_from_existing_content(
689 mut self,
690 clipboards: &Vec<&impl Content>,
691 ) -> Result<Context<HasExistingContent>, NoteError> {
692 //
693 self.insert_front_matter_and_raw_text_from_existing_content2(clipboards)?;
694
695 Ok(Context {
696 ct: self.ct,
697 path: self.path,
698 dir_path: self.dir_path,
699 root_path: self.root_path,
700 doc_file_date: self.doc_file_date,
701 _marker: PhantomData,
702 })
703 }
704
705 /// This adds the following variables to `self`:
706 ///
707 /// * `TMPL_HTML_VAR_VIEWER_DOC_JS` from `viewer_doc_js`
708 /// * `TMPL_HTML_VAR_DOC_ERROR` from `error_message`
709 /// * `TMPL_HTML_VAR_DOC_TEXT` from `note_erroneous_content`
710 ///
711 #[cfg(feature = "viewer")]
712 pub(crate) fn insert_error_content(
713 mut self,
714 note_erroneous_content: &impl Content,
715 error_message: &str,
716 // Java Script live updater inject code. Will be inserted into
717 // `tmpl_html.viewer`.
718 viewer_doc_js: &str,
719 ) -> Context<ReadyForHtmlErrorTemplate> {
720 //
721 self.ct.insert(TMPL_HTML_VAR_VIEWER_DOC_JS, viewer_doc_js);
722
723 self.ct.insert(TMPL_HTML_VAR_DOC_ERROR, error_message);
724 self.ct
725 .insert(TMPL_HTML_VAR_DOC_TEXT, ¬e_erroneous_content.as_str());
726
727 Context {
728 ct: self.ct,
729 path: self.path,
730 dir_path: self.dir_path,
731 root_path: self.root_path,
732 doc_file_date: self.doc_file_date,
733 _marker: PhantomData,
734 }
735 }
736}
737
738impl Context<HasExistingContent> {
739 /// See same method in `Context<HasSettings>`.
740 pub(crate) fn insert_front_matter_and_raw_text_from_existing_content(
741 mut self,
742 clipboards: &Vec<&impl Content>,
743 ) -> Result<Context<HasExistingContent>, NoteError> {
744 //
745 self.insert_front_matter_and_raw_text_from_existing_content2(clipboards)?;
746
747 Ok(Context {
748 ct: self.ct,
749 path: self.path,
750 dir_path: self.dir_path,
751 root_path: self.root_path,
752 doc_file_date: self.doc_file_date,
753 _marker: PhantomData,
754 })
755 }
756
757 /// Mark this as ready for a content template.
758 pub(crate) fn set_state_ready_for_content_template(self) -> Context<ReadyForContentTemplate> {
759 Context {
760 ct: self.ct,
761 path: self.path,
762 dir_path: self.dir_path,
763 root_path: self.root_path,
764 doc_file_date: self.doc_file_date,
765 _marker: PhantomData,
766 }
767 }
768}
769
770impl Context<ReadyForFilenameTemplate> {
771 /// Checks if the front matter variables satisfy preconditions.
772 /// `self.path` is the path to the current document.
773 #[inline]
774 pub(crate) fn assert_precoditions(&self) -> Result<(), NoteError> {
775 let path = &self.path;
776 let lib_cfg = &LIB_CFG.read_recursive();
777
778 // Get front matter scheme if there is any.
779 let fm_all = self.get(TMPL_VAR_FM_ALL);
780 if fm_all.is_none() {
781 return Ok(());
782 }
783 let fm_all = fm_all.unwrap();
784 let fm_scheme = fm_all
785 .get_from_path(TMPL_VAR_FM_SCHEME)
786 .and_then(|v| v.as_str());
787 let scheme_idx = fm_scheme.and_then(|scheme_name| {
788 lib_cfg
789 .scheme
790 .iter()
791 .enumerate()
792 .find_map(|(i, s)| (s.name == scheme_name).then_some(i))
793 });
794 // If not use `current_scheme` from `SETTINGS`
795 let scheme_idx = scheme_idx.unwrap_or_else(|| SETTINGS.read_recursive().current_scheme);
796 let scheme = &lib_cfg.scheme[scheme_idx];
797
798 for (key, conditions) in scheme.tmpl.fm_var.assertions.iter() {
799 if let Some(value) = fm_all.get_from_path(key) {
800 for cond in conditions {
801 match cond {
802 Assertion::IsDefined => {}
803
804 Assertion::IsString => {
805 if !tera_all_leaves(value, &|v| v.is_string()) {
806 return Err(NoteError::FrontMatterFieldIsNotString {
807 field_name: name(scheme, key).to_string(),
808 });
809 }
810 }
811
812 Assertion::IsNotEmptyString => {
813 if !tera_all_leaves(value, &|v| v.is_string() && v.as_str() != Some(""))
814 {
815 return Err(NoteError::FrontMatterFieldIsEmptyString {
816 field_name: name(scheme, key).to_string(),
817 });
818 }
819 }
820
821 Assertion::IsNumber => {
822 if !tera_all_leaves(value, &|v| v.is_number()) {
823 return Err(NoteError::FrontMatterFieldIsNotNumber {
824 field_name: name(scheme, key).to_string(),
825 });
826 }
827 }
828
829 Assertion::IsBool => {
830 if !tera_all_leaves(value, &|v| v.is_bool()) {
831 return Err(NoteError::FrontMatterFieldIsNotBool {
832 field_name: name(scheme, key).to_string(),
833 });
834 }
835 }
836
837 Assertion::IsNotCompound => {
838 if value.is_array() || value.is_map() {
839 return Err(NoteError::FrontMatterFieldIsCompound {
840 field_name: name(scheme, key).to_string(),
841 });
842 }
843 }
844
845 Assertion::IsValidSortTag => {
846 let fm_sort_tag = value.as_str().unwrap_or_default();
847 if !fm_sort_tag.is_empty() {
848 // Check for forbidden characters.
849 let (_, rest, is_sequential) = fm_sort_tag.split_sort_tag(true);
850 if !rest.is_empty() {
851 return Err(NoteError::FrontMatterFieldIsInvalidSortTag {
852 sort_tag: fm_sort_tag.to_owned(),
853 sort_tag_extra_chars: scheme
854 .filename
855 .sort_tag
856 .extra_chars
857 .escape_default()
858 .to_string(),
859 filename_sort_tag_letters_in_succession_max: scheme
860 .filename
861 .sort_tag
862 .letters_in_succession_max,
863 });
864 }
865
866 // Check for duplicate sequential sort-tags.
867 if !is_sequential {
868 // No further checks.
869 return Ok(());
870 }
871 let docpath = path.to_str().unwrap_or_default();
872
873 let (dirpath, filename) =
874 docpath.rsplit_once(['/', '\\']).unwrap_or(("", docpath));
875 let sort_tag = filename.split_sort_tag(false).0;
876 // No further check if filename(path) has no sort-tag
877 // or if sort-tags are identical.
878 if sort_tag.is_empty() || sort_tag == fm_sort_tag {
879 return Ok(());
880 }
881 let dirpath = Path::new(dirpath);
882
883 if let Some(other_file) =
884 dirpath.has_file_with_sort_tag(fm_sort_tag)
885 {
886 return Err(NoteError::FrontMatterFieldIsDuplicateSortTag {
887 sort_tag: fm_sort_tag.to_string(),
888 existing_file: other_file,
889 });
890 }
891 }
892 }
893
894 Assertion::IsTpnoteExtension => {
895 let file_ext = value.as_str().unwrap_or_default();
896
897 if !file_ext.is_empty() && !(*file_ext).is_tpnote_ext() {
898 return Err(NoteError::FrontMatterFieldIsNotTpnoteExtension {
899 extension: file_ext.to_string(),
900 extensions: {
901 use std::fmt::Write;
902 let mut errstr = scheme.filename.extensions.iter().fold(
903 String::new(),
904 |mut output, (k, _v1, _v2)| {
905 let _ = write!(output, "{k}, ");
906 output
907 },
908 );
909 errstr.truncate(errstr.len().saturating_sub(2));
910 errstr
911 },
912 });
913 }
914 }
915
916 Assertion::IsConfiguredScheme => {
917 let fm_scheme = value.as_str().unwrap_or_default();
918 match lib_cfg.scheme_idx(fm_scheme) {
919 Ok(_) => {}
920 Err(LibCfgError::SchemeNotFound {
921 scheme_name,
922 schemes,
923 }) => {
924 return Err(NoteError::SchemeNotFound {
925 scheme_val: scheme_name,
926 scheme_key: key.to_string(),
927 schemes,
928 });
929 }
930 Err(e) => return Err(e.into()),
931 };
932 }
933
934 Assertion::NoOperation => {}
935 } //
936 }
937 //
938 } else if conditions.contains(&Assertion::IsDefined) {
939 return Err(NoteError::FrontMatterFieldMissing {
940 field_name: name(scheme, key).to_string(),
941 });
942 }
943 }
944 Ok(())
945 }
946
947 /// Indicates that this context contains all we need for the content
948 /// template.
949 #[cfg(test)]
950 pub(crate) fn set_state_ready_for_content_template(self) -> Context<ReadyForContentTemplate> {
951 //
952 Context {
953 ct: self.ct,
954 path: self.path,
955 dir_path: self.dir_path,
956 root_path: self.root_path,
957 doc_file_date: self.doc_file_date,
958 _marker: PhantomData,
959 }
960 }
961
962 /// Inserts the following variables into `self`:
963 ///
964 /// * `TMPL_HTML_VAR_VIEWER_DOC_JS` from `viewer_doc_js`
965 /// * `TMPL_VAR_DOC` from `content.header()` and `content.body()`
966 /// * `TMPL_HTML_VAR_EXPORTER_DOC_CSS`
967 /// * `TMPL_HTML_VAR_EXPORTER_HIGHLIGHTING_CSS`
968 /// * `TMPL_HTML_VAR_EXPORTER_HIGHLIGHTING_CSS`
969 /// * `TMPL_HTML_VAR_VIEWER_DOC_CSS_PATH`
970 /// * `TMPL_HTML_VAR_VIEWER_DOC_CSS_PATH_VALUE`
971 /// * `TMPL_HTML_VAR_VIEWER_HIGHLIGHTING_CSS_PATH`
972 /// * `TMPL_HTML_VAR_VIEWER_HIGHLIGHTING_CSS_PATH_VALUE`
973 ///
974 pub(crate) fn insert_raw_content_and_css(
975 mut self,
976 content: &impl Content,
977 viewer_doc_js: &str,
978 ) -> Context<ReadyForHtmlTemplate> {
979 //
980 self.ct.insert(TMPL_HTML_VAR_VIEWER_DOC_JS, viewer_doc_js);
981
982 self.insert_raw_text_from_existing_content(content);
983
984 {
985 let lib_cfg = &LIB_CFG.read_recursive();
986
987 // Insert the raw CSS
988 self.ct.insert(
989 TMPL_HTML_VAR_EXPORTER_DOC_CSS,
990 &(lib_cfg.tmpl_html.exporter_doc_css),
991 );
992
993 // Insert the raw CSS
994 #[cfg(feature = "renderer")]
995 self.ct.insert(
996 TMPL_HTML_VAR_EXPORTER_HIGHLIGHTING_CSS,
997 &(lib_cfg.tmpl_html.exporter_highlighting_css),
998 );
999 } // Drop `lib_cfg`.
1000
1001 // Insert the raw CSS
1002 #[cfg(not(feature = "renderer"))]
1003 self.ct.insert(TMPL_HTML_VAR_EXPORTER_HIGHLIGHTING_CSS, "");
1004
1005 // Insert the web server path to get the Tp-Note's CSS loaded.
1006 self.ct.insert(
1007 TMPL_HTML_VAR_VIEWER_DOC_CSS_PATH,
1008 TMPL_HTML_VAR_VIEWER_DOC_CSS_PATH_VALUE,
1009 );
1010
1011 // Insert the web server path to get the highlighting CSS loaded.
1012 self.ct.insert(
1013 TMPL_HTML_VAR_VIEWER_HIGHLIGHTING_CSS_PATH,
1014 TMPL_HTML_VAR_VIEWER_HIGHLIGHTING_CSS_PATH_VALUE,
1015 );
1016 Context {
1017 ct: self.ct,
1018 path: self.path,
1019 dir_path: self.dir_path,
1020 root_path: self.root_path,
1021 doc_file_date: self.doc_file_date,
1022 _marker: PhantomData,
1023 }
1024 }
1025}
1026
1027/// Auto dereferences for convenient access to `tera::Context`.
1028impl<S: ContextState> Deref for Context<S> {
1029 type Target = tera::Context;
1030
1031 fn deref(&self) -> &Self::Target {
1032 &self.ct
1033 }
1034}
1035
1036#[cfg(test)]
1037mod tests {
1038
1039 use crate::{config::TMPL_VAR_FM_ALL, error::NoteError};
1040
1041 use std::path::Path;
1042 use std::path::PathBuf;
1043
1044 #[test]
1045 fn test_insert_front_matter() {
1046 use crate::context::Context;
1047 use crate::front_matter::FrontMatter;
1048 use std::path::Path;
1049 let context = Context::from(Path::new("/path/to/mynote.md"), Path::new("/path/to").to_path_buf()).unwrap();
1050 let context = context
1051 .insert_front_matter(&FrontMatter::try_from("title: My Stdin.\nsome: text").unwrap());
1052
1053 assert_eq!(
1054 &context
1055 .get(TMPL_VAR_FM_ALL)
1056 .unwrap()
1057 .get_from_path("fm_title")
1058 .unwrap()
1059 .to_string(),
1060 "My Stdin."
1061 );
1062 assert_eq!(
1063 &context
1064 .get(TMPL_VAR_FM_ALL)
1065 .unwrap()
1066 .get_from_path("fm_some")
1067 .unwrap()
1068 .to_string(),
1069 "text"
1070 );
1071 }
1072
1073 #[test]
1074 fn test_insert_front_matter2() {
1075 use crate::context::Context;
1076 use crate::front_matter::FrontMatter;
1077 use std::path::Path;
1078 let context = Context::from(Path::new("/path/to/mynote.md"), Path::new("/path/to").to_path_buf()).unwrap();
1079 let context = context
1080 .insert_front_matter(&FrontMatter::try_from("title: My Stdin.\nsome: text").unwrap());
1081 let context = context.set_state_ready_for_content_template();
1082
1083 assert_eq!(
1084 &context
1085 .get(TMPL_VAR_FM_ALL)
1086 .unwrap()
1087 .get_from_path("fm_title")
1088 .unwrap()
1089 .to_string(),
1090 "My Stdin."
1091 );
1092 assert_eq!(
1093 &context
1094 .get(TMPL_VAR_FM_ALL)
1095 .unwrap()
1096 .get_from_path("fm_some")
1097 .unwrap()
1098 .to_string(),
1099 "text"
1100 );
1101 }
1102
1103 #[test]
1104 fn test_insert_front_matter_and_raw_text_from_existing_content() {
1105 use crate::content::Content;
1106 use crate::content::ContentString;
1107 use crate::context::Context;
1108 use crate::settings::set_test_default_settings;
1109 use std::path::Path;
1110 set_test_default_settings().unwrap();
1111 let context = Context::from(Path::new("/path/to/mynote.md"), Path::new("/path/to").to_path_buf()).unwrap();
1112 let c1 = ContentString::from_string(
1113 String::from("Data from clipboard."),
1114 "txt_clipboard".to_string(),
1115 );
1116 let c2 = ContentString::from_string(
1117 "---\ntitle: My Stdin.\n---\nbody".to_string(),
1118 "stdin".to_string(),
1119 );
1120 let c = vec![&c1, &c2];
1121 let context = context
1122 .insert_front_matter_and_raw_text_from_existing_content(&c)
1123 .unwrap();
1124 assert_eq!(
1125 &context
1126 .get("txt_clipboard")
1127 .unwrap()
1128 .get_from_path("body")
1129 .unwrap()
1130 .to_string(),
1131 "Data from clipboard."
1132 );
1133 assert_eq!(
1134 &context
1135 .get("stdin")
1136 .unwrap()
1137 .get_from_path("body")
1138 .unwrap()
1139 .to_string(),
1140 "body"
1141 );
1142 assert_eq!(
1143 &context
1144 .get("stdin")
1145 .unwrap()
1146 .get_from_path("header")
1147 .unwrap()
1148 .to_string(),
1149 "title: My Stdin."
1150 );
1151 // "fm_title" is dynamically generated from the header variable "title".
1152 assert_eq!(
1153 &context
1154 .get("fm")
1155 .unwrap()
1156 .get_from_path("fm_title")
1157 .unwrap()
1158 .to_string(),
1159 "My Stdin."
1160 );
1161 }
1162
1163 #[test]
1164 fn test_assert_preconditions() {
1165 // Check `tmpl.filter.assert_preconditions` in
1166 // `tpnote_lib/src/config_default.toml` to understand these tests.
1167 use crate::context::Context;
1168 use crate::front_matter::FrontMatter;
1169 use serde_json::json;
1170 //
1171 // Is empty.
1172 let input = "";
1173 let fm = FrontMatter::try_from(input).unwrap();
1174 let cx = Context::from(Path::new("does not matter"), PathBuf::new()).unwrap();
1175 let cx = cx.insert_front_matter(&fm);
1176
1177 assert!(matches!(
1178 cx.assert_precoditions().unwrap_err(),
1179 NoteError::FrontMatterFieldMissing { .. }
1180 ));
1181
1182 //
1183 // Ok as long as no other file with that sort-tag exists.
1184 let input = "# document start
1185 title: The book
1186 sort_tag: 123b";
1187 let fm = FrontMatter::try_from(input).unwrap();
1188 let cx = Context::from(Path::new("./03b-test.md"), PathBuf::new()).unwrap();
1189 let cx = cx.insert_front_matter(&fm);
1190
1191 assert!(matches!(cx.assert_precoditions(), Ok(())));
1192
1193 //
1194 // Should not be a compound type.
1195 let input = "# document start
1196 title: The book
1197 sort_tag:
1198 - 1234
1199 - 456";
1200 let fm = FrontMatter::try_from(input).unwrap();
1201 let cx = Context::from(Path::new("does not matter"), PathBuf::new()).unwrap();
1202 let cx = cx.insert_front_matter(&fm);
1203
1204 assert!(matches!(
1205 cx.assert_precoditions().unwrap_err(),
1206 NoteError::FrontMatterFieldIsCompound { .. }
1207 ));
1208
1209 //
1210 // Should not be a compound type.
1211 let input = "# document start
1212 title: The book
1213 sort_tag:
1214 first: 1234
1215 second: 456";
1216 let fm = FrontMatter::try_from(input).unwrap();
1217 let cx = Context::from(Path::new("does not matter"), PathBuf::new()).unwrap();
1218 let cx = cx.insert_front_matter(&fm);
1219
1220 assert!(matches!(
1221 cx.assert_precoditions().unwrap_err(),
1222 NoteError::FrontMatterFieldIsCompound { .. }
1223 ));
1224
1225 //
1226 // Not registered file extension.
1227 let input = "# document start
1228 title: The book
1229 file_ext: xyz";
1230 let fm = FrontMatter::try_from(input).unwrap();
1231 let cx = Context::from(Path::new("does not matter"), PathBuf::new()).unwrap();
1232 let cx = cx.insert_front_matter(&fm);
1233
1234 assert!(matches!(
1235 cx.assert_precoditions().unwrap_err(),
1236 NoteError::FrontMatterFieldIsNotTpnoteExtension { .. }
1237 ));
1238
1239 //
1240 // Check `bool`
1241 let input = "# document start
1242 title: The book
1243 filename_sync: error, here should be a bool";
1244 let fm = FrontMatter::try_from(input).unwrap();
1245 let cx = Context::from(Path::new("does not matter"), PathBuf::new()).unwrap();
1246 let cx = cx.insert_front_matter(&fm);
1247
1248 assert!(matches!(
1249 cx.assert_precoditions().unwrap_err(),
1250 NoteError::FrontMatterFieldIsNotBool { .. }
1251 ));
1252
1253 //
1254 let input = "# document start
1255 title: my title
1256 subtitle: my subtitle
1257 ";
1258 let expected = json!({"fm_title": "my title", "fm_subtitle": "my subtitle"});
1259
1260 let fm = FrontMatter::try_from(input).unwrap();
1261 let cx = Context::from(Path::new("does not matter"), PathBuf::new()).unwrap();
1262 let cx = cx.insert_front_matter(&fm);
1263 let fm_all_sj = serde_json::to_value(cx.get(TMPL_VAR_FM_ALL).unwrap()).unwrap();
1264 assert_eq!(fm_all_sj, expected);
1265
1266 //
1267 let input = "# document start
1268 title: my title
1269 file_ext: ''
1270 ";
1271 let expected = json!({"fm_title": "my title", "fm_file_ext": ""});
1272
1273 let fm = FrontMatter::try_from(input).unwrap();
1274 let cx = Context::from(Path::new("does not matter"), PathBuf::new()).unwrap();
1275 let cx = cx.insert_front_matter(&fm);
1276 let fm_all_sj = serde_json::to_value(cx.get(TMPL_VAR_FM_ALL).unwrap()).unwrap();
1277 assert_eq!(fm_all_sj, expected);
1278
1279 //
1280 let input = "# document start
1281 title: ''
1282 subtitle: my subtitle
1283 ";
1284 let fm = FrontMatter::try_from(input).unwrap();
1285 let cx = Context::from(Path::new("does not matter"), PathBuf::new()).unwrap();
1286 let cx = cx.insert_front_matter(&fm);
1287
1288 assert!(matches!(
1289 cx.assert_precoditions().unwrap_err(),
1290 NoteError::FrontMatterFieldIsEmptyString { .. }
1291 ));
1292
1293 //
1294 let input = "# document start
1295 title: My doc
1296 author:
1297 - First author
1298 - Second author
1299 ";
1300 let fm = FrontMatter::try_from(input).unwrap();
1301 let cx = Context::from(Path::new("does not matter"), PathBuf::new()).unwrap();
1302 let cx = cx.insert_front_matter(&fm);
1303
1304 assert!(cx.assert_precoditions().is_ok());
1305
1306 //
1307 let input = "# document start
1308 title: My doc
1309 subtitle: my subtitle
1310 author:
1311 - First title
1312 - 1234
1313 ";
1314 let fm = FrontMatter::try_from(input).unwrap();
1315 let cx = Context::from(Path::new("does not matter"), PathBuf::new()).unwrap();
1316 let cx = cx.insert_front_matter(&fm);
1317
1318 assert!(matches!(
1319 cx.assert_precoditions().unwrap_err(),
1320 NoteError::FrontMatterFieldIsNotString { .. }
1321 ));
1322 }
1323
1324 /// When an input stream has syntactically invalid YAML between `---`
1325 /// delimiters, `insert_front_matter_and_raw_text_from_existing_content`
1326 /// must succeed and expose the entire raw content as body with an empty
1327 /// header, instead of returning `InvalidInputYaml`.
1328 #[test]
1329 fn test_invalid_yaml_header_treated_as_body() {
1330 use crate::content::Content;
1331 use crate::content::ContentString;
1332 use crate::context::Context;
1333 use crate::settings::set_test_default_settings;
1334 use std::path::Path;
1335 set_test_default_settings().unwrap();
1336
1337 let raw = "---\n<invalid\n---\nhallo";
1338 let c = ContentString::from_string(raw.to_string(), "stdin".to_string());
1339 // Sanity check: the split logic does recognise a header here.
1340 assert!(!c.header().is_empty());
1341
1342 let context = Context::from(Path::new("/path/to/mynote.md"), Path::new("/path/to").to_path_buf()).unwrap();
1343 let result = context
1344 .insert_front_matter_and_raw_text_from_existing_content(&vec![&c]);
1345
1346 assert!(result.is_ok(), "expected Ok, got: {:?}", result.err());
1347
1348 let context = result.unwrap();
1349 // Header must be empty — the invalid YAML was discarded.
1350 assert_eq!(
1351 context
1352 .get("stdin")
1353 .unwrap()
1354 .get_from_path("header")
1355 .unwrap()
1356 .as_str()
1357 .unwrap(),
1358 ""
1359 );
1360 // Body must be the full raw input.
1361 assert_eq!(
1362 context
1363 .get("stdin")
1364 .unwrap()
1365 .get_from_path("body")
1366 .unwrap()
1367 .as_str()
1368 .unwrap(),
1369 raw
1370 );
1371 }
1372}