Skip to main content

rex_core/
route.rs

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/// How a page is rendered at request time.
14///
15/// Determined at build time from data strategy and route shape:
16/// - **Static**: pre-rendered at build/startup, served from cache without V8
17/// - **ServerRendered**: rendered on every request via V8
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
19pub enum RenderMode {
20    /// Pre-rendered HTML served from cache (no GSSP, no dynamic segments)
21    Static,
22    /// Rendered on every request via V8
23    #[default]
24    ServerRendered,
25}
26
27impl RenderMode {
28    /// Determine render mode from data strategy and whether the route has dynamic segments.
29    pub fn from_strategy(strategy: &DataStrategy, has_dynamic_segments: bool) -> Self {
30        match strategy {
31            // No data function + no dynamic segments → fully static
32            DataStrategy::None if !has_dynamic_segments => RenderMode::Static,
33            // getStaticProps + no dynamic segments → static (pre-rendered with data)
34            DataStrategy::GetStaticProps if !has_dynamic_segments => RenderMode::Static,
35            // Everything else is server-rendered
36            _ => 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    /// `[slug]` - matches a single path segment
48    Single(String),
49    /// `[...slug]` - matches one or more path segments
50    CatchAll(String),
51    /// `[[...slug]]` - matches zero or more path segments
52    OptionalCatchAll(String),
53}
54
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
56pub enum PageType {
57    Regular,
58    Api,      // pages/api/*
59    AppApi,   // app/**/route.ts (app router route handlers)
60    App,      // _app
61    Document, // _document
62    Error,    // _error
63    NotFound, // 404
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct Route {
68    /// The URL pattern, e.g. "/blog/:slug"
69    pub pattern: String,
70    /// Path to the source file relative to pages/
71    pub file_path: PathBuf,
72    /// Absolute path to the source file
73    pub abs_path: PathBuf,
74    /// Dynamic segments extracted from the pattern
75    pub dynamic_segments: Vec<DynamicSegment>,
76    /// Page type classification
77    pub page_type: PageType,
78    /// Higher = more specific, used for route priority
79    pub specificity: u32,
80}
81
82impl Route {
83    /// Get the route's module name for JS registry (e.g., "/blog/[slug]" -> "blog/[slug]")
84    pub fn module_name(&self) -> String {
85        self.file_path
86            .with_extension("")
87            .to_string_lossy()
88            .replace('\\', "/")
89    }
90}
91
92/// The result of matching a URL against the route trie
93#[derive(Debug, Clone)]
94pub struct RouteMatch {
95    pub route: Route,
96    pub params: HashMap<String, String>,
97}
98
99/// Context passed to getServerSideProps
100#[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/// Result from getServerSideProps
113#[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// --- MCP tool types ---
142
143#[derive(Debug, Clone, Serialize, Deserialize)]
144pub struct McpToolRoute {
145    /// Tool name derived from filename stem (e.g., "search" from "search.ts")
146    pub name: String,
147    /// Absolute path to the source file
148    pub abs_path: PathBuf,
149    /// Path relative to the mcp/ directory
150    pub file_path: PathBuf,
151}
152
153// --- Middleware types ---
154
155#[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// --- Middleware config types (rex.config.json / rex.config.toml) ---
177
178/// A redirect rule from the project config
179#[derive(Debug, Clone, Serialize, Deserialize)]
180pub struct RedirectRule {
181    /// Source path pattern (supports :param for dynamic segments)
182    pub source: String,
183    /// Destination path (supports :param references)
184    pub destination: String,
185    /// HTTP status code (301 or 308 for permanent, 302 or 307 for temporary)
186    #[serde(default = "default_redirect_rule_status")]
187    pub status_code: u16,
188    /// Whether this redirect is permanent (overrides status_code)
189    #[serde(default)]
190    pub permanent: bool,
191}
192
193fn default_redirect_rule_status() -> u16 {
194    307
195}
196
197/// A rewrite rule from the project config
198#[derive(Debug, Clone, Serialize, Deserialize)]
199pub struct RewriteRule {
200    /// Source path pattern (supports :param for dynamic segments)
201    pub source: String,
202    /// Destination path (supports :param references)
203    pub destination: String,
204}
205
206/// A custom header rule from the project config
207#[derive(Debug, Clone, Serialize, Deserialize)]
208pub struct HeaderRule {
209    /// Path pattern to match (supports :param for dynamic segments)
210    pub source: String,
211    /// Headers to add to matching responses
212    pub headers: Vec<HeaderEntry>,
213}
214
215/// A single header key-value pair
216#[derive(Debug, Clone, Serialize, Deserialize)]
217pub struct HeaderEntry {
218    pub key: String,
219    pub value: String,
220}
221
222/// Build-time configuration from the project config
223#[derive(Debug, Clone, Serialize, Deserialize)]
224pub struct BuildConfig {
225    /// Additional module aliases (e.g. `"@components": "./src/components"`)
226    #[serde(default)]
227    pub alias: HashMap<String, String>,
228    /// Generate sourcemaps for server and client bundles (default: true)
229    #[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    /// Resolve alias values that are relative paths against the project root.
248    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/// Dev server configuration from the project config
264#[derive(Debug, Clone, Default, Serialize, Deserialize)]
265pub struct DevConfig {
266    #[serde(default)]
267    pub no_tui: bool,
268}
269
270/// Top-level project configuration from rex.config.json or rex.config.toml
271#[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    /// Load project config from the project root.
287    ///
288    /// Checks for `rex.config.toml` and `rex.config.json`. If both exist, returns
289    /// an error. If neither exists, returns the default config.
290    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    /// Match a request path against a source pattern and return captured params.
317    /// Patterns support `:param` for single segments and `*` for catch-all.
318    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            // Check for wildcard catch-all
324            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    /// Apply captured params to a destination string (replace :param with values).
352    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", &params),
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        // Ensure no leftover json file from prior runs
454        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        // Segments before wildcard must match
614        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}