Skip to main content

atman_runtime/
routing.rs

1use std::fmt;
2
3use atman_dsl::ast::File;
4
5use crate::config_hub::{ConfigError, ConfigHub};
6
7#[derive(Debug, Clone, PartialEq, Eq)]
8pub struct RouteMatch {
9    pub command: String,
10    pub args: String,
11}
12
13impl RouteMatch {
14    pub fn slash_call(&self) -> String {
15        if self.args.is_empty() {
16            format!("/{}", self.command)
17        } else {
18            format!("/{} {}", self.command, self.args)
19        }
20    }
21}
22
23#[derive(Debug)]
24pub enum RouteLoadError {
25    Config(ConfigError),
26    Parse(String),
27}
28
29impl fmt::Display for RouteLoadError {
30    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
31        match self {
32            Self::Config(error) => write!(f, "load routes.at: {error}"),
33            Self::Parse(error) => write!(f, "parse routes.at: {error}"),
34        }
35    }
36}
37
38impl std::error::Error for RouteLoadError {}
39
40#[derive(Debug)]
41pub struct RouteProgram {
42    file: Option<File>,
43}
44
45impl RouteProgram {
46    pub fn load(hub: &ConfigHub) -> Result<Self, RouteLoadError> {
47        let file = hub
48            .load_routes_source()
49            .map_err(RouteLoadError::Config)?
50            .map(|source| {
51                atman_dsl::parse::parse_file(&source)
52                    .map_err(|error| RouteLoadError::Parse(error.to_string()))
53            })
54            .transpose()?;
55        Ok(Self { file })
56    }
57
58    pub fn resolve(&self, input: &str) -> Option<RouteMatch> {
59        let file = self.file.as_ref()?;
60        for route in &file.routes {
61            if let Some(rest) = input.strip_prefix(&route.pattern) {
62                return Some(RouteMatch {
63                    command: route.flow.name.clone(),
64                    args: rest.trim().to_string(),
65                });
66            }
67        }
68        file.default_route.as_ref().map(|route| RouteMatch {
69            command: route.flow.name.clone(),
70            args: input.trim().to_string(),
71        })
72    }
73}
74
75#[cfg(test)]
76mod tests {
77    use super::*;
78
79    fn hub_with(source: Option<&str>) -> (tempfile::TempDir, ConfigHub) {
80        let dir = tempfile::tempdir().unwrap();
81        if let Some(source) = source {
82            std::fs::write(dir.path().join("routes.at"), source).unwrap();
83        }
84        let hub = ConfigHub::from_config_dir(dir.path());
85        (dir, hub)
86    }
87
88    #[test]
89    fn resolves_explicit_route_before_default() {
90        let (_dir, hub) = hub_with(Some(
91            "route \"hello\" { flow: greet }\ndefault_route { flow: agent }\n",
92        ));
93        let route = RouteProgram::load(&hub)
94            .unwrap()
95            .resolve("hello world")
96            .unwrap();
97        assert_eq!(route.command, "greet");
98        assert_eq!(route.args, "world");
99    }
100
101    #[test]
102    fn resolves_default_route() {
103        let (_dir, hub) = hub_with(Some("default_route { flow: agent }\n"));
104        let route = RouteProgram::load(&hub)
105            .unwrap()
106            .resolve("  question  ")
107            .unwrap();
108        assert_eq!(route.command, "agent");
109        assert_eq!(route.args, "question");
110    }
111
112    #[test]
113    fn missing_or_unmatched_routes_are_none() {
114        let (_missing_dir, missing_hub) = hub_with(None);
115        assert!(
116            RouteProgram::load(&missing_hub)
117                .unwrap()
118                .resolve("hello")
119                .is_none()
120        );
121
122        let (_unmatched_dir, unmatched_hub) = hub_with(Some("route \"hello\" { flow: greet }\n"));
123        assert!(
124            RouteProgram::load(&unmatched_hub)
125                .unwrap()
126                .resolve("bye")
127                .is_none()
128        );
129    }
130
131    #[test]
132    fn unreadable_routes_surface_config_error() {
133        let dir = tempfile::tempdir().unwrap();
134        std::fs::create_dir(dir.path().join("routes.at")).unwrap();
135        let hub = ConfigHub::from_config_dir(dir.path());
136        let error = RouteProgram::load(&hub).unwrap_err().to_string();
137        assert!(error.contains("load routes.at"), "error: {error}");
138    }
139
140    #[test]
141    fn invalid_routes_surface_parse_error() {
142        let (_dir, hub) = hub_with(Some("route invalid"));
143        let error = RouteProgram::load(&hub).unwrap_err().to_string();
144        assert!(error.contains("parse routes.at"), "error: {error}");
145    }
146
147    #[test]
148    fn routes_toml_is_ignored() {
149        let (dir, hub) = hub_with(None);
150        std::fs::write(dir.path().join("routes.toml"), "\"!\" -> echo\n").unwrap();
151        assert!(
152            RouteProgram::load(&hub)
153                .unwrap()
154                .resolve("!hello")
155                .is_none()
156        );
157    }
158}