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}