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#[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 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
460pub async fn resolve_token(api_url: &str) -> Result<String, Box<dyn std::error::Error>> {
465 if let Ok(key) = std::env::var("KURA_API_KEY") {
467 return Ok(key);
468 }
469
470 if let Some(creds) = load_credentials() {
472 let buffer = chrono::Duration::minutes(5);
474 if Utc::now() + buffer >= creds.expires_at {
475 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
522pub 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 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
657pub 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
668pub 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
738pub 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
759pub 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 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#[cfg(unix)]
785use std::os::unix::fs::OpenOptionsExt;
786
787#[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}