1use crate::client::AxonFlowClient;
16use crate::error::AxonFlowError;
17use crate::types::decisions::{
18 DecisionExplanation, DecisionSummary, ListDecisionsOptions, RateLimitEnvelope,
19};
20use crate::PATH_SEGMENT;
21use percent_encoding::utf8_percent_encode;
22use serde::Deserialize;
23use tracing;
24
25impl AxonFlowClient {
26 #[tracing::instrument(skip(self))]
46 pub async fn explain_decision(
47 &self,
48 decision_id: &str,
49 ) -> Result<DecisionExplanation, AxonFlowError> {
50 if decision_id.is_empty() {
51 return Err(AxonFlowError::ConfigError(
52 "decision_id is required".to_string(),
53 ));
54 }
55
56 let encoded = utf8_percent_encode(decision_id, PATH_SEGMENT).to_string();
60 let url = format!("{}/api/v1/decisions/{}/explain", self.endpoint(), encoded);
61
62 let resp = self.checked_get(&url).await?;
63 let body = resp.text().await?;
64 let parsed: DecisionExplanation = serde_json::from_str(&body)?;
65 Ok(parsed)
66 }
67
68 pub async fn list_decisions(
100 &self,
101 opts: ListDecisionsOptions,
102 ) -> Result<Vec<DecisionSummary>, AxonFlowError> {
103 let mut url = format!("{}/api/v1/decisions", self.endpoint());
104 let qs = build_decisions_query(&opts);
105 if !qs.is_empty() {
106 url.push('?');
107 url.push_str(&qs);
108 }
109
110 let resp = self.raw_get(&url).await?;
114 if resp.status().as_u16() == 429 {
115 let body = resp.text().await?;
116 return match serde_json::from_str::<RateLimitEnvelope>(&body) {
117 Ok(envelope) => Err(AxonFlowError::RateLimited {
118 envelope: Box::new(envelope),
119 }),
120 Err(_) => Err(AxonFlowError::ApiError {
121 status: 429,
122 message: body,
123 }),
124 };
125 }
126 if !resp.status().is_success() {
127 let status = resp.status().as_u16();
128 let message = resp.text().await?;
129 return Err(AxonFlowError::ApiError { status, message });
130 }
131 let body = resp.text().await?;
132 #[derive(Deserialize)]
133 struct ListResponse {
134 #[serde(default)]
135 decisions: Vec<DecisionSummary>,
136 }
137 let parsed: ListResponse = serde_json::from_str(&body)?;
138 Ok(parsed.decisions)
139 }
140}
141
142fn build_decisions_query(opts: &ListDecisionsOptions) -> String {
146 let mut pairs: Vec<(&str, String)> = Vec::with_capacity(5);
147 if let Some(since) = &opts.since {
148 pairs.push((
153 "since",
154 since.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
155 ));
156 }
157 if let Some(decision) = &opts.decision {
158 pairs.push(("decision", decision.clone()));
159 }
160 if let Some(policy_id) = &opts.policy_id {
161 pairs.push(("policy_id", policy_id.clone()));
162 }
163 if let Some(tool_signature) = &opts.tool_signature {
164 pairs.push(("tool_signature", tool_signature.clone()));
165 }
166 if let Some(limit) = opts.limit {
167 pairs.push(("limit", limit.to_string()));
168 }
169 pairs
170 .into_iter()
171 .map(|(k, v)| {
172 let v = utf8_percent_encode(&v, PATH_SEGMENT).to_string();
173 format!("{k}={v}")
174 })
175 .collect::<Vec<_>>()
176 .join("&")
177}
178
179#[cfg(test)]
180mod tests {
181 use crate::types::decisions::DecisionExplanation;
182 use crate::{AxonFlowClient, AxonFlowConfig};
183 use chrono::{TimeZone, Utc};
184 use serde_json::json;
185 use std::time::Duration;
186 use wiremock::matchers::{method, path, query_param};
187 use wiremock::{Mock, MockServer, ResponseTemplate};
188
189 fn make_client(endpoint: String) -> AxonFlowClient {
190 let config = AxonFlowConfig {
191 endpoint,
192 timeout: Duration::from_secs(2),
193 ..Default::default()
194 };
195 AxonFlowClient::new(config).expect("client init")
196 }
197
198 #[tokio::test]
199 async fn empty_decision_id_returns_config_error() {
200 let client = make_client("http://127.0.0.1:1".into());
202 let err = client.explain_decision("").await.unwrap_err();
203 assert!(
204 err.to_string().contains("decision_id is required"),
205 "unexpected error: {err}"
206 );
207 }
208
209 #[tokio::test]
210 async fn happy_path_parses_full_payload() {
211 let server = MockServer::start().await;
212 let want = json!({
213 "decision_id": "dec_wf1_step2",
214 "timestamp": "2026-04-17T12:00:00Z",
215 "decision": "blocked",
216 "reason": "SQL injection detected",
217 "risk_level": "high",
218 "policy_matches": [{
219 "policy_id": "pol-sqli",
220 "policy_name": "SQL Injection Detector",
221 "action": "deny",
222 "risk_level": "high",
223 "allow_override": true
224 }],
225 "override_available": true,
226 "historical_hit_count_session": 3
227 });
228
229 Mock::given(method("GET"))
230 .and(path("/api/v1/decisions/dec_wf1_step2/explain"))
231 .respond_with(
232 ResponseTemplate::new(200)
233 .insert_header("content-type", "application/json")
234 .set_body_json(want),
235 )
236 .expect(1)
237 .mount(&server)
238 .await;
239
240 let client = make_client(server.uri());
241 let got = client.explain_decision("dec_wf1_step2").await.unwrap();
242
243 assert_eq!(got.decision_id, "dec_wf1_step2");
244 assert_eq!(got.decision, "blocked");
245 assert_eq!(got.reason, "SQL injection detected");
246 assert_eq!(got.risk_level.as_deref(), Some("high"));
247 assert_eq!(got.policy_matches.len(), 1);
248 assert_eq!(got.policy_matches[0].policy_id, "pol-sqli");
249 assert!(got.policy_matches[0].allow_override);
250 assert!(got.override_available);
251 assert_eq!(got.historical_hit_count_session, 3);
252 assert_eq!(
253 got.timestamp,
254 Utc.with_ymd_and_hms(2026, 4, 17, 12, 0, 0).unwrap()
255 );
256 }
257
258 #[tokio::test]
259 async fn decision_id_is_url_encoded() {
260 let server = MockServer::start().await;
264 Mock::given(method("GET"))
265 .and(path("/api/v1/decisions/a%2Fb/explain"))
266 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
267 "decision_id": "a/b",
268 "timestamp": "2026-04-17T12:00:00Z",
269 "decision": "allowed",
270 "reason": "",
271 "policy_matches": []
272 })))
273 .expect(1)
274 .mount(&server)
275 .await;
276
277 let client = make_client(server.uri());
278 let _ = client.explain_decision("a/b").await.unwrap();
279 }
280
281 #[tokio::test]
282 async fn http_404_surfaces_as_api_error() {
283 let server = MockServer::start().await;
284 Mock::given(method("GET"))
285 .and(path("/api/v1/decisions/dec-missing/explain"))
286 .respond_with(
287 ResponseTemplate::new(404)
288 .set_body_json(json!({"error": "Decision not found or past retention window"})),
289 )
290 .mount(&server)
291 .await;
292
293 let client = make_client(server.uri());
294 let err = client.explain_decision("dec-missing").await.unwrap_err();
295 match err {
296 crate::error::AxonFlowError::ApiError { status, .. } => assert_eq!(status, 404),
297 other => panic!("expected ApiError(404), got: {other}"),
298 }
299 }
300
301 #[tokio::test]
302 async fn http_401_surfaces_as_api_error() {
303 let server = MockServer::start().await;
308 Mock::given(method("GET"))
309 .and(path("/api/v1/decisions/dec-x/explain"))
310 .respond_with(
311 ResponseTemplate::new(401)
312 .set_body_json(json!({"error": "X-Tenant-ID header is required"})),
313 )
314 .mount(&server)
315 .await;
316
317 let client = make_client(server.uri());
318 let err = client.explain_decision("dec-x").await.unwrap_err();
319 match err {
320 crate::error::AxonFlowError::ApiError { status, .. } => assert_eq!(status, 401),
321 other => panic!("expected ApiError(401), got: {other}"),
322 }
323 }
324
325 #[tokio::test]
326 async fn malformed_json_response_is_serde_error() {
327 let server = MockServer::start().await;
328 Mock::given(method("GET"))
329 .and(path("/api/v1/decisions/dec-x/explain"))
330 .respond_with(
331 ResponseTemplate::new(200)
332 .insert_header("content-type", "application/json")
333 .set_body_string("{not valid json"),
334 )
335 .mount(&server)
336 .await;
337
338 let client = make_client(server.uri());
339 let err = client.explain_decision("dec-x").await.unwrap_err();
340 match err {
341 crate::error::AxonFlowError::SerdeError(_) => {}
342 other => panic!("expected SerdeError, got: {other}"),
343 }
344 }
345
346 #[tokio::test]
347 async fn additive_unknown_fields_are_ignored() {
348 let server = MockServer::start().await;
356 Mock::given(method("GET"))
357 .and(path("/api/v1/decisions/dec-x/explain"))
358 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
359 "decision_id": "dec-x",
360 "timestamp": "2026-04-17T12:00:00Z",
361 "decision": "allowed",
362 "reason": "",
363 "policy_matches": [],
364 "policy_version_at_decision": "v3", "latest_policy_version": "v5", "yet_another_future_field": "shrug" })))
368 .mount(&server)
369 .await;
370
371 let client = make_client(server.uri());
372 let got: DecisionExplanation = client.explain_decision("dec-x").await.unwrap();
373 assert_eq!(got.decision_id, "dec-x");
374 }
375
376 use crate::decisions::build_decisions_query;
382 use crate::error::AxonFlowError;
383 use crate::types::decisions::{DecisionSummary, ListDecisionsOptions};
384
385 #[tokio::test]
386 async fn list_decisions_happy_path_parses_three_rows() {
387 let server = MockServer::start().await;
388 let want = json!({
389 "decisions": [
390 {
391 "decision_id": "dec-1",
392 "timestamp": "2026-05-07T12:00:00Z",
393 "decision": "blocked",
394 "policy_id": "pol-sqli",
395 "tool_signature": "postgres.query"
396 },
397 {
398 "decision_id": "dec-2",
399 "timestamp": "2026-05-07T11:00:00Z",
400 "decision": "allowed",
401 "policy_id": "pol-default",
402 "tool_signature": "github.status"
403 },
404 {
405 "decision_id": "dec-3",
406 "timestamp": "2026-05-07T10:00:00Z",
407 "decision": "needs_approval",
408 "policy_id": "pol-amount",
409 "tool_signature": "stripe.charge"
410 }
411 ]
412 });
413
414 Mock::given(method("GET"))
415 .and(path("/api/v1/decisions"))
416 .respond_with(
417 ResponseTemplate::new(200)
418 .insert_header("content-type", "application/json")
419 .set_body_json(want),
420 )
421 .mount(&server)
422 .await;
423
424 let client = make_client(server.uri());
425 let got = client
426 .list_decisions(ListDecisionsOptions::default())
427 .await
428 .unwrap();
429
430 assert_eq!(got.len(), 3);
431 assert_eq!(got[0].decision_id, "dec-1");
432 assert_eq!(got[0].decision, "blocked");
433 assert_eq!(got[0].policy_id.as_deref(), Some("pol-sqli"));
434 assert_eq!(got[0].tool_signature.as_deref(), Some("postgres.query"));
435 assert_eq!(got[2].decision, "needs_approval");
436 }
437
438 #[tokio::test]
439 async fn list_decisions_serializes_every_filter_into_url() {
440 let server = MockServer::start().await;
441 Mock::given(method("GET"))
446 .and(path("/api/v1/decisions"))
447 .and(query_param("since", "2026-05-07T00:00:00Z"))
448 .and(query_param("decision", "blocked"))
449 .and(query_param("policy_id", "pol-sqli"))
450 .and(query_param("tool_signature", "postgres.query"))
451 .and(query_param("limit", "25"))
452 .respond_with(ResponseTemplate::new(200).set_body_json(json!({"decisions": []})))
453 .expect(1)
454 .mount(&server)
455 .await;
456
457 let client = make_client(server.uri());
458 let opts = ListDecisionsOptions {
459 since: Some(Utc.with_ymd_and_hms(2026, 5, 7, 0, 0, 0).unwrap()),
460 decision: Some("blocked".into()),
461 policy_id: Some("pol-sqli".into()),
462 tool_signature: Some("postgres.query".into()),
463 limit: Some(25),
464 };
465 let _ = client.list_decisions(opts).await.unwrap();
466 }
467
468 #[tokio::test]
469 async fn list_decisions_429_surfaces_typed_rate_limit_envelope() {
470 let server = MockServer::start().await;
471 let envelope = json!({
472 "error": "Free tier shows the last 5 decisions in 24h. Pro raises this to 100 decisions in the last 30 days.",
473 "limit_type": "decision_list_size",
474 "tier": "Community",
475 "limit": 5,
476 "remaining": 0,
477 "upgrade": {
478 "tier": "Pro",
479 "wording": "Free tier shows the last 5 decisions in 24h. Pro raises this to 100 decisions in the last 30 days.",
480 "compare_url": "https://getaxonflow.com/pricing/",
481 "buy_url": "https://buy.stripe.com/bJe28qbztcdVchjdkw8k800"
482 }
483 });
484
485 Mock::given(method("GET"))
486 .and(path("/api/v1/decisions"))
487 .respond_with(
488 ResponseTemplate::new(429)
489 .insert_header("content-type", "application/json")
490 .insert_header("X-Axonflow-Tier-Limit", "decision_list_size")
491 .set_body_json(envelope),
492 )
493 .mount(&server)
494 .await;
495
496 let client = make_client(server.uri());
497 let err = client
498 .list_decisions(ListDecisionsOptions {
499 limit: Some(10),
500 ..Default::default()
501 })
502 .await
503 .expect_err("must reject with RateLimited");
504
505 match err {
506 AxonFlowError::RateLimited { envelope } => {
507 assert_eq!(envelope.tier, "Community");
508 assert_eq!(envelope.limit_type, "decision_list_size");
509 assert_eq!(envelope.limit, 5);
510 assert_eq!(envelope.upgrade.tier, "Pro");
511 assert_eq!(
512 envelope.upgrade.compare_url,
513 "https://getaxonflow.com/pricing/"
514 );
515 assert_eq!(
516 envelope.upgrade.buy_url,
517 "https://buy.stripe.com/bJe28qbztcdVchjdkw8k800"
518 );
519 }
520 other => panic!("expected RateLimited, got {other:?}"),
521 }
522 }
523
524 #[tokio::test]
525 async fn list_decisions_429_with_malformed_body_falls_back_to_apierror() {
526 let server = MockServer::start().await;
530 Mock::given(method("GET"))
531 .and(path("/api/v1/decisions"))
532 .respond_with(
533 ResponseTemplate::new(429)
534 .insert_header("content-type", "application/json")
535 .set_body_string("not a json envelope"),
536 )
537 .mount(&server)
538 .await;
539
540 let client = make_client(server.uri());
541 let err = client
542 .list_decisions(ListDecisionsOptions::default())
543 .await
544 .expect_err("must reject");
545 match err {
546 AxonFlowError::ApiError { status, .. } => assert_eq!(status, 429),
547 other => panic!("expected ApiError{{status=429}}, got {other:?}"),
548 }
549 }
550
551 #[tokio::test]
552 async fn list_decisions_401_surfaces_as_apierror() {
553 let server = MockServer::start().await;
554 Mock::given(method("GET"))
555 .and(path("/api/v1/decisions"))
556 .respond_with(
557 ResponseTemplate::new(401)
558 .insert_header("content-type", "application/json")
559 .set_body_json(json!({"error": "X-Tenant-ID header is required"})),
560 )
561 .mount(&server)
562 .await;
563
564 let client = make_client(server.uri());
565 let err = client
566 .list_decisions(ListDecisionsOptions::default())
567 .await
568 .expect_err("must reject");
569 match err {
570 AxonFlowError::ApiError { status, message } => {
571 assert_eq!(status, 401);
572 assert!(message.contains("X-Tenant-ID"), "msg = {message}");
573 }
574 other => panic!("expected ApiError{{status=401}}, got {other:?}"),
575 }
576 }
577
578 #[tokio::test]
579 async fn list_decisions_forward_compat_unknown_fields_ignored() {
580 let server = MockServer::start().await;
581 let want = json!({
582 "decisions": [{
583 "decision_id": "dec-fwd",
584 "timestamp": "2026-05-07T12:00:00Z",
585 "decision": "blocked",
586 "policy_id": "pol-x",
587 "tool_signature": "tool-x",
588 "policy_version": 7, "latest_policy_version": 9, "arbitrary_unknown": "ignored" }],
592 "next_cursor": "future_cursor_pagination" });
594
595 Mock::given(method("GET"))
596 .and(path("/api/v1/decisions"))
597 .respond_with(ResponseTemplate::new(200).set_body_json(want))
598 .mount(&server)
599 .await;
600
601 let client = make_client(server.uri());
602 let got = client
603 .list_decisions(ListDecisionsOptions::default())
604 .await
605 .unwrap();
606 assert_eq!(got.len(), 1);
607 assert_eq!(got[0].decision_id, "dec-fwd");
608 }
609
610 #[test]
611 fn build_decisions_query_omits_none_fields() {
612 let qs = build_decisions_query(&ListDecisionsOptions::default());
613 assert_eq!(qs, "");
614
615 let qs = build_decisions_query(&ListDecisionsOptions {
616 decision: Some("blocked".into()),
617 limit: Some(7),
618 ..Default::default()
619 });
620 assert_eq!(qs, "decision=blocked&limit=7");
621 }
622
623 #[test]
624 fn decision_summary_optional_fields_round_trip() {
625 let raw = json!({
629 "decision_id": "dec-min",
630 "timestamp": "2026-05-07T12:00:00Z",
631 "decision": "blocked"
632 });
633 let parsed: DecisionSummary = serde_json::from_value(raw).unwrap();
634 assert_eq!(parsed.decision_id, "dec-min");
635 assert_eq!(parsed.policy_id, None);
636 assert_eq!(parsed.tool_signature, None);
637 let s = serde_json::to_string(&parsed).unwrap();
639 assert!(!s.contains("policy_id"), "policy_id must be omitted: {s}");
640 assert!(
641 !s.contains("tool_signature"),
642 "tool_signature must be omitted: {s}"
643 );
644 }
645}