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