1use std::fs;
7use std::path::{Path, PathBuf};
8
9use crate::error::OrbitError;
10
11struct 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#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct InitOptions {
55 pub path: PathBuf,
57 pub title: String,
59}
60
61impl InitOptions {
62 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
74pub 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
119pub 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
161pub 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}