Skip to main content

kura_cli/commands/
agent.rs

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    /// Get negotiated agent capabilities manifest
11    Capabilities,
12    /// Get agent context bundle (system + user profile + key dimensions)
13    Context {
14        /// Max exercise_progression projections to include (default: 5)
15        #[arg(long)]
16        exercise_limit: Option<u32>,
17        /// Max strength_inference projections to include (default: 5)
18        #[arg(long)]
19        strength_limit: Option<u32>,
20        /// Max custom projections to include (default: 10)
21        #[arg(long)]
22        custom_limit: Option<u32>,
23        /// Optional task intent used for context ranking (e.g. "dunk progression")
24        #[arg(long)]
25        task_intent: Option<String>,
26        /// Include deployment-static system config in response payload (default: API default=true)
27        #[arg(long)]
28        include_system: Option<bool>,
29        /// Optional response token budget hint (min 400, max 12000)
30        #[arg(long)]
31        budget_tokens: Option<u32>,
32    },
33    /// Get deterministic section index for startup + targeted follow-up reads
34    SectionIndex {
35        /// Max exercise_progression projections to include (default: 5)
36        #[arg(long)]
37        exercise_limit: Option<u32>,
38        /// Max strength_inference projections to include (default: 5)
39        #[arg(long)]
40        strength_limit: Option<u32>,
41        /// Max custom projections to include (default: 10)
42        #[arg(long)]
43        custom_limit: Option<u32>,
44        /// Optional task intent used for startup section derivation
45        #[arg(long)]
46        task_intent: Option<String>,
47        /// Include deployment-static system config in response payload (default: API default=true)
48        #[arg(long)]
49        include_system: Option<bool>,
50        /// Optional response token budget hint (min 400, max 12000)
51        #[arg(long)]
52        budget_tokens: Option<u32>,
53    },
54    /// Fetch exactly one context section (optionally paged and field-projected)
55    SectionFetch {
56        /// Stable section id from section-index
57        #[arg(long)]
58        section: String,
59        /// Optional page size for paged sections (1..200)
60        #[arg(long)]
61        limit: Option<u32>,
62        /// Optional opaque cursor for paged sections
63        #[arg(long)]
64        cursor: Option<String>,
65        /// Optional comma-separated top-level fields to project
66        #[arg(long)]
67        fields: Option<String>,
68        /// Optional task intent for startup section derivation
69        #[arg(long)]
70        task_intent: Option<String>,
71    },
72    /// Write events with receipts + read-after-write verification
73    WriteWithProof(WriteWithProofArgs),
74    /// Evidence lineage operations
75    Evidence {
76        #[command(subcommand)]
77        command: AgentEvidenceCommands,
78    },
79    /// Set user save-confirmation preference (persist-intent override)
80    SetSaveConfirmationMode {
81        /// auto | always | never
82        #[arg(value_enum)]
83        mode: SaveConfirmationMode,
84    },
85    /// Resolve visualization policy/output for a task intent
86    ResolveVisualization(ResolveVisualizationArgs),
87    /// Direct agent API access under /v1/agent/*
88    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    /// Explain lineage claims for one persisted event
111    Event {
112        /// Target event UUID
113        #[arg(long)]
114        event_id: Uuid,
115    },
116}
117
118#[derive(Args)]
119pub struct AgentRequestArgs {
120    /// HTTP method (GET, POST, PUT, DELETE, PATCH)
121    pub method: String,
122
123    /// Agent path: relative (e.g. context) or absolute (/v1/agent/context)
124    pub path: String,
125
126    /// Request body as JSON string
127    #[arg(long, short = 'd')]
128    pub data: Option<String>,
129
130    /// Read request body from file (use '-' for stdin)
131    #[arg(long, short = 'f', conflicts_with = "data")]
132    pub data_file: Option<String>,
133
134    /// Query parameters (repeatable: key=value)
135    #[arg(long, short = 'q')]
136    pub query: Vec<String>,
137
138    /// Extra headers (repeatable: Key:Value)
139    #[arg(long, short = 'H')]
140    pub header: Vec<String>,
141
142    /// Skip pretty-printing (raw JSON for piping)
143    #[arg(long)]
144    pub raw: bool,
145
146    /// Include HTTP status and headers in response wrapper
147    #[arg(long, short = 'i')]
148    pub include: bool,
149}
150
151#[derive(Args)]
152pub struct WriteWithProofArgs {
153    /// JSON file containing events array or {"events":[...]} (use '-' for stdin)
154    #[arg(
155        long,
156        required_unless_present = "request_file",
157        conflicts_with = "request_file"
158    )]
159    pub events_file: Option<String>,
160
161    /// Read-after-write target in projection_type:key format (repeatable)
162    #[arg(
163        long,
164        required_unless_present = "request_file",
165        conflicts_with = "request_file"
166    )]
167    pub target: Vec<String>,
168
169    /// Max verification wait in milliseconds (100..10000)
170    #[arg(long)]
171    pub verify_timeout_ms: Option<u64>,
172
173    /// Reuse claim_guard.non_trivial_confirmation_challenge.confirmation_token on retry
174    /// to suppress duplicate monitor confirmation prompts for the same payload
175    #[arg(long, conflicts_with_all = ["request_file", "non_trivial_confirmation_file"])]
176    pub non_trivial_confirmation_token: Option<String>,
177
178    /// Full non_trivial_confirmation.v1 payload JSON file (use '-' for stdin)
179    #[arg(long, conflicts_with_all = ["request_file", "non_trivial_confirmation_token"])]
180    pub non_trivial_confirmation_file: Option<String>,
181
182    /// Full request payload JSON file for /v1/agent/write-with-proof
183    #[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    /// Full request payload JSON file for /v1/agent/visualization/resolve
190    #[arg(long, conflicts_with = "task_intent")]
191    pub request_file: Option<String>,
192
193    /// Task intent (required unless --request-file is used)
194    #[arg(long, required_unless_present = "request_file")]
195    pub task_intent: Option<String>,
196
197    /// auto | always | never
198    #[arg(long)]
199    pub user_preference_override: Option<String>,
200
201    /// low | medium | high
202    #[arg(long)]
203    pub complexity_hint: Option<String>,
204
205    /// Allow rich rendering formats when true (default: true)
206    #[arg(long, default_value_t = true)]
207    pub allow_rich_rendering: bool,
208
209    /// Optional visualization_spec JSON file
210    #[arg(long)]
211    pub spec_file: Option<String>,
212
213    /// Optional telemetry session id
214    #[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}