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#[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 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
403pub async fn resolve_token(api_url: &str) -> Result<String, Box<dyn std::error::Error>> {
408 if let Ok(key) = std::env::var("KURA_API_KEY") {
410 return Ok(key);
411 }
412
413 if let Some(creds) = load_credentials() {
415 let buffer = chrono::Duration::minutes(5);
417 if Utc::now() + buffer >= creds.expires_at {
418 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
465pub 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 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
594pub 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
605pub 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
695pub 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
716pub 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 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#[cfg(unix)]
742use std::os::unix::fs::OpenOptionsExt;
743
744#[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}