Skip to main content

orbit_md/scaffold/
mod.rs

1//! Project scaffolding for `orbit init`.
2//!
3//! Templates are embedded at compile time from the `scaffold/` directory so
4//! the CLI works after `cargo install orbit-md` with no external files required.
5
6use std::fs;
7use std::path::{Path, PathBuf};
8
9use crate::error::OrbitError;
10
11/// A file written during project initialization.
12struct ScaffoldFile {
13    relative_path: &'static str,
14    contents: &'static str,
15}
16
17const SCAFFOLD_FILES: &[ScaffoldFile] = &[
18    ScaffoldFile {
19        relative_path: "orbit.yaml",
20        contents: include_str!("../../scaffold/orbit.yaml"),
21    },
22    ScaffoldFile {
23        relative_path: ".gitignore",
24        contents: include_str!("../../scaffold/.gitignore"),
25    },
26    ScaffoldFile {
27        relative_path: "content/index.md",
28        contents: include_str!("../../scaffold/content/index.md"),
29    },
30    ScaffoldFile {
31        relative_path: "content/docs/getting-started.md",
32        contents: include_str!("../../scaffold/content/docs/getting-started.md"),
33    },
34    ScaffoldFile {
35        relative_path: "components/Alert.hbs",
36        contents: include_str!("../../scaffold/components/Alert.hbs"),
37    },
38    ScaffoldFile {
39        relative_path: "components/Button.hbs",
40        contents: include_str!("../../scaffold/components/Button.hbs"),
41    },
42    ScaffoldFile {
43        relative_path: "components/Card.hbs",
44        contents: include_str!("../../scaffold/components/Card.hbs"),
45    },
46    ScaffoldFile {
47        relative_path: "templates/base.hbs",
48        contents: include_str!("../../scaffold/templates/base.hbs"),
49    },
50];
51
52/// Options for creating a new site project.
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct InitOptions {
55    /// Destination directory for the new project.
56    pub path: PathBuf,
57    /// Human-readable site title injected into config and pages.
58    pub title: String,
59}
60
61impl InitOptions {
62    /// Creates init options from a directory path, deriving the title from the folder name.
63    pub fn from_path(path: PathBuf) -> Self {
64        let title = path
65            .file_name()
66            .and_then(|n| n.to_str())
67            .map(title_from_slug)
68            .unwrap_or_else(|| "My Site".to_owned());
69
70        Self { path, title }
71    }
72}
73
74/// Initializes a new Orbit project at `options.path`.
75///
76/// # Errors
77///
78/// Returns [`OrbitError::Config`] when the destination already exists and is not empty.
79pub fn init_project(options: &InitOptions) -> Result<(), OrbitError> {
80    let dest = &options.path;
81
82    if dest.exists() {
83        let mut entries = fs::read_dir(dest).map_err(|source| OrbitError::Io {
84            path: dest.clone(),
85            source,
86        })?;
87        if entries.next().is_some() {
88            return Err(OrbitError::Config(format!(
89                "directory {} is not empty — choose an empty folder or a new name",
90                dest.display()
91            )));
92        }
93    } else {
94        fs::create_dir_all(dest).map_err(|source| OrbitError::Io {
95            path: dest.clone(),
96            source,
97        })?;
98    }
99
100    for file in SCAFFOLD_FILES {
101        let target = dest.join(file.relative_path);
102        if let Some(parent) = target.parent() {
103            fs::create_dir_all(parent).map_err(|source| OrbitError::Io {
104                path: parent.to_path_buf(),
105                source,
106            })?;
107        }
108
109        let contents = substitute_placeholders(file.contents, &options.title);
110        fs::write(&target, contents).map_err(|source| OrbitError::Io {
111            path: target,
112            source,
113        })?;
114    }
115
116    Ok(())
117}
118
119/// Creates a new Markdown page under `content/` with starter front matter.
120pub fn new_page(content_root: &Path, relative: &Path, title: &str) -> Result<PathBuf, OrbitError> {
121    let normalized = normalize_page_path(relative);
122    let target = content_root.join(&normalized);
123
124    if target.exists() {
125        return Err(OrbitError::Config(format!(
126            "page already exists at {}",
127            target.display()
128        )));
129    }
130
131    if let Some(parent) = target.parent() {
132        fs::create_dir_all(parent).map_err(|source| OrbitError::Io {
133            path: parent.to_path_buf(),
134            source,
135        })?;
136    }
137
138    let stem = normalized
139        .file_stem()
140        .and_then(|s| s.to_str())
141        .unwrap_or("Untitled");
142
143    let body = format!(
144        "---\ntitle: {title}\n---\n\n# {heading}\n\nWrite your content here.\n",
145        title = title,
146        heading = stem
147    );
148
149    fs::write(&target, body).map_err(|source| OrbitError::Io {
150        path: target.clone(),
151        source,
152    })?;
153
154    Ok(target)
155}
156
157fn substitute_placeholders(input: &str, title: &str) -> String {
158    input.replace("{{title}}", title)
159}
160
161/// Derives a human-readable title from a slug-like name.
162pub fn title_from_slug(slug: &str) -> String {
163    slug.split(['-', '_'])
164        .filter(|part| !part.is_empty())
165        .map(|part| {
166            let mut chars = part.chars();
167            match chars.next() {
168                None => String::new(),
169                Some(first) => first.to_uppercase().chain(chars).collect(),
170            }
171        })
172        .collect::<Vec<_>>()
173        .join(" ")
174}
175
176fn normalize_page_path(relative: &Path) -> PathBuf {
177    let mut path = relative.to_path_buf();
178    if path.extension().is_none() {
179        path.set_extension("md");
180    }
181    path
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187
188    #[test]
189    fn title_from_slug_formats_name() {
190        assert_eq!(title_from_slug("my-blog"), "My Blog");
191        assert_eq!(title_from_slug("docs_site"), "Docs Site");
192    }
193
194    #[test]
195    fn init_creates_project_tree() {
196        let dir = tempfile::tempdir().unwrap();
197        let path = dir.path().join("demo-site");
198        init_project(&InitOptions {
199            path: path.clone(),
200            title: "Demo Site".into(),
201        })
202        .unwrap();
203
204        assert!(path.join("orbit.yaml").exists());
205        assert!(path.join("content/index.md").exists());
206        assert!(path.join("components/Alert.hbs").exists());
207        assert!(path.join("templates/base.hbs").exists());
208
209        let config = std::fs::read_to_string(path.join("orbit.yaml")).unwrap();
210        assert!(config.contains("title: Demo Site"));
211    }
212
213    #[test]
214    fn init_rejects_nonempty_directory() {
215        let dir = tempfile::tempdir().unwrap();
216        std::fs::write(dir.path().join("existing.txt"), "data").unwrap();
217        let result = init_project(&InitOptions {
218            path: dir.path().to_path_buf(),
219            title: "X".into(),
220        });
221        assert!(result.is_err());
222    }
223
224    #[test]
225    fn new_page_adds_md_extension() {
226        let dir = tempfile::tempdir().unwrap();
227        let content = dir.path().join("content");
228        std::fs::create_dir_all(&content).unwrap();
229
230        let created = new_page(&content, Path::new("blog/post"), "My Post").unwrap();
231        assert_eq!(created, content.join("blog/post.md"));
232        assert!(created.exists());
233    }
234}