use std::env;
use std::io::{self, IsTerminal, Write};
use std::sync::OnceLock;
use serde::Serialize;
use serde_json::Value;
use sylphx_sdk_core::SdkError;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ColorMode {
Auto,
Always,
Never,
}
static COLOR_MODE: OnceLock<ColorMode> = OnceLock::new();
pub fn init_color(mode: ColorMode) {
let _ = COLOR_MODE.set(mode);
}
fn color_enabled() -> bool {
match *COLOR_MODE.get().unwrap_or(&ColorMode::Auto) {
ColorMode::Always => true,
ColorMode::Never => false,
ColorMode::Auto => {
if env::var_os("NO_COLOR").is_some() {
return false;
}
if matches!(
env::var("CLICOLOR").ok().as_deref(),
Some("0")
) {
return false;
}
if matches!(
env::var("CLICOLOR_FORCE").ok().as_deref(),
Some(v) if v != "0"
) {
return true;
}
io::stdout().is_terminal()
}
}
}
const RESET: &str = "\x1b[0m";
const BOLD: &str = "\x1b[1m";
const DIM: &str = "\x1b[2m";
const RED: &str = "\x1b[31m";
const GREEN: &str = "\x1b[32m";
const YELLOW: &str = "\x1b[33m";
const BLUE: &str = "\x1b[34m";
const MAGENTA: &str = "\x1b[35m";
const CYAN: &str = "\x1b[36m";
const BRIGHT_BLACK: &str = "\x1b[90m";
fn paint(code: &str, text: &str) -> String {
if color_enabled() {
format!("{code}{text}{RESET}")
} else {
text.to_string()
}
}
pub fn bold(text: &str) -> String {
paint(BOLD, text)
}
pub fn dim(text: &str) -> String {
paint(DIM, text)
}
pub fn green(text: &str) -> String {
paint(GREEN, text)
}
pub fn yellow(text: &str) -> String {
paint(YELLOW, text)
}
pub fn red(text: &str) -> String {
paint(RED, text)
}
#[allow(dead_code)]
pub fn blue(text: &str) -> String {
paint(BLUE, text)
}
pub fn cyan(text: &str) -> String {
paint(CYAN, text)
}
#[allow(dead_code)]
pub fn magenta(text: &str) -> String {
paint(MAGENTA, text)
}
pub fn muted(text: &str) -> String {
paint(BRIGHT_BLACK, text)
}
pub fn mark_pass() -> String {
green("✓")
}
pub fn mark_warn() -> String {
yellow("!")
}
pub fn mark_fail() -> String {
red("✗")
}
pub fn mark_info() -> String {
cyan("•")
}
pub fn ok_line(msg: &str) {
println!("{} {msg}", mark_pass());
}
#[allow(dead_code)]
pub fn warn_line(msg: &str) {
println!("{} {msg}", mark_warn());
}
#[allow(dead_code)]
pub fn fail_line(msg: &str) {
eprintln!("{} {msg}", mark_fail());
}
#[allow(dead_code)]
pub fn info_line(msg: &str) {
println!("{} {msg}", mark_info());
}
pub fn section(title: &str) {
println!("{}", bold(title));
}
pub fn kv(key: &str, value: impl AsRef<str>) {
let k = format!("{key:<12}");
println!(" {} {}", muted(&k), value.as_ref());
}
pub fn status_badge(status: &str) -> String {
let s = status.trim();
let lower = s.to_ascii_lowercase();
match lower.as_str() {
"ok" | "healthy" | "up" | "running" | "ready" | "active" | "succeeded"
| "success" | "live" | "complete" | "completed" | "enabled" => green(s),
"warn" | "warning" | "degraded" | "pending" | "deploying" | "building"
| "progressing" | "waiting" | "queued" | "in_progress" | "rolling" => yellow(s),
"fail" | "failed" | "error" | "unhealthy" | "down" | "crashed" | "cancelled"
| "canceled" | "timeout" | "disabled" | "deleted" => red(s),
"unknown" | "idle" | "none" | "-" => muted(s),
_ => cyan(s),
}
}
pub fn print_out<T: Serialize>(json: bool, value: &T, human: impl FnOnce()) {
if json {
println!(
"{}",
serde_json::to_string_pretty(value).unwrap_or_else(|_| "{}".into())
);
} else {
human();
}
}
#[allow(dead_code)]
pub fn emit_json_or<T: Serialize>(
json: bool,
value: &T,
human: impl FnOnce(),
) -> anyhow::Result<()> {
print_out(json, value, human);
Ok(())
}
pub fn print_table(headers: &[&str], rows: &[Vec<String>]) {
if rows.is_empty() {
println!("{}", muted("(none)"));
return;
}
let cols = headers.len();
let mut widths: Vec<usize> = headers.iter().map(|h| visible_width(h)).collect();
for row in rows {
for (i, cell) in row.iter().enumerate().take(cols) {
widths[i] = widths[i].max(visible_width(cell));
}
}
let mut header_line = String::new();
for (i, h) in headers.iter().enumerate() {
if i > 0 {
header_line.push_str(" ");
}
header_line.push_str(&pad_visible(h, widths[i]));
}
println!("{}", bold(&header_line));
let mut sep = String::new();
for (i, w) in widths.iter().enumerate() {
if i > 0 {
sep.push_str(" ");
}
sep.push_str(&"─".repeat(*w));
}
println!("{}", muted(&sep));
for row in rows {
let mut line = String::new();
for i in 0..cols {
if i > 0 {
line.push_str(" ");
}
let cell = row.get(i).map(|s| s.as_str()).unwrap_or("");
line.push_str(&pad_visible(cell, widths[i]));
}
println!("{line}");
}
}
pub fn print_id_slug_name(rows: &[(String, String, String)]) {
let table_rows: Vec<Vec<String>> = rows
.iter()
.map(|(id, slug, name)| vec![id.clone(), slug.clone(), name.clone()])
.collect();
print_table(&["ID", "SLUG", "NAME"], &table_rows);
}
pub fn print_json_human(value: &Value) {
match value {
Value::Array(arr) => print_value_rows(arr),
Value::Object(map) => {
if let Some(Value::Array(arr)) = map.get("data") {
print_value_rows(arr);
if let Some(n) = map.get("count").and_then(|v| v.as_u64()) {
println!("{}", muted(&format!("{n} total")));
} else if !arr.is_empty() {
println!("{}", muted(&format!("{} row(s)", arr.len())));
}
} else {
print_object_kv(value);
}
}
other => println!("{}", serde_json::to_string_pretty(other).unwrap_or_default()),
}
}
fn print_value_rows(arr: &[Value]) {
if arr.is_empty() {
println!("{}", muted("(none)"));
return;
}
let prefer = [
"id",
"name",
"slug",
"status",
"kind",
"key",
"domain",
"enabled",
"envType",
"env_type",
"region",
];
let mut headers: Vec<String> = Vec::new();
for key in prefer {
if arr.iter().any(|v| v.get(key).is_some()) {
headers.push(key.to_string());
}
}
if headers.is_empty() {
if let Some(Value::Object(m)) = arr.first() {
headers = m.keys().take(5).cloned().collect();
}
}
if headers.is_empty() {
for v in arr {
println!("{}", serde_json::to_string(v).unwrap_or_default());
}
return;
}
let rows: Vec<Vec<String>> = arr
.iter()
.map(|item| {
headers
.iter()
.map(|h| {
let raw = item.get(h).map(format_cell).unwrap_or_else(|| "-".into());
if h == "status" || h == "enabled" {
raw
} else {
raw
}
})
.collect()
})
.collect();
let cols = headers.len();
let mut widths: Vec<usize> = headers.iter().map(|h| visible_width(h)).collect();
for row in &rows {
for (i, cell) in row.iter().enumerate().take(cols) {
widths[i] = widths[i].max(visible_width(cell));
}
}
let mut header_line = String::new();
for (i, h) in headers.iter().enumerate() {
if i > 0 {
header_line.push_str(" ");
}
header_line.push_str(&pad_visible(&h.to_ascii_uppercase(), widths[i]));
}
println!("{}", bold(&header_line));
let mut sep = String::new();
for (i, w) in widths.iter().enumerate() {
if i > 0 {
sep.push_str(" ");
}
sep.push_str(&"─".repeat(*w));
}
println!("{}", muted(&sep));
for (row_idx, row) in rows.iter().enumerate() {
let mut line = String::new();
for i in 0..cols {
if i > 0 {
line.push_str(" ");
}
let cell = row.get(i).map(|s| s.as_str()).unwrap_or("-");
let h = headers[i].as_str();
let display = if h == "status" {
status_badge(cell)
} else if h == "enabled" {
match cell {
"true" => green("true"),
"false" => muted("false"),
other => other.to_string(),
}
} else if h == "id" {
muted(cell)
} else {
cell.to_string()
};
let pad = widths[i].saturating_sub(visible_width(cell));
line.push_str(&display);
line.push_str(&" ".repeat(pad));
let _ = row_idx;
}
println!("{line}");
}
}
fn print_object_kv(value: &Value) {
if let Value::Object(map) = value {
let priority = [
"status",
"id",
"projectId",
"project_id",
"name",
"slug",
"serviceCount",
"environmentCount",
"deploymentId",
"lastDeployedAt",
];
let mut keys: Vec<&String> = Vec::new();
for p in priority {
if let Some(k) = map.keys().find(|k| k.as_str() == p) {
keys.push(k);
}
}
let mut rest: Vec<&String> = map
.keys()
.filter(|k| !keys.iter().any(|x| *x == *k))
.collect();
rest.sort();
keys.extend(rest);
for k in keys {
let v = format_cell(&map[k]);
let key = format!("{k:<22}");
let display = if k == "status" {
status_badge(&v)
} else {
v
};
println!(" {} {}", muted(&key), display);
}
} else {
println!("{}", serde_json::to_string_pretty(value).unwrap_or_default());
}
}
fn format_cell(v: &Value) -> String {
match v {
Value::Null => "-".into(),
Value::Bool(b) => b.to_string(),
Value::Number(n) => n.to_string(),
Value::String(s) => {
if s.chars().count() > 48 {
let mut out: String = s.chars().take(45).collect();
out.push('…');
out
} else {
s.clone()
}
}
Value::Array(a) => format!("[{}]", a.len()),
Value::Object(_) => "{…}".into(),
}
}
pub fn print_doctor_rows(
rows: &[(/*status*/ &str, /*title*/ &str, /*detail*/ &str, /*fix*/ Option<&str>)],
) {
section("sylphx doctor");
for (status, title, detail, fix) in rows {
let mark = match *status {
"pass" => mark_pass(),
"warn" => mark_warn(),
"fail" => mark_fail(),
_ => mark_info(),
};
let detail_short = truncate_detail(detail, 120);
println!(" {mark} {} — {}", bold(title), detail_short);
if let Some(f) = fix {
println!(" {} {}", muted("fix:"), cyan(f));
}
}
}
fn truncate_detail(s: &str, max: usize) -> String {
if let Some(idx) = s.find("; body=") {
let head = &s[..idx];
return truncate_chars(head, max);
}
if let Some(idx) = s.find(" body={") {
let head = &s[..idx];
return truncate_chars(head, max);
}
truncate_chars(s, max)
}
fn truncate_chars(s: &str, max: usize) -> String {
let count = s.chars().count();
if count <= max {
return s.to_string();
}
let mut out: String = s.chars().take(max.saturating_sub(1)).collect();
out.push('…');
out
}
fn strip_ansi(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c == '\u{1b}' {
if chars.peek() == Some(&'[') {
chars.next();
for x in chars.by_ref() {
if x.is_ascii_alphabetic() {
break;
}
}
}
} else {
out.push(c);
}
}
out
}
fn visible_width(s: &str) -> usize {
strip_ansi(s).chars().count()
}
fn pad_visible(s: &str, width: usize) -> String {
let w = visible_width(s);
if w >= width {
s.to_string()
} else {
format!("{s}{}", " ".repeat(width - w))
}
}
pub fn map_sdk_err(err: SdkError) -> anyhow::Error {
match &err {
SdkError::Api {
status: 404,
code,
message,
} if message.contains("/whoami") || message.contains("\"path\":\"/whoami\"") => {
anyhow::anyhow!(
"api 404: {code}: Management whoami route not found.\n\
\n\
Likely cause: API base URL is missing `/v1`\n\
wrong: https://api.sylphx.com/whoami\n\
right: https://api.sylphx.com/v1/whoami\n\
\n\
Fix:\n\
• sylphx doctor\n\
• sylphx login --token \"$SYLPHX_TOKEN\" --api-url https://api.sylphx.com/v1\n\
• or: export SYLPHX_API_URL=https://api.sylphx.com/v1\n\
\n\
Raw: {message}"
)
}
SdkError::Api {
status: 403,
code,
message,
} if message.contains("user_context_required") => anyhow::anyhow!(
"api 403: {code}: this endpoint needs a user-scoped credential.\n\
\n\
You are likely using a service token (`svc_*`).\n\
• whoami / user profile → use `sylphx login` (device flow)\n\
• deploy / projects / automation → `svc_*` is correct\n\
\n\
Raw: {message}"
),
SdkError::Api {
status: 401,
code,
message,
} => anyhow::anyhow!(
"api 401: {code}: not authenticated.\n\
\n\
Fix:\n\
• sylphx login\n\
• sylphx login --token svc_…\n\
• export SYLPHX_TOKEN=…\n\
\n\
Then: sylphx doctor\n\
Raw: {message}"
),
SdkError::Api {
status: 404,
code,
message,
} => anyhow::anyhow!(
"api 404: {code}: resource not found.\n\
\n\
Check:\n\
• project/org id spelling\n\
• preferred org (`sylphx context show` / `--org-id`)\n\
• sylphx doctor\n\
\n\
Raw: {message}"
),
SdkError::Decode(msg) => anyhow::anyhow!(
"wire decode lag: Management API returned fields the local SDK contract does not know yet.\n\
\n\
This is usually additive API evolution ahead of a CLI/SDK release.\n\
Fix:\n\
• sylphx update\n\
• or use: sylphx api get <path> --json (raw envelope)\n\
\n\
Detail: {msg}"
),
other => anyhow::anyhow!("{other}"),
}
}
pub fn login_verification_err(err: SdkError) -> anyhow::Error {
anyhow::anyhow!(
"login verification failed — credentials were NOT saved: {}",
map_sdk_err(err)
)
}
pub fn confirm_or_yes(yes: bool, json: bool, prompt: &str) -> anyhow::Result<()> {
if yes {
return Ok(());
}
if json {
anyhow::bail!(
"refusing destructive action without --yes (json mode is non-interactive).\n\
Re-run with --yes after reviewing: {prompt}"
);
}
if !io::stdin().is_terminal() {
anyhow::bail!(
"refusing destructive action without --yes (stdin is not a TTY).\n\
Re-run with --yes after reviewing: {prompt}"
);
}
eprint!("{} {} [y/N] ", mark_warn(), prompt);
let _ = io::stderr().flush();
let mut line = String::new();
io::stdin().read_line(&mut line)?;
let answer = line.trim().to_ascii_lowercase();
if answer == "y" || answer == "yes" {
Ok(())
} else {
anyhow::bail!("aborted")
}
}
pub fn print_error(err: &anyhow::Error) {
let msg = err.to_string();
let first = msg.lines().next().unwrap_or("error");
eprintln!("{} {}", mark_fail(), bold(first));
for line in msg.lines().skip(1) {
if line.is_empty() {
eprintln!();
} else {
eprintln!(" {line}");
}
}
let _ = io::stderr().flush();
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn login_verification_error_keeps_sanitized_api_cause() {
let error = login_verification_err(SdkError::Api {
status: 401,
code: "invalid_audience".into(),
message: "credential audience rejected".into(),
})
.to_string();
assert!(error.contains("credentials were NOT saved"));
assert!(error.contains("api 401: invalid_audience"));
assert!(error.contains("credential audience rejected"));
}
#[test]
fn truncate_detail_strips_json_body() {
let s = "status=ok (typed HealthResponse decode lag: decode: unknown field `draining`; body={\"draining\":false,\"status\":\"ok\"})";
let t = truncate_detail(s, 200);
assert!(!t.contains("\"draining\""));
assert!(t.contains("status=ok"));
}
#[test]
fn format_cell_truncates_long_strings() {
let long = "x".repeat(80);
let v = Value::String(long);
let cell = format_cell(&v);
assert!(cell.ends_with('…'));
assert!(cell.chars().count() <= 48);
}
#[test]
fn status_badge_known_tokens() {
assert!(!status_badge("running").is_empty());
assert!(!status_badge("failed").is_empty());
assert!(!status_badge("deploying").is_empty());
}
}