1use anyhow::{Context, Result};
10use std::{fs, path::Path};
11
12fn 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
21pub fn scaffold_project(name: &str) -> Result<()> {
32 let cwd = std::env::current_dir()?;
33 scaffold_project_at(name, &cwd)
34}
35
36pub 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
69fn 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
89fn 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
111fn 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
200fn write_root_templates(root: &Path) -> Result<()> {
242 fail_point!("scaffold::write-root-template", |_| {
243 anyhow::bail!("injected: scaffold::write-root-template")
244 });
245
246 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>© {{ 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
383fn 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
436fn 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 #[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 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 #[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 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 #[test]
618 #[serial_test::parallel(scaffold_fp)]
619 fn scaffold_project_at_refuses_to_overwrite_existing_directory() {
620 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 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 #[test]
651 #[serial_test::serial(cwd, scaffold_fp)]
652 fn scaffold_project_uses_current_working_directory() {
653 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 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 #[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 #[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 create_scaffold_dirs("proj", &root).unwrap();
758 }
759
760 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 let dir = tempdir().unwrap();
1039 let missing_parent = dir.path().join("nope/nope/file.txt");
1040
1041 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 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 #[test]
1063 #[serial_test::serial(cwd, scaffold_fp)]
1064 #[cfg(not(windows))]
1069 fn scaffold_project_fails_when_cwd_has_been_deleted() {
1070 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 std::env::set_current_dir(&prev).expect("popd");
1083 assert!(result.is_err(), "deleted cwd must surface as an error");
1084 }
1085
1086 #[test]
1091 #[cfg(unix)]
1092 #[serial_test::parallel(scaffold_fp)]
1093 fn scaffold_project_at_read_only_base_fails_dir_creation() {
1094 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 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 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 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 #[cfg(feature = "test-fault-injection")]
1179 mod fault_injection {
1180 use super::*;
1181
1182 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 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}