blendtutor_core/
course.rs

1//! Course discovery: a course directory's manifest and the lessons it lists.
2//!
3//! Holds the [`Manifest`] (the parsed `blendtutor.toml`), the [`Course`] that
4//! owns a course directory, and [`Course::discover`] — the effectful walk that
5//! reads each listed lesson and summarizes it. A discovered entry is a
6//! `Result<LessonSummary, DiscoveryError>` so a malformed lesson is reported as
7//! an error row, never aborting the scan nor silently dropped (ADR-0004). This
8//! module does not validate lesson semantics beyond what [`crate::lesson`]
9//! already does (§4.1); it adds only the course-scoped slug and the
10//! partial-failure shape.
11
12use std::error::Error;
13use std::fmt;
14use std::path::{Path, PathBuf};
15
16use serde::Deserialize;
17
18use crate::lesson::{Language, Lesson, LoadError, read_lesson_file};
19
20/// The manifest file at the root of every course directory.
21const MANIFEST_FILENAME: &str = "blendtutor.toml";
22
23/// A lesson's course-scoped identity: the `id` an author gives an entry in
24/// `blendtutor.toml`.
25///
26/// A newtype over `String` (§1.4) so a course slug is never confused with the
27/// lesson's own `lesson_name` title or with arbitrary text. The slug is assigned
28/// by the course, not the lesson (ADR-0004): a lesson file does not know which
29/// course lists it.
30#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
31pub struct LessonSlug(String);
32
33impl fmt::Display for LessonSlug {
34    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
35        f.write_str(&self.0)
36    }
37}
38
39/// One lesson's entry in a [`Manifest`]: its course slug and the file that holds
40/// it, relative to the manifest.
41///
42/// Unknown keys are rejected (§1.3.1) so an author's typo in `blendtutor.toml`
43/// fails loudly rather than dropping silently, matching the lesson model.
44#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
45#[serde(deny_unknown_fields)]
46pub struct ManifestEntry {
47    /// The lesson's course-scoped id.
48    pub id: LessonSlug,
49    /// The lesson file, relative to the course directory.
50    pub path: PathBuf,
51}
52
53/// The default maximum feedback requests per browser session when the `[site]`
54/// section is absent or omits `max_feedback_per_session`. Lifted to a named
55/// function so both the serde default and [`SiteConfig::default`] share one
56/// source (§5.1) — a future tuning change touches one place.
57const fn default_max_feedback() -> u32 {
58    20
59}
60
61/// Site-level configuration from the optional `[site]` section of
62/// `blendtutor.toml`.
63///
64/// Unknown keys are rejected (§1.3.1) so an author's typo in `[site]` fails
65/// loudly rather than dropping silently, matching the lesson and manifest
66/// models. The `max_feedback_per_session` field defaults to 20 when the `[site]`
67/// section is present but omits it (via `#[serde(default = "default_max_feedback")]`);
68/// when the entire `[site]` section is absent, [`Manifest::site`] is `None` and
69/// the caller applies [`SiteConfig::default`] (which also yields 20).
70#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
71#[serde(deny_unknown_fields)]
72pub struct SiteConfig {
73    /// The maximum number of feedback requests a learner may make per browser
74    /// session. Defaults to 20. A value of 0 disables feedback entirely.
75    #[serde(default = "default_max_feedback")]
76    pub max_feedback_per_session: u32,
77}
78
79impl Default for SiteConfig {
80    fn default() -> Self {
81        SiteConfig {
82            max_feedback_per_session: default_max_feedback(),
83        }
84    }
85}
86
87/// A course manifest: the ordered list of lessons a course contains, parsed from
88/// `blendtutor.toml`.
89#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
90#[serde(deny_unknown_fields)]
91pub struct Manifest {
92    /// The lessons this course lists, in author order.
93    pub lessons: Vec<ManifestEntry>,
94    /// Site-level configuration from the optional `[site]` section. `None` when
95    /// the section is absent; the caller applies [`SiteConfig::default`] (max=20).
96    #[serde(default)]
97    pub site: Option<SiteConfig>,
98}
99
100impl Manifest {
101    /// Parse a manifest from a `blendtutor.toml` document.
102    ///
103    /// The pure parse boundary (§2.1): the caller reads the file, this turns the
104    /// text into the typed model. A malformed document or a typo'd key yields
105    /// [`ManifestError::Parse`]; a lesson path that escapes the course directory
106    /// yields [`ManifestError::UnsafePath`]. Both are caught here, before any
107    /// lesson file is read (§1.3.1), so a parsed `Manifest` carries only relative,
108    /// non-`..` paths. The check is lexical — it does not resolve symlinks, so a
109    /// symlink inside the course could still point elsewhere.
110    pub fn parse(toml: &str) -> Result<Manifest, ManifestError> {
111        let manifest: Manifest =
112            toml::from_str(toml).map_err(|e| ManifestError::Parse(e.to_string()))?;
113        manifest.validate_paths()?;
114        Ok(manifest)
115    }
116
117    /// Reject any entry whose path would reach outside the course directory.
118    ///
119    /// A manifest is author-written but a course may be shared, so an absolute
120    /// path or a `..` component is refused at the boundary rather than handed to
121    /// [`Course::discover`] as a read of an arbitrary file (§1.3.1).
122    fn validate_paths(&self) -> Result<(), ManifestError> {
123        for entry in &self.lessons {
124            if escapes_course_dir(&entry.path) {
125                return Err(ManifestError::UnsafePath {
126                    id: entry.id.clone(),
127                    path: entry.path.clone(),
128                });
129            }
130        }
131        Ok(())
132    }
133}
134
135/// Whether `path` is lexically outside the course directory: an absolute path,
136/// or one carrying a `..` component. A lexical check only — it does not resolve
137/// symlinks, so a symlink within the course is not followed.
138fn escapes_course_dir(path: &Path) -> bool {
139    path.is_absolute()
140        || path
141            .components()
142            .any(|component| matches!(component, std::path::Component::ParentDir))
143}
144
145/// Why a `blendtutor.toml` could not be turned into a [`Manifest`].
146///
147/// Read failures stay distinct from parse failures (mirroring
148/// [`LoadError`]) so a missing manifest is never
149/// reported as a malformed one.
150#[derive(Debug)]
151pub enum ManifestError {
152    /// The manifest file could not be read (missing, permissions).
153    Read(std::io::Error),
154    /// The file was read but is not a valid manifest (bad TOML, a missing or
155    /// typo'd key). Carries the parser message.
156    Parse(String),
157    /// An entry's `path` would resolve outside the course directory (it is
158    /// absolute or climbs through `..`), so it is refused before any file is read.
159    UnsafePath {
160        /// The slug of the offending entry.
161        id: LessonSlug,
162        /// The rejected path, as written in the manifest.
163        path: PathBuf,
164    },
165}
166
167impl fmt::Display for ManifestError {
168    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
169        match self {
170            ManifestError::Read(e) => write!(f, "could not read course manifest: {e}"),
171            ManifestError::Parse(msg) => write!(f, "invalid course manifest: {msg}"),
172            ManifestError::UnsafePath { id, path } => write!(
173                f,
174                "lesson {id:?} path {path:?} escapes the course directory; \
175                 lesson paths must be relative and stay within the course",
176            ),
177        }
178    }
179}
180
181impl Error for ManifestError {
182    fn source(&self) -> Option<&(dyn Error + 'static)> {
183        match self {
184            ManifestError::Read(e) => Some(e),
185            ManifestError::Parse(_) | ManifestError::UnsafePath { .. } => None,
186        }
187    }
188}
189
190/// A course directory: its root and parsed manifest.
191///
192/// Construct one through [`Course::open`]; a value of this type has, by
193/// construction, a manifest that parsed.
194#[derive(Debug, Clone)]
195pub struct Course {
196    root: PathBuf,
197    manifest: Manifest,
198}
199
200impl Course {
201    /// Open the course rooted at `dir` by reading and parsing its
202    /// `blendtutor.toml`.
203    ///
204    /// The effectful shell (§2.2) over the pure [`Manifest::parse`]. A missing or
205    /// malformed manifest is a whole-course failure (ADR-0004) — distinct from a
206    /// single bad lesson, which [`discover`](Course::discover) reports as a row.
207    pub fn open(dir: &Path) -> Result<Course, ManifestError> {
208        let text =
209            std::fs::read_to_string(dir.join(MANIFEST_FILENAME)).map_err(ManifestError::Read)?;
210        let manifest = Manifest::parse(&text)?;
211        Ok(Course {
212            root: dir.to_path_buf(),
213            manifest,
214        })
215    }
216
217    /// Discover every lesson the manifest lists, one row per entry.
218    ///
219    /// Each entry yields `Ok(LessonSummary)` if its lesson loads and validates,
220    /// or `Err(DiscoveryError)` carrying the entry's slug if it does not — so a
221    /// malformed lesson neither aborts the scan nor disappears (ADR-0004, §1.2).
222    /// The per-lesson read is effectful; the `summarize` it feeds is pure
223    /// (§2.1, §2.2), depending on [`crate::lesson`] in one direction (§3.1).
224    pub fn discover(&self) -> Vec<Result<LessonSummary, DiscoveryError>> {
225        self.manifest
226            .lessons
227            .iter()
228            .map(|entry| {
229                read_lesson_file(&self.root.join(&entry.path))
230                    .map(|lesson| summarize(entry.id.clone(), &lesson))
231                    .map_err(|source| DiscoveryError {
232                        id: entry.id.clone(),
233                        source,
234                    })
235            })
236            .collect()
237    }
238
239    /// Load every lesson the manifest lists, in full and in author order.
240    ///
241    /// The effectful loader the static-site build needs (ADR-0008). Unlike
242    /// [`discover`](Course::discover) — which tolerates a partial failure by
243    /// returning one row per entry — this short-circuits on the first lesson that
244    /// fails to load: a site cannot be built from a course with a broken lesson,
245    /// so the whole build fails rather than emitting a site that silently drops a
246    /// page. Each entry pairs the manifest slug with its parsed [`Lesson`].
247    pub fn load_lessons(&self) -> Result<Vec<(LessonSlug, Lesson)>, DiscoveryError> {
248        self.manifest
249            .lessons
250            .iter()
251            .map(|entry| {
252                read_lesson_file(&self.root.join(&entry.path))
253                    .map(|lesson| (entry.id.clone(), lesson))
254                    .map_err(|source| DiscoveryError {
255                        id: entry.id.clone(),
256                        source,
257                    })
258            })
259            .collect()
260    }
261
262    /// The site-level configuration from the manifest's `[site]` section, if
263    /// present. Returns `None` when the course has no `[site]` section; the
264    /// caller applies [`SiteConfig::default`] (max=20) in that case.
265    pub fn site_config(&self) -> Option<&SiteConfig> {
266        self.manifest.site.as_ref()
267    }
268}
269
270/// One discovered lesson, as listed: its course slug, language, and title.
271///
272/// The title is the lesson's own `lesson_name`; the slug is the manifest id. They
273/// are distinct fields so the listing can show both, and a missing title can
274/// never quietly default to the id (ADR-0004).
275#[derive(Debug, Clone, PartialEq, Eq)]
276pub struct LessonSummary {
277    /// The lesson's course-scoped id, from the manifest.
278    pub id: LessonSlug,
279    /// The language the lesson is authored in.
280    pub language: Language,
281    /// The lesson's human-readable title (its `lesson_name`).
282    pub title: String,
283}
284
285/// Summarize a loaded lesson under its course slug.
286///
287/// Pure (§2.2): it derives the list row from the already-validated lesson and the
288/// manifest-assigned id. It reads `lesson` and never the other way round (§3.1).
289fn summarize(id: LessonSlug, lesson: &Lesson) -> LessonSummary {
290    LessonSummary {
291        id,
292        language: lesson.language.clone(),
293        title: lesson.lesson_name.to_string(),
294    }
295}
296
297/// Why one manifest entry could not be discovered: its slug and the underlying
298/// load failure.
299///
300/// Carrying the slug lets the listing report a broken lesson as a row that still
301/// names which lesson broke; wrapping the [`LoadError`] preserves the
302/// read-vs-validation distinction rather than flattening it to a bare string.
303#[derive(Debug)]
304pub struct DiscoveryError {
305    id: LessonSlug,
306    source: LoadError,
307}
308
309impl DiscoveryError {
310    /// The course slug of the entry that failed to load.
311    pub fn id(&self) -> &LessonSlug {
312        &self.id
313    }
314}
315
316impl fmt::Display for DiscoveryError {
317    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
318        // The slug is rendered by the listing alongside the message; here we
319        // surface just the cause so callers compose their own framing.
320        self.source.fmt(f)
321    }
322}
323
324impl Error for DiscoveryError {
325    fn source(&self) -> Option<&(dyn Error + 'static)> {
326        Some(&self.source)
327    }
328}
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333
334    /// The course fixture with a manifest and two valid lessons, one R and one
335    /// Python. Lives under this crate's fixtures (the schema's home).
336    fn course_basic() -> &'static Path {
337        Path::new(concat!(
338            env!("CARGO_MANIFEST_DIR"),
339            "/tests/fixtures/course_basic"
340        ))
341    }
342
343    #[test]
344    fn manifest_parse_reads_each_entrys_id_and_path() {
345        let manifest = Manifest::parse(
346            r#"
347[[lessons]]
348id = "add-two"
349path = "add_two.yaml"
350
351[[lessons]]
352id = "greet"
353path = "greet.yaml"
354"#,
355        )
356        .expect("a well-formed manifest should parse");
357
358        assert_eq!(manifest.lessons.len(), 2);
359        assert_eq!(manifest.lessons[0].id, LessonSlug("add-two".to_string()));
360        assert_eq!(manifest.lessons[0].path, PathBuf::from("add_two.yaml"));
361        assert_eq!(manifest.lessons[1].id, LessonSlug("greet".to_string()));
362    }
363
364    #[test]
365    fn manifest_parse_rejects_unknown_key_so_author_typos_surface() {
366        let err = Manifest::parse(
367            r#"
368[[lessons]]
369id = "add-two"
370pathh = "add_two.yaml"
371"#,
372        )
373        .expect_err("a typo'd key must not be silently dropped");
374        assert!(
375            matches!(err, ManifestError::Parse(_)),
376            "a malformed manifest is a parse error, got: {err:?}"
377        );
378    }
379
380    #[test]
381    fn manifest_parse_rejects_a_path_escaping_the_course_directory() {
382        for escaping in ["../secrets.yaml", "/etc/passwd", "nested/../../up.yaml"] {
383            let err = Manifest::parse(&format!("[[lessons]]\nid = \"x\"\npath = {escaping:?}\n"))
384                .expect_err("a path leaving the course directory must be refused");
385            assert!(
386                matches!(err, ManifestError::UnsafePath { .. }),
387                "an escaping path should be UnsafePath, got: {err:?} for {escaping}"
388            );
389        }
390    }
391
392    #[test]
393    fn discover_summarizes_each_lesson_with_its_slug_language_and_title() {
394        let course = Course::open(course_basic()).expect("course_basic should open");
395        let rows = course.discover();
396
397        assert_eq!(rows.len(), 2, "course_basic lists two lessons");
398
399        let summary = |slug: &str| -> LessonSummary {
400            rows.iter()
401                .filter_map(|r| r.as_ref().ok())
402                .find(|s| s.id == LessonSlug(slug.to_string()))
403                .unwrap_or_else(|| panic!("an Ok row with slug {slug:?} should be discovered"))
404                .clone()
405        };
406
407        // The slug comes from the manifest; language and title come from the
408        // parsed lesson — distinct sources, so a "title == id" collapse is caught.
409        let add_two = summary("add-two");
410        assert_eq!(add_two.language, Language::R);
411        assert_eq!(add_two.title, "Add Two Numbers");
412
413        let greet = summary("greet");
414        assert_eq!(greet.language, Language::Python);
415        assert_eq!(greet.title, "Greet Someone");
416    }
417
418    #[test]
419    fn discover_reports_a_malformed_lesson_as_an_error_row_keeping_the_good_ones() {
420        let course = Course::open(Path::new(concat!(
421            env!("CARGO_MANIFEST_DIR"),
422            "/tests/fixtures/course_partial"
423        )))
424        .expect("course_partial should open");
425        let rows = course.discover();
426
427        // Three manifest entries -> three rows. Partial failure is represented as
428        // a Vec<Result>: it is neither flattened to Result<Vec> (which would abort
429        // the whole scan on the first bad lesson) nor filtered down to the good
430        // ones (the R-style silent swallow).
431        assert_eq!(rows.len(), 3, "one row per manifest entry");
432        assert_eq!(
433            rows.iter().filter(|row| row.is_ok()).count(),
434            2,
435            "the two valid lessons still discover"
436        );
437
438        let errors: Vec<&DiscoveryError> =
439            rows.iter().filter_map(|row| row.as_ref().err()).collect();
440        assert_eq!(errors.len(), 1, "exactly the one malformed lesson errors");
441        assert_eq!(
442            errors[0].id().to_string(),
443            "broken",
444            "the error row carries the manifest slug, not the unparseable lesson's name"
445        );
446    }
447
448    #[test]
449    fn open_missing_manifest_is_a_read_error_not_a_parse_error() {
450        let err = Course::open(Path::new("/no/such/course"))
451            .expect_err("a directory without a manifest cannot open");
452        assert!(
453            matches!(err, ManifestError::Read(_)),
454            "a missing manifest is a read error, got: {err:?}"
455        );
456    }
457
458    #[test]
459    fn manifest_error_display_and_source_distinguish_each_variant() {
460        use std::io::{Error as IoError, ErrorKind};
461
462        // Read names itself a read failure and exposes the io::Error as its source.
463        let read = ManifestError::Read(IoError::new(ErrorKind::NotFound, "nope"));
464        assert!(
465            read.to_string().contains("could not read course manifest"),
466            "Read should label itself, got: {read}"
467        );
468        assert!(
469            std::error::Error::source(&read).is_some(),
470            "Read should expose the io::Error as its source"
471        );
472
473        // Parse carries the parser message and has no nested source.
474        let parse = ManifestError::Parse("bad toml".to_string());
475        let parse_msg = parse.to_string();
476        assert!(
477            parse_msg.contains("invalid course manifest") && parse_msg.contains("bad toml"),
478            "Parse should frame and carry the message, got: {parse_msg}"
479        );
480        assert!(std::error::Error::source(&parse).is_none());
481
482        // UnsafePath says the path escapes and has no nested source.
483        let unsafe_path = ManifestError::UnsafePath {
484            id: LessonSlug("x".to_string()),
485            path: PathBuf::from("../escape.yaml"),
486        };
487        assert!(
488            unsafe_path.to_string().contains("escapes"),
489            "UnsafePath should say the path escapes, got: {unsafe_path}"
490        );
491        assert!(std::error::Error::source(&unsafe_path).is_none());
492    }
493
494    #[test]
495    fn discovery_error_display_and_source_surface_the_underlying_load_failure() {
496        let course = Course::open(Path::new(concat!(
497            env!("CARGO_MANIFEST_DIR"),
498            "/tests/fixtures/course_partial"
499        )))
500        .expect("course_partial should open");
501        let rows = course.discover();
502        let err = rows
503            .iter()
504            .filter_map(|row| row.as_ref().err())
505            .next()
506            .expect("the malformed lesson produces an error");
507
508        // Display surfaces the underlying load failure rather than an empty string.
509        assert!(
510            err.to_string().contains("language"),
511            "Display should surface the missing-field cause, got: {err}"
512        );
513        // The LoadError is exposed as the source, preserving the error chain.
514        assert!(
515            std::error::Error::source(err).is_some(),
516            "DiscoveryError should expose its LoadError as the source"
517        );
518    }
519
520    #[test]
521    fn load_lessons_returns_every_lesson_in_full_and_in_author_order() {
522        let course = Course::open(course_basic()).expect("course_basic should open");
523        let lessons = course
524            .load_lessons()
525            .expect("every lesson in course_basic loads");
526
527        // Full lessons paired with their manifest slug, in author order — not the
528        // summaries `discover` returns. The order assertion pins manifest order so
529        // a later reader (the site build) emits pages deterministically.
530        let pairs: Vec<(String, Language)> = lessons
531            .iter()
532            .map(|(slug, lesson)| (slug.to_string(), lesson.language.clone()))
533            .collect();
534        assert_eq!(
535            pairs,
536            vec![
537                ("add-two".to_string(), Language::R),
538                ("greet".to_string(), Language::Python),
539            ],
540        );
541    }
542
543    #[test]
544    fn load_lessons_fails_on_the_first_broken_lesson_rather_than_dropping_it() {
545        let course = Course::open(Path::new(concat!(
546            env!("CARGO_MANIFEST_DIR"),
547            "/tests/fixtures/course_partial"
548        )))
549        .expect("course_partial should open");
550
551        // Unlike `discover`, a single broken lesson fails the whole load — a site
552        // must never be built from an incomplete course.
553        let err = course
554            .load_lessons()
555            .expect_err("a course with a malformed lesson cannot be loaded in full");
556        assert_eq!(
557            err.id().to_string(),
558            "broken",
559            "the failure names the manifest slug of the lesson that broke"
560        );
561    }
562
563    // --- AC-4: client-side rate limiting — SiteConfig parse (predicates 1-3) ----
564
565    #[test]
566    fn feedback_rate_limit_manifest_parses_max_from_site_section() {
567        // Predicate 1: Manifest::parse on [site] max_feedback_per_session = 5
568        // yields manifest.site.max_feedback_per_session == 5.
569        let manifest = Manifest::parse(
570            r#"
571[[lessons]]
572id = "add-two"
573path = "add_two.yaml"
574
575[site]
576max_feedback_per_session = 5
577"#,
578        )
579        .expect("a manifest with [site] max=5 should parse");
580        assert_eq!(
581            manifest
582                .site
583                .as_ref()
584                .expect("site config present when [site] is given")
585                .max_feedback_per_session,
586            5,
587        );
588    }
589
590    #[test]
591    fn feedback_rate_limit_manifest_defaults_to_20_without_site_section() {
592        // Predicate 2: Manifest::parse without [site] yields default 20.
593        // The absent [site] section makes manifest.site None; the caller applies
594        // SiteConfig::default() which carries max=20 (#[serde(default)]).
595        let manifest = Manifest::parse(
596            r#"
597[[lessons]]
598id = "add-two"
599path = "add_two.yaml"
600"#,
601        )
602        .expect("a manifest without [site] should parse");
603        assert!(
604            manifest.site.is_none(),
605            "absent [site] yields None; the caller applies SiteConfig::default()"
606        );
607        // The default the caller applies carries max=20.
608        assert_eq!(SiteConfig::default().max_feedback_per_session, 20);
609    }
610
611    #[test]
612    fn feedback_rate_limit_manifest_rejects_negative_max() {
613        // Predicate 3: Manifest::parse on max_feedback_per_session = -1 is Err
614        // (u32 rejects negatives at the parse boundary — §1.3.1).
615        let err = Manifest::parse(
616            r#"
617[[lessons]]
618id = "add-two"
619path = "add_two.yaml"
620
621[site]
622max_feedback_per_session = -1
623"#,
624        )
625        .expect_err("a negative max must be rejected (u32 rejects negatives)");
626        assert!(
627            matches!(err, ManifestError::Parse(_)),
628            "a negative max is a parse error, got: {err:?}"
629        );
630    }
631
632    // --- Course::site_config accessor — kills mutants on the method itself -----
633
634    #[test]
635    fn site_config_returns_the_manifests_site_section_when_present() {
636        // The accessor must return the actual [site] config, not None and not a
637        // default — a non-default max (5) pins both: None fails is_some, default
638        // (max=20) fails the value check.
639        let manifest = Manifest::parse(
640            r#"
641[[lessons]]
642id = "add-two"
643path = "add_two.yaml"
644
645[site]
646max_feedback_per_session = 5
647"#,
648        )
649        .expect("a manifest with [site] max=5 should parse");
650        let course = Course {
651            root: PathBuf::from("."),
652            manifest,
653        };
654        let config = course
655            .site_config()
656            .expect("site_config must return Some when [site] is present");
657        assert_eq!(config.max_feedback_per_session, 5);
658    }
659
660    #[test]
661    fn site_config_returns_none_when_no_site_section() {
662        // Without [site], the accessor returns None — the caller applies
663        // SiteConfig::default() (max=20). Returning a leaked default here would
664        // break the None contract the build relies on.
665        let manifest = Manifest::parse(
666            r#"
667[[lessons]]
668id = "add-two"
669path = "add_two.yaml"
670"#,
671        )
672        .expect("a manifest without [site] should parse");
673        let course = Course {
674            root: PathBuf::from("."),
675            manifest,
676        };
677        assert!(
678            course.site_config().is_none(),
679            "site_config must return None when [site] is absent"
680        );
681    }
682}