1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3use std::path::{Path, PathBuf};
4
5#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
6pub enum DataStrategy {
7 #[default]
8 None,
9 GetServerSideProps,
10 GetStaticProps,
11}
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
19pub enum RenderMode {
20 Static,
22 #[default]
24 ServerRendered,
25}
26
27impl RenderMode {
28 pub fn from_strategy(strategy: &DataStrategy, has_dynamic_segments: bool) -> Self {
30 match strategy {
31 DataStrategy::None if !has_dynamic_segments => RenderMode::Static,
33 DataStrategy::GetStaticProps if !has_dynamic_segments => RenderMode::Static,
35 _ => RenderMode::ServerRendered,
37 }
38 }
39
40 pub fn is_static(self) -> bool {
41 self == RenderMode::Static
42 }
43}
44
45#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
46pub enum DynamicSegment {
47 Single(String),
49 CatchAll(String),
51 OptionalCatchAll(String),
53}
54
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
56pub enum PageType {
57 Regular,
58 Api, AppApi, App, Document, Error, NotFound, }
65
66#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct Route {
68 pub pattern: String,
70 pub file_path: PathBuf,
72 pub abs_path: PathBuf,
74 pub dynamic_segments: Vec<DynamicSegment>,
76 pub page_type: PageType,
78 pub specificity: u32,
80}
81
82impl Route {
83 pub fn module_name(&self) -> String {
85 self.file_path
86 .with_extension("")
87 .to_string_lossy()
88 .replace('\\', "/")
89 }
90}
91
92#[derive(Debug, Clone)]
94pub struct RouteMatch {
95 pub route: Route,
96 pub params: HashMap<String, String>,
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize)]
101pub struct ServerSidePropsContext {
102 pub params: HashMap<String, String>,
103 pub query: HashMap<String, String>,
104 #[serde(rename = "resolvedUrl")]
105 pub resolved_url: String,
106 #[serde(default)]
107 pub headers: HashMap<String, String>,
108 #[serde(default)]
109 pub cookies: HashMap<String, String>,
110}
111
112#[derive(Debug, Clone, Serialize, Deserialize)]
114#[serde(untagged)]
115pub enum ServerSidePropsResult {
116 Props {
117 props: serde_json::Value,
118 },
119 Redirect {
120 redirect: RedirectConfig,
121 },
122 NotFound {
123 #[serde(rename = "notFound")]
124 not_found: bool,
125 },
126}
127
128#[derive(Debug, Clone, Serialize, Deserialize)]
129pub struct RedirectConfig {
130 pub destination: String,
131 #[serde(default = "default_redirect_status")]
132 pub status_code: u16,
133 #[serde(default)]
134 pub permanent: bool,
135}
136
137fn default_redirect_status() -> u16 {
138 307
139}
140
141#[derive(Debug, Clone, Serialize, Deserialize)]
144pub struct McpToolRoute {
145 pub name: String,
147 pub abs_path: PathBuf,
149 pub file_path: PathBuf,
151}
152
153#[derive(Debug, Clone, Serialize, Deserialize)]
156#[serde(rename_all = "lowercase")]
157pub enum MiddlewareAction {
158 Next,
159 Redirect,
160 Rewrite,
161}
162
163#[derive(Debug, Clone, Serialize, Deserialize)]
164pub struct MiddlewareResult {
165 pub action: MiddlewareAction,
166 #[serde(default)]
167 pub url: Option<String>,
168 #[serde(default = "default_redirect_status")]
169 pub status: u16,
170 #[serde(default)]
171 pub request_headers: HashMap<String, String>,
172 #[serde(default)]
173 pub response_headers: HashMap<String, String>,
174}
175
176#[derive(Debug, Clone, Serialize, Deserialize)]
180pub struct RedirectRule {
181 pub source: String,
183 pub destination: String,
185 #[serde(default = "default_redirect_rule_status")]
187 pub status_code: u16,
188 #[serde(default)]
190 pub permanent: bool,
191}
192
193fn default_redirect_rule_status() -> u16 {
194 307
195}
196
197#[derive(Debug, Clone, Serialize, Deserialize)]
199pub struct RewriteRule {
200 pub source: String,
202 pub destination: String,
204}
205
206#[derive(Debug, Clone, Serialize, Deserialize)]
208pub struct HeaderRule {
209 pub source: String,
211 pub headers: Vec<HeaderEntry>,
213}
214
215#[derive(Debug, Clone, Serialize, Deserialize)]
217pub struct HeaderEntry {
218 pub key: String,
219 pub value: String,
220}
221
222#[derive(Debug, Clone, Serialize, Deserialize)]
224pub struct BuildConfig {
225 #[serde(default)]
227 pub alias: HashMap<String, String>,
228 #[serde(default = "default_true")]
230 pub sourcemap: bool,
231}
232
233fn default_true() -> bool {
234 true
235}
236
237impl Default for BuildConfig {
238 fn default() -> Self {
239 Self {
240 alias: HashMap::default(),
241 sourcemap: true,
242 }
243 }
244}
245
246impl BuildConfig {
247 pub fn resolved_aliases(&self, project_root: &Path) -> Vec<(String, Vec<Option<String>>)> {
249 self.alias
250 .iter()
251 .map(|(key, value)| {
252 let resolved = if value.starts_with("./") || value.starts_with("../") {
253 project_root.join(value).to_string_lossy().to_string()
254 } else {
255 value.clone()
256 };
257 (key.clone(), vec![Some(resolved)])
258 })
259 .collect()
260 }
261}
262
263#[derive(Debug, Clone, Default, Serialize, Deserialize)]
265pub struct DevConfig {
266 #[serde(default)]
267 pub no_tui: bool,
268}
269
270#[derive(Debug, Clone, Default, Serialize, Deserialize)]
272pub struct ProjectConfig {
273 #[serde(default)]
274 pub redirects: Vec<RedirectRule>,
275 #[serde(default)]
276 pub rewrites: Vec<RewriteRule>,
277 #[serde(default)]
278 pub headers: Vec<HeaderRule>,
279 #[serde(default)]
280 pub build: BuildConfig,
281 #[serde(default)]
282 pub dev: DevConfig,
283}
284
285impl ProjectConfig {
286 pub fn load(project_root: &std::path::Path) -> Result<Self, crate::RexError> {
291 let toml_path = project_root.join("rex.config.toml");
292 let json_path = project_root.join("rex.config.json");
293
294 match (toml_path.exists(), json_path.exists()) {
295 (true, true) => Err(crate::RexError::Config(
296 "Found both rex.config.toml and rex.config.json; please use only one".to_string(),
297 )),
298 (true, false) => {
299 let content = std::fs::read_to_string(&toml_path).map_err(|e| {
300 crate::RexError::Config(format!("Failed to read rex.config.toml: {e}"))
301 })?;
302 toml::from_str(&content)
303 .map_err(|e| crate::RexError::Config(format!("Invalid rex.config.toml: {e}")))
304 }
305 (false, true) => {
306 let content = std::fs::read_to_string(&json_path).map_err(|e| {
307 crate::RexError::Config(format!("Failed to read rex.config.json: {e}"))
308 })?;
309 serde_json::from_str(&content)
310 .map_err(|e| crate::RexError::Config(format!("Invalid rex.config.json: {e}")))
311 }
312 (false, false) => Ok(Self::default()),
313 }
314 }
315
316 pub fn match_pattern(pattern: &str, path: &str) -> Option<HashMap<String, String>> {
319 let pat_segs: Vec<&str> = pattern.trim_matches('/').split('/').collect();
320 let path_segs: Vec<&str> = path.trim_matches('/').split('/').collect();
321
322 if pat_segs.len() != path_segs.len() {
323 if let Some(last) = pat_segs.last() {
325 if *last == "*" && path_segs.len() >= pat_segs.len() - 1 {
326 let mut params = HashMap::new();
327 for (p, s) in pat_segs.iter().zip(path_segs.iter()) {
328 if let Some(name) = p.strip_prefix(':') {
329 params.insert(name.to_string(), s.to_string());
330 } else if *p != "*" && *p != *s {
331 return None;
332 }
333 }
334 return Some(params);
335 }
336 }
337 return None;
338 }
339
340 let mut params = HashMap::new();
341 for (p, s) in pat_segs.iter().zip(path_segs.iter()) {
342 if let Some(name) = p.strip_prefix(':') {
343 params.insert(name.to_string(), s.to_string());
344 } else if *p != *s {
345 return None;
346 }
347 }
348 Some(params)
349 }
350
351 pub fn apply_params(destination: &str, params: &HashMap<String, String>) -> String {
353 let mut result = destination.to_string();
354 for (key, value) in params {
355 result = result.replace(&format!(":{key}"), value);
356 }
357 result
358 }
359}
360
361#[cfg(test)]
362#[allow(clippy::unwrap_used)]
363mod tests {
364 use super::*;
365
366 #[test]
367 fn test_match_pattern_static() {
368 let result = ProjectConfig::match_pattern("/about", "/about");
369 assert!(result.is_some());
370 assert!(result.unwrap().is_empty());
371 }
372
373 #[test]
374 fn test_match_pattern_no_match() {
375 assert!(ProjectConfig::match_pattern("/about", "/contact").is_none());
376 assert!(ProjectConfig::match_pattern("/a/b", "/a").is_none());
377 }
378
379 #[test]
380 fn test_match_pattern_dynamic() {
381 let result = ProjectConfig::match_pattern("/blog/:slug", "/blog/hello").unwrap();
382 assert_eq!(result.get("slug").unwrap(), "hello");
383 }
384
385 #[test]
386 fn test_match_pattern_multiple_params() {
387 let result = ProjectConfig::match_pattern("/blog/:year/:slug", "/blog/2025/intro").unwrap();
388 assert_eq!(result.get("year").unwrap(), "2025");
389 assert_eq!(result.get("slug").unwrap(), "intro");
390 }
391
392 #[test]
393 fn test_apply_params() {
394 let mut params = HashMap::new();
395 params.insert("slug".to_string(), "hello".to_string());
396 assert_eq!(
397 ProjectConfig::apply_params("/posts/:slug", ¶ms),
398 "/posts/hello"
399 );
400 }
401
402 #[test]
403 fn test_config_load_missing_file() {
404 let tmp = std::env::temp_dir().join("rex_test_no_config");
405 let _ = std::fs::create_dir_all(&tmp);
406 let config = ProjectConfig::load(&tmp).unwrap();
407 assert!(config.redirects.is_empty());
408 assert!(config.rewrites.is_empty());
409 assert!(config.headers.is_empty());
410 }
411
412 #[test]
413 fn test_config_load_json() {
414 let tmp = std::env::temp_dir().join("rex_test_config_load");
415 let _ = std::fs::create_dir_all(&tmp);
416 std::fs::write(
417 tmp.join("rex.config.json"),
418 r#"{
419 "redirects": [
420 { "source": "/old", "destination": "/new", "permanent": true }
421 ],
422 "rewrites": [
423 { "source": "/api/:path", "destination": "/api/v2/:path" }
424 ],
425 "headers": [
426 {
427 "source": "/:path",
428 "headers": [
429 { "key": "X-Frame-Options", "value": "DENY" }
430 ]
431 }
432 ]
433 }"#,
434 )
435 .unwrap();
436
437 let config = ProjectConfig::load(&tmp).unwrap();
438 assert_eq!(config.redirects.len(), 1);
439 assert_eq!(config.redirects[0].source, "/old");
440 assert_eq!(config.redirects[0].destination, "/new");
441 assert!(config.redirects[0].permanent);
442 assert_eq!(config.rewrites.len(), 1);
443 assert_eq!(config.headers.len(), 1);
444 assert_eq!(config.headers[0].headers[0].key, "X-Frame-Options");
445
446 let _ = std::fs::remove_dir_all(&tmp);
447 }
448
449 #[test]
450 fn test_config_load_toml() {
451 let tmp = std::env::temp_dir().join("rex_test_config_load_toml");
452 let _ = std::fs::create_dir_all(&tmp);
453 let _ = std::fs::remove_file(tmp.join("rex.config.json"));
455 std::fs::write(
456 tmp.join("rex.config.toml"),
457 r#"
458[[redirects]]
459source = "/old"
460destination = "/new"
461permanent = true
462
463[[rewrites]]
464source = "/api/:path"
465destination = "/api/v2/:path"
466
467[[headers]]
468source = "/:path"
469
470 [[headers.headers]]
471 key = "X-Frame-Options"
472 value = "DENY"
473
474[build]
475[build.alias]
476"@components" = "./src/components"
477
478[dev]
479no_tui = true
480"#,
481 )
482 .unwrap();
483
484 let config = ProjectConfig::load(&tmp).unwrap();
485 assert_eq!(config.redirects.len(), 1);
486 assert_eq!(config.redirects[0].source, "/old");
487 assert_eq!(config.redirects[0].destination, "/new");
488 assert!(config.redirects[0].permanent);
489 assert_eq!(config.rewrites.len(), 1);
490 assert_eq!(config.headers.len(), 1);
491 assert_eq!(config.headers[0].headers[0].key, "X-Frame-Options");
492 assert_eq!(
493 config.build.alias.get("@components").unwrap(),
494 "./src/components"
495 );
496 assert!(config.dev.no_tui);
497
498 let _ = std::fs::remove_dir_all(&tmp);
499 }
500
501 #[test]
502 fn test_config_load_both_errors() {
503 let tmp = std::env::temp_dir().join("rex_test_config_load_both");
504 let _ = std::fs::create_dir_all(&tmp);
505 std::fs::write(tmp.join("rex.config.json"), "{}").unwrap();
506 std::fs::write(tmp.join("rex.config.toml"), "").unwrap();
507
508 let err = ProjectConfig::load(&tmp).unwrap_err();
509 assert!(err.to_string().contains("please use only one"));
510
511 let _ = std::fs::remove_dir_all(&tmp);
512 }
513
514 #[test]
515 fn test_middleware_result_deserialize_next() {
516 let json = r#"{"action":"next"}"#;
517 let result: MiddlewareResult = serde_json::from_str(json).unwrap();
518 assert!(matches!(result.action, MiddlewareAction::Next));
519 assert!(result.url.is_none());
520 assert_eq!(result.status, 307);
521 assert!(result.request_headers.is_empty());
522 assert!(result.response_headers.is_empty());
523 }
524
525 #[test]
526 fn test_middleware_result_deserialize_redirect() {
527 let json = r#"{"action":"redirect","url":"/login","status":302}"#;
528 let result: MiddlewareResult = serde_json::from_str(json).unwrap();
529 assert!(matches!(result.action, MiddlewareAction::Redirect));
530 assert_eq!(result.url.as_deref(), Some("/login"));
531 assert_eq!(result.status, 302);
532 }
533
534 #[test]
535 fn test_middleware_result_deserialize_rewrite() {
536 let json =
537 r#"{"action":"rewrite","url":"/internal","response_headers":{"x-rewritten":"true"}}"#;
538 let result: MiddlewareResult = serde_json::from_str(json).unwrap();
539 assert!(matches!(result.action, MiddlewareAction::Rewrite));
540 assert_eq!(result.url.as_deref(), Some("/internal"));
541 assert_eq!(result.response_headers.get("x-rewritten").unwrap(), "true");
542 }
543
544 #[test]
545 fn render_mode_none_no_dynamic_is_static() {
546 let mode = RenderMode::from_strategy(&DataStrategy::None, false);
547 assert_eq!(mode, RenderMode::Static);
548 assert!(mode.is_static());
549 }
550
551 #[test]
552 fn render_mode_gsp_no_dynamic_is_static() {
553 let mode = RenderMode::from_strategy(&DataStrategy::GetStaticProps, false);
554 assert_eq!(mode, RenderMode::Static);
555 }
556
557 #[test]
558 fn render_mode_gssp_is_server_rendered() {
559 let mode = RenderMode::from_strategy(&DataStrategy::GetServerSideProps, false);
560 assert_eq!(mode, RenderMode::ServerRendered);
561 assert!(!mode.is_static());
562 }
563
564 #[test]
565 fn render_mode_none_with_dynamic_is_server_rendered() {
566 let mode = RenderMode::from_strategy(&DataStrategy::None, true);
567 assert_eq!(mode, RenderMode::ServerRendered);
568 }
569
570 #[test]
571 fn render_mode_gsp_with_dynamic_is_server_rendered() {
572 let mode = RenderMode::from_strategy(&DataStrategy::GetStaticProps, true);
573 assert_eq!(mode, RenderMode::ServerRendered);
574 }
575
576 #[test]
577 fn render_mode_gssp_with_dynamic_is_server_rendered() {
578 let mode = RenderMode::from_strategy(&DataStrategy::GetServerSideProps, true);
579 assert_eq!(mode, RenderMode::ServerRendered);
580 }
581
582 #[test]
583 fn render_mode_default_is_server_rendered() {
584 assert_eq!(RenderMode::default(), RenderMode::ServerRendered);
585 }
586
587 #[test]
588 fn render_mode_serde_round_trip() {
589 let json = serde_json::to_string(&RenderMode::Static).unwrap();
590 let loaded: RenderMode = serde_json::from_str(&json).unwrap();
591 assert_eq!(loaded, RenderMode::Static);
592
593 let json = serde_json::to_string(&RenderMode::ServerRendered).unwrap();
594 let loaded: RenderMode = serde_json::from_str(&json).unwrap();
595 assert_eq!(loaded, RenderMode::ServerRendered);
596 }
597
598 #[test]
599 fn test_match_pattern_wildcard() {
600 let result = ProjectConfig::match_pattern("/api/*", "/api/users/list");
601 assert!(result.is_some());
602 }
603
604 #[test]
605 fn test_match_pattern_wildcard_with_param() {
606 let result = ProjectConfig::match_pattern("/:version/*", "/v2/docs/intro");
607 let params = result.unwrap();
608 assert_eq!(params.get("version").unwrap(), "v2");
609 }
610
611 #[test]
612 fn test_match_pattern_wildcard_no_match() {
613 let result = ProjectConfig::match_pattern("/api/:name/*", "/other/foo/bar");
615 assert!(result.is_none());
616 }
617
618 #[test]
619 fn test_build_config_resolved_aliases_relative() {
620 let config = BuildConfig {
621 alias: {
622 let mut m = HashMap::new();
623 m.insert("@components".into(), "./src/components".into());
624 m
625 },
626 sourcemap: true,
627 };
628 let aliases = config.resolved_aliases(Path::new("/project"));
629 assert_eq!(aliases.len(), 1);
630 let (key, vals) = &aliases[0];
631 assert_eq!(key, "@components");
632 assert!(vals[0].as_ref().unwrap().contains("project"));
633 assert!(vals[0].as_ref().unwrap().contains("src/components"));
634 }
635
636 #[test]
637 fn test_build_config_resolved_aliases_absolute() {
638 let config = BuildConfig {
639 alias: {
640 let mut m = HashMap::new();
641 m.insert("react".into(), "preact/compat".into());
642 m
643 },
644 sourcemap: true,
645 };
646 let aliases = config.resolved_aliases(Path::new("/project"));
647 let (_, vals) = &aliases[0];
648 assert_eq!(vals[0].as_ref().unwrap(), "preact/compat");
649 }
650
651 #[test]
652 fn test_build_config_default() {
653 let config = BuildConfig::default();
654 assert!(config.alias.is_empty());
655 assert!(config.sourcemap);
656 }
657
658 #[test]
659 fn test_data_strategy_serde() {
660 let json = serde_json::to_string(&DataStrategy::GetStaticProps).unwrap();
661 let loaded: DataStrategy = serde_json::from_str(&json).unwrap();
662 assert_eq!(loaded, DataStrategy::GetStaticProps);
663
664 let json = serde_json::to_string(&DataStrategy::None).unwrap();
665 let loaded: DataStrategy = serde_json::from_str(&json).unwrap();
666 assert_eq!(loaded, DataStrategy::None);
667 }
668}