Skip to main content

kura_cli/commands/
analysis.rs

1use std::time::{Duration, Instant};
2
3use clap::{Args, Subcommand};
4use serde::Deserialize;
5use serde_json::{Map, Value, json};
6use tokio::time::sleep;
7use uuid::Uuid;
8
9use crate::util::{
10    api_request, client, dry_run_enabled, emit_dry_run_request, exit_error, print_json_stderr,
11    print_json_stdout, read_json_from_file,
12};
13
14const CLI_ANALYSIS_RUN_OVERALL_TIMEOUT_MS_DEFAULT: u64 = 90_000;
15const CLI_ANALYSIS_RUN_OVERALL_TIMEOUT_MS_MIN: u64 = 1_000;
16const CLI_ANALYSIS_RUN_OVERALL_TIMEOUT_MS_MAX: u64 = 300_000;
17const CLI_ANALYSIS_RUN_POLL_INTERVAL_MS_DEFAULT: u64 = 1_000;
18const CLI_ANALYSIS_RUN_POLL_INTERVAL_MS_MIN: u64 = 100;
19const CLI_ANALYSIS_RUN_POLL_INTERVAL_MS_MAX: u64 = 5_000;
20const ANALYZE_HELP: &str = r#"Examples:
21  kura analyze "Why has my CMJ changed over the last 60 days, and what recovery or training context lines up with the best and worst results?" --horizon-days 60 --focus performance_tests --focus recovery --focus activity --wait-timeout-ms 15000
22  kura analyze "Am I stagnating in jump power, and what should I inspect next?" --horizon-days 90 --focus jump --focus performance_tests --focus recovery --focus health
23  kura analyze "Compare Hang Power Clean below knee progress with CMJ trend without mixing variants" --horizon-days 120 --focus activity --focus performance_tests
24  kura analyze "Why is my sprint speed stalling?" --method recommended_by_server --program-json '{"sources":["performance_tests","activity_session_history","recovery"],"target_signal":"sprint_speed","method":"recommended_by_server"}'
25  kura analyze --request-file analysis_payload.json
26
27Rules:
28  Use analyze first for trend, stagnation, driver, influence, relationship, cross-domain explanation, and training-decision questions.
29  Do not prefetch every source with read_query before analyze; analyze loads the relevant evidence pack itself.
30  Do not invent ad-hoc formulas when backend analysis methods can answer the question; ask for method-guided analysis instead.
31  Use read_query first only for exact records, lists, timelines, or when analyze asks for a supporting read.
32"#;
33
34pub fn public_analyze_payload_contract() -> Value {
35    json!({
36        "schema_version": "public_analyze_payload_contract.v1",
37        "entrypoint": "kura analyze",
38        "truth_namespace": "bounded_deep_analysis",
39        "principles": [
40            "Use analyze as the first tool for trend, stagnation, driver, influence, relationship, cross-domain explanation, and training-decision questions.",
41            "Do not prefetch every source with read_query before analyze; analyze loads the relevant evidence pack itself.",
42            "Phrase objective as the exact user-facing analysis goal.",
43            "Keep focus hints short and domain-specific."
44        ],
45        "examples": [
46            {
47                "label": "Cross-family performance explanation",
48                "payload": {
49                    "objective": "Explain how my CMJ changed over the last 60 days and what recovery or health context lined up with the best and worst results.",
50                    "horizon_days": 60,
51                    "focus": ["performance_tests", "recovery", "health", "activity"],
52                    "method": "recommended_by_server",
53                    "wait_timeout_ms": 15000
54                }
55            },
56            {
57                "label": "Stagnation review",
58                "payload": {
59                    "objective": "Am I stagnating in jump power, and what should I inspect next?",
60                    "horizon_days": 90,
61                    "focus": ["jump", "performance_tests", "recovery", "health", "activity"],
62                    "method": "recommended_by_server",
63                    "wait_timeout_ms": 15000
64                }
65            },
66            {
67                "label": "Variant-aware relationship analysis",
68                "payload": {
69                    "objective": "Compare Hang Power Clean below knee progress with CMJ trend without mixing variants.",
70                    "horizon_days": 120,
71                    "focus": ["activity", "performance_tests"],
72                    "method": "recommended_by_server",
73                    "analysis_program": {
74                        "method": "recommended_by_server",
75                        "sources": ["activity_session_history", "performance_tests"],
76                        "target_signal": "cmj",
77                        "comparison_streams": ["hang_power_clean::below_knee"],
78                        "group_by": ["comparison_stream"]
79                    },
80                    "wait_timeout_ms": 15000
81                }
82            }
83        ]
84    })
85}
86
87#[derive(Subcommand)]
88pub enum AnalysisCommands {
89    /// Queue and wait (bounded) for deep analysis via /v1/analysis/jobs/run
90    Run {
91        /// Full JSON request payload (use '-' for stdin)
92        #[arg(long, conflicts_with_all = ["objective", "objective_text", "horizon_days", "focus", "filters_json", "method", "analysis_program_json"])]
93        request_file: Option<String>,
94        /// Free-text analysis objective (preferred for one-off agent calls)
95        #[arg(long, short = 'o', conflicts_with = "objective_text")]
96        objective: Option<String>,
97        /// Free-text analysis objective (positional alias for --objective)
98        #[arg(value_name = "OBJECTIVE", conflicts_with = "objective")]
99        objective_text: Option<String>,
100        /// Horizon in days (defaults server-side to 90)
101        #[arg(long = "horizon-days", alias = "days")]
102        horizon_days: Option<i32>,
103        /// Focus hints (repeatable). Example: --focus lower_body --focus power
104        #[arg(long)]
105        focus: Vec<String>,
106        /// Optional structured filters JSON object
107        #[arg(long = "filters-json", alias = "filters")]
108        filters_json: Option<String>,
109        /// Optional method-registry template. Prefer recommended_by_server unless the user asked for a specific analysis kind.
110        #[arg(long)]
111        method: Option<String>,
112        /// Optional bounded read-only analysis_program JSON object. This is not Python/SQL.
113        #[arg(long = "program-json", alias = "analysis-program")]
114        analysis_program_json: Option<String>,
115        /// Override server-side initial wait timeout in milliseconds (server clamps to safe bounds)
116        #[arg(long)]
117        wait_timeout_ms: Option<u64>,
118        /// Total CLI wait budget including timeout fallback polling
119        #[arg(long)]
120        overall_timeout_ms: Option<u64>,
121        /// Poll interval used after server-side timeout fallback kicks in
122        #[arg(long)]
123        poll_interval_ms: Option<u64>,
124    },
125    /// Queue a new async analysis job via /v1/analysis/jobs
126    #[command(hide = true)]
127    Create {
128        /// Full JSON request payload (use '-' for stdin)
129        #[arg(long)]
130        request_file: String,
131    },
132    /// Fetch analysis job status by id
133    #[command(hide = true)]
134    Status {
135        /// Analysis job UUID
136        #[arg(long)]
137        job_id: Uuid,
138    },
139    /// Validate a draft answer against the authoritative analysis admissibility contract
140    #[command(hide = true)]
141    ValidateAnswer(ValidateAnswerArgs),
142}
143
144#[derive(Args, Clone)]
145#[command(after_help = ANALYZE_HELP)]
146pub struct AnalyzeArgs {
147    /// Full JSON request payload (use '-' for stdin)
148    #[arg(long, conflicts_with_all = ["objective", "objective_text", "horizon_days", "focus", "filters_json", "method", "analysis_program_json"])]
149    pub request_file: Option<String>,
150    /// Free-text analysis objective (preferred for one-off agent calls)
151    #[arg(long, short = 'o', conflicts_with = "objective_text")]
152    pub objective: Option<String>,
153    /// Free-text analysis objective (positional alias for --objective)
154    #[arg(value_name = "OBJECTIVE", conflicts_with = "objective")]
155    pub objective_text: Option<String>,
156    /// Horizon in days (defaults server-side to 90)
157    #[arg(long = "horizon-days", alias = "days")]
158    pub horizon_days: Option<i32>,
159    /// Focus hints (repeatable). Example: --focus lower_body --focus power
160    #[arg(long)]
161    pub focus: Vec<String>,
162    /// Optional structured filters JSON object
163    #[arg(long = "filters-json", alias = "filters")]
164    pub filters_json: Option<String>,
165    /// Optional method-registry template. Prefer recommended_by_server unless the user asked for a specific analysis kind.
166    #[arg(long)]
167    pub method: Option<String>,
168    /// Optional bounded read-only analysis_program JSON object. This is not Python/SQL.
169    #[arg(long = "program-json", alias = "analysis-program")]
170    pub analysis_program_json: Option<String>,
171    /// Override server-side initial wait timeout in milliseconds (server clamps to safe bounds)
172    #[arg(long)]
173    pub wait_timeout_ms: Option<u64>,
174    /// Total CLI wait budget including timeout fallback polling
175    #[arg(long)]
176    pub overall_timeout_ms: Option<u64>,
177    /// Poll interval used after server-side timeout fallback kicks in
178    #[arg(long)]
179    pub poll_interval_ms: Option<u64>,
180}
181
182#[derive(Args)]
183pub struct ValidateAnswerArgs {
184    /// Current user request, preferably passed verbatim
185    #[arg(long)]
186    task_intent: String,
187    /// Draft user-facing answer to validate
188    #[arg(long)]
189    draft_answer: String,
190}
191
192pub async fn run(api_url: &str, token: Option<&str>, command: AnalysisCommands) -> i32 {
193    match command {
194        AnalysisCommands::Run {
195            request_file,
196            objective,
197            objective_text,
198            horizon_days,
199            focus,
200            filters_json,
201            method,
202            analysis_program_json,
203            wait_timeout_ms,
204            overall_timeout_ms,
205            poll_interval_ms,
206        } => {
207            run_blocking(
208                api_url,
209                token,
210                request_file.as_deref(),
211                objective,
212                objective_text,
213                horizon_days,
214                focus,
215                filters_json,
216                method,
217                analysis_program_json,
218                wait_timeout_ms,
219                overall_timeout_ms,
220                poll_interval_ms,
221            )
222            .await
223        }
224        AnalysisCommands::Create { request_file } => create(api_url, token, &request_file).await,
225        AnalysisCommands::Status { job_id } => status(api_url, token, job_id).await,
226        AnalysisCommands::ValidateAnswer(args) => {
227            validate_answer(api_url, token, args.task_intent, args.draft_answer).await
228        }
229    }
230}
231
232pub async fn analyze(api_url: &str, token: Option<&str>, args: AnalyzeArgs) -> i32 {
233    run_blocking(
234        api_url,
235        token,
236        args.request_file.as_deref(),
237        args.objective,
238        args.objective_text,
239        args.horizon_days,
240        args.focus,
241        args.filters_json,
242        args.method,
243        args.analysis_program_json,
244        args.wait_timeout_ms,
245        args.overall_timeout_ms,
246        args.poll_interval_ms,
247    )
248    .await
249}
250
251async fn run_blocking(
252    api_url: &str,
253    token: Option<&str>,
254    request_file: Option<&str>,
255    objective_flag: Option<String>,
256    objective_positional: Option<String>,
257    horizon_days: Option<i32>,
258    focus: Vec<String>,
259    filters_json: Option<String>,
260    method: Option<String>,
261    analysis_program_json: Option<String>,
262    wait_timeout_ms: Option<u64>,
263    overall_timeout_ms: Option<u64>,
264    poll_interval_ms: Option<u64>,
265) -> i32 {
266    let base_body = build_run_request_body(
267        request_file,
268        objective_flag,
269        objective_positional,
270        horizon_days,
271        focus,
272        filters_json,
273        method,
274        analysis_program_json,
275    )
276    .unwrap_or_else(|e| {
277        exit_error(
278            &e,
279            Some(
280                "Use `kura analyze --objective \"...\"` (or positional objective) for user-facing analyses, or `--request-file payload.json` for full JSON requests.",
281            ),
282        )
283    });
284    let body = apply_wait_timeout_override(base_body, wait_timeout_ms).unwrap_or_else(|e| {
285        exit_error(
286            &e,
287            Some("Analysis run request must be a JSON object with objective/horizon/focus fields."),
288        )
289    });
290
291    let overall_timeout_ms = clamp_cli_overall_timeout_ms(overall_timeout_ms);
292    let poll_interval_ms = clamp_cli_poll_interval_ms(poll_interval_ms);
293
294    if dry_run_enabled() {
295        return emit_dry_run_request(
296            &reqwest::Method::POST,
297            api_url,
298            "/v1/analysis/jobs/run",
299            token.is_some(),
300            Some(&body),
301            &[],
302            &[],
303            false,
304            Some(
305                "Dry-run skips server execution and timeout polling. Use `kura analysis status --job-id <id>` on a real run.",
306            ),
307        );
308    }
309
310    let started = Instant::now();
311
312    let (status, run_body) = request_json(
313        api_url,
314        reqwest::Method::POST,
315        "/v1/analysis/jobs/run",
316        token,
317        Some(body),
318    )
319    .await
320    .unwrap_or_else(|e| exit_error(&e, Some("Check API availability/auth and retry.")));
321
322    if !is_success_status(status) {
323        if is_run_endpoint_unsupported_status(status) {
324            let blocked = build_run_endpoint_contract_block(status, run_body);
325            return print_json_response(status, &blocked);
326        }
327        return print_json_response(status, &run_body);
328    }
329
330    let Some(run_response) = parse_cli_run_response(&run_body) else {
331        return print_json_response(status, &run_body);
332    };
333
334    if !run_response_needs_cli_poll_fallback(&run_response) {
335        return print_json_response(status, &run_body);
336    }
337
338    let mut latest_job = run_body.get("job").cloned().unwrap_or(Value::Null);
339    let mut polls = 0u32;
340    let mut last_retry_after_ms =
341        clamp_cli_poll_interval_ms(run_response.retry_after_ms.or(Some(poll_interval_ms)));
342    let job_id = run_response.job.job_id;
343    let path = format!("/v1/analysis/jobs/{job_id}");
344
345    loop {
346        let elapsed_total_ms = elapsed_ms(started);
347        if elapsed_total_ms >= overall_timeout_ms {
348            let timeout_output = build_cli_poll_fallback_output(
349                latest_job,
350                elapsed_total_ms,
351                false,
352                true,
353                Some(last_retry_after_ms),
354                polls,
355                overall_timeout_ms,
356                run_response.mode.as_deref(),
357                "server_run_timeout_poll",
358            );
359            return print_json_response(200, &timeout_output);
360        }
361
362        let remaining_ms = overall_timeout_ms.saturating_sub(elapsed_total_ms);
363        let sleep_ms = min_u64(last_retry_after_ms, remaining_ms);
364        sleep(Duration::from_millis(sleep_ms)).await;
365
366        let (poll_status, poll_body) =
367            request_json(api_url, reqwest::Method::GET, &path, token, None)
368                .await
369                .unwrap_or_else(|e| {
370                    exit_error(
371                        &e,
372                        Some(
373                            "Blocking analysis fallback polling failed. Retry `kura analysis status --job-id <id>` in the same session if needed.",
374                        ),
375                    )
376                });
377
378        if !is_success_status(poll_status) {
379            return print_json_response(poll_status, &poll_body);
380        }
381
382        polls = polls.saturating_add(1);
383        latest_job = poll_body.clone();
384
385        if let Some(job_status) = parse_cli_job_status(&poll_body) {
386            if analysis_job_status_is_terminal(&job_status.status) {
387                let final_output = build_cli_poll_fallback_output(
388                    poll_body,
389                    elapsed_ms(started),
390                    true,
391                    false,
392                    None,
393                    polls,
394                    overall_timeout_ms,
395                    run_response.mode.as_deref(),
396                    "server_run_timeout_poll",
397                );
398                return print_json_response(200, &final_output);
399            }
400        } else {
401            // If shape is unexpected, surface the payload instead of masking it.
402            return print_json_response(200, &poll_body);
403        }
404
405        last_retry_after_ms = poll_interval_ms;
406    }
407}
408
409async fn create(api_url: &str, token: Option<&str>, request_file: &str) -> i32 {
410    let body = match read_json_from_file(request_file) {
411        Ok(v) => v,
412        Err(e) => {
413            crate::util::exit_error(&e, Some("Provide a valid JSON analysis request payload."))
414        }
415    };
416
417    if dry_run_enabled() {
418        return emit_dry_run_request(
419            &reqwest::Method::POST,
420            api_url,
421            "/v1/analysis/jobs",
422            token.is_some(),
423            Some(&body),
424            &[],
425            &[],
426            false,
427            None,
428        );
429    }
430
431    api_request(
432        api_url,
433        reqwest::Method::POST,
434        "/v1/analysis/jobs",
435        token,
436        Some(body),
437        &[],
438        &[],
439        false,
440        false,
441    )
442    .await
443}
444
445async fn status(api_url: &str, token: Option<&str>, job_id: Uuid) -> i32 {
446    let path = format!("/v1/analysis/jobs/{job_id}");
447    api_request(
448        api_url,
449        reqwest::Method::GET,
450        &path,
451        token,
452        None,
453        &[],
454        &[],
455        false,
456        false,
457    )
458    .await
459}
460
461async fn validate_answer(
462    api_url: &str,
463    token: Option<&str>,
464    task_intent: String,
465    draft_answer: String,
466) -> i32 {
467    let body = build_validate_answer_body(task_intent, draft_answer);
468    api_request(
469        api_url,
470        reqwest::Method::POST,
471        "/v1/agent/answer-admissibility",
472        token,
473        Some(body),
474        &[],
475        &[],
476        false,
477        false,
478    )
479    .await
480}
481
482fn build_run_request_body(
483    request_file: Option<&str>,
484    objective_flag: Option<String>,
485    objective_positional: Option<String>,
486    horizon_days: Option<i32>,
487    focus: Vec<String>,
488    filters_json: Option<String>,
489    method: Option<String>,
490    analysis_program_json: Option<String>,
491) -> Result<Value, String> {
492    if let Some(path) = request_file {
493        if objective_flag.is_some()
494            || objective_positional.is_some()
495            || horizon_days.is_some()
496            || filters_json.is_some()
497            || method.is_some()
498            || analysis_program_json.is_some()
499        {
500            return Err(
501                "`--request-file` cannot be combined with inline objective/horizon/filter/method/program flags"
502                    .to_string(),
503            );
504        }
505        return normalize_analysis_request_file_payload(read_json_from_file(path)?);
506    }
507
508    let objective = choose_inline_objective(objective_flag, objective_positional)?;
509    let objective = objective
510        .ok_or_else(|| "Missing analysis objective. Provide `--objective \"...\"`, positional OBJECTIVE, or `--request-file`.".to_string())?;
511
512    let normalized_focus = normalize_focus_flags(focus);
513    let mut body = json!({ "objective": objective });
514    let obj = body
515        .as_object_mut()
516        .ok_or_else(|| "analysis request body must be a JSON object".to_string())?;
517    if let Some(days) = horizon_days {
518        obj.insert("horizon_days".to_string(), json!(days));
519    }
520    if !normalized_focus.is_empty() {
521        obj.insert("focus".to_string(), json!(normalized_focus));
522    }
523    if let Some(filters) =
524        parse_optional_json_object_arg(filters_json.as_deref(), "--filters-json")?
525    {
526        obj.insert("filters".to_string(), Value::Object(filters));
527    }
528    if let Some(method) = method
529        .map(|value| value.trim().to_string())
530        .filter(|value| !value.is_empty())
531    {
532        obj.insert("method".to_string(), json!(method));
533    }
534    if let Some(program) =
535        parse_optional_json_object_arg(analysis_program_json.as_deref(), "--program-json")?
536    {
537        obj.insert("analysis_program".to_string(), Value::Object(program));
538    }
539    Ok(body)
540}
541
542fn normalize_analysis_request_file_payload(value: Value) -> Result<Value, String> {
543    if value.get("objective").is_some() {
544        return Ok(value);
545    }
546    for pointer in [
547        "/analyze_payload",
548        "/analysis_handoff/analyze_payload",
549        "/analysis_handoff/next_request/payload",
550        "/next_request/payload",
551    ] {
552        if let Some(candidate) = value.pointer(pointer) {
553            if candidate.get("objective").is_some() {
554                return Ok(candidate.clone());
555            }
556        }
557    }
558    Err("Analysis request file must be an analyze payload with objective or a read_query analysis_handoff containing analyze_payload/next_request.payload.".to_string())
559}
560
561fn build_validate_answer_body(task_intent: String, draft_answer: String) -> Value {
562    json!({
563        "task_intent": task_intent,
564        "draft_answer": draft_answer,
565    })
566}
567
568fn choose_inline_objective(
569    objective_flag: Option<String>,
570    objective_positional: Option<String>,
571) -> Result<Option<String>, String> {
572    if objective_flag.is_some() && objective_positional.is_some() {
573        return Err(
574            "Provide the objective either as positional text or via `--objective`, not both."
575                .to_string(),
576        );
577    }
578    Ok(objective_flag
579        .or(objective_positional)
580        .map(|s| s.trim().to_string())
581        .filter(|s| !s.is_empty()))
582}
583
584fn normalize_focus_flags(values: Vec<String>) -> Vec<String> {
585    let mut out = Vec::new();
586    for value in values {
587        let normalized = value.trim();
588        if normalized.is_empty() {
589            continue;
590        }
591        let normalized = normalized.to_string();
592        if !out.contains(&normalized) {
593            out.push(normalized);
594        }
595    }
596    out
597}
598
599fn parse_optional_json_object_arg(
600    raw: Option<&str>,
601    flag_name: &str,
602) -> Result<Option<Map<String, Value>>, String> {
603    let Some(raw) = raw.map(str::trim).filter(|value| !value.is_empty()) else {
604        return Ok(None);
605    };
606    let parsed: Value = serde_json::from_str(raw)
607        .map_err(|error| format!("Could not parse {flag_name} as JSON: {error}"))?;
608    let object = parsed
609        .as_object()
610        .cloned()
611        .ok_or_else(|| format!("{flag_name} must be a JSON object"))?;
612    Ok(Some(object))
613}
614
615fn apply_wait_timeout_override(
616    mut body: Value,
617    wait_timeout_ms: Option<u64>,
618) -> Result<Value, String> {
619    if let Some(timeout_ms) = wait_timeout_ms {
620        let obj = body
621            .as_object_mut()
622            .ok_or_else(|| "analysis request body must be a JSON object".to_string())?;
623        obj.insert("wait_timeout_ms".to_string(), json!(timeout_ms));
624    }
625    Ok(body)
626}
627
628#[derive(Debug, Deserialize)]
629struct CliRunAnalysisResponse {
630    #[allow(dead_code)]
631    mode: Option<String>,
632    terminal: bool,
633    timed_out: bool,
634    #[serde(default)]
635    retry_after_ms: Option<u64>,
636    job: CliJobStatusRef,
637}
638
639#[derive(Debug, Deserialize)]
640struct CliJobStatusRef {
641    job_id: Uuid,
642    status: String,
643}
644
645#[derive(Debug, Deserialize)]
646struct CliJobStatusEnvelope {
647    status: String,
648}
649
650fn parse_cli_run_response(value: &Value) -> Option<CliRunAnalysisResponse> {
651    serde_json::from_value(value.clone()).ok()
652}
653
654fn parse_cli_job_status(value: &Value) -> Option<CliJobStatusEnvelope> {
655    serde_json::from_value(value.clone()).ok()
656}
657
658fn run_response_needs_cli_poll_fallback(response: &CliRunAnalysisResponse) -> bool {
659    response.timed_out
660        && !response.terminal
661        && !analysis_job_status_is_terminal(&response.job.status)
662}
663
664fn analysis_job_status_is_terminal(status: &str) -> bool {
665    matches!(status, "completed" | "failed")
666}
667
668fn is_run_endpoint_unsupported_status(status: u16) -> bool {
669    matches!(status, 404 | 405)
670}
671
672fn clamp_cli_overall_timeout_ms(timeout_ms: Option<u64>) -> u64 {
673    timeout_ms
674        .unwrap_or(CLI_ANALYSIS_RUN_OVERALL_TIMEOUT_MS_DEFAULT)
675        .clamp(
676            CLI_ANALYSIS_RUN_OVERALL_TIMEOUT_MS_MIN,
677            CLI_ANALYSIS_RUN_OVERALL_TIMEOUT_MS_MAX,
678        )
679}
680
681fn clamp_cli_poll_interval_ms(timeout_ms: Option<u64>) -> u64 {
682    timeout_ms
683        .unwrap_or(CLI_ANALYSIS_RUN_POLL_INTERVAL_MS_DEFAULT)
684        .clamp(
685            CLI_ANALYSIS_RUN_POLL_INTERVAL_MS_MIN,
686            CLI_ANALYSIS_RUN_POLL_INTERVAL_MS_MAX,
687        )
688}
689
690fn elapsed_ms(started: Instant) -> u64 {
691    let ms = started.elapsed().as_millis();
692    if ms > u128::from(u64::MAX) {
693        u64::MAX
694    } else {
695        ms as u64
696    }
697}
698
699fn min_u64(a: u64, b: u64) -> u64 {
700    if a < b { a } else { b }
701}
702
703fn build_cli_poll_fallback_output(
704    job: Value,
705    waited_ms: u64,
706    terminal: bool,
707    timed_out: bool,
708    retry_after_ms: Option<u64>,
709    polls: u32,
710    overall_timeout_ms: u64,
711    initial_mode: Option<&str>,
712    fallback_kind: &str,
713) -> Value {
714    let mut out = json!({
715        "mode": if terminal {
716            format!("blocking_cli_poll_fallback_completed:{fallback_kind}")
717        } else {
718            format!("blocking_cli_poll_fallback_timeout:{fallback_kind}")
719        },
720        "terminal": terminal,
721        "timed_out": timed_out,
722        "waited_ms": waited_ms,
723        "retry_after_ms": retry_after_ms,
724        "job": job,
725        "cli_fallback": {
726            "used": true,
727            "kind": fallback_kind,
728            "polls": polls,
729            "overall_timeout_ms": overall_timeout_ms,
730            "initial_mode": initial_mode,
731        }
732    });
733    if retry_after_ms.is_none() {
734        if let Some(obj) = out.as_object_mut() {
735            obj.insert("retry_after_ms".to_string(), Value::Null);
736        }
737    }
738    out
739}
740
741fn build_run_endpoint_contract_block(status: u16, details: Value) -> Value {
742    json!({
743        "error": "agent_mode_blocked",
744        "reason_code": "analysis_run_contract_missing",
745        "message": "Public `kura analyze` is blocked because the server did not honor the `/v1/analysis/jobs/run` contract.",
746        "blocked_command": "kura analyze",
747        "required_endpoint": "/v1/analysis/jobs/run",
748        "legacy_fallback_disabled": true,
749        "next_action": "Update the CLI/server pair until `/v1/analysis/jobs/run` is supported, then retry `kura analyze`.",
750        "status": status,
751        "details": details,
752    })
753}
754
755async fn request_json(
756    api_url: &str,
757    method: reqwest::Method,
758    path: &str,
759    token: Option<&str>,
760    body: Option<Value>,
761) -> Result<(u16, Value), String> {
762    let url = reqwest::Url::parse(&format!("{api_url}{path}"))
763        .map_err(|e| format!("Invalid URL: {api_url}{path}: {e}"))?;
764
765    let mut req = client().request(method, url);
766    if let Some(t) = token {
767        req = req.header("Authorization", format!("Bearer {t}"));
768    }
769    if let Some(b) = body {
770        req = req.json(&b);
771    }
772
773    let resp = req.send().await.map_err(|e| format!("{e}"))?;
774    let status = resp.status().as_u16();
775    let body: Value = match resp.bytes().await {
776        Ok(bytes) => {
777            if bytes.is_empty() {
778                Value::Null
779            } else {
780                serde_json::from_slice(&bytes)
781                    .unwrap_or_else(|_| Value::String(String::from_utf8_lossy(&bytes).to_string()))
782            }
783        }
784        Err(e) => json!({"raw_error": format!("Failed to read response body: {e}")}),
785    };
786    Ok((status, body))
787}
788
789fn is_success_status(status: u16) -> bool {
790    (200..=299).contains(&status)
791}
792
793fn http_status_exit_code(status: u16) -> i32 {
794    match status {
795        200..=299 => 0,
796        400..=499 => 1,
797        _ => 2,
798    }
799}
800
801fn print_json_response(status: u16, body: &Value) -> i32 {
802    let exit_code = http_status_exit_code(status);
803    if exit_code == 0 {
804        print_json_stdout(body);
805    } else {
806        print_json_stderr(body);
807    }
808    exit_code
809}
810
811#[cfg(test)]
812mod tests {
813    use super::{
814        analysis_job_status_is_terminal, apply_wait_timeout_override,
815        build_run_endpoint_contract_block, build_run_request_body, build_validate_answer_body,
816        clamp_cli_overall_timeout_ms, clamp_cli_poll_interval_ms,
817        is_run_endpoint_unsupported_status, parse_cli_job_status,
818        run_response_needs_cli_poll_fallback,
819    };
820    use serde_json::json;
821
822    #[test]
823    fn apply_wait_timeout_override_merges_field_into_request_object() {
824        let body = json!({
825            "objective": "trend of readiness",
826            "horizon_days": 90
827        });
828        let patched = apply_wait_timeout_override(body, Some(2500)).unwrap();
829        assert_eq!(patched["wait_timeout_ms"], json!(2500));
830        assert_eq!(patched["objective"], json!("trend of readiness"));
831    }
832
833    #[test]
834    fn apply_wait_timeout_override_rejects_non_object_when_override_present() {
835        let err = apply_wait_timeout_override(json!(["bad"]), Some(1000)).unwrap_err();
836        assert!(err.contains("JSON object"));
837    }
838
839    #[test]
840    fn build_run_request_body_accepts_inline_objective_and_flags() {
841        let body = build_run_request_body(
842            None,
843            Some("trend of plyometric quality".to_string()),
844            None,
845            Some(90),
846            vec![
847                "plyo".to_string(),
848                "  lower_body ".to_string(),
849                "".to_string(),
850            ],
851            Some(r#"{"performance_tests":{"test_types":["cmj"]}}"#.to_string()),
852            Some("recommended_by_server".to_string()),
853            Some(
854                r#"{"sources":["performance_tests"],"method":"recommended_by_server"}"#.to_string(),
855            ),
856        )
857        .unwrap();
858        assert_eq!(body["objective"], json!("trend of plyometric quality"));
859        assert_eq!(body["horizon_days"], json!(90));
860        assert_eq!(body["focus"], json!(["plyo", "lower_body"]));
861        assert_eq!(
862            body["filters"]["performance_tests"]["test_types"],
863            json!(["cmj"])
864        );
865        assert_eq!(body["method"], json!("recommended_by_server"));
866        assert_eq!(
867            body["analysis_program"]["sources"],
868            json!(["performance_tests"])
869        );
870    }
871
872    #[test]
873    fn build_run_request_body_supports_positional_objective() {
874        let body = build_run_request_body(
875            None,
876            None,
877            Some("trend of sleep quality".to_string()),
878            None,
879            vec![],
880            None,
881            None,
882            None,
883        )
884        .unwrap();
885        assert_eq!(body["objective"], json!("trend of sleep quality"));
886        assert!(body.get("horizon_days").is_none());
887    }
888
889    #[test]
890    fn build_run_request_body_rejects_missing_input() {
891        let err =
892            build_run_request_body(None, None, None, None, vec![], None, None, None).unwrap_err();
893        assert!(err.contains("Missing analysis objective"));
894    }
895
896    #[test]
897    fn build_run_request_body_rejects_duplicate_inline_objective_sources() {
898        let err = build_run_request_body(
899            None,
900            Some("a".to_string()),
901            Some("b".to_string()),
902            None,
903            vec![],
904            None,
905            None,
906            None,
907        )
908        .unwrap_err();
909        assert!(err.contains("either as positional"));
910    }
911
912    #[test]
913    fn request_file_payload_can_be_read_query_analysis_handoff() {
914        let normalized = super::normalize_analysis_request_file_payload(json!({
915            "analysis_handoff": {
916                "next_request": {
917                    "payload": {
918                        "objective": "Analyze handoff",
919                        "method": "recommended_by_server",
920                        "focus": ["performance_tests"]
921                    }
922                }
923            }
924        }))
925        .unwrap();
926
927        assert_eq!(normalized["objective"], json!("Analyze handoff"));
928        assert_eq!(normalized["method"], json!("recommended_by_server"));
929    }
930
931    #[test]
932    fn build_validate_answer_body_serializes_expected_shape() {
933        let body = build_validate_answer_body(
934            "How has my squat progressed?".to_string(),
935            "Your squat is clearly up 15%.".to_string(),
936        );
937        assert_eq!(body["task_intent"], json!("How has my squat progressed?"));
938        assert_eq!(body["draft_answer"], json!("Your squat is clearly up 15%."));
939    }
940
941    #[test]
942    fn clamp_cli_timeouts_apply_bounds() {
943        assert_eq!(clamp_cli_overall_timeout_ms(None), 90_000);
944        assert_eq!(clamp_cli_overall_timeout_ms(Some(1)), 1_000);
945        assert_eq!(clamp_cli_overall_timeout_ms(Some(999_999)), 300_000);
946        assert_eq!(clamp_cli_poll_interval_ms(None), 1_000);
947        assert_eq!(clamp_cli_poll_interval_ms(Some(1)), 100);
948        assert_eq!(clamp_cli_poll_interval_ms(Some(50_000)), 5_000);
949    }
950
951    #[test]
952    fn analysis_terminal_status_matches_api_contract() {
953        assert!(analysis_job_status_is_terminal("completed"));
954        assert!(analysis_job_status_is_terminal("failed"));
955        assert!(!analysis_job_status_is_terminal("queued"));
956        assert!(!analysis_job_status_is_terminal("processing"));
957    }
958
959    #[test]
960    fn run_response_fallback_detection_requires_timeout_and_non_terminal_job() {
961        let timed_out = serde_json::from_value(json!({
962            "terminal": false,
963            "timed_out": true,
964            "retry_after_ms": 500,
965            "job": { "job_id": "00000000-0000-0000-0000-000000000000", "status": "queued" }
966        }))
967        .unwrap();
968        assert!(run_response_needs_cli_poll_fallback(&timed_out));
969
970        let completed = serde_json::from_value(json!({
971            "terminal": true,
972            "timed_out": false,
973            "job": { "job_id": "00000000-0000-0000-0000-000000000000", "status": "completed" }
974        }))
975        .unwrap();
976        assert!(!run_response_needs_cli_poll_fallback(&completed));
977    }
978
979    #[test]
980    fn parse_cli_job_status_reads_status_field() {
981        let parsed = parse_cli_job_status(&json!({
982            "job_id": "00000000-0000-0000-0000-000000000000",
983            "status": "queued"
984        }))
985        .unwrap();
986        assert_eq!(parsed.status, "queued");
987    }
988
989    #[test]
990    fn run_endpoint_unsupported_status_detection_matches_hard_block_cases() {
991        assert!(is_run_endpoint_unsupported_status(404));
992        assert!(is_run_endpoint_unsupported_status(405));
993        assert!(!is_run_endpoint_unsupported_status(400));
994        assert!(!is_run_endpoint_unsupported_status(401));
995        assert!(!is_run_endpoint_unsupported_status(500));
996    }
997
998    #[test]
999    fn run_endpoint_contract_block_disables_legacy_async_escape() {
1000        let output = build_run_endpoint_contract_block(
1001            404,
1002            json!({
1003                "error": "not_found",
1004            }),
1005        );
1006
1007        assert_eq!(output["error"], json!("agent_mode_blocked"));
1008        assert_eq!(
1009            output["reason_code"],
1010            json!("analysis_run_contract_missing")
1011        );
1012        assert_eq!(output["required_endpoint"], json!("/v1/analysis/jobs/run"));
1013        assert_eq!(output["legacy_fallback_disabled"], json!(true));
1014        assert_eq!(output["status"], json!(404));
1015    }
1016}