1use anyhow::{Context, Result};
7use std::fs;
8use std::path::Path;
9
10use super::embedded;
11
12pub struct ThemeFile {
14 pub name: &'static str,
15 pub content: &'static str,
16 pub description: &'static str,
17}
18
19pub fn get_theme_files() -> Vec<ThemeFile> {
21 vec![
22 ThemeFile {
23 name: "base.html",
24 content: embedded::BASE_HTML,
25 description: "Page skeleton, head, nav",
26 },
27 ThemeFile {
28 name: "spec.html",
29 content: embedded::SPEC_HTML,
30 description: "Individual spec page",
31 },
32 ThemeFile {
33 name: "index.html",
34 content: embedded::INDEX_HTML,
35 description: "Main index page",
36 },
37 ThemeFile {
38 name: "status-index.html",
39 content: embedded::STATUS_INDEX_HTML,
40 description: "By-status listing",
41 },
42 ThemeFile {
43 name: "label-index.html",
44 content: embedded::LABEL_INDEX_HTML,
45 description: "By-label listing",
46 },
47 ThemeFile {
48 name: "timeline.html",
49 content: embedded::TIMELINE_HTML,
50 description: "Timeline view",
51 },
52 ThemeFile {
53 name: "graph.html",
54 content: embedded::GRAPH_HTML,
55 description: "Dependency graph view",
56 },
57 ThemeFile {
58 name: "changelog.html",
59 content: embedded::CHANGELOG_HTML,
60 description: "Changelog view",
61 },
62 ThemeFile {
63 name: "styles.css",
64 content: embedded::STYLES_CSS,
65 description: "All styling",
66 },
67 ]
68}
69
70pub fn init_theme(theme_dir: &Path, force: bool) -> Result<InitResult> {
72 let mut result = InitResult::default();
73
74 fs::create_dir_all(theme_dir).with_context(|| {
76 format!(
77 "Failed to create theme directory at {}",
78 theme_dir.display()
79 )
80 })?;
81
82 for file in get_theme_files() {
84 let target_path = theme_dir.join(file.name);
85
86 if target_path.exists() && !force {
87 result.skipped.push(file.name.to_string());
88 continue;
89 }
90
91 fs::write(&target_path, file.content)
92 .with_context(|| format!("Failed to write {}", target_path.display()))?;
93
94 result.created.push(file.name.to_string());
95 }
96
97 Ok(result)
98}
99
100pub fn theme_exists(theme_dir: &Path) -> bool {
102 theme_dir.exists() && theme_dir.is_dir()
103}
104
105pub fn list_theme_files(theme_dir: &Path) -> Result<Vec<String>> {
107 let mut files = Vec::new();
108
109 if !theme_dir.exists() {
110 return Ok(files);
111 }
112
113 for entry in fs::read_dir(theme_dir)? {
114 let entry = entry?;
115 let path = entry.path();
116 if path.is_file() {
117 if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
118 files.push(name.to_string());
119 }
120 }
121 }
122
123 files.sort();
124 Ok(files)
125}
126
127#[derive(Debug, Default)]
129pub struct InitResult {
130 pub created: Vec<String>,
132 pub skipped: Vec<String>,
134}
135
136impl InitResult {
137 pub fn has_changes(&self) -> bool {
139 !self.created.is_empty()
140 }
141}
142
143pub fn get_template_variables_doc() -> &'static str {
145 r#"# Template Variables Reference
146
147## Global Variables (available in all templates)
148
149- `site_title` - The site title from config
150- `base_url` - The base URL for all links
151- `features` - Object with feature toggles:
152 - `features.changelog`
153 - `features.dependency_graph`
154 - `features.timeline`
155 - `features.status_indexes`
156 - `features.label_indexes`
157- `labels` - List of all labels used across specs
158
159## Index Page (`index.html`)
160
161- `specs` - List of all spec objects
162- `stats` - Site statistics object:
163 - `stats.total`
164 - `stats.completed`
165 - `stats.in_progress`
166 - `stats.pending`
167 - `stats.failed`
168 - `stats.other`
169
170## Spec Page (`spec.html`)
171
172- `spec` - The current spec object:
173 - `spec.id` - Full spec ID
174 - `spec.short_id` - Short ID (last segment)
175 - `spec.title` - Spec title (may be null)
176 - `spec.status` - Status string (lowercase)
177 - `spec.type` - Spec type
178 - `spec.labels` - List of label strings
179 - `spec.depends_on` - List of dependency IDs
180 - `spec.target_files` - List of target file paths
181 - `spec.completed_at` - Completion timestamp (may be null)
182 - `spec.model` - Model used (may be null)
183 - `spec.body_html` - Rendered markdown body as HTML
184- `prev_spec` - Previous spec (may be null)
185- `next_spec` - Next spec (may be null)
186
187## Status Index Page (`status-index.html`)
188
189- `status` - Status key (e.g., "completed")
190- `status_display` - Display name (e.g., "Completed")
191- `specs` - List of specs with this status
192
193## Label Index Page (`label-index.html`)
194
195- `label` - The label name
196- `specs` - List of specs with this label
197
198## Timeline Page (`timeline.html`)
199
200- `timeline_groups` - List of timeline groups:
201 - `group.date` - Date/period label
202 - `group.ascii_tree` - ASCII tree visualization
203
204## Graph Page (`graph.html`)
205
206- `ascii_graph` - ASCII dependency graph
207- `roots` - List of root specs (no dependencies)
208- `leaves` - List of leaf specs (no dependents)
209
210## Changelog Page (`changelog.html`)
211
212- `changelog_groups` - List of changelog entries:
213 - `group.date` - Completion date
214 - `group.specs` - List of specs completed on this date
215
216## Filters
217
218- `slugify` - Convert string to URL-safe slug
219 - Example: `{{ label | slugify }}`
220
221## Example Template Snippet
222
223```html
224{% for spec in specs %}
225<div class="spec-card">
226 <h2>{{ spec.title | default(value="Untitled") }}</h2>
227 <span class="status-{{ spec.status | slugify }}">{{ spec.status }}</span>
228 {% for label in spec.labels %}
229 <span class="label">{{ label }}</span>
230 {% endfor %}
231</div>
232{% endfor %}
233```
234"#
235}
236
237#[cfg(test)]
238mod tests {
239 use super::*;
240 use tempfile::TempDir;
241
242 #[test]
243 fn test_get_theme_files() {
244 let files = get_theme_files();
245 assert!(!files.is_empty());
246
247 let names: Vec<_> = files.iter().map(|f| f.name).collect();
249 assert!(names.contains(&"base.html"));
250 assert!(names.contains(&"index.html"));
251 assert!(names.contains(&"spec.html"));
252 assert!(names.contains(&"styles.css"));
253 }
254
255 #[test]
256 fn test_init_theme() {
257 let tmp = TempDir::new().unwrap();
258 let theme_dir = tmp.path().join("theme");
259
260 let result = init_theme(&theme_dir, false).unwrap();
261
262 assert!(result.has_changes());
263 assert!(result.skipped.is_empty());
264 assert!(theme_dir.join("base.html").exists());
265 assert!(theme_dir.join("styles.css").exists());
266 }
267
268 #[test]
269 fn test_init_theme_skip_existing() {
270 let tmp = TempDir::new().unwrap();
271 let theme_dir = tmp.path().join("theme");
272
273 init_theme(&theme_dir, false).unwrap();
275
276 let result = init_theme(&theme_dir, false).unwrap();
278 assert!(!result.has_changes());
279 assert!(!result.skipped.is_empty());
280 }
281
282 #[test]
283 fn test_init_theme_force() {
284 let tmp = TempDir::new().unwrap();
285 let theme_dir = tmp.path().join("theme");
286
287 init_theme(&theme_dir, false).unwrap();
289
290 let result = init_theme(&theme_dir, true).unwrap();
292 assert!(result.has_changes());
293 assert!(result.skipped.is_empty());
294 }
295
296 #[test]
297 fn test_list_theme_files() {
298 let tmp = TempDir::new().unwrap();
299 let theme_dir = tmp.path().join("theme");
300
301 init_theme(&theme_dir, false).unwrap();
302
303 let files = list_theme_files(&theme_dir).unwrap();
304 assert!(files.contains(&"base.html".to_string()));
305 assert!(files.contains(&"styles.css".to_string()));
306 }
307}