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 = ["events_file", "target", "verify_timeout_ms"])]
175 pub request_file: Option<String>,
176}
177
178#[derive(Args)]
179pub struct ResolveVisualizationArgs {
180 #[arg(long, conflicts_with = "task_intent")]
182 pub request_file: Option<String>,
183
184 #[arg(long, required_unless_present = "request_file")]
186 pub task_intent: Option<String>,
187
188 #[arg(long)]
190 pub user_preference_override: Option<String>,
191
192 #[arg(long)]
194 pub complexity_hint: Option<String>,
195
196 #[arg(long, default_value_t = true)]
198 pub allow_rich_rendering: bool,
199
200 #[arg(long)]
202 pub spec_file: Option<String>,
203
204 #[arg(long)]
206 pub telemetry_session_id: Option<String>,
207}
208
209pub async fn run(api_url: &str, token: Option<&str>, command: AgentCommands) -> i32 {
210 match command {
211 AgentCommands::Capabilities => capabilities(api_url, token).await,
212 AgentCommands::Context {
213 exercise_limit,
214 strength_limit,
215 custom_limit,
216 task_intent,
217 include_system,
218 budget_tokens,
219 } => {
220 context(
221 api_url,
222 token,
223 exercise_limit,
224 strength_limit,
225 custom_limit,
226 task_intent,
227 include_system,
228 budget_tokens,
229 )
230 .await
231 }
232 AgentCommands::SectionIndex {
233 exercise_limit,
234 strength_limit,
235 custom_limit,
236 task_intent,
237 include_system,
238 budget_tokens,
239 } => {
240 section_index(
241 api_url,
242 token,
243 exercise_limit,
244 strength_limit,
245 custom_limit,
246 task_intent,
247 include_system,
248 budget_tokens,
249 )
250 .await
251 }
252 AgentCommands::SectionFetch {
253 section,
254 limit,
255 cursor,
256 fields,
257 task_intent,
258 } => section_fetch(api_url, token, section, limit, cursor, fields, task_intent).await,
259 AgentCommands::WriteWithProof(args) => write_with_proof(api_url, token, args).await,
260 AgentCommands::Evidence { command } => match command {
261 AgentEvidenceCommands::Event { event_id } => {
262 evidence_event(api_url, token, event_id).await
263 }
264 },
265 AgentCommands::SetSaveConfirmationMode { mode } => {
266 set_save_confirmation_mode(api_url, token, mode).await
267 }
268 AgentCommands::ResolveVisualization(args) => {
269 resolve_visualization(api_url, token, args).await
270 }
271 AgentCommands::Request(args) => request(api_url, token, args).await,
272 }
273}
274
275async fn capabilities(api_url: &str, token: Option<&str>) -> i32 {
276 api_request(
277 api_url,
278 reqwest::Method::GET,
279 "/v1/agent/capabilities",
280 token,
281 None,
282 &[],
283 &[],
284 false,
285 false,
286 )
287 .await
288}
289
290pub async fn context(
291 api_url: &str,
292 token: Option<&str>,
293 exercise_limit: Option<u32>,
294 strength_limit: Option<u32>,
295 custom_limit: Option<u32>,
296 task_intent: Option<String>,
297 include_system: Option<bool>,
298 budget_tokens: Option<u32>,
299) -> i32 {
300 let query = build_context_query(
301 exercise_limit,
302 strength_limit,
303 custom_limit,
304 task_intent,
305 include_system,
306 budget_tokens,
307 );
308
309 api_request(
310 api_url,
311 reqwest::Method::GET,
312 "/v1/agent/context",
313 token,
314 None,
315 &query,
316 &[],
317 false,
318 false,
319 )
320 .await
321}
322
323async fn section_index(
324 api_url: &str,
325 token: Option<&str>,
326 exercise_limit: Option<u32>,
327 strength_limit: Option<u32>,
328 custom_limit: Option<u32>,
329 task_intent: Option<String>,
330 include_system: Option<bool>,
331 budget_tokens: Option<u32>,
332) -> i32 {
333 let query = build_context_query(
334 exercise_limit,
335 strength_limit,
336 custom_limit,
337 task_intent,
338 include_system,
339 budget_tokens,
340 );
341 api_request(
342 api_url,
343 reqwest::Method::GET,
344 "/v1/agent/context/section-index",
345 token,
346 None,
347 &query,
348 &[],
349 false,
350 false,
351 )
352 .await
353}
354
355async fn section_fetch(
356 api_url: &str,
357 token: Option<&str>,
358 section: String,
359 limit: Option<u32>,
360 cursor: Option<String>,
361 fields: Option<String>,
362 task_intent: Option<String>,
363) -> i32 {
364 let query = build_section_fetch_query(section, limit, cursor, fields, task_intent);
365 api_request(
366 api_url,
367 reqwest::Method::GET,
368 "/v1/agent/context/section-fetch",
369 token,
370 None,
371 &query,
372 &[],
373 false,
374 false,
375 )
376 .await
377}
378
379async fn evidence_event(api_url: &str, token: Option<&str>, event_id: Uuid) -> i32 {
380 let path = format!("/v1/agent/evidence/event/{event_id}");
381 api_request(
382 api_url,
383 reqwest::Method::GET,
384 &path,
385 token,
386 None,
387 &[],
388 &[],
389 false,
390 false,
391 )
392 .await
393}
394
395async fn set_save_confirmation_mode(
396 api_url: &str,
397 token: Option<&str>,
398 mode: SaveConfirmationMode,
399) -> i32 {
400 let body = json!({
401 "timestamp": Utc::now().to_rfc3339(),
402 "event_type": "preference.set",
403 "data": {
404 "key": "save_confirmation_mode",
405 "value": mode.as_str(),
406 },
407 "metadata": {
408 "source": "cli",
409 "agent": "kura-cli",
410 "idempotency_key": Uuid::now_v7().to_string(),
411 }
412 });
413 api_request(
414 api_url,
415 reqwest::Method::POST,
416 "/v1/events",
417 token,
418 Some(body),
419 &[],
420 &[],
421 false,
422 false,
423 )
424 .await
425}
426
427async fn request(api_url: &str, token: Option<&str>, args: AgentRequestArgs) -> i32 {
428 let method = parse_method(&args.method);
429 let path = normalize_agent_path(&args.path);
430 let query = parse_query_pairs(&args.query);
431 let headers = parse_headers(&args.header);
432 let body = resolve_body(args.data.as_deref(), args.data_file.as_deref());
433
434 api_request(
435 api_url,
436 method,
437 &path,
438 token,
439 body,
440 &query,
441 &headers,
442 args.raw,
443 args.include,
444 )
445 .await
446}
447
448pub async fn write_with_proof(api_url: &str, token: Option<&str>, args: WriteWithProofArgs) -> i32 {
449 let body = if let Some(file) = args.request_file.as_deref() {
450 load_full_request(file)
451 } else {
452 build_request_from_events_and_targets(
453 args.events_file.as_deref().unwrap_or(""),
454 &args.target,
455 args.verify_timeout_ms,
456 )
457 };
458
459 api_request(
460 api_url,
461 reqwest::Method::POST,
462 "/v1/agent/write-with-proof",
463 token,
464 Some(body),
465 &[],
466 &[],
467 false,
468 false,
469 )
470 .await
471}
472
473async fn resolve_visualization(
474 api_url: &str,
475 token: Option<&str>,
476 args: ResolveVisualizationArgs,
477) -> i32 {
478 let body = if let Some(file) = args.request_file.as_deref() {
479 match read_json_from_file(file) {
480 Ok(v) => v,
481 Err(e) => exit_error(
482 &e,
483 Some("Provide a valid JSON payload for /v1/agent/visualization/resolve."),
484 ),
485 }
486 } else {
487 let task_intent = match args.task_intent {
488 Some(intent) if !intent.trim().is_empty() => intent,
489 _ => exit_error(
490 "task_intent is required unless --request-file is used.",
491 Some("Use --task-intent or provide --request-file."),
492 ),
493 };
494
495 let mut body = json!({
496 "task_intent": task_intent,
497 "allow_rich_rendering": args.allow_rich_rendering
498 });
499 if let Some(mode) = args.user_preference_override {
500 body["user_preference_override"] = json!(mode);
501 }
502 if let Some(complexity) = args.complexity_hint {
503 body["complexity_hint"] = json!(complexity);
504 }
505 if let Some(session_id) = args.telemetry_session_id {
506 body["telemetry_session_id"] = json!(session_id);
507 }
508 if let Some(spec_file) = args.spec_file.as_deref() {
509 let spec = match read_json_from_file(spec_file) {
510 Ok(v) => v,
511 Err(e) => exit_error(&e, Some("Provide a valid JSON visualization_spec payload.")),
512 };
513 body["visualization_spec"] = spec;
514 }
515 body
516 };
517
518 api_request(
519 api_url,
520 reqwest::Method::POST,
521 "/v1/agent/visualization/resolve",
522 token,
523 Some(body),
524 &[],
525 &[],
526 false,
527 false,
528 )
529 .await
530}
531
532fn parse_method(raw: &str) -> reqwest::Method {
533 match raw.to_uppercase().as_str() {
534 "GET" => reqwest::Method::GET,
535 "POST" => reqwest::Method::POST,
536 "PUT" => reqwest::Method::PUT,
537 "DELETE" => reqwest::Method::DELETE,
538 "PATCH" => reqwest::Method::PATCH,
539 "HEAD" => reqwest::Method::HEAD,
540 "OPTIONS" => reqwest::Method::OPTIONS,
541 other => exit_error(
542 &format!("Unknown HTTP method: {other}"),
543 Some("Supported methods: GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS"),
544 ),
545 }
546}
547
548fn normalize_agent_path(raw: &str) -> String {
549 let trimmed = raw.trim();
550 if trimmed.is_empty() {
551 exit_error(
552 "Agent path must not be empty.",
553 Some("Use relative path like 'context' or absolute path '/v1/agent/context'."),
554 );
555 }
556
557 if trimmed.starts_with("/v1/agent") {
558 return trimmed.to_string();
559 }
560 if trimmed.starts_with("v1/agent") {
561 return format!("/{trimmed}");
562 }
563 if trimmed.starts_with('/') {
564 exit_error(
565 &format!("Invalid agent path '{trimmed}'."),
566 Some(
567 "`kura agent request` only supports /v1/agent/* paths. Use `kura api` for other endpoints.",
568 ),
569 );
570 }
571
572 format!("/v1/agent/{}", trimmed.trim_start_matches('/'))
573}
574
575fn parse_query_pairs(raw: &[String]) -> Vec<(String, String)> {
576 raw.iter()
577 .map(|entry| {
578 entry.split_once('=').map_or_else(
579 || {
580 exit_error(
581 &format!("Invalid query parameter: '{entry}'"),
582 Some("Format: key=value, e.g. --query event_type=set.logged"),
583 )
584 },
585 |(k, v)| (k.to_string(), v.to_string()),
586 )
587 })
588 .collect()
589}
590
591fn build_context_query(
592 exercise_limit: Option<u32>,
593 strength_limit: Option<u32>,
594 custom_limit: Option<u32>,
595 task_intent: Option<String>,
596 include_system: Option<bool>,
597 budget_tokens: Option<u32>,
598) -> Vec<(String, String)> {
599 let mut query = Vec::new();
600 if let Some(v) = exercise_limit {
601 query.push(("exercise_limit".to_string(), v.to_string()));
602 }
603 if let Some(v) = strength_limit {
604 query.push(("strength_limit".to_string(), v.to_string()));
605 }
606 if let Some(v) = custom_limit {
607 query.push(("custom_limit".to_string(), v.to_string()));
608 }
609 if let Some(v) = task_intent {
610 query.push(("task_intent".to_string(), v));
611 }
612 if let Some(v) = include_system {
613 query.push(("include_system".to_string(), v.to_string()));
614 }
615 if let Some(v) = budget_tokens {
616 query.push(("budget_tokens".to_string(), v.to_string()));
617 }
618 query
619}
620
621fn build_section_fetch_query(
622 section: String,
623 limit: Option<u32>,
624 cursor: Option<String>,
625 fields: Option<String>,
626 task_intent: Option<String>,
627) -> Vec<(String, String)> {
628 let section = section.trim();
629 if section.is_empty() {
630 exit_error(
631 "section must not be empty",
632 Some("Provide --section using an id from /v1/agent/context/section-index"),
633 );
634 }
635 let mut query = vec![("section".to_string(), section.to_string())];
636 if let Some(v) = limit {
637 query.push(("limit".to_string(), v.to_string()));
638 }
639 if let Some(v) = cursor {
640 query.push(("cursor".to_string(), v));
641 }
642 if let Some(v) = fields {
643 query.push(("fields".to_string(), v));
644 }
645 if let Some(v) = task_intent {
646 query.push(("task_intent".to_string(), v));
647 }
648 query
649}
650
651fn parse_headers(raw: &[String]) -> Vec<(String, String)> {
652 raw.iter()
653 .map(|entry| {
654 entry.split_once(':').map_or_else(
655 || {
656 exit_error(
657 &format!("Invalid header: '{entry}'"),
658 Some("Format: Key:Value, e.g. --header Content-Type:application/json"),
659 )
660 },
661 |(k, v)| (k.trim().to_string(), v.trim().to_string()),
662 )
663 })
664 .collect()
665}
666
667fn resolve_body(data: Option<&str>, data_file: Option<&str>) -> Option<serde_json::Value> {
668 if let Some(raw) = data {
669 match serde_json::from_str(raw) {
670 Ok(v) => return Some(v),
671 Err(e) => exit_error(
672 &format!("Invalid JSON in --data: {e}"),
673 Some("Provide valid JSON string"),
674 ),
675 }
676 }
677
678 if let Some(file) = data_file {
679 return match read_json_from_file(file) {
680 Ok(v) => Some(v),
681 Err(e) => exit_error(&e, Some("Provide a valid JSON file or use '-' for stdin")),
682 };
683 }
684
685 None
686}
687
688fn load_full_request(path: &str) -> serde_json::Value {
689 let payload = match read_json_from_file(path) {
690 Ok(v) => v,
691 Err(e) => exit_error(
692 &e,
693 Some(
694 "Provide JSON with events, read_after_write_targets, and optional verify_timeout_ms.",
695 ),
696 ),
697 };
698 if payload
699 .get("events")
700 .and_then(|value| value.as_array())
701 .is_none()
702 {
703 exit_error(
704 "request payload must include an events array",
705 Some(
706 "Use --request-file with {\"events\": [...], \"read_after_write_targets\": [...]}",
707 ),
708 );
709 }
710 if payload
711 .get("read_after_write_targets")
712 .and_then(|value| value.as_array())
713 .is_none()
714 {
715 exit_error(
716 "request payload must include read_after_write_targets array",
717 Some("Set read_after_write_targets to [{\"projection_type\":\"...\",\"key\":\"...\"}]"),
718 );
719 }
720 payload
721}
722
723fn build_request_from_events_and_targets(
724 events_file: &str,
725 raw_targets: &[String],
726 verify_timeout_ms: Option<u64>,
727) -> serde_json::Value {
728 if raw_targets.is_empty() {
729 exit_error(
730 "--target is required when --request-file is not used",
731 Some("Repeat --target projection_type:key for read-after-write checks."),
732 );
733 }
734
735 let parsed_targets = parse_targets(raw_targets);
736 let events_payload = match read_json_from_file(events_file) {
737 Ok(v) => v,
738 Err(e) => exit_error(
739 &e,
740 Some("Provide --events-file as JSON array or object with events array."),
741 ),
742 };
743
744 let events = extract_events_array(events_payload);
745 build_write_with_proof_request(events, parsed_targets, verify_timeout_ms)
746}
747
748fn parse_targets(raw_targets: &[String]) -> Vec<serde_json::Value> {
749 raw_targets
750 .iter()
751 .map(|raw| {
752 let (projection_type, key) = raw.split_once(':').unwrap_or_else(|| {
753 exit_error(
754 &format!("Invalid --target '{raw}'"),
755 Some("Use format projection_type:key, e.g. user_profile:me"),
756 )
757 });
758 let projection_type = projection_type.trim();
759 let key = key.trim();
760 if projection_type.is_empty() || key.is_empty() {
761 exit_error(
762 &format!("Invalid --target '{raw}'"),
763 Some("projection_type and key must both be non-empty."),
764 );
765 }
766 json!({
767 "projection_type": projection_type,
768 "key": key,
769 })
770 })
771 .collect()
772}
773
774fn extract_events_array(events_payload: serde_json::Value) -> Vec<serde_json::Value> {
775 if let Some(events) = events_payload.as_array() {
776 return events.to_vec();
777 }
778 if let Some(events) = events_payload
779 .get("events")
780 .and_then(|value| value.as_array())
781 {
782 return events.to_vec();
783 }
784 exit_error(
785 "events payload must be an array or object with events array",
786 Some("Example: --events-file events.json where file is [{...}] or {\"events\": [{...}]}"),
787 );
788}
789
790fn build_write_with_proof_request(
791 events: Vec<serde_json::Value>,
792 parsed_targets: Vec<serde_json::Value>,
793 verify_timeout_ms: Option<u64>,
794) -> serde_json::Value {
795 let mut request = json!({
796 "events": events,
797 "read_after_write_targets": parsed_targets,
798 });
799 if let Some(timeout) = verify_timeout_ms {
800 request["verify_timeout_ms"] = json!(timeout);
801 }
802 request
803}
804
805#[cfg(test)]
806mod tests {
807 use super::{
808 SaveConfirmationMode, build_context_query, build_section_fetch_query,
809 build_write_with_proof_request, extract_events_array, normalize_agent_path, parse_method,
810 parse_targets,
811 };
812 use serde_json::json;
813
814 #[test]
815 fn normalize_agent_path_accepts_relative_path() {
816 assert_eq!(
817 normalize_agent_path("evidence/event/abc"),
818 "/v1/agent/evidence/event/abc"
819 );
820 }
821
822 #[test]
823 fn normalize_agent_path_accepts_absolute_agent_path() {
824 assert_eq!(
825 normalize_agent_path("/v1/agent/context"),
826 "/v1/agent/context"
827 );
828 }
829
830 #[test]
831 fn parse_method_accepts_standard_http_methods() {
832 for method in &[
833 "get", "GET", "post", "PUT", "delete", "patch", "head", "OPTIONS",
834 ] {
835 let parsed = parse_method(method);
836 assert!(!parsed.as_str().is_empty());
837 }
838 }
839
840 #[test]
841 fn parse_targets_accepts_projection_type_key_format() {
842 let parsed = parse_targets(&[
843 "user_profile:me".to_string(),
844 "training_timeline:overview".to_string(),
845 ]);
846 assert_eq!(parsed[0]["projection_type"], "user_profile");
847 assert_eq!(parsed[0]["key"], "me");
848 assert_eq!(parsed[1]["projection_type"], "training_timeline");
849 assert_eq!(parsed[1]["key"], "overview");
850 }
851
852 #[test]
853 fn extract_events_array_supports_plain_array() {
854 let events = extract_events_array(json!([
855 {"event_type":"set.logged"},
856 {"event_type":"metric.logged"}
857 ]));
858 assert_eq!(events.len(), 2);
859 }
860
861 #[test]
862 fn extract_events_array_supports_object_wrapper() {
863 let events = extract_events_array(json!({
864 "events": [{"event_type":"set.logged"}]
865 }));
866 assert_eq!(events.len(), 1);
867 }
868
869 #[test]
870 fn build_write_with_proof_request_serializes_expected_fields() {
871 let request = build_write_with_proof_request(
872 vec![json!({"event_type":"set.logged"})],
873 vec![json!({"projection_type":"user_profile","key":"me"})],
874 Some(1200),
875 );
876 assert_eq!(request["events"].as_array().unwrap().len(), 1);
877 assert_eq!(
878 request["read_after_write_targets"]
879 .as_array()
880 .unwrap()
881 .len(),
882 1
883 );
884 assert_eq!(request["verify_timeout_ms"], 1200);
885 }
886
887 #[test]
888 fn save_confirmation_mode_serializes_expected_values() {
889 assert_eq!(SaveConfirmationMode::Auto.as_str(), "auto");
890 assert_eq!(SaveConfirmationMode::Always.as_str(), "always");
891 assert_eq!(SaveConfirmationMode::Never.as_str(), "never");
892 }
893
894 #[test]
895 fn build_context_query_includes_budget_tokens_when_present() {
896 let query = build_context_query(
897 Some(3),
898 Some(2),
899 Some(1),
900 Some("readiness check".to_string()),
901 Some(false),
902 Some(900),
903 );
904 assert!(query.contains(&("exercise_limit".to_string(), "3".to_string())));
905 assert!(query.contains(&("strength_limit".to_string(), "2".to_string())));
906 assert!(query.contains(&("custom_limit".to_string(), "1".to_string())));
907 assert!(query.contains(&("task_intent".to_string(), "readiness check".to_string())));
908 assert!(query.contains(&("include_system".to_string(), "false".to_string())));
909 assert!(query.contains(&("budget_tokens".to_string(), "900".to_string())));
910 }
911
912 #[test]
913 fn build_context_query_supports_section_index_parity_params() {
914 let query = build_context_query(
915 Some(5),
916 Some(5),
917 Some(10),
918 Some("startup".to_string()),
919 Some(false),
920 Some(1200),
921 );
922 assert!(query.contains(&("exercise_limit".to_string(), "5".to_string())));
923 assert!(query.contains(&("strength_limit".to_string(), "5".to_string())));
924 assert!(query.contains(&("custom_limit".to_string(), "10".to_string())));
925 assert!(query.contains(&("task_intent".to_string(), "startup".to_string())));
926 assert!(query.contains(&("include_system".to_string(), "false".to_string())));
927 assert!(query.contains(&("budget_tokens".to_string(), "1200".to_string())));
928 }
929
930 #[test]
931 fn build_section_fetch_query_serializes_optional_params() {
932 let query = build_section_fetch_query(
933 "projections.exercise_progression".to_string(),
934 Some(50),
935 Some("abc123".to_string()),
936 Some("data,meta".to_string()),
937 Some("bench plateau".to_string()),
938 );
939 assert_eq!(
940 query,
941 vec![
942 (
943 "section".to_string(),
944 "projections.exercise_progression".to_string(),
945 ),
946 ("limit".to_string(), "50".to_string()),
947 ("cursor".to_string(), "abc123".to_string()),
948 ("fields".to_string(), "data,meta".to_string()),
949 ("task_intent".to_string(), "bench plateau".to_string()),
950 ]
951 );
952 }
953}