Skip to main content

kura_cli/
util.rs

1use std::collections::BTreeSet;
2use std::io::Write;
3use std::sync::{LazyLock, Mutex};
4
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7use serde_json::{Value, json};
8
9const KURA_CLIENT_NAME_HEADER: &str = "x-kura-client-name";
10const KURA_CLIENT_VERSION_HEADER: &str = "x-kura-client-version";
11const KURA_CLIENT_INSTALL_CHANNEL_HEADER: &str = "x-kura-client-install-channel";
12const KURA_CLIENT_NOTICE_ACK_HEADER: &str = "x-kura-client-notice-ack";
13const KURA_UPGRADE_SIGNAL_HEADER: &str = "x-kura-upgrade-signal";
14const KURA_UPGRADE_PHASE_HEADER: &str = "x-kura-upgrade-phase";
15const KURA_UPGRADE_ENDPOINT_HEADER: &str = "x-kura-upgrade-endpoint";
16const KURA_UPGRADE_ACTION_HEADER: &str = "x-kura-upgrade-action";
17const KURA_UPGRADE_REASON_HEADER: &str = "x-kura-upgrade-reason";
18const KURA_UPGRADE_DOCS_HEADER: &str = "x-kura-upgrade-docs";
19const KURA_UPGRADE_BOOTSTRAP_TASK_HEADER: &str = "x-kura-upgrade-bootstrap-task";
20const KURA_UPGRADE_BOOTSTRAP_SURFACE_HEADER: &str = "x-kura-upgrade-bootstrap-surface";
21const KURA_UPGRADE_BOOTSTRAP_COMMAND_HEADER: &str = "x-kura-upgrade-bootstrap-command";
22const KURA_CLI_CLIENT_NAME: &str = "kura-cli";
23const KURA_NOTICE_ACK_MAX_IDS: usize = 16;
24
25static PENDING_NOTICE_ACK_IDS: LazyLock<Mutex<BTreeSet<String>>> =
26    LazyLock::new(|| Mutex::new(BTreeSet::new()));
27static CLI_RUNTIME_OPTIONS: LazyLock<Mutex<CliRuntimeOptions>> =
28    LazyLock::new(|| Mutex::new(CliRuntimeOptions::default()));
29
30#[derive(Clone, Copy, Debug, Eq, PartialEq)]
31pub enum CliOutputMode {
32    Json,
33    JsonCompact,
34}
35
36#[derive(Clone, Copy, Debug)]
37pub struct CliRuntimeOptions {
38    pub output_mode: CliOutputMode,
39    pub quiet_stderr: bool,
40    pub dry_run: bool,
41}
42
43impl Default for CliRuntimeOptions {
44    fn default() -> Self {
45        Self {
46            output_mode: CliOutputMode::Json,
47            quiet_stderr: false,
48            dry_run: false,
49        }
50    }
51}
52
53pub fn set_cli_runtime_options(options: CliRuntimeOptions) {
54    let mut current = CLI_RUNTIME_OPTIONS
55        .lock()
56        .unwrap_or_else(|poisoned| poisoned.into_inner());
57    *current = options;
58}
59
60pub fn cli_runtime_options() -> CliRuntimeOptions {
61    *CLI_RUNTIME_OPTIONS
62        .lock()
63        .unwrap_or_else(|poisoned| poisoned.into_inner())
64}
65
66pub fn dry_run_enabled() -> bool {
67    cli_runtime_options().dry_run
68}
69
70pub fn stderr_is_quiet() -> bool {
71    cli_runtime_options().quiet_stderr
72}
73
74pub fn emit_stderr_line(line: &str) {
75    if !stderr_is_quiet() {
76        eprintln!("{line}");
77    }
78}
79
80fn should_compact_output(raw_override: bool) -> bool {
81    raw_override
82        || matches!(
83            cli_runtime_options().output_mode,
84            CliOutputMode::JsonCompact
85        )
86}
87
88pub fn format_json_output(value: &Value, raw_override: bool) -> String {
89    if should_compact_output(raw_override) {
90        serde_json::to_string(value).unwrap_or_else(|_| value.to_string())
91    } else {
92        serde_json::to_string_pretty(value).unwrap_or_else(|_| value.to_string())
93    }
94}
95
96pub fn print_json_stdout(value: &Value) {
97    println!("{}", format_json_output(value, false));
98}
99
100pub fn print_json_stderr(value: &Value) {
101    eprintln!("{}", format_json_output(value, false));
102}
103
104pub fn print_json_stdout_with_raw(value: &Value, raw_override: bool) {
105    println!("{}", format_json_output(value, raw_override));
106}
107
108#[derive(Debug)]
109pub struct RawApiResponse {
110    pub status: u16,
111    pub body: Value,
112    pub cli_upgrade_signal: Option<Value>,
113}
114
115pub fn is_mutating_method(method: &reqwest::Method) -> bool {
116    matches!(
117        *method,
118        reqwest::Method::POST
119            | reqwest::Method::PUT
120            | reqwest::Method::PATCH
121            | reqwest::Method::DELETE
122    )
123}
124
125pub fn emit_dry_run_request(
126    method: &reqwest::Method,
127    api_url: &str,
128    path: &str,
129    token_present: bool,
130    body: Option<&Value>,
131    query: &[(String, String)],
132    headers: &[(String, String)],
133    raw_output: bool,
134    note: Option<&str>,
135) -> i32 {
136    let query_entries: Vec<Value> = query
137        .iter()
138        .map(|(key, value)| json!({ "key": key, "value": value }))
139        .collect();
140    let header_entries: Vec<Value> = headers
141        .iter()
142        .map(|(key, value)| json!({ "key": key, "value": value }))
143        .collect();
144
145    let mut preview = json!({
146        "dry_run": true,
147        "status": "not_executed",
148        "method": method.as_str(),
149        "path": path,
150        "url": format!("{api_url}{path}"),
151        "auth": {
152            "authorization_header_present": token_present
153        },
154        "query": query_entries,
155        "headers": header_entries,
156        "body": body.cloned().unwrap_or(Value::Null)
157    });
158
159    if let Some(note) = note {
160        preview["note"] = json!(note);
161    }
162
163    print_json_stdout_with_raw(&preview, raw_output);
164    0
165}
166
167/// Stored credentials for the CLI
168#[derive(Debug, Serialize, Deserialize)]
169pub struct StoredCredentials {
170    pub api_url: String,
171    pub access_token: String,
172    pub refresh_token: String,
173    pub expires_at: DateTime<Utc>,
174}
175
176#[derive(Deserialize)]
177pub struct TokenResponse {
178    pub access_token: String,
179    pub refresh_token: String,
180    pub expires_in: i64,
181}
182
183pub fn client() -> reqwest::Client {
184    reqwest::Client::new()
185}
186
187fn build_api_url(
188    api_url: &str,
189    path: &str,
190    query: &[(String, String)],
191) -> Result<reqwest::Url, String> {
192    let mut url = reqwest::Url::parse(&format!("{api_url}{path}"))
193        .map_err(|e| format!("Invalid URL: {api_url}{path}: {e}"))?;
194    if !query.is_empty() {
195        let mut params = url.query_pairs_mut();
196        for (key, value) in query {
197            params.append_pair(key, value);
198        }
199    }
200    Ok(url)
201}
202
203fn cli_install_channel() -> String {
204    std::env::var("KURA_CLI_INSTALL_CHANNEL")
205        .ok()
206        .map(|value| value.trim().to_ascii_lowercase())
207        .filter(|value| !value.is_empty())
208        .unwrap_or_else(|| "cargo".to_string())
209}
210
211fn with_cli_client_headers(mut req: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
212    req = req.header(KURA_CLIENT_NAME_HEADER, KURA_CLI_CLIENT_NAME);
213    req = req.header(KURA_CLIENT_VERSION_HEADER, env!("CARGO_PKG_VERSION"));
214    req = req.header(KURA_CLIENT_INSTALL_CHANNEL_HEADER, cli_install_channel());
215    if let Some(ack_header_value) = pending_notice_ack_header_value() {
216        req = req.header(KURA_CLIENT_NOTICE_ACK_HEADER, ack_header_value);
217    }
218    req
219}
220
221fn parse_user_notice_ack_ids(body: &serde_json::Value) -> Vec<String> {
222    body.get("user_notices")
223        .and_then(|value| value.as_array())
224        .map(|items| {
225            items
226                .iter()
227                .filter_map(|item| item.as_object())
228                .filter_map(|item| item.get("notice_id").and_then(|value| value.as_str()))
229                .map(str::trim)
230                .filter(|value| is_valid_notice_ack_id(value))
231                .map(ToString::to_string)
232                .collect::<Vec<_>>()
233        })
234        .unwrap_or_default()
235}
236
237fn queue_user_notice_acks(body: &serde_json::Value) {
238    let notice_ids = parse_user_notice_ack_ids(body);
239    if notice_ids.is_empty() {
240        return;
241    }
242    let mut pending = PENDING_NOTICE_ACK_IDS
243        .lock()
244        .unwrap_or_else(|poisoned| poisoned.into_inner());
245    for notice_id in notice_ids {
246        if pending.len() >= KURA_NOTICE_ACK_MAX_IDS {
247            break;
248        }
249        pending.insert(notice_id);
250    }
251}
252
253fn pending_notice_ack_header_value() -> Option<String> {
254    let pending = PENDING_NOTICE_ACK_IDS
255        .lock()
256        .unwrap_or_else(|poisoned| poisoned.into_inner());
257    if pending.is_empty() {
258        return None;
259    }
260    let value = pending
261        .iter()
262        .take(KURA_NOTICE_ACK_MAX_IDS)
263        .cloned()
264        .collect::<Vec<_>>()
265        .join(",");
266    if value.is_empty() { None } else { Some(value) }
267}
268
269fn is_valid_notice_ack_id(raw: &str) -> bool {
270    let trimmed = raw.trim();
271    if trimmed.is_empty() || trimmed.len() > 200 {
272        return false;
273    }
274    trimmed
275        .chars()
276        .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, ':' | '_' | '-' | '.'))
277}
278
279fn extract_user_notice_lines(body: &serde_json::Value) -> Vec<String> {
280    let notices = body
281        .get("user_notices")
282        .and_then(|value| value.as_array())
283        .cloned()
284        .unwrap_or_default();
285
286    let mut lines = Vec::new();
287    for notice in notices {
288        let Some(obj) = notice.as_object() else {
289            continue;
290        };
291        let message = obj
292            .get("message_short")
293            .and_then(|value| value.as_str())
294            .map(str::trim)
295            .filter(|value| !value.is_empty());
296        let cmd = obj
297            .get("upgrade_command")
298            .and_then(|value| value.as_str())
299            .map(str::trim)
300            .filter(|value| !value.is_empty());
301        let docs_hint = obj
302            .get("docs_hint")
303            .and_then(|value| value.as_str())
304            .map(str::trim)
305            .filter(|value| !value.is_empty());
306
307        let mut line = String::from("[kura notice]");
308        if let Some(message) = message {
309            line.push(' ');
310            line.push_str(message);
311        }
312        if let Some(cmd) = cmd {
313            line.push_str(" Update: ");
314            line.push_str(cmd);
315        } else if let Some(docs_hint) = docs_hint {
316            line.push(' ');
317            line.push_str(docs_hint);
318        }
319        if line != "[kura notice]" {
320            lines.push(line);
321        }
322    }
323    lines
324}
325
326fn header_value(headers: &reqwest::header::HeaderMap, key: &str) -> Option<String> {
327    headers
328        .get(key)
329        .and_then(|value| value.to_str().ok())
330        .map(str::trim)
331        .filter(|value| !value.is_empty())
332        .map(str::to_string)
333}
334
335fn extract_cli_upgrade_signal(headers: &reqwest::header::HeaderMap) -> Option<Value> {
336    let signal_id = header_value(headers, KURA_UPGRADE_SIGNAL_HEADER)?;
337    let mut payload = serde_json::Map::new();
338    payload.insert("signal_id".to_string(), json!(signal_id));
339    if let Some(value) = header_value(headers, KURA_UPGRADE_PHASE_HEADER) {
340        payload.insert("compatibility_phase".to_string(), json!(value));
341    }
342    if let Some(value) = header_value(headers, KURA_UPGRADE_ENDPOINT_HEADER) {
343        payload.insert("recommended_endpoint".to_string(), json!(value));
344    }
345    if let Some(value) = header_value(headers, KURA_UPGRADE_ACTION_HEADER) {
346        payload.insert("action_hint".to_string(), json!(value));
347    }
348    if let Some(value) = header_value(headers, KURA_UPGRADE_REASON_HEADER) {
349        payload.insert("reason".to_string(), json!(value));
350    }
351    if let Some(value) = header_value(headers, KURA_UPGRADE_DOCS_HEADER) {
352        payload.insert("docs".to_string(), json!(value));
353    }
354    if let Some(value) = header_value(headers, KURA_UPGRADE_BOOTSTRAP_TASK_HEADER) {
355        payload.insert("bootstrap_task".to_string(), json!(value));
356    }
357    if let Some(value) = header_value(headers, KURA_UPGRADE_BOOTSTRAP_SURFACE_HEADER) {
358        payload.insert("bootstrap_surface".to_string(), json!(value));
359    }
360    if let Some(value) = header_value(headers, KURA_UPGRADE_BOOTSTRAP_COMMAND_HEADER) {
361        payload.insert("bootstrap_command".to_string(), json!(value));
362    }
363    Some(Value::Object(payload))
364}
365
366fn attach_cli_upgrade_signal(body: Value, upgrade_signal: Option<&Value>) -> Value {
367    let Some(upgrade_signal) = upgrade_signal.cloned() else {
368        return body;
369    };
370    match body {
371        Value::Object(mut object) => {
372            object.insert("cli_upgrade_signal".to_string(), upgrade_signal);
373            Value::Object(object)
374        }
375        other => json!({
376            "body": other,
377            "cli_upgrade_signal": upgrade_signal,
378        }),
379    }
380}
381
382pub fn merge_cli_upgrade_signal(body: Value, cli_upgrade_signal: Option<Value>) -> Value {
383    attach_cli_upgrade_signal(body, cli_upgrade_signal.as_ref())
384}
385
386pub fn env_flag_enabled(name: &str) -> bool {
387    std::env::var(name)
388        .ok()
389        .map(|value| {
390            let normalized = value.trim().to_ascii_lowercase();
391            matches!(normalized.as_str(), "1" | "true" | "yes" | "on")
392        })
393        .unwrap_or(false)
394}
395
396pub fn admin_surface_enabled() -> bool {
397    env_flag_enabled("KURA_ENABLE_ADMIN_SURFACE")
398}
399
400pub fn is_admin_api_path(path: &str) -> bool {
401    let trimmed = path.trim();
402    if trimmed.is_empty() {
403        return false;
404    }
405
406    let normalized = if trimmed.starts_with('/') {
407        trimmed.to_ascii_lowercase()
408    } else {
409        format!("/{}", trimmed.to_ascii_lowercase())
410    };
411
412    normalized == "/v1/admin" || normalized.starts_with("/v1/admin/")
413}
414
415pub fn exit_error(message: &str, docs_hint: Option<&str>) -> ! {
416    let mut err = json!({
417        "error": "cli_error",
418        "message": message
419    });
420    if let Some(hint) = docs_hint {
421        err["docs_hint"] = json!(hint);
422    }
423    print_json_stderr(&err);
424    std::process::exit(1);
425}
426
427pub fn config_path() -> std::path::PathBuf {
428    let config_dir = dirs::config_dir()
429        .unwrap_or_else(|| std::path::PathBuf::from("."))
430        .join("kura");
431    config_dir.join("config.json")
432}
433
434pub fn load_credentials() -> Option<StoredCredentials> {
435    let path = config_path();
436    let data = std::fs::read_to_string(&path).ok()?;
437    serde_json::from_str(&data).ok()
438}
439
440pub fn save_credentials(creds: &StoredCredentials) -> Result<(), Box<dyn std::error::Error>> {
441    let path = config_path();
442    if let Some(parent) = path.parent() {
443        std::fs::create_dir_all(parent)?;
444    }
445
446    let data = serde_json::to_string_pretty(creds)?;
447
448    // Write with restricted permissions (0o600)
449    let mut file = std::fs::OpenOptions::new()
450        .write(true)
451        .create(true)
452        .truncate(true)
453        .mode(0o600)
454        .open(&path)?;
455    file.write_all(data.as_bytes())?;
456
457    Ok(())
458}
459
460/// Resolve a Bearer token for API requests (priority order):
461/// 1. KURA_API_KEY env var
462/// 2. ~/.config/kura/config.json (with auto-refresh)
463/// 3. Error
464pub async fn resolve_token(api_url: &str) -> Result<String, Box<dyn std::error::Error>> {
465    // 1. Environment variable
466    if let Ok(key) = std::env::var("KURA_API_KEY") {
467        return Ok(key);
468    }
469
470    // 2. Stored credentials
471    if let Some(creds) = load_credentials() {
472        // Check if access token needs refresh (5-min buffer)
473        let buffer = chrono::Duration::minutes(5);
474        if Utc::now() + buffer >= creds.expires_at {
475            // Try to refresh
476            match refresh_stored_token(api_url, &creds).await {
477                Ok(new_creds) => {
478                    save_credentials(&new_creds)?;
479                    return Ok(new_creds.access_token);
480                }
481                Err(_) => {
482                    return Err(
483                        "Access token expired and refresh failed. Run `kura login` again.".into(),
484                    );
485                }
486            }
487        }
488        return Ok(creds.access_token);
489    }
490
491    Err("No credentials found. Run `kura login` or set KURA_API_KEY.".into())
492}
493
494async fn refresh_stored_token(
495    api_url: &str,
496    creds: &StoredCredentials,
497) -> Result<StoredCredentials, Box<dyn std::error::Error>> {
498    let resp = client()
499        .post(format!("{api_url}/v1/auth/token"))
500        .json(&json!({
501            "grant_type": "refresh_token",
502            "refresh_token": creds.refresh_token,
503            "client_id": "kura-cli"
504        }))
505        .send()
506        .await?;
507
508    if !resp.status().is_success() {
509        let body: serde_json::Value = resp.json().await?;
510        return Err(format!("Token refresh failed: {}", body).into());
511    }
512
513    let token_resp: TokenResponse = resp.json().await?;
514    Ok(StoredCredentials {
515        api_url: creds.api_url.clone(),
516        access_token: token_resp.access_token,
517        refresh_token: token_resp.refresh_token,
518        expires_at: Utc::now() + chrono::Duration::seconds(token_resp.expires_in),
519    })
520}
521
522/// Execute an authenticated API request, print response, exit with structured code.
523///
524/// Exit codes: 0=success (2xx), 1=client error (4xx), 2=server error (5xx),
525///             3=connection error, 4=usage error
526pub async fn api_request(
527    api_url: &str,
528    method: reqwest::Method,
529    path: &str,
530    token: Option<&str>,
531    body: Option<serde_json::Value>,
532    query: &[(String, String)],
533    extra_headers: &[(String, String)],
534    raw: bool,
535    include: bool,
536) -> i32 {
537    let url = match build_api_url(api_url, path, query) {
538        Ok(url) => url,
539        Err(message) => {
540            let err = json!({
541                "error": "cli_error",
542                "message": message
543            });
544            print_json_stderr(&err);
545            return 4;
546        }
547    };
548
549    if dry_run_enabled() && is_mutating_method(&method) {
550        return emit_dry_run_request(
551            &method,
552            api_url,
553            path,
554            token.is_some(),
555            body.as_ref(),
556            query,
557            extra_headers,
558            raw,
559            None,
560        );
561    }
562
563    let mut req = with_cli_client_headers(client().request(method, url));
564
565    if let Some(t) = token {
566        req = req.header("Authorization", format!("Bearer {t}"));
567    }
568
569    for (k, v) in extra_headers {
570        req = req.header(k.as_str(), v.as_str());
571    }
572
573    if let Some(b) = body {
574        req = req.json(&b);
575    }
576
577    let resp = match req.send().await {
578        Ok(r) => r,
579        Err(e) => {
580            let err = json!({
581                "error": "connection_error",
582                "message": format!("{e}"),
583                "docs_hint": "Is the API server running? Check KURA_API_URL."
584            });
585            print_json_stderr(&err);
586            return 3;
587        }
588    };
589
590    let status = resp.status().as_u16();
591    let exit_code = match status {
592        200..=299 => 0,
593        400..=499 => 1,
594        _ => 2,
595    };
596    let cli_upgrade_signal = extract_cli_upgrade_signal(resp.headers());
597
598    // Collect headers before consuming response
599    let headers: serde_json::Map<String, serde_json::Value> = if include {
600        resp.headers()
601            .iter()
602            .map(|(k, v)| (k.to_string(), json!(v.to_str().unwrap_or("<binary>"))))
603            .collect()
604    } else {
605        serde_json::Map::new()
606    };
607
608    let resp_body: serde_json::Value = match resp.bytes().await {
609        Ok(bytes) => {
610            if bytes.is_empty() {
611                serde_json::Value::Null
612            } else {
613                serde_json::from_slice(&bytes).unwrap_or_else(|_| {
614                    serde_json::Value::String(String::from_utf8_lossy(&bytes).to_string())
615                })
616            }
617        }
618        Err(e) => json!({"raw_error": format!("Failed to read response body: {e}")}),
619    };
620
621    let user_notice_lines = if exit_code == 0 {
622        queue_user_notice_acks(&resp_body);
623        extract_user_notice_lines(&resp_body)
624    } else {
625        Vec::new()
626    };
627
628    let output = if include {
629        let mut output = json!({
630            "status": status,
631            "headers": headers,
632            "body": resp_body
633        });
634        if let Some(upgrade_signal) = cli_upgrade_signal {
635            output["cli_upgrade_signal"] = upgrade_signal;
636        }
637        output
638    } else {
639        attach_cli_upgrade_signal(resp_body, cli_upgrade_signal.as_ref())
640    };
641
642    let formatted = format_json_output(&output, raw);
643
644    for line in user_notice_lines {
645        emit_stderr_line(&line);
646    }
647
648    if exit_code == 0 {
649        println!("{formatted}");
650    } else {
651        eprintln!("{formatted}");
652    }
653
654    exit_code
655}
656
657/// Execute a raw API request and return the response (no printing).
658/// Used by doctor and other commands that need to inspect the response.
659pub async fn raw_api_request(
660    api_url: &str,
661    method: reqwest::Method,
662    path: &str,
663    token: Option<&str>,
664) -> Result<(u16, serde_json::Value), String> {
665    raw_api_request_with_query(api_url, method, path, token, &[]).await
666}
667
668/// Execute a raw API request with query parameters and return the response (no printing).
669pub async fn raw_api_request_with_query(
670    api_url: &str,
671    method: reqwest::Method,
672    path: &str,
673    token: Option<&str>,
674    query: &[(String, String)],
675) -> Result<(u16, serde_json::Value), String> {
676    let url = build_api_url(api_url, path, query)?;
677
678    let mut req = with_cli_client_headers(client().request(method, url));
679    if let Some(t) = token {
680        req = req.header("Authorization", format!("Bearer {t}"));
681    }
682
683    let resp = req.send().await.map_err(|e| format!("{e}"))?;
684    let status = resp.status().as_u16();
685    let body: serde_json::Value = resp
686        .json()
687        .await
688        .unwrap_or(json!({"error": "non-json response"}));
689    if (200..=299).contains(&status) {
690        queue_user_notice_acks(&body);
691    }
692
693    Ok((status, body))
694}
695
696pub async fn raw_api_request_json(
697    api_url: &str,
698    method: reqwest::Method,
699    path: &str,
700    token: Option<&str>,
701    body: Option<Value>,
702    query: &[(String, String)],
703    extra_headers: &[(String, String)],
704) -> Result<RawApiResponse, String> {
705    let url = build_api_url(api_url, path, query)?;
706
707    let mut req = with_cli_client_headers(client().request(method, url));
708    if let Some(t) = token {
709        req = req.header("Authorization", format!("Bearer {t}"));
710    }
711
712    for (k, v) in extra_headers {
713        req = req.header(k.as_str(), v.as_str());
714    }
715
716    if let Some(body) = body {
717        req = req.json(&body);
718    }
719
720    let resp = req.send().await.map_err(|e| format!("{e}"))?;
721    let cli_upgrade_signal = extract_cli_upgrade_signal(resp.headers());
722    let status = resp.status().as_u16();
723    let body: Value = resp
724        .json()
725        .await
726        .unwrap_or_else(|_| json!({"error": "non-json response"}));
727    if (200..=299).contains(&status) {
728        queue_user_notice_acks(&body);
729    }
730
731    Ok(RawApiResponse {
732        status,
733        body,
734        cli_upgrade_signal,
735    })
736}
737
738/// Check if auth is configured (without making a request).
739/// Returns (method_name, detail) or None.
740pub fn check_auth_configured() -> Option<(&'static str, String)> {
741    if let Ok(key) = std::env::var("KURA_API_KEY") {
742        let prefix = if key.len() > 12 { &key[..12] } else { &key };
743        return Some(("api_key (env)", format!("{prefix}...")));
744    }
745
746    if let Some(creds) = load_credentials() {
747        let expired = chrono::Utc::now() >= creds.expires_at;
748        let detail = if expired {
749            format!("expired at {}", creds.expires_at)
750        } else {
751            format!("valid until {}", creds.expires_at)
752        };
753        return Some(("oauth_token (stored)", detail));
754    }
755
756    None
757}
758
759/// Read JSON from a file path or stdin (when path is "-").
760pub fn read_json_from_file(path: &str) -> Result<serde_json::Value, String> {
761    let raw = if path == "-" {
762        let mut buf = String::new();
763        std::io::stdin()
764            .read_line(&mut buf)
765            .map_err(|e| format!("Failed to read stdin: {e}"))?;
766        // Read remaining lines too
767        let mut rest = String::new();
768        while std::io::stdin()
769            .read_line(&mut rest)
770            .map_err(|e| format!("Failed to read stdin: {e}"))?
771            > 0
772        {
773            buf.push_str(&rest);
774            rest.clear();
775        }
776        buf
777    } else {
778        std::fs::read_to_string(path).map_err(|e| format!("Failed to read file '{path}': {e}"))?
779    };
780    serde_json::from_str(&raw).map_err(|e| format!("Invalid JSON in '{path}': {e}"))
781}
782
783// Unix-specific imports for file permissions
784#[cfg(unix)]
785use std::os::unix::fs::OpenOptionsExt;
786
787// No-op on non-unix (won't compile for Windows without this)
788#[cfg(not(unix))]
789trait OpenOptionsExt {
790    fn mode(&mut self, _mode: u32) -> &mut Self;
791}
792
793#[cfg(not(unix))]
794impl OpenOptionsExt for std::fs::OpenOptions {
795    fn mode(&mut self, _mode: u32) -> &mut Self {
796        self
797    }
798}
799
800#[cfg(test)]
801mod tests {
802    use super::{
803        attach_cli_upgrade_signal, extract_cli_upgrade_signal, extract_user_notice_lines,
804        is_admin_api_path, parse_user_notice_ack_ids, pending_notice_ack_header_value,
805        queue_user_notice_acks,
806    };
807    use reqwest::header::{HeaderMap, HeaderValue};
808    use serde_json::json;
809
810    #[test]
811    fn admin_path_detection_matches_v1_admin_namespace_only() {
812        assert!(is_admin_api_path("/v1/admin"));
813        assert!(is_admin_api_path("/v1/admin/invites"));
814        assert!(is_admin_api_path("v1/admin/security/kill-switch"));
815        assert!(!is_admin_api_path("/v1/agent/context"));
816        assert!(!is_admin_api_path("/health"));
817    }
818
819    #[test]
820    fn extract_user_notice_lines_reads_message_and_upgrade_command() {
821        let current_version = env!("CARGO_PKG_VERSION");
822        let body = json!({
823            "user_notices": [{
824                "kind": "client_update",
825                "message_short": format!("Kura CLI update available ({}).", current_version),
826                "upgrade_command": "cargo install kura-cli --locked --force"
827            }]
828        });
829        let lines = extract_user_notice_lines(&body);
830        assert_eq!(lines.len(), 1);
831        assert!(lines[0].contains("[kura notice]"));
832        assert!(lines[0].contains("Kura CLI update available"));
833        assert!(lines[0].contains("cargo install kura-cli --locked --force"));
834    }
835
836    #[test]
837    fn extract_user_notice_lines_returns_empty_when_absent() {
838        let lines = extract_user_notice_lines(&json!({"ok": true}));
839        assert!(lines.is_empty());
840    }
841
842    #[test]
843    fn parse_user_notice_ack_ids_collects_notice_ids() {
844        let body = json!({
845            "user_notices": [
846                {"notice_id": "client_update:kura-cli:0.1.5"},
847                {"notice_id": "client_update:kura-mcp:0.1.5"}
848            ]
849        });
850        let ids = parse_user_notice_ack_ids(&body);
851        assert_eq!(
852            ids,
853            vec![
854                "client_update:kura-cli:0.1.5".to_string(),
855                "client_update:kura-mcp:0.1.5".to_string()
856            ]
857        );
858    }
859
860    #[test]
861    fn queue_user_notice_acks_makes_ack_header_available() {
862        super::PENDING_NOTICE_ACK_IDS
863            .lock()
864            .unwrap_or_else(|poisoned| poisoned.into_inner())
865            .clear();
866        queue_user_notice_acks(&json!({
867            "user_notices": [{"notice_id": "client_update:kura-cli:0.1.5"}]
868        }));
869        let ack_header = pending_notice_ack_header_value();
870        assert_eq!(ack_header.as_deref(), Some("client_update:kura-cli:0.1.5"));
871    }
872
873    #[test]
874    fn extract_cli_upgrade_signal_reads_bootstrap_fields() {
875        let mut headers = HeaderMap::new();
876        headers.insert(
877            super::KURA_UPGRADE_SIGNAL_HEADER,
878            HeaderValue::from_static("legacy_logging_write_contract"),
879        );
880        headers.insert(
881            super::KURA_UPGRADE_ENDPOINT_HEADER,
882            HeaderValue::from_static("/v3/agent/training"),
883        );
884        headers.insert(
885            super::KURA_UPGRADE_BOOTSTRAP_TASK_HEADER,
886            HeaderValue::from_static("logging"),
887        );
888        headers.insert(
889            super::KURA_UPGRADE_BOOTSTRAP_SURFACE_HEADER,
890            HeaderValue::from_static("/v1/agent/capabilities"),
891        );
892        headers.insert(
893            super::KURA_UPGRADE_BOOTSTRAP_COMMAND_HEADER,
894            HeaderValue::from_static("kura agent logging-bootstrap"),
895        );
896
897        let signal = extract_cli_upgrade_signal(&headers).expect("upgrade signal");
898        assert_eq!(signal["signal_id"], json!("legacy_logging_write_contract"));
899        assert_eq!(signal["recommended_endpoint"], json!("/v3/agent/training"));
900        assert_eq!(signal["bootstrap_task"], json!("logging"));
901        assert_eq!(signal["bootstrap_surface"], json!("/v1/agent/capabilities"));
902        assert_eq!(
903            signal["bootstrap_command"],
904            json!("kura agent logging-bootstrap")
905        );
906    }
907
908    #[test]
909    fn attach_cli_upgrade_signal_merges_into_object_body() {
910        let output = attach_cli_upgrade_signal(
911            json!({"event_id": "abc"}),
912            Some(&json!({"signal_id": "legacy_logging_write_contract"})),
913        );
914        assert_eq!(output["event_id"], json!("abc"));
915        assert_eq!(
916            output["cli_upgrade_signal"]["signal_id"],
917            json!("legacy_logging_write_contract")
918        );
919    }
920
921    #[test]
922    fn attach_cli_upgrade_signal_wraps_non_object_body() {
923        let output = attach_cli_upgrade_signal(
924            json!(["a", "b"]),
925            Some(&json!({"signal_id": "legacy_projection_read_contract"})),
926        );
927        assert_eq!(output["body"], json!(["a", "b"]));
928        assert_eq!(
929            output["cli_upgrade_signal"]["signal_id"],
930            json!("legacy_projection_read_contract")
931        );
932    }
933}