use anyhow::{Context, Result};
use serde::Serialize;
#[derive(Debug, Serialize)]
pub struct SessionSummary {
pub username: String,
pub name: String,
pub is_admin: bool,
pub accessible_groups: Vec<String>,
}
#[derive(Debug, Serialize)]
pub struct ConfigSummary {
pub config_file: String,
pub log_level: String,
pub current_site: Option<String>,
pub configured_site: Option<String>,
pub read_only: bool,
pub session: Option<SessionSummary>,
pub current_group: Option<String>,
}
fn render_text(summary: &ConfigSummary) -> String {
let mut out = String::new();
push_local_section(&mut out, summary);
out.push('\n');
push_jwt_section(&mut out, summary.session.as_ref());
out.push('\n');
push_server_section(&mut out, summary.session.as_ref());
out
}
fn push_local_section(out: &mut String, summary: &ConfigSummary) {
out.push_str(&format!(
"From local config file ({}):\n",
summary.config_file
));
let site_value = match (&summary.current_site, &summary.configured_site) {
(Some(cur), Some(cfg)) if cur != cfg => {
format!("{cur} (--site override; cli.toml: {cfg})")
}
(Some(cur), _) => cur.clone(),
(None, _) => "(unset)".to_string(),
};
let group_value = summary
.current_group
.clone()
.unwrap_or_else(|| "(unset)".to_string());
let read_only_value = if summary.read_only { "yes" } else { "no" };
let rows = [
("Log level", summary.log_level.as_str()),
("Current site", site_value.as_str()),
("Current group", group_value.as_str()),
("Read-only", read_only_value),
];
push_aligned_rows(out, &rows);
}
fn push_jwt_section(out: &mut String, session: Option<&SessionSummary>) {
out.push_str("From JWT token:\n");
match session {
None => out.push_str(" (unavailable — no site selected)\n"),
Some(s) => {
let admin = if s.is_admin { "yes" } else { "no" };
let rows = [
("Username", s.username.as_str()),
("Name", s.name.as_str()),
("Admin", admin),
];
push_aligned_rows(out, &rows);
}
}
}
fn push_server_section(out: &mut String, session: Option<&SessionSummary>) {
out.push_str("From server API:\n");
match session {
None => out.push_str(" (unavailable — no site selected)\n"),
Some(s) => {
let groups = if s.accessible_groups.is_empty() {
"(none)".to_string()
} else {
s.accessible_groups.join(", ")
};
let rows = [("Accessible groups", groups.as_str())];
push_aligned_rows(out, &rows);
}
}
}
fn push_aligned_rows(out: &mut String, rows: &[(&str, &str)]) {
let width = rows.iter().map(|(k, _)| k.len()).max().unwrap_or(0) + 1;
for (k, v) in rows {
let label = format!("{k}:");
out.push_str(&format!(" {label:<width$} {v}\n"));
}
}
pub fn print(summary: &ConfigSummary, output_opt: Option<&str>) -> Result<()> {
if let Some("json") = output_opt {
println!(
"{}",
serde_json::to_string(summary)
.context("Failed to serialize config summary to JSON")?
);
} else {
print!("{}", render_text(summary));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::Value;
fn sample() -> ConfigSummary {
ConfigSummary {
config_file: "/home/u/.config/manta/cli.toml".to_string(),
log_level: "info".to_string(),
current_site: Some("alps".to_string()),
configured_site: Some("alps".to_string()),
read_only: false,
session: Some(SessionSummary {
username: "alice".to_string(),
name: "Alice Smith".to_string(),
is_admin: false,
accessible_groups: vec!["compute".to_string(), "uan".to_string()],
}),
current_group: Some("compute".to_string()),
}
}
#[test]
fn json_mode_emits_configured_site() {
let s = sample();
let json = serde_json::to_string(&s).unwrap();
let v: Value = serde_json::from_str(&json).unwrap();
assert_eq!(v["configured_site"], "alps");
let mut s2 = sample();
s2.configured_site = None;
let json2 = serde_json::to_string(&s2).unwrap();
let v2: Value = serde_json::from_str(&json2).unwrap();
assert!(v2["configured_site"].is_null());
}
#[test]
fn text_mode_renders_without_panicking() {
print(&sample(), None).unwrap();
print(&sample(), Some("table")).unwrap();
}
#[test]
fn json_mode_emits_session_subobject() {
let s = sample();
let json = serde_json::to_string(&s).unwrap();
let v: Value = serde_json::from_str(&json).unwrap();
assert_eq!(v["config_file"], "/home/u/.config/manta/cli.toml");
assert_eq!(v["log_level"], "info");
assert_eq!(v["current_site"], "alps");
assert_eq!(v["session"]["username"], "alice");
assert_eq!(v["session"]["is_admin"], false);
assert!(
v["session"].get("is_read_only").is_none(),
"is_read_only should not be on `session`"
);
assert_eq!(v["session"]["accessible_groups"][0], "compute");
assert_eq!(v["current_group"], "compute");
assert_eq!(v["read_only"], false);
assert!(
v.get("groups_available").is_none(),
"no top-level groups_available"
);
}
#[test]
fn session_none_serialises_as_null() {
let mut s = sample();
s.session = None;
let json = serde_json::to_string(&s).unwrap();
let v: Value = serde_json::from_str(&json).unwrap();
assert!(v["session"].is_null());
}
#[test]
fn current_site_none_renders_without_panic() {
let mut s = sample();
s.current_site = None;
print(&s, None).unwrap();
}
#[test]
fn text_mode_annotates_site_override() {
let mut s = sample();
s.current_site = Some("alps".to_string());
s.configured_site = Some("daint".to_string());
let out = render_text(&s);
assert!(
out.contains("alps (--site override; cli.toml: daint)"),
"expected override annotation, got:\n{out}"
);
}
#[test]
fn text_mode_no_override_when_sites_match() {
let mut s = sample();
s.current_site = Some("alps".to_string());
s.configured_site = Some("alps".to_string());
let out = render_text(&s);
assert!(
!out.contains("override"),
"unexpected override marker:\n{out}"
);
assert!(
out.contains("Current site: alps"),
"missing aligned site line:\n{out}"
);
}
#[test]
fn text_mode_renders_three_section_headers() {
let out = render_text(&sample());
assert!(
out.contains("From local config file ("),
"missing local section:\n{out}"
);
assert!(
out.contains("From JWT token:"),
"missing JWT section:\n{out}"
);
assert!(
out.contains("From server API:"),
"missing server section:\n{out}"
);
}
#[test]
fn text_mode_read_only_yes_appears_in_local_section() {
let mut s = sample();
s.read_only = true;
let out = render_text(&s);
let jwt_pos = out
.find("From JWT token:")
.expect("jwt section header present");
let read_only_line = out
.lines()
.find(|l| l.contains("Read-only:"))
.expect("read-only row present");
assert!(
read_only_line.trim_end().ends_with("yes"),
"read-only row should end with `yes`, got: {read_only_line:?}"
);
let read_only_pos = out.find("Read-only:").expect("read-only row present");
assert!(
read_only_pos < jwt_pos,
"read-only row must appear before the JWT section header, got:\n{out}"
);
}
#[test]
fn text_mode_no_session_shows_unavailable_in_both_sections() {
let mut s = sample();
s.session = None;
let out = render_text(&s);
assert!(
out.contains("From JWT token:"),
"missing JWT section header:\n{out}"
);
assert!(
out.contains("From server API:"),
"missing server section header:\n{out}"
);
let unavailable_count =
out.matches("(unavailable — no site selected)").count();
assert_eq!(
unavailable_count, 2,
"expected unavailable line in both sections, got:\n{out}"
);
}
}