Skip to main content

chant/site/
theme.rs

1//! Theme management for site generation.
2//!
3//! This module handles copying the default theme to the user's project
4//! for customization.
5
6use anyhow::{Context, Result};
7use std::fs;
8use std::path::Path;
9
10use super::embedded;
11
12/// Theme file information
13pub struct ThemeFile {
14    pub name: &'static str,
15    pub content: &'static str,
16    pub description: &'static str,
17}
18
19/// Get all embedded theme files
20pub 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
70/// Initialize the theme directory with default templates
71pub fn init_theme(theme_dir: &Path, force: bool) -> Result<InitResult> {
72    let mut result = InitResult::default();
73
74    // Create theme directory
75    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    // Copy each template file
83    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
100/// Check if a custom theme directory exists
101pub fn theme_exists(theme_dir: &Path) -> bool {
102    theme_dir.exists() && theme_dir.is_dir()
103}
104
105/// List files in a custom theme directory
106pub 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/// Result of theme initialization
128#[derive(Debug, Default)]
129pub struct InitResult {
130    /// Files that were created
131    pub created: Vec<String>,
132    /// Files that were skipped (already exist)
133    pub skipped: Vec<String>,
134}
135
136impl InitResult {
137    /// Check if any files were created
138    pub fn has_changes(&self) -> bool {
139        !self.created.is_empty()
140    }
141}
142
143/// Template variable documentation
144pub 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        // Check required files exist
248        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        // First init
274        init_theme(&theme_dir, false).unwrap();
275
276        // Second init should skip
277        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        // First init
288        init_theme(&theme_dir, false).unwrap();
289
290        // Force should overwrite
291        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}