1use std::io::IsTerminal;
16use std::time::Duration;
17
18#[derive(Debug, Clone)]
22pub struct DeployConfig {
23 pub file: String,
24 pub server: String,
25 pub backend: String,
26 pub auth_token: String,
27}
28
29#[derive(Debug)]
33pub struct DeployResult {
34 pub success: bool,
35 pub deployed: Vec<String>,
36 pub error: Option<String>,
37 pub phase: Option<String>,
38 pub raw_json: serde_json::Value,
39}
40
41const DEPLOY_TIMEOUT: Duration = Duration::from_secs(30);
44
45pub fn run_deploy(config: &DeployConfig) -> i32 {
47 let use_color = std::io::stdout().is_terminal();
48
49 let source = match std::fs::read_to_string(&config.file) {
51 Ok(s) => s,
52 Err(e) => {
53 let msg = format!("Cannot read '{}': {e}", config.file);
54 if use_color {
55 eprintln!("\x1b[1;31m{msg}\x1b[0m");
56 } else {
57 eprintln!("{msg}");
58 }
59 return 2;
60 }
61 };
62
63 if !config.server.starts_with("http://") && !config.server.starts_with("https://") {
65 let msg = format!(
66 "Invalid server URL '{}'. Must start with http:// or https://.",
67 config.server
68 );
69 if use_color {
70 eprintln!("\x1b[1;31m{msg}\x1b[0m");
71 } else {
72 eprintln!("{msg}");
73 }
74 return 2;
75 }
76
77 let deploy_url = format!(
79 "{}/v1/deploy",
80 config.server.trim_end_matches('/')
81 );
82
83 if use_color {
84 eprintln!(
85 "\x1b[1;36m⬡ Deploying '{}' to {}\x1b[0m",
86 config.file, config.server
87 );
88 } else {
89 eprintln!("Deploying '{}' to {}", config.file, config.server);
90 }
91
92 let result = send_deploy(&deploy_url, &config.file, &source, &config.backend, &config.auth_token);
94
95 match result {
96 Ok(deploy) => {
97 if deploy.success {
98 let names = deploy.deployed.join(", ");
99 if use_color {
100 eprintln!(
101 "\x1b[1;32m ✓ Deployed: {names} ({} flow{})\x1b[0m",
102 deploy.deployed.len(),
103 if deploy.deployed.len() == 1 { "" } else { "s" },
104 );
105 } else {
106 eprintln!(
107 " Deployed: {names} ({} flow{})",
108 deploy.deployed.len(),
109 if deploy.deployed.len() == 1 { "" } else { "s" },
110 );
111 }
112 0
113 } else {
114 let error = deploy.error.unwrap_or_else(|| "unknown error".to_string());
115 let phase = deploy.phase.unwrap_or_else(|| "unknown".to_string());
116 if use_color {
117 eprintln!(
118 "\x1b[1;31m ✗ Deploy failed ({phase}): {error}\x1b[0m",
119 );
120 } else {
121 eprintln!(" Deploy failed ({phase}): {error}");
122 }
123 1
124 }
125 }
126 Err(e) => {
127 if use_color {
128 eprintln!("\x1b[1;31m ✗ {e}\x1b[0m");
129 } else {
130 eprintln!(" {e}");
131 }
132 2
133 }
134 }
135}
136
137fn send_deploy(
139 url: &str,
140 filename: &str,
141 source: &str,
142 backend: &str,
143 auth_token: &str,
144) -> Result<DeployResult, String> {
145 let client = reqwest::blocking::Client::builder()
146 .timeout(DEPLOY_TIMEOUT)
147 .build()
148 .map_err(|e| format!("Failed to create HTTP client: {e}"))?;
149
150 let body = serde_json::json!({
151 "source": source,
152 "filename": filename,
153 "backend": backend,
154 });
155
156 let mut request = client
157 .post(url)
158 .header("Content-Type", "application/json");
159
160 if !auth_token.is_empty() {
161 request = request.header("Authorization", format!("Bearer {auth_token}"));
162 }
163
164 let response = request
165 .body(body.to_string())
166 .send()
167 .map_err(|e| {
168 if e.is_timeout() {
169 format!("Server timed out after {}s", DEPLOY_TIMEOUT.as_secs())
170 } else if e.is_connect() {
171 format!("Cannot connect to server at {url}. Is `axon serve` running?")
172 } else {
173 format!("Request failed: {e}")
174 }
175 })?;
176
177 let status = response.status();
178
179 if status == reqwest::StatusCode::UNAUTHORIZED {
180 return Err("Authentication required. Use --auth-token <TOKEN>.".to_string());
181 }
182 if status == reqwest::StatusCode::FORBIDDEN {
183 return Err("Invalid auth token. Check your --auth-token value.".to_string());
184 }
185
186 let text = response
187 .text()
188 .map_err(|e| format!("Failed to read response: {e}"))?;
189
190 if !status.is_success() {
191 return Err(format!("Server returned HTTP {}: {text}", status.as_u16()));
192 }
193
194 let json: serde_json::Value = serde_json::from_str(&text)
195 .map_err(|e| format!("Invalid JSON response: {e}"))?;
196
197 let success = json["success"].as_bool().unwrap_or(false);
198 let deployed = json["deployed"]
199 .as_array()
200 .map(|arr| {
201 arr.iter()
202 .filter_map(|v| v.as_str().map(String::from))
203 .collect()
204 })
205 .unwrap_or_default();
206 let error = json["error"].as_str().map(String::from);
207 let phase = json["phase"].as_str().map(String::from);
208
209 Ok(DeployResult {
210 success,
211 deployed,
212 error,
213 phase,
214 raw_json: json,
215 })
216}
217
218#[cfg(test)]
221mod tests {
222 use super::*;
223
224 #[test]
225 fn deploy_config_defaults() {
226 let cfg = DeployConfig {
227 file: "test.axon".into(),
228 server: "http://localhost:8420".into(),
229 backend: "anthropic".into(),
230 auth_token: String::new(),
231 };
232 assert_eq!(cfg.file, "test.axon");
233 assert_eq!(cfg.server, "http://localhost:8420");
234 }
235
236 #[test]
237 fn deploy_file_not_found() {
238 let cfg = DeployConfig {
239 file: "nonexistent_file_xyz.axon".into(),
240 server: "http://localhost:8420".into(),
241 backend: "anthropic".into(),
242 auth_token: String::new(),
243 };
244 assert_eq!(run_deploy(&cfg), 2);
245 }
246
247 #[test]
248 fn deploy_invalid_server_url() {
249 let tmp = std::env::temp_dir().join("axon_test_deploy_url.axon");
251 std::fs::write(&tmp, "persona P { tone: \"analytical\" }\n").unwrap();
252
253 let cfg = DeployConfig {
254 file: tmp.to_str().unwrap().into(),
255 server: "ftp://badscheme".into(),
256 backend: "anthropic".into(),
257 auth_token: String::new(),
258 };
259 assert_eq!(run_deploy(&cfg), 2);
260 let _ = std::fs::remove_file(tmp);
261 }
262
263 #[test]
264 fn deploy_connection_refused() {
265 let tmp = std::env::temp_dir().join("axon_test_deploy_conn.axon");
266 std::fs::write(&tmp, "persona P { tone: \"analytical\" }\n").unwrap();
267
268 let cfg = DeployConfig {
269 file: tmp.to_str().unwrap().into(),
270 server: "http://127.0.0.1:1".into(), backend: "anthropic".into(),
272 auth_token: String::new(),
273 };
274 assert_eq!(run_deploy(&cfg), 2);
275 let _ = std::fs::remove_file(tmp);
276 }
277
278 #[test]
279 fn deploy_result_parsing() {
280 let json: serde_json::Value = serde_json::json!({
281 "success": true,
282 "deployed": ["FlowA", "FlowB"],
283 "flow_count": 2,
284 "backend": "anthropic"
285 });
286
287 let success = json["success"].as_bool().unwrap_or(false);
288 let deployed: Vec<String> = json["deployed"]
289 .as_array()
290 .map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect())
291 .unwrap_or_default();
292
293 assert!(success);
294 assert_eq!(deployed, vec!["FlowA", "FlowB"]);
295 }
296
297 #[test]
298 fn deploy_error_result_parsing() {
299 let json: serde_json::Value = serde_json::json!({
300 "success": false,
301 "error": "parse error: unexpected token",
302 "phase": "parser"
303 });
304
305 let success = json["success"].as_bool().unwrap_or(false);
306 let error = json["error"].as_str().map(String::from);
307 let phase = json["phase"].as_str().map(String::from);
308
309 assert!(!success);
310 assert_eq!(error.unwrap(), "parse error: unexpected token");
311 assert_eq!(phase.unwrap(), "parser");
312 }
313
314 #[test]
315 fn deploy_url_construction() {
316 let base = "http://localhost:8420";
317 let url = format!("{}/v1/deploy", base.trim_end_matches('/'));
318 assert_eq!(url, "http://localhost:8420/v1/deploy");
319
320 let base_trailing = "http://localhost:8420/";
321 let url = format!("{}/v1/deploy", base_trailing.trim_end_matches('/'));
322 assert_eq!(url, "http://localhost:8420/v1/deploy");
323 }
324}