Skip to main content

ssg/
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.
22pub fn scaffold_project(name: &str) -> Result<()> {
23    let cwd = std::env::current_dir()?;
24    scaffold_project_at(name, &cwd)
25}
26
27/// Generates a new project at the given base directory.
28pub fn scaffold_project_at(name: &str, base: &Path) -> Result<()> {
29    let root = base.join(name);
30    if root.exists() {
31        anyhow::bail!("Directory '{name}' already exists");
32    }
33
34    create_scaffold_dirs(name, &root)?;
35    write_config_file(name, &root)?;
36    write_content_files(name, &root)?;
37    write_template_files(&root)?;
38    write_static_assets(&root)?;
39    write_data_files(&root)?;
40
41    println!("Created new project: {name}");
42    println!("  cd {name}");
43    println!("  ssg -f config.toml");
44
45    Ok(())
46}
47
48/// Creates the scaffold directory structure.
49fn create_scaffold_dirs(name: &str, root: &Path) -> Result<()> {
50    let dirs = [
51        "",
52        "content",
53        "content/blog",
54        "templates/tera",
55        "static/css",
56        "data",
57    ];
58    for dir in &dirs {
59        fail_point!("scaffold::create-dir", |_| {
60            anyhow::bail!("injected: scaffold::create-dir")
61        });
62        fs::create_dir_all(root.join(dir))
63            .with_context(|| format!("Failed to create {name}/{dir}"))?;
64    }
65    Ok(())
66}
67
68/// Writes the config.toml file.
69fn write_config_file(name: &str, root: &Path) -> Result<()> {
70    fail_point!("scaffold::write-config", |_| {
71        anyhow::bail!("injected: scaffold::write-config")
72    });
73    write_scaffold_file(
74        &root.join("config.toml"),
75        format!(
76            r#"site_name = "{name}"
77content_dir = "content"
78output_dir = "public"
79template_dir = "templates"
80base_url = "http://127.0.0.1:8000"
81site_title = "{name}"
82site_description = "A site built with SSG"
83language = "en-GB"
84"#
85        ),
86        "config.toml",
87    )
88}
89
90/// Writes all content markdown files.
91fn write_content_files(name: &str, root: &Path) -> Result<()> {
92    fail_point!("scaffold::write-index", |_| {
93        anyhow::bail!("injected: scaffold::write-index")
94    });
95    write_scaffold_file(
96        &root.join("content/index.md"),
97        format!(
98            r"---
99title: Welcome to {name}
100description: A fast, accessible static site built with SSG
101layout: index
102---
103
104# Welcome
105
106This is your new SSG site. Edit `content/index.md` to get started.
107
108## Features
109
110- Tera templating with inheritance
111- WCAG 2.1 AA accessibility by default
112- JSON-LD structured data for SEO
113- Syntax highlighting for code blocks
114- Responsive image optimisation
115- Client-side search
116"
117        ),
118        "content/index.md",
119    )?;
120
121    fail_point!("scaffold::write-about", |_| {
122        anyhow::bail!("injected: scaffold::write-about")
123    });
124    write_scaffold_file(
125        &root.join("content/about.md"),
126        r"---
127title: About
128description: About this site
129layout: page
130---
131
132# About
133
134This page was generated by [SSG](https://static-site-generator.one).
135",
136        "content/about.md",
137    )?;
138
139    fail_point!("scaffold::write-post", |_| {
140        anyhow::bail!("injected: scaffold::write-post")
141    });
142    write_scaffold_file(
143        &root.join("content/blog/first-post.md"),
144        format!(
145            r#"---
146title: First Post
147description: My first blog post
148layout: post
149date: 2026-01-01
150author: {name} Team
151tags:
152  - welcome
153  - getting-started
154categories:
155  - blog
156---
157
158# First Post
159
160Welcome to **{name}**! This is your first blog post.
161
162## Code Example
163
164```rust
165fn main() {{
166    println!("Hello from {name}!");
167}}
168```
169
170{{{{< tip >}}}}
171Edit this file at `content/blog/first-post.md`.
172{{{{< /tip >}}}}
173"#
174        ),
175        "content/blog/first-post.md",
176    )
177}
178
179/// Writes all template files.
180fn write_template_files(root: &Path) -> Result<()> {
181    fail_point!("scaffold::write-base", |_| {
182        anyhow::bail!("injected: scaffold::write-base")
183    });
184    write_scaffold_file(
185        &root.join("templates/tera/base.html"),
186        r##"<!DOCTYPE html>
187<html lang="{{ site.language | default(value='en') }}">
188<head>
189  <meta charset="utf-8">
190  <meta name="viewport" content="width=device-width, initial-scale=1">
191  <title>{% block title %}{{ page.title | default(value="Untitled") }}{% if site.title %} — {{ site.title }}{% endif %}{% endblock %}</title>
192  {% if page.description %}<meta name="description" content="{{ page.description }}">{% endif %}
193  <link rel="stylesheet" href="/css/style.css">
194  {% block head_extra %}{% endblock %}
195</head>
196<body>
197  <a href="#main-content" class="sr-only">Skip to main content</a>
198  <header role="banner">
199    <nav aria-label="Main navigation">
200      <a href="/">{{ site.name | default(value="Home") }}</a>
201      <a href="/about.html">About</a>
202    </nav>
203  </header>
204  <main id="main-content" role="main">
205    {% block content %}{% endblock %}
206  </main>
207  <footer role="contentinfo">
208    <p>&copy; {{ site.name | default(value="") }}. Built with <a href="https://static-site-generator.one">SSG</a>.</p>
209  </footer>
210</body>
211</html>
212"##,
213        "templates/tera/base.html",
214    )?;
215
216    fail_point!("scaffold::write-page-tpl", |_| {
217        anyhow::bail!("injected: scaffold::write-page-tpl")
218    });
219    write_scaffold_file(
220        &root.join("templates/tera/page.html"),
221        r#"{% extends "base.html" %}
222{% block content %}{{ page.content | safe }}{% endblock %}
223"#,
224        "templates/tera/page.html",
225    )?;
226
227    fail_point!("scaffold::write-post-tpl", |_| {
228        anyhow::bail!("injected: scaffold::write-post-tpl")
229    });
230    write_scaffold_file(
231        &root.join("templates/tera/post.html"),
232        r#"{% extends "base.html" %}
233{% block content %}
234<article>
235  <header>
236    <h1>{{ page.title | default(value="") }}</h1>
237    {% if page.date %}<time datetime="{{ page.date }}">{{ page.date }}</time>{% endif %}
238    {% if page.author %}<span class="author">by {{ page.author }}</span>{% endif %}
239    {% if page.content %}<span class="reading-time">{{ page.content | reading_time }}</span>{% endif %}
240  </header>
241  <div class="post-body">{{ page.content | safe }}</div>
242  {% if page.tags %}
243  <footer>
244    <ul class="tags" aria-label="Tags">
245      {% for tag in page.tags %}<li><a href="/tags/{{ tag | slugify }}/">{{ tag }}</a></li>{% endfor %}
246    </ul>
247  </footer>
248  {% endif %}
249</article>
250{% endblock %}
251"#,
252        "templates/tera/post.html",
253    )?;
254
255    fail_point!("scaffold::write-index-tpl", |_| {
256        anyhow::bail!("injected: scaffold::write-index-tpl")
257    });
258    write_scaffold_file(
259        &root.join("templates/tera/index.html"),
260        r#"{% extends "base.html" %}
261{% block title %}{{ site.title | default(value="Home") }}{% endblock %}
262{% block content %}
263<section>{{ page.content | safe }}</section>
264{% endblock %}
265"#,
266        "templates/tera/index.html",
267    )
268}
269
270/// Writes static assets (CSS).
271fn write_static_assets(root: &Path) -> Result<()> {
272    fail_point!("scaffold::write-css", |_| {
273        anyhow::bail!("injected: scaffold::write-css")
274    });
275    write_scaffold_file(
276        &root.join("static/css/style.css"),
277        r#"/* SSG Default Styles */
278:root {
279  --text: #1a1a2e;
280  --bg: #ffffff;
281  --accent: #0066cc;
282  --muted: #6b7280;
283  --border: #e5e7eb;
284  --radius: 6px;
285}
286@media (prefers-color-scheme: dark) {
287  :root {
288    --text: #e6edf3;
289    --bg: #0d1117;
290    --accent: #58a6ff;
291    --muted: #8b949e;
292    --border: #30363d;
293  }
294}
295*, *::before, *::after { box-sizing: border-box; }
296body {
297  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
298  color: var(--text);
299  background: var(--bg);
300  line-height: 1.6;
301  max-width: 48rem;
302  margin: 0 auto;
303  padding: 1rem 1.5rem;
304}
305a { color: var(--accent); }
306nav { display: flex; gap: 1rem; padding: 1rem 0; border-bottom: 1px solid var(--border); }
307main { padding: 2rem 0; }
308footer { border-top: 1px solid var(--border); padding: 1rem 0; color: var(--muted); font-size: 0.875rem; }
309pre { background: #f6f8fa; border: 1px solid var(--border); border-radius: var(--radius); padding: 1em; overflow-x: auto; }
310@media (prefers-color-scheme: dark) { pre { background: #161b22; } }
311code { font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; font-size: 0.875em; }
312.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; }
313.admonition { border-left: 4px solid var(--accent); padding: 0.75rem 1rem; margin: 1rem 0; border-radius: var(--radius); }
314.admonition-title { font-weight: 600; margin-bottom: 0.25rem; }
315.tags { list-style: none; padding: 0; display: flex; gap: 0.5rem; }
316.tags li a { background: var(--border); padding: 0.25rem 0.5rem; border-radius: var(--radius); text-decoration: none; font-size: 0.875rem; }
317article time, article .author, article .reading-time { color: var(--muted); font-size: 0.875rem; margin-right: 1rem; }
318"#,
319        "static/css/style.css",
320    )
321}
322
323/// Writes data files (nav.toml).
324fn write_data_files(root: &Path) -> Result<()> {
325    fail_point!("scaffold::write-nav", |_| {
326        anyhow::bail!("injected: scaffold::write-nav")
327    });
328    write_scaffold_file(
329        &root.join("data/nav.toml"),
330        r#"[[links]]
331name = "Home"
332url = "/"
333
334[[links]]
335name = "About"
336url = "/about.html"
337
338[[links]]
339name = "Blog"
340url = "/blog/"
341"#,
342        "data/nav.toml",
343    )
344}
345
346#[cfg(test)]
347#[allow(clippy::unwrap_used, clippy::expect_used)]
348mod tests {
349    use super::*;
350    use tempfile::tempdir;
351
352    // -------------------------------------------------------------------
353    // scaffold_project_at — directory layout
354    // -------------------------------------------------------------------
355
356    #[test]
357    fn scaffold_project_at_creates_complete_directory_structure() {
358        let dir = tempdir().unwrap();
359        let name = "test-site";
360        let project = dir.path().join(name);
361        scaffold_project_at(name, dir.path()).unwrap();
362
363        // Every directory in the `dirs` array must exist.
364        for sub in [
365            "",
366            "content",
367            "content/blog",
368            "templates/tera",
369            "static/css",
370            "data",
371        ] {
372            assert!(
373                project.join(sub).exists(),
374                "subdirectory `{sub}` should have been created"
375            );
376        }
377    }
378
379    #[test]
380    fn scaffold_project_at_writes_all_expected_files() {
381        let dir = tempdir().unwrap();
382        let name = "demo";
383        let project = dir.path().join(name);
384        scaffold_project_at(name, dir.path()).unwrap();
385
386        for file in [
387            "config.toml",
388            "content/index.md",
389            "content/about.md",
390            "content/blog/first-post.md",
391            "templates/tera/base.html",
392            "templates/tera/page.html",
393            "templates/tera/post.html",
394            "templates/tera/index.html",
395            "static/css/style.css",
396            "data/nav.toml",
397        ] {
398            assert!(
399                project.join(file).exists(),
400                "file `{file}` should have been scaffolded"
401            );
402        }
403    }
404
405    // -------------------------------------------------------------------
406    // scaffold_project_at — content-template personalisation
407    // -------------------------------------------------------------------
408
409    #[test]
410    fn scaffold_project_at_injects_project_name_into_config() {
411        let dir = tempdir().unwrap();
412        let name = "my-cool-site";
413        scaffold_project_at(name, dir.path()).unwrap();
414
415        let config =
416            fs::read_to_string(dir.path().join(name).join("config.toml"))
417                .unwrap();
418        assert!(config.contains(&format!(r#"site_name = "{name}""#)));
419        assert!(config.contains(&format!(r#"site_title = "{name}""#)));
420        assert!(config.contains(r#"language = "en-GB""#));
421    }
422
423    #[test]
424    fn scaffold_project_at_injects_project_name_into_index_md() {
425        let dir = tempdir().unwrap();
426        let name = "hello";
427        scaffold_project_at(name, dir.path()).unwrap();
428
429        let index =
430            fs::read_to_string(dir.path().join(name).join("content/index.md"))
431                .unwrap();
432        assert!(index.contains(&format!("title: Welcome to {name}")));
433        assert!(index.contains("layout: index"));
434    }
435
436    #[test]
437    fn scaffold_project_at_injects_project_name_into_first_post() {
438        let dir = tempdir().unwrap();
439        let name = "projectx";
440        scaffold_project_at(name, dir.path()).unwrap();
441
442        let post = fs::read_to_string(
443            dir.path().join(name).join("content/blog/first-post.md"),
444        )
445        .unwrap();
446        assert!(post.contains(&format!("author: {name} Team")));
447        assert!(post.contains(&format!("Welcome to **{name}**")));
448        assert!(post.contains(&format!(r#"println!("Hello from {name}!");"#)));
449    }
450
451    #[test]
452    fn scaffold_project_at_static_assets_include_dark_mode_block() {
453        // Guards the prefers-color-scheme media query in style.css —
454        // accessibility regression tripwire.
455        let dir = tempdir().unwrap();
456        scaffold_project_at("a", dir.path()).unwrap();
457        let css = fs::read_to_string(
458            dir.path().join("a").join("static/css/style.css"),
459        )
460        .unwrap();
461        assert!(css.contains("@media (prefers-color-scheme: dark)"));
462        assert!(css.contains(".sr-only"));
463    }
464
465    #[test]
466    fn scaffold_project_at_base_template_has_accessibility_landmarks() {
467        let dir = tempdir().unwrap();
468        scaffold_project_at("x", dir.path()).unwrap();
469        let base = fs::read_to_string(
470            dir.path().join("x").join("templates/tera/base.html"),
471        )
472        .unwrap();
473        assert!(base.contains(r#"role="banner""#));
474        assert!(base.contains(r#"role="main""#));
475        assert!(base.contains(r#"role="contentinfo""#));
476        assert!(base.contains(r#"aria-label="Main navigation""#));
477        assert!(base.contains(r#"class="sr-only""#));
478    }
479
480    #[test]
481    fn scaffold_project_at_nav_toml_has_three_default_links() {
482        let dir = tempdir().unwrap();
483        scaffold_project_at("y", dir.path()).unwrap();
484        let nav =
485            fs::read_to_string(dir.path().join("y").join("data/nav.toml"))
486                .unwrap();
487        assert_eq!(nav.matches("[[links]]").count(), 3);
488        assert!(nav.contains(r#"name = "Home""#));
489        assert!(nav.contains(r#"name = "About""#));
490        assert!(nav.contains(r#"name = "Blog""#));
491    }
492
493    // -------------------------------------------------------------------
494    // scaffold_project_at — failure paths
495    // -------------------------------------------------------------------
496
497    #[test]
498    fn scaffold_project_at_refuses_to_overwrite_existing_directory() {
499        // The `anyhow::bail!` at line 22 protects user content from
500        // being silently overwritten.
501        let dir = tempdir().unwrap();
502        let name = "existing";
503        fs::create_dir(dir.path().join(name)).unwrap();
504
505        let err = scaffold_project_at(name, dir.path()).unwrap_err();
506        let msg = format!("{err}");
507        assert!(
508            msg.contains("already exists"),
509            "error should mention `already exists`: {msg}"
510        );
511    }
512
513    #[test]
514    fn scaffold_project_at_refuses_to_overwrite_existing_file() {
515        // Same guard, but the pre-existing entry is a file, not a
516        // directory — both trigger the `root.exists()` check.
517        let dir = tempdir().unwrap();
518        let name = "blocker";
519        fs::write(dir.path().join(name), "i exist").unwrap();
520
521        assert!(scaffold_project_at(name, dir.path()).is_err());
522    }
523
524    // -------------------------------------------------------------------
525    // scaffold_project — default wrapper around CWD
526    // -------------------------------------------------------------------
527
528    #[test]
529    fn scaffold_project_uses_current_working_directory() {
530        // Exercises the `scaffold_project` entry point at line 13,
531        // which wraps `scaffold_project_at` with env::current_dir().
532        // We pushd into a tempdir so we don't pollute the repo root.
533        let dir = tempdir().unwrap();
534        let prev = std::env::current_dir().expect("read current dir");
535        std::env::set_current_dir(&dir).expect("pushd");
536
537        let result = scaffold_project("from-cwd");
538
539        // Always restore cwd, even if the call failed.
540        std::env::set_current_dir(&prev).expect("popd");
541
542        result.expect("scaffold should succeed in a fresh cwd");
543        assert!(dir.path().join("from-cwd").join("config.toml").exists());
544    }
545
546    // -----------------------------------------------------------------
547    // write_scaffold_file — unit tests
548    // -----------------------------------------------------------------
549
550    #[test]
551    fn write_scaffold_file_creates_file() {
552        let dir = tempdir().unwrap();
553        let path = dir.path().join("test.txt");
554        write_scaffold_file(&path, "hello", "test.txt").unwrap();
555        assert_eq!(fs::read_to_string(&path).unwrap(), "hello");
556    }
557
558    #[test]
559    fn write_scaffold_file_overwrites_existing() {
560        let dir = tempdir().unwrap();
561        let path = dir.path().join("test.txt");
562        fs::write(&path, "old").unwrap();
563        write_scaffold_file(&path, "new", "test.txt").unwrap();
564        assert_eq!(fs::read_to_string(&path).unwrap(), "new");
565    }
566
567    #[test]
568    fn write_scaffold_file_error_has_label() {
569        let err = write_scaffold_file(
570            Path::new("/no/such/dir/file.txt"),
571            "x",
572            "my-label",
573        )
574        .unwrap_err();
575        let msg = format!("{err:#}");
576        assert!(
577            msg.contains("my-label"),
578            "error should include the label: {msg}"
579        );
580    }
581
582    #[test]
583    fn write_scaffold_file_empty_content() {
584        let dir = tempdir().unwrap();
585        let path = dir.path().join("empty.txt");
586        write_scaffold_file(&path, "", "empty.txt").unwrap();
587        assert_eq!(fs::read_to_string(&path).unwrap(), "");
588    }
589
590    #[test]
591    fn write_scaffold_file_binary_content() {
592        let dir = tempdir().unwrap();
593        let path = dir.path().join("bin.dat");
594        let data: &[u8] = &[0x00, 0xFF, 0xAB, 0xCD];
595        write_scaffold_file(&path, data, "bin.dat").unwrap();
596        assert_eq!(fs::read(&path).unwrap(), data);
597    }
598
599    // -----------------------------------------------------------------
600    // create_scaffold_dirs — direct tests
601    // -----------------------------------------------------------------
602
603    #[test]
604    fn create_scaffold_dirs_creates_all_expected_dirs() {
605        let dir = tempdir().unwrap();
606        let root = dir.path().join("proj");
607        create_scaffold_dirs("proj", &root).unwrap();
608
609        for sub in [
610            "",
611            "content",
612            "content/blog",
613            "templates/tera",
614            "static/css",
615            "data",
616        ] {
617            assert!(root.join(sub).exists(), "{sub} should exist");
618        }
619    }
620
621    #[test]
622    fn create_scaffold_dirs_idempotent() {
623        let dir = tempdir().unwrap();
624        let root = dir.path().join("proj");
625        create_scaffold_dirs("proj", &root).unwrap();
626        // Calling again should not fail
627        create_scaffold_dirs("proj", &root).unwrap();
628    }
629
630    // -----------------------------------------------------------------
631    // write_config_file — direct tests
632    // -----------------------------------------------------------------
633
634    #[test]
635    fn write_config_file_content() {
636        let dir = tempdir().unwrap();
637        let root = dir.path().join("proj");
638        fs::create_dir_all(&root).unwrap();
639        write_config_file("my-site", &root).unwrap();
640
641        let content = fs::read_to_string(root.join("config.toml")).unwrap();
642        assert!(content.contains(r#"site_name = "my-site""#));
643        assert!(content.contains(r#"site_title = "my-site""#));
644        assert!(content.contains(r#"content_dir = "content""#));
645        assert!(content.contains(r#"output_dir = "public""#));
646        assert!(content.contains(r#"template_dir = "templates""#));
647        assert!(content.contains("http://127.0.0.1:8000"));
648        assert!(content.contains(r#"language = "en-GB""#));
649    }
650
651    // -----------------------------------------------------------------
652    // write_content_files — direct tests
653    // -----------------------------------------------------------------
654
655    #[test]
656    fn write_content_files_creates_all_content() {
657        let dir = tempdir().unwrap();
658        let root = dir.path().join("proj");
659        fs::create_dir_all(root.join("content/blog")).unwrap();
660        write_content_files("test-proj", &root).unwrap();
661
662        assert!(root.join("content/index.md").exists());
663        assert!(root.join("content/about.md").exists());
664        assert!(root.join("content/blog/first-post.md").exists());
665    }
666
667    #[test]
668    fn write_content_files_about_has_correct_frontmatter() {
669        let dir = tempdir().unwrap();
670        let root = dir.path().join("proj");
671        fs::create_dir_all(root.join("content/blog")).unwrap();
672        write_content_files("test-proj", &root).unwrap();
673
674        let about = fs::read_to_string(root.join("content/about.md")).unwrap();
675        assert!(about.contains("title: About"));
676        assert!(about.contains("layout: page"));
677        assert!(about.contains("static-site-generator.one"));
678    }
679
680    #[test]
681    fn write_content_files_index_has_features_list() {
682        let dir = tempdir().unwrap();
683        let root = dir.path().join("proj");
684        fs::create_dir_all(root.join("content/blog")).unwrap();
685        write_content_files("proj", &root).unwrap();
686
687        let index = fs::read_to_string(root.join("content/index.md")).unwrap();
688        assert!(index.contains("## Features"));
689        assert!(index.contains("- Tera templating"));
690    }
691
692    #[test]
693    fn write_content_files_first_post_has_tags_and_code() {
694        let dir = tempdir().unwrap();
695        let root = dir.path().join("proj");
696        fs::create_dir_all(root.join("content/blog")).unwrap();
697        write_content_files("proj", &root).unwrap();
698
699        let post = fs::read_to_string(root.join("content/blog/first-post.md"))
700            .unwrap();
701        assert!(post.contains("tags:"));
702        assert!(post.contains("- welcome"));
703        assert!(post.contains("```rust"));
704        assert!(post.contains("categories:"));
705    }
706
707    // -----------------------------------------------------------------
708    // write_template_files — direct tests
709    // -----------------------------------------------------------------
710
711    #[test]
712    fn write_template_files_creates_all_templates() {
713        let dir = tempdir().unwrap();
714        let root = dir.path().join("proj");
715        fs::create_dir_all(root.join("templates/tera")).unwrap();
716        write_template_files(&root).unwrap();
717
718        for file in [
719            "templates/tera/base.html",
720            "templates/tera/page.html",
721            "templates/tera/post.html",
722            "templates/tera/index.html",
723        ] {
724            assert!(root.join(file).exists(), "{file} should exist");
725        }
726    }
727
728    #[test]
729    fn write_template_files_page_extends_base() {
730        let dir = tempdir().unwrap();
731        let root = dir.path().join("proj");
732        fs::create_dir_all(root.join("templates/tera")).unwrap();
733        write_template_files(&root).unwrap();
734
735        let page =
736            fs::read_to_string(root.join("templates/tera/page.html")).unwrap();
737        assert!(page.contains(r#"extends "base.html""#));
738        assert!(page.contains("block content"));
739    }
740
741    #[test]
742    fn write_template_files_post_has_article_structure() {
743        let dir = tempdir().unwrap();
744        let root = dir.path().join("proj");
745        fs::create_dir_all(root.join("templates/tera")).unwrap();
746        write_template_files(&root).unwrap();
747
748        let post =
749            fs::read_to_string(root.join("templates/tera/post.html")).unwrap();
750        assert!(post.contains("<article>"));
751        assert!(post.contains("page.title"));
752        assert!(post.contains("page.date"));
753        assert!(post.contains("page.tags"));
754        assert!(post.contains("reading_time"));
755    }
756
757    #[test]
758    fn write_template_files_index_extends_base() {
759        let dir = tempdir().unwrap();
760        let root = dir.path().join("proj");
761        fs::create_dir_all(root.join("templates/tera")).unwrap();
762        write_template_files(&root).unwrap();
763
764        let index =
765            fs::read_to_string(root.join("templates/tera/index.html")).unwrap();
766        assert!(index.contains(r#"extends "base.html""#));
767        assert!(index.contains("site.title"));
768    }
769
770    // -----------------------------------------------------------------
771    // write_static_assets — direct tests
772    // -----------------------------------------------------------------
773
774    #[test]
775    fn write_static_assets_creates_stylesheet() {
776        let dir = tempdir().unwrap();
777        let root = dir.path().join("proj");
778        fs::create_dir_all(root.join("static/css")).unwrap();
779        write_static_assets(&root).unwrap();
780
781        let css =
782            fs::read_to_string(root.join("static/css/style.css")).unwrap();
783        assert!(css.contains(":root"));
784        assert!(css.contains("--text:"));
785        assert!(css.contains("--bg:"));
786        assert!(css.contains("--accent:"));
787        assert!(css.contains("box-sizing: border-box"));
788    }
789
790    // -----------------------------------------------------------------
791    // write_data_files — direct tests
792    // -----------------------------------------------------------------
793
794    #[test]
795    fn write_data_files_creates_nav_toml() {
796        let dir = tempdir().unwrap();
797        let root = dir.path().join("proj");
798        fs::create_dir_all(root.join("data")).unwrap();
799        write_data_files(&root).unwrap();
800
801        let nav = fs::read_to_string(root.join("data/nav.toml")).unwrap();
802        assert_eq!(nav.matches("[[links]]").count(), 3);
803        assert!(nav.contains(r#"url = "/""#));
804        assert!(nav.contains(r#"url = "/about.html""#));
805        assert!(nav.contains(r#"url = "/blog/""#));
806    }
807
808    // -----------------------------------------------------------------
809    // scaffold_project_at — name injection edge cases
810    // -----------------------------------------------------------------
811
812    #[test]
813    fn scaffold_project_at_special_chars_in_name() {
814        let dir = tempdir().unwrap();
815        let name = "my-cool_site.2026";
816        scaffold_project_at(name, dir.path()).unwrap();
817
818        let config =
819            fs::read_to_string(dir.path().join(name).join("config.toml"))
820                .unwrap();
821        assert!(config.contains(&format!(r#"site_name = "{name}""#)));
822    }
823
824    #[test]
825    fn scaffold_project_at_single_char_name() {
826        let dir = tempdir().unwrap();
827        scaffold_project_at("z", dir.path()).unwrap();
828        assert!(dir.path().join("z").join("config.toml").exists());
829    }
830
831    // -----------------------------------------------------------------
832    // scaffold_project_at — template content validation
833    // -----------------------------------------------------------------
834
835    #[test]
836    fn scaffold_project_at_base_template_is_valid_html() {
837        let dir = tempdir().unwrap();
838        scaffold_project_at("t", dir.path()).unwrap();
839        let base = fs::read_to_string(
840            dir.path().join("t").join("templates/tera/base.html"),
841        )
842        .unwrap();
843        assert!(base.starts_with("<!DOCTYPE html>"));
844        assert!(base.contains("</html>"));
845        assert!(base.contains("<head>"));
846        assert!(base.contains("</head>"));
847        assert!(base.contains("<body>"));
848        assert!(base.contains("</body>"));
849    }
850
851    #[test]
852    fn scaffold_project_at_config_has_all_required_keys() {
853        let dir = tempdir().unwrap();
854        scaffold_project_at("k", dir.path()).unwrap();
855        let config =
856            fs::read_to_string(dir.path().join("k").join("config.toml"))
857                .unwrap();
858        for key in [
859            "site_name",
860            "content_dir",
861            "output_dir",
862            "template_dir",
863            "base_url",
864            "site_title",
865            "site_description",
866            "language",
867        ] {
868            assert!(
869                config.contains(key),
870                "config.toml should contain key: {key}"
871            );
872        }
873    }
874}