Skip to main content

typub_core/
content.rs

1//! Content parsing and discovery
2
3use crate::ThemeId;
4use anyhow::{Context, Result};
5use chrono::NaiveDate;
6use serde::{Deserialize, Deserializer};
7use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9use walkdir::WalkDir;
10
11/// Source format of the content file
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum ContentFormat {
14    /// Native Typst format (.typ)
15    Typst,
16    /// Markdown format (.md) - rendered via cmarker
17    Markdown,
18}
19
20/// Represents a single content post
21#[derive(Debug)]
22pub struct Content {
23    /// Path to the post directory
24    pub path: PathBuf,
25    /// Parsed metadata from meta.toml
26    pub meta: ContentMeta,
27    /// Path to the main content file (content.typ or content.md)
28    pub content_file: PathBuf,
29    /// Source format of the content
30    pub source_format: ContentFormat,
31    /// Optional path to slides file (slides.typ)
32    pub slides_file: Option<PathBuf>,
33    /// List of asset files
34    pub assets: Vec<PathBuf>,
35}
36
37/// Deserialize a TOML date or date string into NaiveDate
38fn deserialize_date<'de, D>(deserializer: D) -> std::result::Result<NaiveDate, D::Error>
39where
40    D: Deserializer<'de>,
41{
42    use serde::de::Error;
43
44    // Try to deserialize as toml::value::Date first, then as string
45    let value = toml::Value::deserialize(deserializer)?;
46
47    match value {
48        toml::Value::Datetime(dt) => {
49            // TOML datetime - extract date part
50            let date = dt
51                .date
52                .ok_or_else(|| D::Error::custom("datetime missing date"))?;
53            NaiveDate::from_ymd_opt(date.year as i32, date.month as u32, date.day as u32)
54                .ok_or_else(|| D::Error::custom("invalid date"))
55        }
56        toml::Value::String(s) => {
57            // Parse as string
58            NaiveDate::parse_from_str(&s, "%Y-%m-%d")
59                .map_err(|e| D::Error::custom(format!("invalid date string: {}", e)))
60        }
61        _ => Err(D::Error::custom("expected date or string")),
62    }
63}
64
65/// Deserialize an optional TOML date
66fn deserialize_optional_date<'de, D>(
67    deserializer: D,
68) -> std::result::Result<Option<NaiveDate>, D::Error>
69where
70    D: Deserializer<'de>,
71{
72    use serde::de::Error;
73
74    let value: Option<toml::Value> = Option::deserialize(deserializer)?;
75
76    match value {
77        None => Ok(None),
78        Some(toml::Value::Datetime(dt)) => {
79            let date = dt
80                .date
81                .ok_or_else(|| D::Error::custom("datetime missing date"))?;
82            Ok(NaiveDate::from_ymd_opt(
83                date.year as i32,
84                date.month as u32,
85                date.day as u32,
86            ))
87        }
88        Some(toml::Value::String(s)) => Ok(NaiveDate::parse_from_str(&s, "%Y-%m-%d").ok()),
89        _ => Err(D::Error::custom("expected date or string")),
90    }
91}
92
93/// Metadata from meta.toml
94#[derive(Debug, Deserialize)]
95pub struct ContentMeta {
96    pub title: String,
97    #[serde(deserialize_with = "deserialize_date")]
98    pub created: NaiveDate,
99    #[serde(default, deserialize_with = "deserialize_optional_date")]
100    pub updated: Option<NaiveDate>,
101    #[serde(default)]
102    pub tags: Vec<String>,
103    #[serde(default)]
104    pub categories: Vec<String>,
105    /// Per-content default for published state (layer 2 per [[RFC-0005:C-RESOLUTION-ORDER]])
106    #[serde(default)]
107    pub published: Option<bool>,
108    /// Per-content default theme (layer 2 in theme resolution chain)
109    #[serde(default)]
110    pub theme: Option<ThemeId>,
111    /// Preferred platform for internal link resolution in copypaste adapters.
112    /// Per-post override (layer 1). Falls back to global config or auto-selection.
113    #[serde(default)]
114    pub internal_link_target: Option<String>,
115    /// Per-content Typst render preamble override (layer 2 per [[RFC-0005:C-RESOLUTION-ORDER]]).
116    #[serde(default)]
117    pub preamble: Option<String>,
118    #[serde(default)]
119    pub platforms: HashMap<String, PostPlatformConfig>,
120}
121
122/// Per-post platform configuration (overrides global)
123#[derive(Debug, Clone, Deserialize)]
124pub struct PostPlatformConfig {
125    /// Per-content platform-specific published setting (layer 1 per [[RFC-0005:C-RESOLUTION-ORDER]])
126    #[serde(default)]
127    pub published: Option<bool>,
128    /// Per-content platform-specific internal link target (layer 1 in resolution chain)
129    #[serde(default)]
130    pub internal_link_target: Option<String>,
131    #[serde(flatten)]
132    pub extra: HashMap<String, toml::Value>,
133}
134
135impl PostPlatformConfig {
136    /// Get a string value from config, expanding environment variables.
137    ///
138    /// Supports shell-like variable substitution via the `subst` crate:
139    /// - `$VAR` or `${VAR}` — substitute from environment
140    /// - `${VAR:default}` — use default if VAR is unset
141    pub fn get_str(&self, key: &str) -> Option<String> {
142        self.extra
143            .get(key)
144            .and_then(|v| v.as_str())
145            .map(|s| subst::substitute(s, &subst::Env).unwrap_or_else(|_| s.to_string()))
146    }
147
148    /// Get a raw string value without environment variable expansion.
149    pub fn get_str_raw(&self, key: &str) -> Option<&str> {
150        self.extra.get(key).and_then(|v| v.as_str())
151    }
152
153    /// Get an integer value from config
154    pub fn get_int(&self, key: &str) -> Option<i64> {
155        self.extra.get(key).and_then(|v| v.as_integer())
156    }
157}
158
159/// Lightweight post info for display and sorting.
160///
161/// This is a projection of `Content` that avoids the Clone requirement
162/// and contains only the fields needed for listing, sorting, and filtering.
163#[derive(Debug, Clone)]
164pub struct PostInfo {
165    /// Path to the post directory
166    pub path: PathBuf,
167    /// Post title
168    pub title: String,
169    /// Post slug (directory name)
170    pub slug: String,
171    /// Created date
172    pub created: NaiveDate,
173    /// Updated date (defaults to created if not specified in meta.toml)
174    pub updated: NaiveDate,
175    /// Post tags
176    pub tags: Vec<String>,
177    /// Platform publish status: platform_id → (is_published, optional_url)
178    pub status: HashMap<String, (bool, Option<String>)>,
179}
180
181impl PostInfo {
182    /// Create a PostInfo from a Content and its publish status.
183    pub fn from_content(
184        content: &Content,
185        status: HashMap<String, (bool, Option<String>)>,
186    ) -> Self {
187        Self {
188            path: content.path.clone(),
189            title: content.meta.title.clone(),
190            slug: content.slug().to_string(),
191            created: content.meta.created,
192            updated: content.meta.updated.unwrap_or(content.meta.created),
193            tags: content.meta.tags.clone(),
194            status,
195        }
196    }
197}
198
199/// Result of discovering content posts in a directory.
200///
201/// Contains both successfully loaded posts and any errors encountered.
202/// The caller can decide how to handle errors (e.g., log them, display warnings).
203#[derive(Debug)]
204pub struct DiscoverResult {
205    /// Successfully loaded content posts, sorted by created date (newest first)
206    pub contents: Vec<Content>,
207    /// Errors encountered while loading posts: (path, error)
208    pub errors: Vec<(PathBuf, anyhow::Error)>,
209}
210
211impl Content {
212    /// Load a content post from a directory
213    pub fn load(path: &Path) -> Result<Self> {
214        let path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
215
216        // Read meta.toml
217        let meta_path = path.join("meta.toml");
218        let meta_content = std::fs::read_to_string(&meta_path)
219            .with_context(|| format!("Failed to read meta.toml at {}", meta_path.display()))?;
220        let meta: ContentMeta = toml::from_str(&meta_content)
221            .with_context(|| format!("Failed to parse meta.toml at {}", meta_path.display()))?;
222
223        // Find content file: prefer .typ over .md
224        let typ_file = path.join("content.typ");
225        let md_file = path.join("content.md");
226
227        let (content_file, source_format) = if typ_file.exists() {
228            (typ_file, ContentFormat::Typst)
229        } else if md_file.exists() {
230            (md_file, ContentFormat::Markdown)
231        } else {
232            anyhow::bail!(
233                "No content file found at {} (expected content.typ or content.md)",
234                path.display()
235            );
236        };
237
238        // Check for slides.typ
239        let slides_file = path.join("slides.typ");
240        let slides_file = if slides_file.exists() {
241            Some(slides_file)
242        } else {
243            None
244        };
245
246        // Discover assets
247        let assets_dir = path.join("assets");
248        let assets = if assets_dir.exists() {
249            Self::discover_assets(&assets_dir)?
250        } else {
251            Vec::new()
252        };
253
254        Ok(Self {
255            path,
256            meta,
257            content_file,
258            source_format,
259            slides_file,
260            assets,
261        })
262    }
263
264    /// Discover all content posts in a directory.
265    ///
266    /// Returns a [`DiscoverResult`] containing both successfully loaded posts
267    /// and any errors encountered. The caller can decide how to handle errors
268    /// (e.g., log them in verbose mode, display warnings).
269    pub fn discover_all(content_dir: &Path) -> Result<DiscoverResult> {
270        let mut contents = Vec::new();
271        let mut errors = Vec::new();
272
273        if !content_dir.exists() {
274            return Ok(DiscoverResult { contents, errors });
275        }
276
277        for entry in std::fs::read_dir(content_dir)? {
278            let entry = entry?;
279            let path = entry.path();
280
281            if path.is_dir() {
282                // Check if it has a meta.toml (making it a valid post)
283                if path.join("meta.toml").exists() {
284                    match Self::load(&path) {
285                        Ok(content) => contents.push(content),
286                        Err(e) => errors.push((path, e)),
287                    }
288                }
289            }
290        }
291
292        // Sort by created date, newest first
293        contents.sort_by_key(|item| std::cmp::Reverse(item.meta.created));
294
295        Ok(DiscoverResult { contents, errors })
296    }
297
298    /// Discover all assets in the assets directory
299    fn discover_assets(assets_dir: &Path) -> Result<Vec<PathBuf>> {
300        let mut assets = Vec::new();
301
302        for entry in WalkDir::new(assets_dir).follow_links(true) {
303            let entry = entry?;
304            if entry.file_type().is_file() {
305                assets.push(entry.path().to_path_buf());
306            }
307        }
308
309        Ok(assets)
310    }
311
312    /// Get the post's slug (directory name)
313    pub fn slug(&self) -> &str {
314        self.path
315            .file_name()
316            .and_then(|s| s.to_str())
317            .unwrap_or("unknown")
318    }
319
320    /// Get platform-specific config, if any
321    pub fn platform_config(&self, platform: &str) -> Option<&PostPlatformConfig> {
322        self.meta.platforms.get(platform)
323    }
324}
325
326#[cfg(test)]
327mod tests {
328    #![allow(clippy::expect_used)]
329    use super::*;
330
331    #[test]
332    fn test_content_meta_parsing() {
333        let toml_str = r#"
334title = "Test Post"
335created = 2024-01-15
336tags = ["rust", "test"]
337categories = ["engineering", "rust"]
338
339[platforms.astro]
340slug = "test-post"
341"#;
342        let meta: ContentMeta = toml::from_str(toml_str).expect("parse TOML");
343        assert_eq!(meta.title, "Test Post");
344        assert_eq!(meta.tags, vec!["rust", "test"]);
345        assert_eq!(meta.categories, vec!["engineering", "rust"]);
346        assert!(meta.platforms.contains_key("astro"));
347    }
348
349    #[test]
350    fn test_content_meta_with_updated_date() {
351        let toml_str = r#"
352title = "Updated Post"
353created = 2024-01-15
354updated = 2024-06-01
355tags = []
356"#;
357        let meta: ContentMeta = toml::from_str(toml_str).expect("parse TOML");
358        assert_eq!(
359            meta.updated,
360            Some(NaiveDate::from_ymd_opt(2024, 6, 1).expect("valid date"))
361        );
362    }
363
364    #[test]
365    fn test_content_meta_with_root_preamble() {
366        let toml_str = r##"
367title = "Preamble Post"
368created = 2024-01-15
369preamble = "#set text(size: 11pt)"
370"##;
371        let meta: ContentMeta = toml::from_str(toml_str).expect("parse TOML");
372        assert_eq!(meta.preamble.as_deref(), Some("#set text(size: 11pt)"));
373    }
374
375    #[test]
376    fn test_content_meta_minimal() {
377        let toml_str = r#"
378title = "Minimal"
379created = 2024-01-01
380"#;
381        let meta: ContentMeta = toml::from_str(toml_str).expect("parse TOML");
382        assert_eq!(meta.title, "Minimal");
383        assert!(meta.updated.is_none());
384        assert!(meta.tags.is_empty());
385        assert!(meta.categories.is_empty());
386        assert!(meta.platforms.is_empty());
387    }
388
389    #[test]
390    fn test_content_meta_categories_only() {
391        let toml_str = r#"
392title = "Categories"
393created = 2024-01-01
394categories = ["backend", "api"]
395"#;
396        let meta: ContentMeta = toml::from_str(toml_str).expect("parse TOML");
397        assert_eq!(meta.categories, vec!["backend", "api"]);
398        assert!(meta.tags.is_empty());
399    }
400
401    #[test]
402    fn test_content_meta_string_date() {
403        let toml_str = r#"
404title = "String Date"
405created = "2024-03-15"
406"#;
407        let meta: ContentMeta = toml::from_str(toml_str).expect("parse TOML");
408        assert_eq!(
409            meta.created,
410            NaiveDate::from_ymd_opt(2024, 3, 15).expect("valid date")
411        );
412    }
413
414    #[test]
415    fn test_content_meta_invalid_date() {
416        let toml_str = r#"
417title = "Bad Date"
418created = "not-a-date"
419"#;
420        assert!(toml::from_str::<ContentMeta>(toml_str).is_err());
421    }
422
423    #[test]
424    fn test_content_meta_missing_title() {
425        let toml_str = r#"
426created = 2024-01-01
427"#;
428        assert!(toml::from_str::<ContentMeta>(toml_str).is_err());
429    }
430
431    #[test]
432    fn test_post_platform_config_accessors() {
433        let toml_str = r#"
434title = "Test"
435created = 2024-01-01
436
437[platforms.notion]
438database_id = "abc"
439priority = 5
440"#;
441        let meta: ContentMeta = toml::from_str(toml_str).expect("parse TOML");
442        let notion = meta.platforms.get("notion").expect("key should exist");
443        assert_eq!(notion.get_str("database_id"), Some("abc".to_string()));
444        assert_eq!(notion.get_int("priority"), Some(5));
445        assert_eq!(notion.get_str("nonexistent"), None);
446        assert_eq!(notion.get_int("database_id"), None); // wrong type
447    }
448
449    #[test]
450    fn test_get_str_no_env_var() {
451        let toml_str = r#"
452title = "Test"
453created = 2024-01-01
454
455[platforms.test]
456key = "plain-value"
457"#;
458        let meta: ContentMeta = toml::from_str(toml_str).expect("parse TOML");
459        let config = meta.platforms.get("test").expect("platform config");
460        assert_eq!(config.get_str("key"), Some("plain-value".to_string()));
461    }
462
463    #[test]
464    fn test_get_str_with_env_var() {
465        // SAFETY: Test runs single-threaded via cargo test
466        unsafe {
467            std::env::set_var("TYPUB_TEST_VAR", "expanded-value");
468        }
469        let toml_str = r#"
470title = "Test"
471created = 2024-01-01
472
473[platforms.test]
474key = "$TYPUB_TEST_VAR"
475"#;
476        let meta: ContentMeta = toml::from_str(toml_str).expect("parse TOML");
477        let config = meta.platforms.get("test").expect("platform config");
478        assert_eq!(config.get_str("key"), Some("expanded-value".to_string()));
479        // SAFETY: Test cleanup
480        unsafe {
481            std::env::remove_var("TYPUB_TEST_VAR");
482        }
483    }
484
485    #[test]
486    fn test_get_str_raw_no_expansion() {
487        // SAFETY: Test runs single-threaded via cargo test
488        unsafe {
489            std::env::set_var("TYPUB_RAW_TEST", "should-not-appear");
490        }
491        let toml_str = r#"
492title = "Test"
493created = 2024-01-01
494
495[platforms.test]
496key = "$TYPUB_RAW_TEST"
497"#;
498        let meta: ContentMeta = toml::from_str(toml_str).expect("parse TOML");
499        let config = meta.platforms.get("test").expect("platform config");
500        // get_str_raw should NOT expand
501        assert_eq!(config.get_str_raw("key"), Some("$TYPUB_RAW_TEST"));
502        // SAFETY: Test cleanup
503        unsafe {
504            std::env::remove_var("TYPUB_RAW_TEST");
505        }
506    }
507
508    #[test]
509    fn test_content_load_typ() {
510        let dir = tempfile::tempdir().expect("create temp dir");
511        let post_dir = dir.path().join("test-post");
512        std::fs::create_dir_all(&post_dir).expect("create dir");
513
514        std::fs::write(
515            post_dir.join("meta.toml"),
516            "title = \"Test\"\ncreated = 2024-01-01\n",
517        )
518        .expect("write file");
519        std::fs::write(post_dir.join("content.typ"), "#set text(size: 12pt)").expect("write file");
520
521        let content = Content::load(&post_dir).expect("load content");
522        assert_eq!(content.source_format, ContentFormat::Typst);
523        assert_eq!(content.meta.title, "Test");
524        assert!(content.slides_file.is_none());
525        assert!(content.assets.is_empty());
526    }
527
528    #[test]
529    fn test_content_load_md() {
530        let dir = tempfile::tempdir().expect("create temp dir");
531        let post_dir = dir.path().join("my-md-post");
532        std::fs::create_dir_all(&post_dir).expect("create dir");
533
534        std::fs::write(
535            post_dir.join("meta.toml"),
536            "title = \"MD\"\ncreated = 2024-02-01\n",
537        )
538        .expect("write file");
539        std::fs::write(post_dir.join("content.md"), "# Hello").expect("write file");
540
541        let content = Content::load(&post_dir).expect("load content");
542        assert_eq!(content.source_format, ContentFormat::Markdown);
543    }
544
545    #[test]
546    fn test_content_load_prefers_typ_over_md() {
547        let dir = tempfile::tempdir().expect("create temp dir");
548        let post_dir = dir.path().join("dual-post");
549        std::fs::create_dir_all(&post_dir).expect("create dir");
550
551        std::fs::write(
552            post_dir.join("meta.toml"),
553            "title = \"Dual\"\ncreated = 2024-01-01\n",
554        )
555        .expect("write file");
556        std::fs::write(post_dir.join("content.typ"), "typst content").expect("write file");
557        std::fs::write(post_dir.join("content.md"), "markdown content").expect("write file");
558
559        let content = Content::load(&post_dir).expect("load content");
560        assert_eq!(content.source_format, ContentFormat::Typst);
561    }
562
563    #[test]
564    fn test_content_load_no_content_file() {
565        let dir = tempfile::tempdir().expect("create temp dir");
566        let post_dir = dir.path().join("empty-post");
567        std::fs::create_dir_all(&post_dir).expect("create dir");
568
569        std::fs::write(
570            post_dir.join("meta.toml"),
571            "title = \"Empty\"\ncreated = 2024-01-01\n",
572        )
573        .expect("write file");
574
575        assert!(Content::load(&post_dir).is_err());
576    }
577
578    #[test]
579    fn test_content_load_no_meta() {
580        let dir = tempfile::tempdir().expect("create temp dir");
581        let post_dir = dir.path().join("no-meta");
582        std::fs::create_dir_all(&post_dir).expect("create dir");
583        std::fs::write(post_dir.join("content.typ"), "hello").expect("write file");
584
585        assert!(Content::load(&post_dir).is_err());
586    }
587
588    #[test]
589    fn test_content_load_with_assets() {
590        let dir = tempfile::tempdir().expect("create temp dir");
591        let post_dir = dir.path().join("asset-post");
592        let assets_dir = post_dir.join("assets");
593        std::fs::create_dir_all(&assets_dir).expect("create dir");
594
595        std::fs::write(
596            post_dir.join("meta.toml"),
597            "title = \"Assets\"\ncreated = 2024-01-01\n",
598        )
599        .expect("write file");
600        std::fs::write(post_dir.join("content.typ"), "content").expect("write file");
601        std::fs::write(assets_dir.join("photo.png"), "fake png").expect("write file");
602        std::fs::write(assets_dir.join("diagram.svg"), "fake svg").expect("write file");
603
604        let content = Content::load(&post_dir).expect("load content");
605        assert_eq!(content.assets.len(), 2);
606    }
607
608    #[test]
609    fn test_content_load_with_slides() {
610        let dir = tempfile::tempdir().expect("create temp dir");
611        let post_dir = dir.path().join("slides-post");
612        std::fs::create_dir_all(&post_dir).expect("create dir");
613
614        std::fs::write(
615            post_dir.join("meta.toml"),
616            "title = \"Slides\"\ncreated = 2024-01-01\n",
617        )
618        .expect("write file");
619        std::fs::write(post_dir.join("content.typ"), "content").expect("write file");
620        std::fs::write(post_dir.join("slides.typ"), "slide content").expect("write file");
621
622        let content = Content::load(&post_dir).expect("load content");
623        assert!(content.slides_file.is_some());
624    }
625
626    #[test]
627    fn test_slug() {
628        let dir = tempfile::tempdir().expect("create temp dir");
629        let post_dir = dir.path().join("2024-01-15-my-great-post");
630        std::fs::create_dir_all(&post_dir).expect("create dir");
631
632        std::fs::write(
633            post_dir.join("meta.toml"),
634            "title = \"Great\"\ncreated = 2024-01-15\n",
635        )
636        .expect("write file");
637        std::fs::write(post_dir.join("content.typ"), "x").expect("write file");
638
639        let content = Content::load(&post_dir).expect("load content");
640        assert_eq!(content.slug(), "2024-01-15-my-great-post");
641    }
642
643    #[test]
644    fn test_platform_config() {
645        let dir = tempfile::tempdir().expect("create temp dir");
646        let post_dir = dir.path().join("pc-post");
647        std::fs::create_dir_all(&post_dir).expect("create dir");
648
649        std::fs::write(
650            post_dir.join("meta.toml"),
651            r#"
652title = "PC"
653created = 2024-01-01
654
655[platforms.astro]
656slug = "custom-slug"
657"#,
658        )
659        .expect("write file");
660        std::fs::write(post_dir.join("content.typ"), "x").expect("write file");
661
662        let content = Content::load(&post_dir).expect("load content");
663        assert!(content.platform_config("astro").is_some());
664        assert!(content.platform_config("notion").is_none());
665    }
666
667    #[test]
668    fn test_discover_all() {
669        let dir = tempfile::tempdir().expect("create temp dir");
670
671        // Create two valid posts
672        for name in ["2024-01-01-first", "2024-02-01-second"] {
673            let post = dir.path().join(name);
674            std::fs::create_dir_all(&post).expect("create dir");
675            std::fs::write(
676                post.join("meta.toml"),
677                format!("title = \"{name}\"\ncreated = {}\n", &name[..10]),
678            )
679            .expect("write file");
680            std::fs::write(post.join("content.typ"), "x").expect("write file");
681        }
682
683        // Create a directory without meta.toml (should be skipped)
684        let invalid = dir.path().join("not-a-post");
685        std::fs::create_dir_all(&invalid).expect("create dir");
686
687        let result = Content::discover_all(dir.path()).expect("discover all");
688        assert_eq!(result.contents.len(), 2);
689        assert!(result.errors.is_empty());
690        // Newest first
691        assert_eq!(result.contents[0].slug(), "2024-02-01-second");
692        assert_eq!(result.contents[1].slug(), "2024-01-01-first");
693    }
694
695    #[test]
696    fn test_discover_all_with_errors() {
697        let dir = tempfile::tempdir().expect("create temp dir");
698
699        // Create a valid post
700        let valid = dir.path().join("valid-post");
701        std::fs::create_dir_all(&valid).expect("create dir");
702        std::fs::write(
703            valid.join("meta.toml"),
704            "title = \"Valid\"\ncreated = 2024-01-01\n",
705        )
706        .expect("write file");
707        std::fs::write(valid.join("content.typ"), "x").expect("write file");
708
709        // Create a post with invalid meta.toml
710        let invalid = dir.path().join("invalid-post");
711        std::fs::create_dir_all(&invalid).expect("create dir");
712        std::fs::write(invalid.join("meta.toml"), "invalid toml {{{{").expect("write file");
713
714        let result = Content::discover_all(dir.path()).expect("discover all");
715        assert_eq!(result.contents.len(), 1);
716        assert_eq!(result.errors.len(), 1);
717        assert!(result.errors[0].0.ends_with("invalid-post"));
718    }
719
720    #[test]
721    fn test_discover_all_empty_dir() {
722        let dir = tempfile::tempdir().expect("create temp dir");
723        let result = Content::discover_all(dir.path()).expect("discover all");
724        assert!(result.contents.is_empty());
725        assert!(result.errors.is_empty());
726    }
727
728    #[test]
729    fn test_discover_all_nonexistent_dir() {
730        let result = Content::discover_all(Path::new("/tmp/nonexistent-contents-dir"))
731            .expect("discover all");
732        assert!(result.contents.is_empty());
733        assert!(result.errors.is_empty());
734    }
735}