1use chrono::Utc;
2use clap::{Args, Subcommand, ValueEnum};
3use serde_json::json;
4use uuid::Uuid;
5
6use crate::util::{api_request, exit_error, read_json_from_file};
7
8#[derive(Subcommand)]
9pub enum AgentCommands {
10 Capabilities,
12 Context {
14 #[arg(long)]
16 exercise_limit: Option<u32>,
17 #[arg(long)]
19 strength_limit: Option<u32>,
20 #[arg(long)]
22 custom_limit: Option<u32>,
23 #[arg(long)]
25 task_intent: Option<String>,
26 #[arg(long)]
28 include_system: Option<bool>,
29 #[arg(long)]
31 budget_tokens: Option<u32>,
32 },
33 SectionIndex {
35 #[arg(long)]
37 exercise_limit: Option<u32>,
38 #[arg(long)]
40 strength_limit: Option<u32>,
41 #[arg(long)]
43 custom_limit: Option<u32>,
44 #[arg(long)]
46 task_intent: Option<String>,
47 #[arg(long)]
49 include_system: Option<bool>,
50 #[arg(long)]
52 budget_tokens: Option<u32>,
53 },
54 SectionFetch {
56 #[arg(long)]
58 section: String,
59 #[arg(long)]
61 limit: Option<u32>,
62 #[arg(long)]
64 cursor: Option<String>,
65 #[arg(long)]
67 fields: Option<String>,
68 #[arg(long)]
70 task_intent: Option<String>,
71 },
72 WriteWithProof(WriteWithProofArgs),
74 Evidence {
76 #[command(subcommand)]
77 command: AgentEvidenceCommands,
78 },
79 SetSaveConfirmationMode {
81 #[arg(value_enum)]
83 mode: SaveConfirmationMode,
84 },
85 ResolveVisualization(ResolveVisualizationArgs),
87 Request(AgentRequestArgs),
89}
90
91#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
92pub enum SaveConfirmationMode {
93 Auto,
94 Always,
95 Never,
96}
97
98impl SaveConfirmationMode {
99 fn as_str(self) -> &'static str {
100 match self {
101 SaveConfirmationMode::Auto => "auto",
102 SaveConfirmationMode::Always => "always",
103 SaveConfirmationMode::Never => "never",
104 }
105 }
106}
107
108#[derive(Subcommand)]
109pub enum AgentEvidenceCommands {
110 Event {
112 #[arg(long)]
114 event_id: Uuid,
115 },
116}
117
118#[derive(Args)]
119pub struct AgentRequestArgs {
120 pub method: String,
122
123 pub path: String,
125
126 #[arg(long, short = 'd')]
128 pub data: Option<String>,
129
130 #[arg(long, short = 'f', conflicts_with = "data")]
132 pub data_file: Option<String>,
133
134 #[arg(long, short = 'q')]
136 pub query: Vec<String>,
137
138 #[arg(long, short = 'H')]
140 pub header: Vec<String>,
141
142 #[arg(long)]
144 pub raw: bool,
145
146 #[arg(long, short = 'i')]
148 pub include: bool,
149}
150
151#[derive(Args)]
152pub struct WriteWithProofArgs {
153 #[arg(
155 long,
156 required_unless_present = "request_file",
157 conflicts_with = "request_file"
158 )]
159 pub events_file: Option<String>,
160
161 #[arg(
163 long,
164 required_unless_present = "request_file",
165 conflicts_with = "request_file"
166 )]
167 pub target: Vec<String>,
168
169 #[arg(long)]
171 pub verify_timeout_ms: Option<u64>,
172
173 #[arg(long, conflicts_with_all = ["request_file", "non_trivial_confirmation_file"])]
176 pub non_trivial_confirmation_token: Option<String>,
177
178 #[arg(long, conflicts_with_all = ["request_file", "non_trivial_confirmation_token"])]
180 pub non_trivial_confirmation_file: Option<String>,
181
182 #[arg(long, conflicts_with_all = ["events_file", "target", "verify_timeout_ms", "non_trivial_confirmation_token", "non_trivial_confirmation_file"])]
184 pub request_file: Option<String>,
185}
186
187#[derive(Args)]
188pub struct ResolveVisualizationArgs {
189 #[arg(long, conflicts_with = "task_intent")]
191 pub request_file: Option<String>,
192
193 #[arg(long, required_unless_present = "request_file")]
195 pub task_intent: Option<String>,
196
197 #[arg(long)]
199 pub user_preference_override: Option<String>,
200
201 #[arg(long)]
203 pub complexity_hint: Option<String>,
204
205 #[arg(long, default_value_t = true)]
207 pub allow_rich_rendering: bool,
208
209 #[arg(long)]
211 pub spec_file: Option<String>,
212
213 #[arg(long)]
215 pub telemetry_session_id: Option<String>,
216}
217
218pub async fn run(api_url: &str, token: Option<&str>, command: AgentCommands) -> i32 {
219 match command {
220 AgentCommands::Capabilities => capabilities(api_url, token).await,
221 AgentCommands::Context {
222 exercise_limit,
223 strength_limit,
224 custom_limit,
225 task_intent,
226 include_system,
227 budget_tokens,
228 } => {
229 context(
230 api_url,
231 token,
232 exercise_limit,
233 strength_limit,
234 custom_limit,
235 task_intent,
236 include_system,
237 budget_tokens,
238 )
239 .await
240 }
241 AgentCommands::SectionIndex {
242 exercise_limit,
243 strength_limit,
244 custom_limit,
245 task_intent,
246 include_system,
247 budget_tokens,
248 } => {
249 section_index(
250 api_url,
251 token,
252 exercise_limit,
253 strength_limit,
254 custom_limit,
255 task_intent,
256 include_system,
257 budget_tokens,
258 )
259 .await
260 }
261 AgentCommands::SectionFetch {
262 section,
263 limit,
264 cursor,
265 fields,
266 task_intent,
267 } => section_fetch(api_url, token, section, limit, cursor, fields, task_intent).await,
268 AgentCommands::WriteWithProof(args) => write_with_proof(api_url, token, args).await,
269 AgentCommands::Evidence { command } => match command {
270 AgentEvidenceCommands::Event { event_id } => {
271 evidence_event(api_url, token, event_id).await
272 }
273 },
274 AgentCommands::SetSaveConfirmationMode { mode } => {
275 set_save_confirmation_mode(api_url, token, mode).await
276 }
277 AgentCommands::ResolveVisualization(args) => {
278 resolve_visualization(api_url, token, args).await
279 }
280 AgentCommands::Request(args) => request(api_url, token, args).await,
281 }
282}
283
284async fn capabilities(api_url: &str, token: Option<&str>) -> i32 {
285 api_request(
286 api_url,
287 reqwest::Method::GET,
288 "/v1/agent/capabilities",
289 token,
290 None,
291 &[],
292 &[],
293 false,
294 false,
295 )
296 .await
297}
298
299pub async fn context(
300 api_url: &str,
301 token: Option<&str>,
302 exercise_limit: Option<u32>,
303 strength_limit: Option<u32>,
304 custom_limit: Option<u32>,
305 task_intent: Option<String>,
306 include_system: Option<bool>,
307 budget_tokens: Option<u32>,
308) -> i32 {
309 let query = build_context_query(
310 exercise_limit,
311 strength_limit,
312 custom_limit,
313 task_intent,
314 include_system,
315 budget_tokens,
316 );
317
318 api_request(
319 api_url,
320 reqwest::Method::GET,
321 "/v1/agent/context",
322 token,
323 None,
324 &query,
325 &[],
326 false,
327 false,
328 )
329 .await
330}
331
332async fn section_index(
333 api_url: &str,
334 token: Option<&str>,
335 exercise_limit: Option<u32>,
336 strength_limit: Option<u32>,
337 custom_limit: Option<u32>,
338 task_intent: Option<String>,
339 include_system: Option<bool>,
340 budget_tokens: Option<u32>,
341) -> i32 {
342 let query = build_context_query(
343 exercise_limit,
344 strength_limit,
345 custom_limit,
346 task_intent,
347 include_system,
348 budget_tokens,
349 );
350 api_request(
351 api_url,
352 reqwest::Method::GET,
353 "/v1/agent/context/section-index",
354 token,
355 None,
356 &query,
357 &[],
358 false,
359 false,
360 )
361 .await
362}
363
364async fn section_fetch(
365 api_url: &str,
366 token: Option<&str>,
367 section: String,
368 limit: Option<u32>,
369 cursor: Option<String>,
370 fields: Option<String>,
371 task_intent: Option<String>,
372) -> i32 {
373 let query = build_section_fetch_query(section, limit, cursor, fields, task_intent);
374 api_request(
375 api_url,
376 reqwest::Method::GET,
377 "/v1/agent/context/section-fetch",
378 token,
379 None,
380 &query,
381 &[],
382 false,
383 false,
384 )
385 .await
386}
387
388async fn evidence_event(api_url: &str, token: Option<&str>, event_id: Uuid) -> i32 {
389 let path = format!("/v1/agent/evidence/event/{event_id}");
390 api_request(
391 api_url,
392 reqwest::Method::GET,
393 &path,
394 token,
395 None,
396 &[],
397 &[],
398 false,
399 false,
400 )
401 .await
402}
403
404async fn set_save_confirmation_mode(
405 api_url: &str,
406 token: Option<&str>,
407 mode: SaveConfirmationMode,
408) -> i32 {
409 let body = json!({
410 "timestamp": Utc::now().to_rfc3339(),
411 "event_type": "preference.set",
412 "data": {
413 "key": "save_confirmation_mode",
414 "value": mode.as_str(),
415 },
416 "metadata": {
417 "source": "cli",
418 "agent": "kura-cli",
419 "idempotency_key": Uuid::now_v7().to_string(),
420 }
421 });
422 api_request(
423 api_url,
424 reqwest::Method::POST,
425 "/v1/events",
426 token,
427 Some(body),
428 &[],
429 &[],
430 false,
431 false,
432 )
433 .await
434}
435
436async fn request(api_url: &str, token: Option<&str>, args: AgentRequestArgs) -> i32 {
437 let method = parse_method(&args.method);
438 let path = normalize_agent_path(&args.path);
439 let query = parse_query_pairs(&args.query);
440 let headers = parse_headers(&args.header);
441 let body = resolve_body(args.data.as_deref(), args.data_file.as_deref());
442
443 api_request(
444 api_url,
445 method,
446 &path,
447 token,
448 body,
449 &query,
450 &headers,
451 args.raw,
452 args.include,
453 )
454 .await
455}
456
457pub async fn write_with_proof(api_url: &str, token: Option<&str>, args: WriteWithProofArgs) -> i32 {
458 let body = if let Some(file) = args.request_file.as_deref() {
459 load_full_request(file)
460 } else {
461 build_request_from_events_and_targets(
462 args.events_file.as_deref().unwrap_or(""),
463 &args.target,
464 args.verify_timeout_ms,
465 args.non_trivial_confirmation_token.as_deref(),
466 args.non_trivial_confirmation_file.as_deref(),
467 )
468 };
469
470 api_request(
471 api_url,
472 reqwest::Method::POST,
473 "/v1/agent/write-with-proof",
474 token,
475 Some(body),
476 &[],
477 &[],
478 false,
479 false,
480 )
481 .await
482}
483
484async fn resolve_visualization(
485 api_url: &str,
486 token: Option<&str>,
487 args: ResolveVisualizationArgs,
488) -> i32 {
489 let body = if let Some(file) = args.request_file.as_deref() {
490 match read_json_from_file(file) {
491 Ok(v) => v,
492 Err(e) => exit_error(
493 &e,
494 Some("Provide a valid JSON payload for /v1/agent/visualization/resolve."),
495 ),
496 }
497 } else {
498 let task_intent = match args.task_intent {
499 Some(intent) if !intent.trim().is_empty() => intent,
500 _ => exit_error(
501 "task_intent is required unless --request-file is used.",
502 Some("Use --task-intent or provide --request-file."),
503 ),
504 };
505
506 let mut body = json!({
507 "task_intent": task_intent,
508 "allow_rich_rendering": args.allow_rich_rendering
509 });
510 if let Some(mode) = args.user_preference_override {
511 body["user_preference_override"] = json!(mode);
512 }
513 if let Some(complexity) = args.complexity_hint {
514 body["complexity_hint"] = json!(complexity);
515 }
516 if let Some(session_id) = args.telemetry_session_id {
517 body["telemetry_session_id"] = json!(session_id);
518 }
519 if let Some(spec_file) = args.spec_file.as_deref() {
520 let spec = match read_json_from_file(spec_file) {
521 Ok(v) => v,
522 Err(e) => exit_error(&e, Some("Provide a valid JSON visualization_spec payload.")),
523 };
524 body["visualization_spec"] = spec;
525 }
526 body
527 };
528
529 api_request(
530 api_url,
531 reqwest::Method::POST,
532 "/v1/agent/visualization/resolve",
533 token,
534 Some(body),
535 &[],
536 &[],
537 false,
538 false,
539 )
540 .await
541}
542
543fn parse_method(raw: &str) -> reqwest::Method {
544 match raw.to_uppercase().as_str() {
545 "GET" => reqwest::Method::GET,
546 "POST" => reqwest::Method::POST,
547 "PUT" => reqwest::Method::PUT,
548 "DELETE" => reqwest::Method::DELETE,
549 "PATCH" => reqwest::Method::PATCH,
550 "HEAD" => reqwest::Method::HEAD,
551 "OPTIONS" => reqwest::Method::OPTIONS,
552 other => exit_error(
553 &format!("Unknown HTTP method: {other}"),
554 Some("Supported methods: GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS"),
555 ),
556 }
557}
558
559fn normalize_agent_path(raw: &str) -> String {
560 let trimmed = raw.trim();
561 if trimmed.is_empty() {
562 exit_error(
563 "Agent path must not be empty.",
564 Some("Use relative path like 'context' or absolute path '/v1/agent/context'."),
565 );
566 }
567
568 if trimmed.starts_with("/v1/agent") {
569 return trimmed.to_string();
570 }
571 if trimmed.starts_with("v1/agent") {
572 return format!("/{trimmed}");
573 }
574 if trimmed.starts_with('/') {
575 exit_error(
576 &format!("Invalid agent path '{trimmed}'."),
577 Some(
578 "`kura agent request` only supports /v1/agent/* paths. Use `kura api` for other endpoints.",
579 ),
580 );
581 }
582
583 format!("/v1/agent/{}", trimmed.trim_start_matches('/'))
584}
585
586fn parse_query_pairs(raw: &[String]) -> Vec<(String, String)> {
587 raw.iter()
588 .map(|entry| {
589 entry.split_once('=').map_or_else(
590 || {
591 exit_error(
592 &format!("Invalid query parameter: '{entry}'"),
593 Some("Format: key=value, e.g. --query event_type=set.logged"),
594 )
595 },
596 |(k, v)| (k.to_string(), v.to_string()),
597 )
598 })
599 .collect()
600}
601
602fn build_context_query(
603 exercise_limit: Option<u32>,
604 strength_limit: Option<u32>,
605 custom_limit: Option<u32>,
606 task_intent: Option<String>,
607 include_system: Option<bool>,
608 budget_tokens: Option<u32>,
609) -> Vec<(String, String)> {
610 let mut query = Vec::new();
611 if let Some(v) = exercise_limit {
612 query.push(("exercise_limit".to_string(), v.to_string()));
613 }
614 if let Some(v) = strength_limit {
615 query.push(("strength_limit".to_string(), v.to_string()));
616 }
617 if let Some(v) = custom_limit {
618 query.push(("custom_limit".to_string(), v.to_string()));
619 }
620 if let Some(v) = task_intent {
621 query.push(("task_intent".to_string(), v));
622 }
623 if let Some(v) = include_system {
624 query.push(("include_system".to_string(), v.to_string()));
625 }
626 if let Some(v) = budget_tokens {
627 query.push(("budget_tokens".to_string(), v.to_string()));
628 }
629 query
630}
631
632fn build_section_fetch_query(
633 section: String,
634 limit: Option<u32>,
635 cursor: Option<String>,
636 fields: Option<String>,
637 task_intent: Option<String>,
638) -> Vec<(String, String)> {
639 let section = section.trim();
640 if section.is_empty() {
641 exit_error(
642 "section must not be empty",
643 Some("Provide --section using an id from /v1/agent/context/section-index"),
644 );
645 }
646 let mut query = vec![("section".to_string(), section.to_string())];
647 if let Some(v) = limit {
648 query.push(("limit".to_string(), v.to_string()));
649 }
650 if let Some(v) = cursor {
651 query.push(("cursor".to_string(), v));
652 }
653 if let Some(v) = fields {
654 query.push(("fields".to_string(), v));
655 }
656 if let Some(v) = task_intent {
657 query.push(("task_intent".to_string(), v));
658 }
659 query
660}
661
662fn parse_headers(raw: &[String]) -> Vec<(String, String)> {
663 raw.iter()
664 .map(|entry| {
665 entry.split_once(':').map_or_else(
666 || {
667 exit_error(
668 &format!("Invalid header: '{entry}'"),
669 Some("Format: Key:Value, e.g. --header Content-Type:application/json"),
670 )
671 },
672 |(k, v)| (k.trim().to_string(), v.trim().to_string()),
673 )
674 })
675 .collect()
676}
677
678fn resolve_body(data: Option<&str>, data_file: Option<&str>) -> Option<serde_json::Value> {
679 if let Some(raw) = data {
680 match serde_json::from_str(raw) {
681 Ok(v) => return Some(v),
682 Err(e) => exit_error(
683 &format!("Invalid JSON in --data: {e}"),
684 Some("Provide valid JSON string"),
685 ),
686 }
687 }
688
689 if let Some(file) = data_file {
690 return match read_json_from_file(file) {
691 Ok(v) => Some(v),
692 Err(e) => exit_error(&e, Some("Provide a valid JSON file or use '-' for stdin")),
693 };
694 }
695
696 None
697}
698
699fn load_full_request(path: &str) -> serde_json::Value {
700 let payload = match read_json_from_file(path) {
701 Ok(v) => v,
702 Err(e) => exit_error(
703 &e,
704 Some(
705 "Provide JSON with events, read_after_write_targets, and optional verify_timeout_ms.",
706 ),
707 ),
708 };
709 if payload
710 .get("events")
711 .and_then(|value| value.as_array())
712 .is_none()
713 {
714 exit_error(
715 "request payload must include an events array",
716 Some(
717 "Use --request-file with {\"events\": [...], \"read_after_write_targets\": [...]}",
718 ),
719 );
720 }
721 if payload
722 .get("read_after_write_targets")
723 .and_then(|value| value.as_array())
724 .is_none()
725 {
726 exit_error(
727 "request payload must include read_after_write_targets array",
728 Some("Set read_after_write_targets to [{\"projection_type\":\"...\",\"key\":\"...\"}]"),
729 );
730 }
731 payload
732}
733
734fn build_request_from_events_and_targets(
735 events_file: &str,
736 raw_targets: &[String],
737 verify_timeout_ms: Option<u64>,
738 non_trivial_confirmation_token: Option<&str>,
739 non_trivial_confirmation_file: Option<&str>,
740) -> serde_json::Value {
741 if raw_targets.is_empty() {
742 exit_error(
743 "--target is required when --request-file is not used",
744 Some("Repeat --target projection_type:key for read-after-write checks."),
745 );
746 }
747
748 let parsed_targets = parse_targets(raw_targets);
749 let events_payload = match read_json_from_file(events_file) {
750 Ok(v) => v,
751 Err(e) => exit_error(
752 &e,
753 Some("Provide --events-file as JSON array or object with events array."),
754 ),
755 };
756
757 let events = extract_events_array(events_payload);
758 let non_trivial_confirmation = resolve_non_trivial_confirmation(
759 non_trivial_confirmation_token,
760 non_trivial_confirmation_file,
761 );
762 build_write_with_proof_request(
763 events,
764 parsed_targets,
765 verify_timeout_ms,
766 non_trivial_confirmation,
767 )
768}
769
770fn resolve_non_trivial_confirmation(
771 confirmation_token: Option<&str>,
772 confirmation_file: Option<&str>,
773) -> Option<serde_json::Value> {
774 if let Some(path) = confirmation_file {
775 let payload = match read_json_from_file(path) {
776 Ok(v) => v,
777 Err(e) => exit_error(
778 &e,
779 Some("Provide a valid JSON object for non_trivial_confirmation.v1."),
780 ),
781 };
782 if !payload.is_object() {
783 exit_error(
784 "non_trivial_confirmation payload must be a JSON object",
785 Some("Provide non_trivial_confirmation.v1 as an object."),
786 );
787 }
788 return Some(payload);
789 }
790
791 confirmation_token.map(build_non_trivial_confirmation_from_token)
792}
793
794fn build_non_trivial_confirmation_from_token(confirmation_token: &str) -> serde_json::Value {
795 let token = confirmation_token.trim();
796 if token.is_empty() {
797 exit_error(
798 "non_trivial_confirmation token must not be empty",
799 Some("Use the confirmation token from claim_guard.non_trivial_confirmation_challenge."),
800 );
801 }
802 json!({
803 "schema_version": "non_trivial_confirmation.v1",
804 "confirmed": true,
805 "confirmed_at": Utc::now().to_rfc3339(),
806 "confirmation_token": token,
807 })
808}
809
810fn parse_targets(raw_targets: &[String]) -> Vec<serde_json::Value> {
811 raw_targets
812 .iter()
813 .map(|raw| {
814 let (projection_type, key) = raw.split_once(':').unwrap_or_else(|| {
815 exit_error(
816 &format!("Invalid --target '{raw}'"),
817 Some("Use format projection_type:key, e.g. user_profile:me"),
818 )
819 });
820 let projection_type = projection_type.trim();
821 let key = key.trim();
822 if projection_type.is_empty() || key.is_empty() {
823 exit_error(
824 &format!("Invalid --target '{raw}'"),
825 Some("projection_type and key must both be non-empty."),
826 );
827 }
828 json!({
829 "projection_type": projection_type,
830 "key": key,
831 })
832 })
833 .collect()
834}
835
836fn extract_events_array(events_payload: serde_json::Value) -> Vec<serde_json::Value> {
837 if let Some(events) = events_payload.as_array() {
838 return events.to_vec();
839 }
840 if let Some(events) = events_payload
841 .get("events")
842 .and_then(|value| value.as_array())
843 {
844 return events.to_vec();
845 }
846 exit_error(
847 "events payload must be an array or object with events array",
848 Some("Example: --events-file events.json where file is [{...}] or {\"events\": [{...}]}"),
849 );
850}
851
852fn build_write_with_proof_request(
853 events: Vec<serde_json::Value>,
854 parsed_targets: Vec<serde_json::Value>,
855 verify_timeout_ms: Option<u64>,
856 non_trivial_confirmation: Option<serde_json::Value>,
857) -> serde_json::Value {
858 let mut request = json!({
859 "events": events,
860 "read_after_write_targets": parsed_targets,
861 });
862 if let Some(timeout) = verify_timeout_ms {
863 request["verify_timeout_ms"] = json!(timeout);
864 }
865 if let Some(non_trivial_confirmation) = non_trivial_confirmation {
866 request["non_trivial_confirmation"] = non_trivial_confirmation;
867 }
868 request
869}
870
871#[cfg(test)]
872mod tests {
873 use super::{
874 SaveConfirmationMode, build_context_query, build_non_trivial_confirmation_from_token,
875 build_section_fetch_query, build_write_with_proof_request, extract_events_array,
876 normalize_agent_path, parse_method, parse_targets,
877 };
878 use serde_json::json;
879
880 #[test]
881 fn normalize_agent_path_accepts_relative_path() {
882 assert_eq!(
883 normalize_agent_path("evidence/event/abc"),
884 "/v1/agent/evidence/event/abc"
885 );
886 }
887
888 #[test]
889 fn normalize_agent_path_accepts_absolute_agent_path() {
890 assert_eq!(
891 normalize_agent_path("/v1/agent/context"),
892 "/v1/agent/context"
893 );
894 }
895
896 #[test]
897 fn parse_method_accepts_standard_http_methods() {
898 for method in &[
899 "get", "GET", "post", "PUT", "delete", "patch", "head", "OPTIONS",
900 ] {
901 let parsed = parse_method(method);
902 assert!(!parsed.as_str().is_empty());
903 }
904 }
905
906 #[test]
907 fn parse_targets_accepts_projection_type_key_format() {
908 let parsed = parse_targets(&[
909 "user_profile:me".to_string(),
910 "training_timeline:overview".to_string(),
911 ]);
912 assert_eq!(parsed[0]["projection_type"], "user_profile");
913 assert_eq!(parsed[0]["key"], "me");
914 assert_eq!(parsed[1]["projection_type"], "training_timeline");
915 assert_eq!(parsed[1]["key"], "overview");
916 }
917
918 #[test]
919 fn extract_events_array_supports_plain_array() {
920 let events = extract_events_array(json!([
921 {"event_type":"set.logged"},
922 {"event_type":"metric.logged"}
923 ]));
924 assert_eq!(events.len(), 2);
925 }
926
927 #[test]
928 fn extract_events_array_supports_object_wrapper() {
929 let events = extract_events_array(json!({
930 "events": [{"event_type":"set.logged"}]
931 }));
932 assert_eq!(events.len(), 1);
933 }
934
935 #[test]
936 fn build_write_with_proof_request_serializes_expected_fields() {
937 let request = build_write_with_proof_request(
938 vec![json!({"event_type":"set.logged"})],
939 vec![json!({"projection_type":"user_profile","key":"me"})],
940 Some(1200),
941 None,
942 );
943 assert_eq!(request["events"].as_array().unwrap().len(), 1);
944 assert_eq!(
945 request["read_after_write_targets"]
946 .as_array()
947 .unwrap()
948 .len(),
949 1
950 );
951 assert_eq!(request["verify_timeout_ms"], 1200);
952 }
953
954 #[test]
955 fn build_write_with_proof_request_includes_non_trivial_confirmation_when_present() {
956 let request = build_write_with_proof_request(
957 vec![json!({"event_type":"set.logged"})],
958 vec![json!({"projection_type":"user_profile","key":"me"})],
959 None,
960 Some(json!({
961 "schema_version": "non_trivial_confirmation.v1",
962 "confirmed": true,
963 "confirmed_at": "2026-02-25T12:00:00Z",
964 "confirmation_token": "abc"
965 })),
966 );
967 assert_eq!(
968 request["non_trivial_confirmation"]["schema_version"],
969 "non_trivial_confirmation.v1"
970 );
971 assert_eq!(
972 request["non_trivial_confirmation"]["confirmation_token"],
973 "abc"
974 );
975 }
976
977 #[test]
978 fn build_non_trivial_confirmation_from_token_uses_expected_shape() {
979 let payload = build_non_trivial_confirmation_from_token("tok-123");
980 assert_eq!(payload["schema_version"], "non_trivial_confirmation.v1");
981 assert_eq!(payload["confirmed"], true);
982 assert_eq!(payload["confirmation_token"], "tok-123");
983 assert!(payload["confirmed_at"].as_str().is_some());
984 }
985
986 #[test]
987 fn save_confirmation_mode_serializes_expected_values() {
988 assert_eq!(SaveConfirmationMode::Auto.as_str(), "auto");
989 assert_eq!(SaveConfirmationMode::Always.as_str(), "always");
990 assert_eq!(SaveConfirmationMode::Never.as_str(), "never");
991 }
992
993 #[test]
994 fn build_context_query_includes_budget_tokens_when_present() {
995 let query = build_context_query(
996 Some(3),
997 Some(2),
998 Some(1),
999 Some("readiness check".to_string()),
1000 Some(false),
1001 Some(900),
1002 );
1003 assert!(query.contains(&("exercise_limit".to_string(), "3".to_string())));
1004 assert!(query.contains(&("strength_limit".to_string(), "2".to_string())));
1005 assert!(query.contains(&("custom_limit".to_string(), "1".to_string())));
1006 assert!(query.contains(&("task_intent".to_string(), "readiness check".to_string())));
1007 assert!(query.contains(&("include_system".to_string(), "false".to_string())));
1008 assert!(query.contains(&("budget_tokens".to_string(), "900".to_string())));
1009 }
1010
1011 #[test]
1012 fn build_context_query_supports_section_index_parity_params() {
1013 let query = build_context_query(
1014 Some(5),
1015 Some(5),
1016 Some(10),
1017 Some("startup".to_string()),
1018 Some(false),
1019 Some(1200),
1020 );
1021 assert!(query.contains(&("exercise_limit".to_string(), "5".to_string())));
1022 assert!(query.contains(&("strength_limit".to_string(), "5".to_string())));
1023 assert!(query.contains(&("custom_limit".to_string(), "10".to_string())));
1024 assert!(query.contains(&("task_intent".to_string(), "startup".to_string())));
1025 assert!(query.contains(&("include_system".to_string(), "false".to_string())));
1026 assert!(query.contains(&("budget_tokens".to_string(), "1200".to_string())));
1027 }
1028
1029 #[test]
1030 fn build_section_fetch_query_serializes_optional_params() {
1031 let query = build_section_fetch_query(
1032 "projections.exercise_progression".to_string(),
1033 Some(50),
1034 Some("abc123".to_string()),
1035 Some("data,meta".to_string()),
1036 Some("bench plateau".to_string()),
1037 );
1038 assert_eq!(
1039 query,
1040 vec![
1041 (
1042 "section".to_string(),
1043 "projections.exercise_progression".to_string(),
1044 ),
1045 ("limit".to_string(), "50".to_string()),
1046 ("cursor".to_string(), "abc123".to_string()),
1047 ("fields".to_string(), "data,meta".to_string()),
1048 ("task_intent".to_string(), "bench plateau".to_string()),
1049 ]
1050 );
1051 }
1052}