Skip to main content

ssg/core/
scaffold.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Project scaffolding.
5//!
6//! Generates a complete starter project structure when `ssg --new`
7//! is invoked, including content, templates, config, and static assets.
8
9use anyhow::{Context, Result};
10use std::{fs, path::Path};
11
12/// Writes a file inside the scaffold project, with a contextual error message.
13fn write_scaffold_file(
14    path: &Path,
15    content: impl AsRef<[u8]>,
16    label: &str,
17) -> Result<()> {
18    fs::write(path, content).with_context(|| format!("Failed to write {label}"))
19}
20
21/// Generates a new project with the given name in the current directory.
22///
23/// # Examples
24///
25/// ```no_run
26/// use ssg::scaffold::scaffold_project;
27///
28/// // Writes into $CWD — gated `no_run` so doctests don't pollute the workspace.
29/// scaffold_project("my-blog").unwrap();
30/// ```
31pub fn scaffold_project(name: &str) -> Result<()> {
32    let cwd = std::env::current_dir()?;
33    scaffold_project_at(name, &cwd)
34}
35
36/// Generates a new project at the given base directory.
37///
38/// # Examples
39///
40/// ```rust
41/// use ssg::scaffold::scaffold_project_at;
42/// use tempfile::tempdir;
43///
44/// let dir = tempdir().unwrap();
45/// scaffold_project_at("demo", dir.path()).unwrap();
46/// assert!(dir.path().join("demo").join("content").is_dir());
47/// ```
48pub fn scaffold_project_at(name: &str, base: &Path) -> Result<()> {
49    let root = base.join(name);
50    if root.exists() {
51        anyhow::bail!("Directory '{name}' already exists");
52    }
53
54    create_scaffold_dirs(name, &root)?;
55    write_config_file(name, &root)?;
56    write_content_files(name, &root)?;
57    write_template_files(&root)?;
58    write_root_templates(&root)?;
59    write_static_assets(&root)?;
60    write_data_files(&root)?;
61
62    println!("Created new project: {name}");
63    println!("  cd {name}");
64    println!("  ssg -f config.toml");
65
66    Ok(())
67}
68
69/// Creates the scaffold directory structure.
70fn create_scaffold_dirs(name: &str, root: &Path) -> Result<()> {
71    let dirs = [
72        "",
73        "content",
74        "content/blog",
75        "templates/tera",
76        "static/css",
77        "data",
78    ];
79    for dir in &dirs {
80        fail_point!("scaffold::create-dir", |_| {
81            anyhow::bail!("injected: scaffold::create-dir")
82        });
83        fs::create_dir_all(root.join(dir))
84            .with_context(|| format!("Failed to create {name}/{dir}"))?;
85    }
86    Ok(())
87}
88
89/// Writes the config.toml file.
90fn write_config_file(name: &str, root: &Path) -> Result<()> {
91    fail_point!("scaffold::write-config", |_| {
92        anyhow::bail!("injected: scaffold::write-config")
93    });
94    write_scaffold_file(
95        &root.join("config.toml"),
96        format!(
97            r#"site_name = "{name}"
98content_dir = "content"
99output_dir = "public"
100template_dir = "templates"
101base_url = "http://127.0.0.1:8000"
102site_title = "{name}"
103site_description = "A site built with SSG"
104language = "en-GB"
105"#
106        ),
107        "config.toml",
108    )
109}
110
111/// Writes all content markdown files.
112fn write_content_files(name: &str, root: &Path) -> Result<()> {
113    fail_point!("scaffold::write-index", |_| {
114        anyhow::bail!("injected: scaffold::write-index")
115    });
116    write_scaffold_file(
117        &root.join("content/index.md"),
118        format!(
119            r"---
120title: Welcome to {name}
121description: A fast, accessible static site built with SSG
122layout: index
123---
124
125# Welcome
126
127This is your new SSG site. Edit `content/index.md` to get started.
128
129## Features
130
131- Tera templating with inheritance
132- WCAG 2.1 AA accessibility by default
133- JSON-LD structured data for SEO
134- Syntax highlighting for code blocks
135- Responsive image optimisation
136- Client-side search
137"
138        ),
139        "content/index.md",
140    )?;
141
142    fail_point!("scaffold::write-about", |_| {
143        anyhow::bail!("injected: scaffold::write-about")
144    });
145    write_scaffold_file(
146        &root.join("content/about.md"),
147        r"---
148title: About
149description: About this site
150layout: page
151---
152
153# About
154
155This page was generated by [SSG](https://static-site-generator.one).
156",
157        "content/about.md",
158    )?;
159
160    fail_point!("scaffold::write-post", |_| {
161        anyhow::bail!("injected: scaffold::write-post")
162    });
163    write_scaffold_file(
164        &root.join("content/blog/first-post.md"),
165        format!(
166            r#"---
167title: First Post
168description: My first blog post
169layout: post
170date: 2026-01-01
171author: {name} Team
172tags:
173  - welcome
174  - getting-started
175categories:
176  - blog
177---
178
179# First Post
180
181Welcome to **{name}**! This is your first blog post.
182
183## Code Example
184
185```rust
186fn main() {{
187    println!("Hello from {name}!");
188}}
189```
190
191{{{{< tip >}}}}
192Edit this file at `content/blog/first-post.md`.
193{{{{< /tip >}}}}
194"#
195        ),
196        "content/blog/first-post.md",
197    )
198}
199
200/// Writes all template files.
201///
202/// The scaffolded `base.html` emits
203/// `<html lang="{{ site.language | default(value='en') }}">`. That is
204/// *not* a site-wide constant: the template engine resolves
205/// `site.language` per page (spec A5, plan §2 1.5 — front-matter
206/// `language` → front-matter `hreflang` → site default → `"en"`, see
207/// `TemplateEngine::render_page` in `core/template_engine.rs`), so a
208/// page with front-matter `language: hi` renders `<html lang="hi">`
209/// even when the site default is `en-GB`.
210/// The `StaticWeaver` templates the compile step requires at the template
211/// directory root.
212///
213/// # Why there are two template sets
214///
215/// A build runs in two stages. `compile_site` delegates markdown → HTML
216/// to `staticdatagen`/`StaticWeaver`, which reads `{{variable}}` templates
217/// from the *root* of the template directory. The plugin pipeline then
218/// post-processes, and one of those plugins renders `MiniJinja` templates
219/// from `templates/tera/`.
220///
221/// Scaffolding only the `MiniJinja` set left `ssg new` producing a project
222/// that `ssg` could not build: the first stage failed before the plugin
223/// that would have used them ever ran, with an opaque
224/// `I/O error at 'public.build-tmp' ... No such file or directory` that
225/// named the output staging directory rather than the missing template
226/// (issue #752).
227///
228/// Four files are required — `template.html`, `index.html`, `page.html`
229/// and `post.html`. That was measured by adding them one at a time to a
230/// scaffolded project: three of the four still fails, and the auxiliary
231/// files ADR-0007 once needed (`main.js`, `sw.js`) make no difference
232/// since `staticdatagen 0.0.10` stopped requiring them.
233///
234/// These are deliberately minimal rather than copies of
235/// `examples/templates/en/*` — those are 262-line demonstrations with a
236/// CDN host baked in, which is not what a new project should start
237/// from. Undeclared `{{ var }}` references are pre-filled by
238/// `content_stager::stage_content_with_site_defaults`, so a template can
239/// reference more than the front matter declares without failing the
240/// build.
241fn write_root_templates(root: &Path) -> Result<()> {
242    fail_point!("scaffold::write-root-template", |_| {
243        anyhow::bail!("injected: scaffold::write-root-template")
244    });
245
246    // The shell every page shares. `{{!content}}` is unescaped: the body
247    // is already HTML by the time it lands here.
248    const BASE: &str = r##"<!DOCTYPE html>
249<html lang="{{language}}">
250<head>
251  <meta charset="{{charset}}" />
252  <meta name="viewport" content="{{viewport}}" />
253  <title>{{title}}</title>
254  <meta name="description" content="{{description}}" />
255  <link rel="canonical" href="{{permalink}}" />
256  <link rel="stylesheet" href="/css/style.css" />
257</head>
258<body>
259  <a href="#main-content" class="sr-only">Skip to main content</a>
260  <header role="banner">
261    <nav aria-label="Main navigation">
262      <a href="/">Home</a>
263      <a href="/about/">About</a>
264    </nav>
265  </header>
266  <main id="main-content" role="main">
267    {{!content}}
268  </main>
269  <footer role="contentinfo">
270    <p>Built with <a href="https://static-site-generator.one">SSG</a>.</p>
271  </footer>
272</body>
273</html>
274"##;
275
276    for (rel, label) in [
277        ("templates/template.html", "templates/template.html"),
278        ("templates/index.html", "templates/index.html"),
279        ("templates/page.html", "templates/page.html"),
280        ("templates/post.html", "templates/post.html"),
281    ] {
282        write_scaffold_file(&root.join(rel), BASE, label)?;
283    }
284
285    Ok(())
286}
287
288fn write_template_files(root: &Path) -> Result<()> {
289    fail_point!("scaffold::write-base", |_| {
290        anyhow::bail!("injected: scaffold::write-base")
291    });
292    write_scaffold_file(
293        &root.join("templates/tera/base.html"),
294        r##"<!DOCTYPE html>
295<html lang="{{ site.language | default(value='en') }}">
296<head>
297  <meta charset="utf-8">
298  <meta name="viewport" content="width=device-width, initial-scale=1">
299  <title>{% block title %}{{ page.title | default(value="Untitled") }}{% if site.title %} — {{ site.title }}{% endif %}{% endblock %}</title>
300  {# `page` is absent on taxonomy pages, which render through this
301     same base with `tag`/`posts` in scope instead. Without the
302     `is defined` guard the whole build aborts with
303     `undefined value (in base.html:7)` while naming `tag.html`,
304     a file the author never wrote. #}
305  {% if page is defined and page.description %}<meta name="description" content="{{ page.description }}">{% endif %}
306  <link rel="stylesheet" href="/css/style.css">
307  {% block head_extra %}{% endblock %}
308</head>
309<body>
310  <a href="#main-content" class="sr-only">Skip to main content</a>
311  <header role="banner">
312    <nav aria-label="Main navigation">
313      <a href="/">{{ site.name | default(value="Home") }}</a>
314      <a href="/about.html">About</a>
315    </nav>
316  </header>
317  <main id="main-content" role="main">
318    {% block content %}{% endblock %}
319  </main>
320  <footer role="contentinfo">
321    <p>&copy; {{ site.name | default(value="") }}. Built with <a href="https://static-site-generator.one">SSG</a>.</p>
322  </footer>
323</body>
324</html>
325"##,
326        "templates/tera/base.html",
327    )?;
328
329    fail_point!("scaffold::write-page-tpl", |_| {
330        anyhow::bail!("injected: scaffold::write-page-tpl")
331    });
332    write_scaffold_file(
333        &root.join("templates/tera/page.html"),
334        r#"{% extends "base.html" %}
335{% block content %}{{ page.content | safe }}{% endblock %}
336"#,
337        "templates/tera/page.html",
338    )?;
339
340    fail_point!("scaffold::write-post-tpl", |_| {
341        anyhow::bail!("injected: scaffold::write-post-tpl")
342    });
343    write_scaffold_file(
344        &root.join("templates/tera/post.html"),
345        r#"{% extends "base.html" %}
346{% block content %}
347<article>
348  <header>
349    <h1>{{ page.title | default(value="") }}</h1>
350    {% if page.date %}<time datetime="{{ page.date }}">{{ page.date }}</time>{% endif %}
351    {% if page.author %}<span class="author">by {{ page.author }}</span>{% endif %}
352    {% if page.content %}<span class="reading-time">{{ page.content | reading_time }}</span>{% endif %}
353  </header>
354  <div class="post-body">{{ page.content | safe }}</div>
355  {% if page.tags %}
356  <footer>
357    <ul class="tags" aria-label="Tags">
358      {% for tag in page.tags %}<li><a href="/tags/{{ tag | slugify }}/">{{ tag }}</a></li>{% endfor %}
359    </ul>
360  </footer>
361  {% endif %}
362</article>
363{% endblock %}
364"#,
365        "templates/tera/post.html",
366    )?;
367
368    fail_point!("scaffold::write-index-tpl", |_| {
369        anyhow::bail!("injected: scaffold::write-index-tpl")
370    });
371    write_scaffold_file(
372        &root.join("templates/tera/index.html"),
373        r#"{% extends "base.html" %}
374{% block title %}{{ site.title | default(value="Home") }}{% endblock %}
375{% block content %}
376<section>{{ page.content | safe }}</section>
377{% endblock %}
378"#,
379        "templates/tera/index.html",
380    )
381}
382
383/// Writes static assets (CSS).
384fn write_static_assets(root: &Path) -> Result<()> {
385    fail_point!("scaffold::write-css", |_| {
386        anyhow::bail!("injected: scaffold::write-css")
387    });
388    write_scaffold_file(
389        &root.join("static/css/style.css"),
390        r#"/* SSG Default Styles */
391:root {
392  --text: #1a1a2e;
393  --bg: #ffffff;
394  --accent: #0066cc;
395  --muted: #6b7280;
396  --border: #e5e7eb;
397  --radius: 6px;
398}
399@media (prefers-color-scheme: dark) {
400  :root {
401    --text: #e6edf3;
402    --bg: #0d1117;
403    --accent: #58a6ff;
404    --muted: #8b949e;
405    --border: #30363d;
406  }
407}
408*, *::before, *::after { box-sizing: border-box; }
409body {
410  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
411  color: var(--text);
412  background: var(--bg);
413  line-height: 1.6;
414  max-width: 48rem;
415  margin: 0 auto;
416  padding: 1rem 1.5rem;
417}
418a { color: var(--accent); }
419nav { display: flex; gap: 1rem; padding: 1rem 0; border-bottom: 1px solid var(--border); }
420main { padding: 2rem 0; }
421footer { border-top: 1px solid var(--border); padding: 1rem 0; color: var(--muted); font-size: 0.875rem; }
422pre { background: #f6f8fa; border: 1px solid var(--border); border-radius: var(--radius); padding: 1em; overflow-x: auto; }
423@media (prefers-color-scheme: dark) { pre { background: #161b22; } }
424code { font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; font-size: 0.875em; }
425.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; }
426.admonition { border-left: 4px solid var(--accent); padding: 0.75rem 1rem; margin: 1rem 0; border-radius: var(--radius); }
427.admonition-title { font-weight: 600; margin-bottom: 0.25rem; }
428.tags { list-style: none; padding: 0; display: flex; gap: 0.5rem; }
429.tags li a { background: var(--border); padding: 0.25rem 0.5rem; border-radius: var(--radius); text-decoration: none; font-size: 0.875rem; }
430article time, article .author, article .reading-time { color: var(--muted); font-size: 0.875rem; margin-right: 1rem; }
431"#,
432        "static/css/style.css",
433    )
434}
435
436/// Writes data files (nav.toml).
437fn write_data_files(root: &Path) -> Result<()> {
438    fail_point!("scaffold::write-nav", |_| {
439        anyhow::bail!("injected: scaffold::write-nav")
440    });
441    write_scaffold_file(
442        &root.join("data/nav.toml"),
443        r#"[[links]]
444name = "Home"
445url = "/"
446
447[[links]]
448name = "About"
449url = "/about.html"
450
451[[links]]
452name = "Blog"
453url = "/blog/"
454"#,
455        "data/nav.toml",
456    )
457}
458
459#[cfg(test)]
460mod tests {
461    use super::*;
462    use tempfile::tempdir;
463
464    // -------------------------------------------------------------------
465    // scaffold_project_at — directory layout
466    // -------------------------------------------------------------------
467
468    #[test]
469    #[serial_test::parallel(scaffold_fp)]
470    fn scaffold_project_at_creates_complete_directory_structure() {
471        let dir = tempdir().unwrap();
472        let name = "test-site";
473        let project = dir.path().join(name);
474        scaffold_project_at(name, dir.path()).unwrap();
475
476        // Every directory in the `dirs` array must exist.
477        for sub in [
478            "",
479            "content",
480            "content/blog",
481            "templates/tera",
482            "static/css",
483            "data",
484        ] {
485            assert!(
486                project.join(sub).exists(),
487                "subdirectory `{sub}` should have been created"
488            );
489        }
490    }
491
492    #[test]
493    #[serial_test::parallel(scaffold_fp)]
494    fn scaffold_project_at_writes_all_expected_files() {
495        let dir = tempdir().unwrap();
496        let name = "demo";
497        let project = dir.path().join(name);
498        scaffold_project_at(name, dir.path()).unwrap();
499
500        for file in [
501            "config.toml",
502            "content/index.md",
503            "content/about.md",
504            "content/blog/first-post.md",
505            "templates/tera/base.html",
506            "templates/tera/page.html",
507            "templates/tera/post.html",
508            "templates/tera/index.html",
509            "static/css/style.css",
510            "data/nav.toml",
511        ] {
512            assert!(
513                project.join(file).exists(),
514                "file `{file}` should have been scaffolded"
515            );
516        }
517    }
518
519    // -------------------------------------------------------------------
520    // scaffold_project_at — content-template personalisation
521    // -------------------------------------------------------------------
522
523    #[test]
524    #[serial_test::parallel(scaffold_fp)]
525    fn scaffold_project_at_injects_project_name_into_config() {
526        let dir = tempdir().unwrap();
527        let name = "my-cool-site";
528        scaffold_project_at(name, dir.path()).unwrap();
529
530        let config =
531            fs::read_to_string(dir.path().join(name).join("config.toml"))
532                .unwrap();
533        assert!(config.contains(&format!(r#"site_name = "{name}""#)));
534        assert!(config.contains(&format!(r#"site_title = "{name}""#)));
535        assert!(config.contains(r#"language = "en-GB""#));
536    }
537
538    #[test]
539    #[serial_test::parallel(scaffold_fp)]
540    fn scaffold_project_at_injects_project_name_into_index_md() {
541        let dir = tempdir().unwrap();
542        let name = "hello";
543        scaffold_project_at(name, dir.path()).unwrap();
544
545        let index =
546            fs::read_to_string(dir.path().join(name).join("content/index.md"))
547                .unwrap();
548        assert!(index.contains(&format!("title: Welcome to {name}")));
549        assert!(index.contains("layout: index"));
550    }
551
552    #[test]
553    #[serial_test::parallel(scaffold_fp)]
554    fn scaffold_project_at_injects_project_name_into_first_post() {
555        let dir = tempdir().unwrap();
556        let name = "projectx";
557        scaffold_project_at(name, dir.path()).unwrap();
558
559        let post = fs::read_to_string(
560            dir.path().join(name).join("content/blog/first-post.md"),
561        )
562        .unwrap();
563        assert!(post.contains(&format!("author: {name} Team")));
564        assert!(post.contains(&format!("Welcome to **{name}**")));
565        assert!(post.contains(&format!(r#"println!("Hello from {name}!");"#)));
566    }
567
568    #[test]
569    #[serial_test::parallel(scaffold_fp)]
570    fn scaffold_project_at_static_assets_include_dark_mode_block() {
571        // Guards the prefers-color-scheme media query in style.css —
572        // accessibility regression tripwire.
573        let dir = tempdir().unwrap();
574        scaffold_project_at("a", dir.path()).unwrap();
575        let css = fs::read_to_string(
576            dir.path().join("a").join("static/css/style.css"),
577        )
578        .unwrap();
579        assert!(css.contains("@media (prefers-color-scheme: dark)"));
580        assert!(css.contains(".sr-only"));
581    }
582
583    #[test]
584    #[serial_test::parallel(scaffold_fp)]
585    fn scaffold_project_at_base_template_has_accessibility_landmarks() {
586        let dir = tempdir().unwrap();
587        scaffold_project_at("x", dir.path()).unwrap();
588        let base = fs::read_to_string(
589            dir.path().join("x").join("templates/tera/base.html"),
590        )
591        .unwrap();
592        assert!(base.contains(r#"role="banner""#));
593        assert!(base.contains(r#"role="main""#));
594        assert!(base.contains(r#"role="contentinfo""#));
595        assert!(base.contains(r#"aria-label="Main navigation""#));
596        assert!(base.contains(r#"class="sr-only""#));
597    }
598
599    #[test]
600    #[serial_test::parallel(scaffold_fp)]
601    fn scaffold_project_at_nav_toml_has_three_default_links() {
602        let dir = tempdir().unwrap();
603        scaffold_project_at("y", dir.path()).unwrap();
604        let nav =
605            fs::read_to_string(dir.path().join("y").join("data/nav.toml"))
606                .unwrap();
607        assert_eq!(nav.matches("[[links]]").count(), 3);
608        assert!(nav.contains(r#"name = "Home""#));
609        assert!(nav.contains(r#"name = "About""#));
610        assert!(nav.contains(r#"name = "Blog""#));
611    }
612
613    // -------------------------------------------------------------------
614    // scaffold_project_at — failure paths
615    // -------------------------------------------------------------------
616
617    #[test]
618    #[serial_test::parallel(scaffold_fp)]
619    fn scaffold_project_at_refuses_to_overwrite_existing_directory() {
620        // The `anyhow::bail!` at line 22 protects user content from
621        // being silently overwritten.
622        let dir = tempdir().unwrap();
623        let name = "existing";
624        fs::create_dir(dir.path().join(name)).unwrap();
625
626        let err = scaffold_project_at(name, dir.path()).unwrap_err();
627        let msg = format!("{err}");
628        assert!(
629            msg.contains("already exists"),
630            "error should mention `already exists`: {msg}"
631        );
632    }
633
634    #[test]
635    #[serial_test::parallel(scaffold_fp)]
636    fn scaffold_project_at_refuses_to_overwrite_existing_file() {
637        // Same guard, but the pre-existing entry is a file, not a
638        // directory — both trigger the `root.exists()` check.
639        let dir = tempdir().unwrap();
640        let name = "blocker";
641        fs::write(dir.path().join(name), "i exist").unwrap();
642
643        assert!(scaffold_project_at(name, dir.path()).is_err());
644    }
645
646    // -------------------------------------------------------------------
647    // scaffold_project — default wrapper around CWD
648    // -------------------------------------------------------------------
649
650    #[test]
651    #[serial_test::serial(cwd, scaffold_fp)]
652    fn scaffold_project_uses_current_working_directory() {
653        // Exercises the `scaffold_project` entry point at line 13,
654        // which wraps `scaffold_project_at` with env::current_dir().
655        // We pushd into a tempdir so we don't pollute the repo root.
656        let dir = tempdir().unwrap();
657        let prev = std::env::current_dir().expect("read current dir");
658        std::env::set_current_dir(&dir).expect("pushd");
659
660        let result = scaffold_project("from-cwd");
661
662        // Always restore cwd, even if the call failed.
663        std::env::set_current_dir(&prev).expect("popd");
664
665        result.expect("scaffold should succeed in a fresh cwd");
666        assert!(dir.path().join("from-cwd").join("config.toml").exists());
667    }
668
669    // -----------------------------------------------------------------
670    // write_scaffold_file — unit tests
671    // -----------------------------------------------------------------
672
673    #[test]
674    #[serial_test::parallel(scaffold_fp)]
675    fn write_scaffold_file_creates_file() {
676        let dir = tempdir().unwrap();
677        let path = dir.path().join("test.txt");
678        write_scaffold_file(&path, "hello", "test.txt").unwrap();
679        assert_eq!(fs::read_to_string(&path).unwrap(), "hello");
680    }
681
682    #[test]
683    #[serial_test::parallel(scaffold_fp)]
684    fn write_scaffold_file_overwrites_existing() {
685        let dir = tempdir().unwrap();
686        let path = dir.path().join("test.txt");
687        fs::write(&path, "old").unwrap();
688        write_scaffold_file(&path, "new", "test.txt").unwrap();
689        assert_eq!(fs::read_to_string(&path).unwrap(), "new");
690    }
691
692    #[test]
693    #[serial_test::parallel(scaffold_fp)]
694    fn write_scaffold_file_error_has_label() {
695        let err = write_scaffold_file(
696            Path::new("/no/such/dir/file.txt"),
697            "x",
698            "my-label",
699        )
700        .unwrap_err();
701        let msg = format!("{err:#}");
702        assert!(
703            msg.contains("my-label"),
704            "error should include the label: {msg}"
705        );
706    }
707
708    #[test]
709    #[serial_test::parallel(scaffold_fp)]
710    fn write_scaffold_file_empty_content() {
711        let dir = tempdir().unwrap();
712        let path = dir.path().join("empty.txt");
713        write_scaffold_file(&path, "", "empty.txt").unwrap();
714        assert_eq!(fs::read_to_string(&path).unwrap(), "");
715    }
716
717    #[test]
718    #[serial_test::parallel(scaffold_fp)]
719    fn write_scaffold_file_binary_content() {
720        let dir = tempdir().unwrap();
721        let path = dir.path().join("bin.dat");
722        let data: &[u8] = &[0x00, 0xFF, 0xAB, 0xCD];
723        write_scaffold_file(&path, data, "bin.dat").unwrap();
724        assert_eq!(fs::read(&path).unwrap(), data);
725    }
726
727    // -----------------------------------------------------------------
728    // create_scaffold_dirs — direct tests
729    // -----------------------------------------------------------------
730
731    #[test]
732    #[serial_test::parallel(scaffold_fp)]
733    fn create_scaffold_dirs_creates_all_expected_dirs() {
734        let dir = tempdir().unwrap();
735        let root = dir.path().join("proj");
736        create_scaffold_dirs("proj", &root).unwrap();
737
738        for sub in [
739            "",
740            "content",
741            "content/blog",
742            "templates/tera",
743            "static/css",
744            "data",
745        ] {
746            assert!(root.join(sub).exists(), "{sub} should exist");
747        }
748    }
749
750    #[test]
751    #[serial_test::parallel(scaffold_fp)]
752    fn create_scaffold_dirs_idempotent() {
753        let dir = tempdir().unwrap();
754        let root = dir.path().join("proj");
755        create_scaffold_dirs("proj", &root).unwrap();
756        // Calling again should not fail
757        create_scaffold_dirs("proj", &root).unwrap();
758    }
759
760    // -----------------------------------------------------------------
761    // write_config_file — direct tests
762    // -----------------------------------------------------------------
763
764    #[test]
765    #[serial_test::parallel(scaffold_fp)]
766    fn write_config_file_content() {
767        let dir = tempdir().unwrap();
768        let root = dir.path().join("proj");
769        fs::create_dir_all(&root).unwrap();
770        write_config_file("my-site", &root).unwrap();
771
772        let content = fs::read_to_string(root.join("config.toml")).unwrap();
773        assert!(content.contains(r#"site_name = "my-site""#));
774        assert!(content.contains(r#"site_title = "my-site""#));
775        assert!(content.contains(r#"content_dir = "content""#));
776        assert!(content.contains(r#"output_dir = "public""#));
777        assert!(content.contains(r#"template_dir = "templates""#));
778        assert!(content.contains("http://127.0.0.1:8000"));
779        assert!(content.contains(r#"language = "en-GB""#));
780    }
781
782    // -----------------------------------------------------------------
783    // write_content_files — direct tests
784    // -----------------------------------------------------------------
785
786    #[test]
787    #[serial_test::parallel(scaffold_fp)]
788    fn write_content_files_creates_all_content() {
789        let dir = tempdir().unwrap();
790        let root = dir.path().join("proj");
791        fs::create_dir_all(root.join("content/blog")).unwrap();
792        write_content_files("test-proj", &root).unwrap();
793
794        assert!(root.join("content/index.md").exists());
795        assert!(root.join("content/about.md").exists());
796        assert!(root.join("content/blog/first-post.md").exists());
797    }
798
799    #[test]
800    #[serial_test::parallel(scaffold_fp)]
801    fn write_content_files_about_has_correct_frontmatter() {
802        let dir = tempdir().unwrap();
803        let root = dir.path().join("proj");
804        fs::create_dir_all(root.join("content/blog")).unwrap();
805        write_content_files("test-proj", &root).unwrap();
806
807        let about = fs::read_to_string(root.join("content/about.md")).unwrap();
808        assert!(about.contains("title: About"));
809        assert!(about.contains("layout: page"));
810        assert!(about.contains("static-site-generator.one"));
811    }
812
813    #[test]
814    #[serial_test::parallel(scaffold_fp)]
815    fn write_content_files_index_has_features_list() {
816        let dir = tempdir().unwrap();
817        let root = dir.path().join("proj");
818        fs::create_dir_all(root.join("content/blog")).unwrap();
819        write_content_files("proj", &root).unwrap();
820
821        let index = fs::read_to_string(root.join("content/index.md")).unwrap();
822        assert!(index.contains("## Features"));
823        assert!(index.contains("- Tera templating"));
824    }
825
826    #[test]
827    #[serial_test::parallel(scaffold_fp)]
828    fn write_content_files_first_post_has_tags_and_code() {
829        let dir = tempdir().unwrap();
830        let root = dir.path().join("proj");
831        fs::create_dir_all(root.join("content/blog")).unwrap();
832        write_content_files("proj", &root).unwrap();
833
834        let post = fs::read_to_string(root.join("content/blog/first-post.md"))
835            .unwrap();
836        assert!(post.contains("tags:"));
837        assert!(post.contains("- welcome"));
838        assert!(post.contains("```rust"));
839        assert!(post.contains("categories:"));
840    }
841
842    // -----------------------------------------------------------------
843    // write_template_files — direct tests
844    // -----------------------------------------------------------------
845
846    #[test]
847    #[serial_test::parallel(scaffold_fp)]
848    fn write_template_files_creates_all_templates() {
849        let dir = tempdir().unwrap();
850        let root = dir.path().join("proj");
851        fs::create_dir_all(root.join("templates/tera")).unwrap();
852        write_template_files(&root).unwrap();
853
854        for file in [
855            "templates/tera/base.html",
856            "templates/tera/page.html",
857            "templates/tera/post.html",
858            "templates/tera/index.html",
859        ] {
860            assert!(root.join(file).exists(), "{file} should exist");
861        }
862    }
863
864    #[test]
865    #[serial_test::parallel(scaffold_fp)]
866    fn write_template_files_page_extends_base() {
867        let dir = tempdir().unwrap();
868        let root = dir.path().join("proj");
869        fs::create_dir_all(root.join("templates/tera")).unwrap();
870        write_template_files(&root).unwrap();
871
872        let page =
873            fs::read_to_string(root.join("templates/tera/page.html")).unwrap();
874        assert!(page.contains(r#"extends "base.html""#));
875        assert!(page.contains("block content"));
876    }
877
878    #[test]
879    #[serial_test::parallel(scaffold_fp)]
880    fn write_template_files_post_has_article_structure() {
881        let dir = tempdir().unwrap();
882        let root = dir.path().join("proj");
883        fs::create_dir_all(root.join("templates/tera")).unwrap();
884        write_template_files(&root).unwrap();
885
886        let post =
887            fs::read_to_string(root.join("templates/tera/post.html")).unwrap();
888        assert!(post.contains("<article>"));
889        assert!(post.contains("page.title"));
890        assert!(post.contains("page.date"));
891        assert!(post.contains("page.tags"));
892        assert!(post.contains("reading_time"));
893    }
894
895    #[test]
896    #[serial_test::parallel(scaffold_fp)]
897    fn write_template_files_index_extends_base() {
898        let dir = tempdir().unwrap();
899        let root = dir.path().join("proj");
900        fs::create_dir_all(root.join("templates/tera")).unwrap();
901        write_template_files(&root).unwrap();
902
903        let index =
904            fs::read_to_string(root.join("templates/tera/index.html")).unwrap();
905        assert!(index.contains(r#"extends "base.html""#));
906        assert!(index.contains("site.title"));
907    }
908
909    // -----------------------------------------------------------------
910    // write_static_assets — direct tests
911    // -----------------------------------------------------------------
912
913    #[test]
914    #[serial_test::parallel(scaffold_fp)]
915    fn write_static_assets_creates_stylesheet() {
916        let dir = tempdir().unwrap();
917        let root = dir.path().join("proj");
918        fs::create_dir_all(root.join("static/css")).unwrap();
919        write_static_assets(&root).unwrap();
920
921        let css =
922            fs::read_to_string(root.join("static/css/style.css")).unwrap();
923        assert!(css.contains(":root"));
924        assert!(css.contains("--text:"));
925        assert!(css.contains("--bg:"));
926        assert!(css.contains("--accent:"));
927        assert!(css.contains("box-sizing: border-box"));
928    }
929
930    // -----------------------------------------------------------------
931    // write_data_files — direct tests
932    // -----------------------------------------------------------------
933
934    #[test]
935    #[serial_test::parallel(scaffold_fp)]
936    fn write_data_files_creates_nav_toml() {
937        let dir = tempdir().unwrap();
938        let root = dir.path().join("proj");
939        fs::create_dir_all(root.join("data")).unwrap();
940        write_data_files(&root).unwrap();
941
942        let nav = fs::read_to_string(root.join("data/nav.toml")).unwrap();
943        assert_eq!(nav.matches("[[links]]").count(), 3);
944        assert!(nav.contains(r#"url = "/""#));
945        assert!(nav.contains(r#"url = "/about.html""#));
946        assert!(nav.contains(r#"url = "/blog/""#));
947    }
948
949    // -----------------------------------------------------------------
950    // scaffold_project_at — name injection edge cases
951    // -----------------------------------------------------------------
952
953    #[test]
954    #[serial_test::parallel(scaffold_fp)]
955    fn scaffold_project_at_special_chars_in_name() {
956        let dir = tempdir().unwrap();
957        let name = "my-cool_site.2026";
958        scaffold_project_at(name, dir.path()).unwrap();
959
960        let config =
961            fs::read_to_string(dir.path().join(name).join("config.toml"))
962                .unwrap();
963        assert!(config.contains(&format!(r#"site_name = "{name}""#)));
964    }
965
966    #[test]
967    #[serial_test::parallel(scaffold_fp)]
968    fn scaffold_project_at_single_char_name() {
969        let dir = tempdir().unwrap();
970        scaffold_project_at("z", dir.path()).unwrap();
971        assert!(dir.path().join("z").join("config.toml").exists());
972    }
973
974    // -----------------------------------------------------------------
975    // scaffold_project_at — template content validation
976    // -----------------------------------------------------------------
977
978    #[test]
979    #[serial_test::parallel(scaffold_fp)]
980    fn scaffold_project_at_base_template_is_valid_html() {
981        let dir = tempdir().unwrap();
982        scaffold_project_at("t", dir.path()).unwrap();
983        let base = fs::read_to_string(
984            dir.path().join("t").join("templates/tera/base.html"),
985        )
986        .unwrap();
987        assert!(base.starts_with("<!DOCTYPE html>"));
988        assert!(base.contains("</html>"));
989        assert!(base.contains("<head>"));
990        assert!(base.contains("</head>"));
991        assert!(base.contains("<body>"));
992        assert!(base.contains("</body>"));
993    }
994
995    #[test]
996    #[serial_test::parallel(scaffold_fp)]
997    fn scaffold_project_at_config_has_all_required_keys() {
998        let dir = tempdir().unwrap();
999        scaffold_project_at("k", dir.path()).unwrap();
1000        let config =
1001            fs::read_to_string(dir.path().join("k").join("config.toml"))
1002                .unwrap();
1003        for key in [
1004            "site_name",
1005            "content_dir",
1006            "output_dir",
1007            "template_dir",
1008            "base_url",
1009            "site_title",
1010            "site_description",
1011            "language",
1012        ] {
1013            assert!(
1014                config.contains(key),
1015                "config.toml should contain key: {key}"
1016            );
1017        }
1018    }
1019
1020    #[test]
1021    #[serial_test::parallel(scaffold_fp)]
1022    fn scaffold_project_at_errors_when_root_already_exists() {
1023        // Pre-create the project dir so the `if root.exists()` arm fires.
1024        let dir = tempdir().unwrap();
1025        fs::create_dir_all(dir.path().join("dupe")).unwrap();
1026        let res = scaffold_project_at("dupe", dir.path());
1027        assert!(res.is_err());
1028        let msg = format!("{:?}", res.unwrap_err());
1029        assert!(msg.contains("already exists"));
1030    }
1031
1032    #[test]
1033    #[serial_test::parallel(scaffold_fp)]
1034    fn write_scaffold_file_errors_propagate_via_with_context() {
1035        // Both generic instantiations: &str and String. Pointing at a
1036        // path whose parent doesn't exist makes fs::write fail and
1037        // exercises the with_context closure for that monomorphisation.
1038        let dir = tempdir().unwrap();
1039        let missing_parent = dir.path().join("nope/nope/file.txt");
1040
1041        // &[u8]-as-AsRef::<[u8]> from &str:
1042        let res_str = write_scaffold_file(&missing_parent, "body", "label-str");
1043        assert!(res_str.is_err());
1044        let msg_str = format!("{:?}", res_str.unwrap_err());
1045        assert!(msg_str.contains("label-str"));
1046
1047        // String instantiation:
1048        let res_string = write_scaffold_file(
1049            &missing_parent,
1050            String::from("body"),
1051            "label-string",
1052        );
1053        assert!(res_string.is_err());
1054        let msg_string = format!("{:?}", res_string.unwrap_err());
1055        assert!(msg_string.contains("label-string"));
1056    }
1057
1058    // -----------------------------------------------------------------
1059    // scaffold_project — current_dir failure
1060    // -----------------------------------------------------------------
1061
1062    #[test]
1063    #[serial_test::serial(cwd, scaffold_fp)]
1064    // Windows locks a directory that is a process's current working
1065    // directory, so `fs::remove_dir` on it fails with "the process
1066    // cannot access the file" — there is no way to simulate a deleted
1067    // cwd via this mechanism on that platform.
1068    #[cfg(not(windows))]
1069    fn scaffold_project_fails_when_cwd_has_been_deleted() {
1070        // Deleting the process's working directory makes
1071        // `std::env::current_dir()` fail, driving the `?` on line 32.
1072        let dir = tempdir().unwrap();
1073        let doomed = dir.path().join("gone");
1074        fs::create_dir(&doomed).unwrap();
1075        let prev = std::env::current_dir().expect("read current dir");
1076        std::env::set_current_dir(&doomed).expect("pushd");
1077        fs::remove_dir(&doomed).expect("delete cwd");
1078
1079        let result = scaffold_project("orphan");
1080
1081        // Always restore cwd before asserting.
1082        std::env::set_current_dir(&prev).expect("popd");
1083        assert!(result.is_err(), "deleted cwd must surface as an error");
1084    }
1085
1086    // -----------------------------------------------------------------
1087    // create_scaffold_dirs / write_* — real filesystem failures
1088    // -----------------------------------------------------------------
1089
1090    #[test]
1091    #[cfg(unix)]
1092    #[serial_test::parallel(scaffold_fp)]
1093    fn scaffold_project_at_read_only_base_fails_dir_creation() {
1094        // The very first create_dir_all fails, exercising the
1095        // with_context closure in create_scaffold_dirs and the `?`
1096        // in scaffold_project_at.
1097        use std::os::unix::fs::PermissionsExt;
1098        let dir = tempdir().unwrap();
1099        fs::set_permissions(dir.path(), fs::Permissions::from_mode(0o555))
1100            .unwrap();
1101
1102        let res = scaffold_project_at("blocked", dir.path());
1103
1104        let _ =
1105            fs::set_permissions(dir.path(), fs::Permissions::from_mode(0o755));
1106        // Root bypasses permissions on some CI runners, so tolerate
1107        // Ok; when it failed, the context must name the directory.
1108        assert!(res
1109            .err()
1110            .is_none_or(|e| format!("{e:?}").contains("Failed to create")));
1111    }
1112
1113    #[test]
1114    #[serial_test::parallel(scaffold_fp)]
1115    fn write_content_files_index_write_failure_propagates() {
1116        // A directory squatting on content/index.md fails the first
1117        // write in write_content_files.
1118        let dir = tempdir().unwrap();
1119        let root = dir.path().join("proj");
1120        fs::create_dir_all(root.join("content/blog")).unwrap();
1121        fs::create_dir_all(root.join("content/index.md")).unwrap();
1122
1123        assert!(write_content_files("p", &root).is_err());
1124    }
1125
1126    #[test]
1127    #[serial_test::parallel(scaffold_fp)]
1128    fn write_content_files_about_write_failure_propagates() {
1129        // index.md succeeds, about.md fails.
1130        let dir = tempdir().unwrap();
1131        let root = dir.path().join("proj");
1132        fs::create_dir_all(root.join("content/blog")).unwrap();
1133        fs::create_dir_all(root.join("content/about.md")).unwrap();
1134
1135        assert!(write_content_files("p", &root).is_err());
1136        assert!(root.join("content/index.md").is_file());
1137    }
1138
1139    #[test]
1140    #[serial_test::parallel(scaffold_fp)]
1141    fn write_template_files_base_write_failure_propagates() {
1142        let dir = tempdir().unwrap();
1143        let root = dir.path().join("proj");
1144        fs::create_dir_all(root.join("templates/tera/base.html")).unwrap();
1145
1146        assert!(write_template_files(&root).is_err());
1147    }
1148
1149    #[test]
1150    #[serial_test::parallel(scaffold_fp)]
1151    fn write_template_files_page_write_failure_propagates() {
1152        let dir = tempdir().unwrap();
1153        let root = dir.path().join("proj");
1154        fs::create_dir_all(root.join("templates/tera/page.html")).unwrap();
1155
1156        assert!(write_template_files(&root).is_err());
1157        assert!(root.join("templates/tera/base.html").is_file());
1158    }
1159
1160    #[test]
1161    #[serial_test::parallel(scaffold_fp)]
1162    fn write_template_files_post_write_failure_propagates() {
1163        let dir = tempdir().unwrap();
1164        let root = dir.path().join("proj");
1165        fs::create_dir_all(root.join("templates/tera/post.html")).unwrap();
1166
1167        assert!(write_template_files(&root).is_err());
1168        assert!(root.join("templates/tera/page.html").is_file());
1169    }
1170
1171    // -----------------------------------------------------------------
1172    // Fault injection — every scaffold failpoint, driven from the lib
1173    // test binary so `cargo llvm-cov --lib` sees the closures execute.
1174    // Each test holds the `scaffold_fp` serial key, so the parallel
1175    // scaffold tests above never observe an activated failpoint.
1176    // -----------------------------------------------------------------
1177
1178    #[cfg(feature = "test-fault-injection")]
1179    mod fault_injection {
1180        use super::*;
1181
1182        /// RAII guard that disables a failpoint on drop, so a
1183        /// panicking assertion still cleans up global state.
1184        struct FailGuard(&'static str);
1185        impl Drop for FailGuard {
1186            fn drop(&mut self) {
1187                let _ = fail::cfg(self.0, "off");
1188            }
1189        }
1190
1191        /// Activates `name`, scaffolds into a fresh tempdir, and
1192        /// returns the error for assertion.
1193        fn run_scaffold_with_failpoint(name: &'static str) -> anyhow::Error {
1194            let _guard = FailGuard(name);
1195            fail::cfg(name, "return").expect("activate failpoint");
1196            let dir = tempdir().expect("tempdir");
1197            scaffold_project_at("fault-site", dir.path())
1198                .expect_err("scaffold should fail when failpoint is active")
1199        }
1200
1201        macro_rules! scaffold_failpoint_test {
1202            ($test_name:ident, $failpoint:literal) => {
1203                #[test]
1204                #[serial_test::serial(scaffold_fp)]
1205                fn $test_name() {
1206                    let err = run_scaffold_with_failpoint($failpoint);
1207                    assert!(
1208                        format!("{err:?}").contains($failpoint),
1209                        "error should carry the injected context: {err:?}"
1210                    );
1211                }
1212            };
1213        }
1214
1215        scaffold_failpoint_test!(fp_create_dir, "scaffold::create-dir");
1216        scaffold_failpoint_test!(fp_write_config, "scaffold::write-config");
1217        scaffold_failpoint_test!(fp_write_index, "scaffold::write-index");
1218        scaffold_failpoint_test!(fp_write_about, "scaffold::write-about");
1219        scaffold_failpoint_test!(fp_write_post, "scaffold::write-post");
1220        scaffold_failpoint_test!(fp_write_base, "scaffold::write-base");
1221        scaffold_failpoint_test!(fp_write_page_tpl, "scaffold::write-page-tpl");
1222        scaffold_failpoint_test!(fp_write_post_tpl, "scaffold::write-post-tpl");
1223        scaffold_failpoint_test!(
1224            fp_write_index_tpl,
1225            "scaffold::write-index-tpl"
1226        );
1227        scaffold_failpoint_test!(fp_write_css, "scaffold::write-css");
1228        scaffold_failpoint_test!(fp_write_nav, "scaffold::write-nav");
1229    }
1230}