Skip to main content

reinhardt_utils/staticfiles/
template_integration.rs

1//! Template integration for static files
2//!
3//! Provides configuration types for static file URL generation in templates.
4
5use super::{ManifestStaticFilesStorage, StaticFilesConfig};
6use std::collections::HashMap;
7use std::io;
8
9/// Configuration for static files in templates
10///
11/// This configuration can be used with template systems to generate URLs for static files.
12/// It can be constructed from `StaticFilesConfig`.
13#[derive(Debug, Clone)]
14pub struct TemplateStaticConfig {
15	/// Base URL for static files (e.g., "/static/")
16	pub static_url: String,
17	/// Whether to use hashed filenames from manifest
18	pub use_manifest: bool,
19	/// Manifest mapping original paths to hashed paths
20	pub manifest: HashMap<String, String>,
21}
22
23impl From<&StaticFilesConfig> for TemplateStaticConfig {
24	fn from(config: &StaticFilesConfig) -> Self {
25		Self {
26			static_url: config.static_url.clone(),
27			use_manifest: false,
28			manifest: HashMap::new(),
29		}
30	}
31}
32
33impl TemplateStaticConfig {
34	/// Create a new template static configuration
35	///
36	/// # Examples
37	///
38	/// ```rust
39	/// use reinhardt_utils::staticfiles::template_integration::TemplateStaticConfig;
40	///
41	/// let config = TemplateStaticConfig::new("/static/".to_string());
42	/// assert_eq!(config.static_url, "/static/");
43	/// assert!(!config.use_manifest);
44	/// ```
45	pub fn new(static_url: String) -> Self {
46		Self {
47			static_url,
48			use_manifest: false,
49			manifest: HashMap::new(),
50		}
51	}
52
53	/// Enable manifest-based hashed filenames
54	///
55	/// # Examples
56	///
57	/// ```rust
58	/// use reinhardt_utils::staticfiles::template_integration::TemplateStaticConfig;
59	/// use std::collections::HashMap;
60	///
61	/// let mut manifest = HashMap::new();
62	/// manifest.insert("css/style.css".to_string(), "css/style.abc123.css".to_string());
63	///
64	/// let config = TemplateStaticConfig::new("/static/".to_string())
65	///     .with_manifest(manifest);
66	///
67	/// assert!(config.use_manifest);
68	/// assert_eq!(config.manifest.len(), 1);
69	/// ```
70	pub fn with_manifest(mut self, manifest: HashMap<String, String>) -> Self {
71		self.use_manifest = true;
72		self.manifest = manifest;
73		self
74	}
75
76	/// Load manifest from ManifestStaticFilesStorage
77	///
78	/// # Examples
79	///
80	/// ```rust,no_run
81	/// use reinhardt_utils::staticfiles::template_integration::TemplateStaticConfig;
82	/// use reinhardt_utils::staticfiles::ManifestStaticFilesStorage;
83	/// use std::path::PathBuf;
84	///
85	/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
86	/// let storage = ManifestStaticFilesStorage::new(
87	///     PathBuf::from("/var/www/static"),
88	///     "/static/"
89	/// );
90	///
91	/// let config = TemplateStaticConfig::from_storage(&storage).await?;
92	/// assert!(config.use_manifest);
93	/// # Ok(())
94	/// # }
95	/// ```
96	pub async fn from_storage(storage: &ManifestStaticFilesStorage) -> io::Result<Self> {
97		let manifest_path = storage.location.join(&storage.manifest_name);
98
99		if !manifest_path.exists() {
100			return Ok(Self {
101				static_url: storage.base_url.clone(),
102				use_manifest: false,
103				manifest: HashMap::new(),
104			});
105		}
106
107		let manifest_content = tokio::fs::read_to_string(&manifest_path).await?;
108
109		// Try parsing as structured format first: {"version": "...", "paths": {...}} or {"paths": {...}}
110		let manifest =
111			if let Ok(structured) = serde_json::from_str::<serde_json::Value>(&manifest_content) {
112				if let Some(paths) = structured.get("paths").and_then(|v| v.as_object()) {
113					paths
114						.iter()
115						.filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
116						.collect()
117				} else if let Some(files) = structured.get("files").and_then(|v| v.as_object()) {
118					// Legacy format with "files" key
119					files
120						.iter()
121						.filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
122						.collect()
123				} else {
124					// Try as simple HashMap (legacy flat format)
125					serde_json::from_str::<HashMap<String, String>>(&manifest_content)
126						.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?
127				}
128			} else {
129				return Err(io::Error::new(
130					io::ErrorKind::InvalidData,
131					"Invalid manifest JSON",
132				));
133			};
134
135		Ok(Self {
136			static_url: storage.base_url.clone(),
137			use_manifest: true,
138			manifest,
139		})
140	}
141
142	/// Resolve a static file path to a URL
143	///
144	/// This method generates a URL for a static file, optionally using
145	/// manifest-based hashed filenames for cache busting.
146	///
147	/// # Arguments
148	///
149	/// * `name` - The file path relative to static root, optionally with query string and/or fragment
150	///
151	/// # Examples
152	///
153	/// Basic usage:
154	///
155	/// ```rust
156	/// use reinhardt_utils::staticfiles::template_integration::TemplateStaticConfig;
157	///
158	/// let config = TemplateStaticConfig::new("/static/".to_string());
159	/// assert_eq!(config.resolve_url("css/style.css"), "/static/css/style.css");
160	/// ```
161	///
162	/// With manifest:
163	///
164	/// ```rust
165	/// use reinhardt_utils::staticfiles::template_integration::TemplateStaticConfig;
166	/// use std::collections::HashMap;
167	///
168	/// let mut manifest = HashMap::new();
169	/// manifest.insert("css/style.css".to_string(), "css/style.abc123.css".to_string());
170	///
171	/// let config = TemplateStaticConfig::new("/static/".to_string())
172	///     .with_manifest(manifest);
173	///
174	/// assert_eq!(config.resolve_url("css/style.css"), "/static/css/style.abc123.css");
175	/// ```
176	///
177	/// With query string and fragment:
178	///
179	/// ```rust
180	/// use reinhardt_utils::staticfiles::template_integration::TemplateStaticConfig;
181	///
182	/// let config = TemplateStaticConfig::new("/static/".to_string());
183	/// assert_eq!(
184	///     config.resolve_url("test.css?v=1#section"),
185	///     "/static/test.css?v=1#section"
186	/// );
187	/// ```
188	pub fn resolve_url(&self, name: &str) -> String {
189		// 1. Split path, query string, and fragment
190		let (path, query_fragment) = match name.split_once('?') {
191			Some((p, qf)) => (p, Some(qf)),
192			None => (name, None),
193		};
194
195		// 2. Check manifest for hashed filename
196		let resolved_path = if self.use_manifest {
197			self.manifest.get(path).map(|s| s.as_str()).unwrap_or(path)
198		} else {
199			path
200		};
201
202		// 3. Normalize and join URL
203		let base = self.static_url.trim_end_matches('/');
204		let path = resolved_path.trim_start_matches('/');
205		let mut url = format!("{}/{}", base, path);
206
207		// 4. Append query string and fragment
208		if let Some(qf) = query_fragment {
209			url.push('?');
210			url.push_str(qf);
211		}
212
213		url
214	}
215}
216
217#[cfg(test)]
218mod tests {
219	use super::*;
220
221	#[test]
222	fn test_template_static_config_new() {
223		let config = TemplateStaticConfig::new("/static/".to_string());
224		assert_eq!(config.static_url, "/static/");
225		assert!(!config.use_manifest);
226		assert!(config.manifest.is_empty());
227	}
228
229	#[test]
230	fn test_template_static_config_with_manifest() {
231		let mut manifest = HashMap::new();
232		manifest.insert(
233			"css/style.css".to_string(),
234			"css/style.abc123.css".to_string(),
235		);
236
237		let config =
238			TemplateStaticConfig::new("/static/".to_string()).with_manifest(manifest.clone());
239
240		assert_eq!(config.static_url, "/static/");
241		assert!(config.use_manifest);
242		assert_eq!(config.manifest.len(), 1);
243		assert_eq!(
244			config.manifest.get("css/style.css"),
245			Some(&"css/style.abc123.css".to_string())
246		);
247	}
248
249	#[test]
250	fn test_template_static_config_from_static_files_config() {
251		let static_config = StaticFilesConfig {
252			static_root: std::path::PathBuf::from("/var/www/static"),
253			static_url: "/assets/".to_string(),
254			staticfiles_dirs: vec![],
255			media_url: None,
256		};
257
258		let template_config = TemplateStaticConfig::from(&static_config);
259		assert_eq!(template_config.static_url, "/assets/");
260		assert!(!template_config.use_manifest);
261		assert!(template_config.manifest.is_empty());
262	}
263
264	#[tokio::test]
265	async fn test_from_storage() {
266		use tempfile::tempdir;
267
268		let temp_dir = tempdir().unwrap();
269		let static_root = temp_dir.path().to_path_buf();
270
271		// Create manifest file (canonical format with version and paths)
272		let manifest_content = r#"{
273  "version": "1.0",
274  "paths": {
275    "css/style.css": "css/style.abc123.css",
276    "js/app.js": "js/app.def456.js"
277  }
278}"#;
279
280		std::fs::write(static_root.join("staticfiles.json"), manifest_content).unwrap();
281
282		let storage = ManifestStaticFilesStorage::new(static_root, "/static/");
283		let config = TemplateStaticConfig::from_storage(&storage).await.unwrap();
284
285		assert_eq!(config.static_url, "/static/");
286		assert!(config.use_manifest);
287		assert_eq!(config.manifest.len(), 2);
288		assert_eq!(
289			config.manifest.get("css/style.css"),
290			Some(&"css/style.abc123.css".to_string())
291		);
292		assert_eq!(
293			config.manifest.get("js/app.js"),
294			Some(&"js/app.def456.js".to_string())
295		);
296	}
297
298	#[tokio::test]
299	async fn test_from_storage_with_version_and_paths() {
300		use tempfile::tempdir;
301
302		let temp_dir = tempdir().unwrap();
303		let static_root = temp_dir.path().to_path_buf();
304
305		// Canonical format: {"version": "1.0", "paths": {...}}
306		let manifest_content = r#"{
307  "version": "1.0",
308  "paths": {
309    "css/style.css": "css/style.abc123.css"
310  }
311}"#;
312
313		std::fs::write(static_root.join("staticfiles.json"), manifest_content).unwrap();
314
315		let storage = ManifestStaticFilesStorage::new(static_root, "/static/");
316		let config = TemplateStaticConfig::from_storage(&storage).await.unwrap();
317
318		assert_eq!(config.static_url, "/static/");
319		assert!(config.use_manifest);
320		assert_eq!(config.manifest.len(), 1);
321		assert_eq!(
322			config.manifest.get("css/style.css"),
323			Some(&"css/style.abc123.css".to_string())
324		);
325	}
326
327	#[tokio::test]
328	async fn test_from_storage_with_legacy_files_key() {
329		use tempfile::tempdir;
330
331		let temp_dir = tempdir().unwrap();
332		let static_root = temp_dir.path().to_path_buf();
333
334		// Legacy format: {"version": "1.0", "files": {...}}
335		let manifest_content = r#"{
336  "version": "1.0",
337  "files": {
338    "js/app.js": "js/app.def456.js"
339  }
340}"#;
341
342		std::fs::write(static_root.join("staticfiles.json"), manifest_content).unwrap();
343
344		let storage = ManifestStaticFilesStorage::new(static_root, "/static/");
345		let config = TemplateStaticConfig::from_storage(&storage).await.unwrap();
346
347		assert_eq!(config.static_url, "/static/");
348		assert!(config.use_manifest);
349		assert_eq!(config.manifest.len(), 1);
350		assert_eq!(
351			config.manifest.get("js/app.js"),
352			Some(&"js/app.def456.js".to_string())
353		);
354	}
355
356	#[test]
357	fn test_resolve_url_basic() {
358		let config = TemplateStaticConfig::new("/static/".to_string());
359		assert_eq!(config.resolve_url("css/style.css"), "/static/css/style.css");
360	}
361
362	#[test]
363	fn test_resolve_url_with_leading_slash() {
364		let config = TemplateStaticConfig::new("/static/".to_string());
365		assert_eq!(
366			config.resolve_url("/css/style.css"),
367			"/static/css/style.css"
368		);
369	}
370
371	#[test]
372	fn test_resolve_url_with_manifest() {
373		let mut manifest = HashMap::new();
374		manifest.insert(
375			"css/style.css".to_string(),
376			"css/style.abc123.css".to_string(),
377		);
378
379		let config = TemplateStaticConfig::new("/static/".to_string()).with_manifest(manifest);
380
381		assert_eq!(
382			config.resolve_url("css/style.css"),
383			"/static/css/style.abc123.css"
384		);
385	}
386
387	#[test]
388	fn test_resolve_url_manifest_fallback() {
389		let mut manifest = HashMap::new();
390		manifest.insert(
391			"css/style.css".to_string(),
392			"css/style.abc123.css".to_string(),
393		);
394
395		let config = TemplateStaticConfig::new("/static/".to_string()).with_manifest(manifest);
396
397		// File not in manifest should fallback to original path
398		assert_eq!(config.resolve_url("js/app.js"), "/static/js/app.js");
399	}
400
401	#[test]
402	fn test_resolve_url_with_query_string() {
403		let config = TemplateStaticConfig::new("/static/".to_string());
404		assert_eq!(config.resolve_url("test.css?v=1"), "/static/test.css?v=1");
405	}
406
407	#[test]
408	fn test_resolve_url_with_fragment() {
409		let config = TemplateStaticConfig::new("/static/".to_string());
410		assert_eq!(
411			config.resolve_url("test.css#section"),
412			"/static/test.css#section"
413		);
414	}
415
416	#[test]
417	fn test_resolve_url_with_query_and_fragment() {
418		let config = TemplateStaticConfig::new("/static/".to_string());
419		assert_eq!(
420			config.resolve_url("test.css?v=1#section"),
421			"/static/test.css?v=1#section"
422		);
423	}
424
425	#[test]
426	fn test_resolve_url_manifest_with_query_string() {
427		let mut manifest = HashMap::new();
428		manifest.insert(
429			"css/style.css".to_string(),
430			"css/style.abc123.css".to_string(),
431		);
432
433		let config = TemplateStaticConfig::new("/static/".to_string()).with_manifest(manifest);
434
435		// Manifest lookup should work with query string
436		assert_eq!(
437			config.resolve_url("css/style.css?v=1"),
438			"/static/css/style.abc123.css?v=1"
439		);
440	}
441
442	#[test]
443	fn test_resolve_url_different_base_urls() {
444		let config1 = TemplateStaticConfig::new("/static/".to_string());
445		assert_eq!(config1.resolve_url("test.txt"), "/static/test.txt");
446
447		let config2 = TemplateStaticConfig::new("/static".to_string());
448		assert_eq!(config2.resolve_url("test.txt"), "/static/test.txt");
449
450		let config3 = TemplateStaticConfig::new("static/".to_string());
451		assert_eq!(config3.resolve_url("test.txt"), "static/test.txt");
452	}
453
454	#[test]
455	fn test_resolve_url_empty_path() {
456		let config = TemplateStaticConfig::new("/static/".to_string());
457		assert_eq!(config.resolve_url(""), "/static/");
458	}
459
460	#[test]
461	fn test_resolve_url_cdn_url() {
462		let config = TemplateStaticConfig::new("https://cdn.example.com/static/".to_string());
463		assert_eq!(
464			config.resolve_url("css/style.css"),
465			"https://cdn.example.com/static/css/style.css"
466		);
467	}
468}