use crate::error::CliError;
use crate::json::{self, OValue};
use crate::output::{Format, Output, terminal_safe};
use crate::render::style::Style;
use crate::render::{cell_text, js_json_number, pretty, table, utf16_prefix};
use scm::airs::management::models::{CustomTopic, FiniteNumber, Policy, SecurityProfile};
use serde::Serialize;
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ProfileView<'a> {
#[serde(skip_serializing_if = "Option::is_none")]
pub profile_id: Option<&'a str>,
pub profile_name: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
pub revision: Option<&'a FiniteNumber>,
#[serde(skip_serializing_if = "Option::is_none")]
pub active: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub created_by: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
pub updated_by: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_modified_ts: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
pub policy: Option<&'a Policy>,
}
impl<'a> From<&'a SecurityProfile> for ProfileView<'a> {
fn from(profile: &'a SecurityProfile) -> Self {
Self {
profile_id: profile
.profile_id
.as_ref()
.into_option()
.map(String::as_str),
profile_name: &profile.profile_name,
revision: profile.revision.as_ref().into_option(),
active: profile.active.as_ref().into_option().copied(),
created_by: profile
.created_by
.as_ref()
.into_option()
.map(String::as_str),
updated_by: profile
.updated_by
.as_ref()
.into_option()
.map(String::as_str),
last_modified_ts: profile
.last_modified_ts
.as_ref()
.into_option()
.map(String::as_str),
policy: profile.policy.as_ref().into_option(),
}
}
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TopicView<'a> {
#[serde(skip_serializing_if = "Option::is_none")]
pub topic_id: Option<&'a str>,
pub topic_name: &'a str,
pub revision: &'a FiniteNumber,
#[serde(skip_serializing_if = "Option::is_none")]
pub active: Option<bool>,
pub description: &'a str,
pub examples: &'a [String],
#[serde(skip_serializing_if = "Option::is_none")]
pub created_by: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
pub updated_by: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_modified_ts: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
pub created_ts: Option<&'a str>,
}
impl<'a> From<&'a CustomTopic> for TopicView<'a> {
fn from(topic: &'a CustomTopic) -> Self {
let text = |value: &'a scm::data::Optional<String>| {
value.as_ref().into_option().map(String::as_str)
};
Self {
topic_id: text(&topic.topic_id),
topic_name: &topic.topic_name,
revision: &topic.revision,
active: topic.active.as_ref().into_option().copied(),
description: &topic.description,
examples: topic
.examples
.as_ref()
.into_option()
.map_or(&[], Vec::as_slice),
created_by: text(&topic.created_by),
updated_by: text(&topic.updated_by),
last_modified_ts: text(&topic.last_modified_ts),
created_ts: text(&topic.created_ts),
}
}
}
pub fn profile_views(profiles: &[SecurityProfile]) -> Vec<ProfileView<'_>> {
profiles.iter().map(ProfileView::from).collect()
}
pub fn topic_views(topics: &[CustomTopic]) -> Vec<TopicView<'_>> {
topics.iter().map(TopicView::from).collect()
}
pub(crate) fn runtime_banner(out: &mut Output) {
pretty::banner(
out,
"Prisma AIRS — Runtime Configuration",
Some("Security profile and topic management"),
);
}
fn serialization_failed() -> CliError {
CliError::operational("AISEC_SDK_ERROR:Output could not be serialized as JSON")
}
fn ordered<T: Serialize>(view: &T) -> Result<OValue, CliError> {
json::to_ordered(view).map_err(|_| serialization_failed())
}
fn table_rows<T: Serialize>(views: &[T], columns: &[&str]) -> Result<Vec<Vec<String>>, CliError> {
views
.iter()
.map(|view| {
let value = ordered(view)?;
Ok(columns
.iter()
.map(|key| cell_text(value.get(key)))
.collect())
})
.collect()
}
fn detail_table<T: Serialize>(view: &T, keys: &[&str]) -> Result<String, CliError> {
let value = ordered(view)?;
Ok(table::key_value_table(
keys.iter()
.map(|key| ((*key).to_owned(), cell_text(value.get(key))))
.collect(),
))
}
fn emit_list_table(out: &mut Output, labels: &[&str], rows: &[Vec<String>]) {
let text = table::table(labels, rows);
if !text.is_empty() {
out.data_line(&text);
}
}
fn id_line(out: &mut Output, id: Option<&str>) {
if let Some(id) = id {
let id = out.paint(Style::Dim, &terminal_safe(id));
out.data_line(&format!(" {id}"));
}
}
fn revision_text(revision: &FiniteNumber) -> String {
js_json_number(revision.as_number())
}
fn status_word(out: &Output, active: Option<bool>) -> String {
if active == Some(true) {
out.paint(Style::Green, "active")
} else {
out.paint(Style::Yellow, "inactive")
}
}
const PROFILE_LIST_KEYS: [&str; 4] = ["profileId", "profileName", "active", "revision"];
const PROFILE_LIST_LABELS: [&str; 4] = ["ID", "Name", "Active", "Revision"];
const PROFILE_DETAIL_KEYS: [&str; 8] = [
"profileId",
"profileName",
"revision",
"active",
"createdBy",
"updatedBy",
"lastModifiedTs",
"policy",
];
pub fn render_profile_list(
out: &mut Output,
profiles: &[SecurityProfile],
format: Format,
) -> Result<(), CliError> {
let views = profile_views(profiles);
match format {
Format::Json => out.json(&views),
Format::Table => {
let rows = table_rows(&views, &PROFILE_LIST_KEYS)?;
emit_list_table(out, &PROFILE_LIST_LABELS, &rows);
Ok(())
}
Format::Pretty => {
runtime_banner(out);
if views.is_empty() {
pretty::empty_list(out, "profiles");
return Ok(());
}
pretty::section(out, "Security Profiles:");
for view in &views {
id_line(out, view.profile_id);
let status = status_word(out, view.active);
let revision = view.revision.map_or_else(String::new, |revision| {
out.paint(Style::Dim, &format!(" rev:{}", revision_text(revision)))
});
out.data_line(&format!(
" {} {status}{revision}",
terminal_safe(view.profile_name)
));
}
out.data("\n");
Ok(())
}
}
}
fn policy_block(policy: &Policy) -> Result<String, CliError> {
let text = json::to_pretty(policy).map_err(|_| serialization_failed())?;
Ok(text
.split('\n')
.map(|line| terminal_safe(line).into_owned())
.collect::<Vec<_>>()
.join("\n "))
}
fn non_empty(value: Option<&str>) -> Option<String> {
value
.filter(|text| !text.is_empty())
.map(|text| terminal_safe(text).into_owned())
}
pub fn render_profile_detail(
out: &mut Output,
profile: &SecurityProfile,
format: Format,
) -> Result<(), CliError> {
let view = ProfileView::from(profile);
match format {
Format::Json => out.json(&view),
Format::Table => {
let text = detail_table(&view, &PROFILE_DETAIL_KEYS)?;
out.data_line(&text);
Ok(())
}
Format::Pretty => {
runtime_banner(out);
pretty::section(out, "Profile Detail:");
let mut pairs = vec![
(
"ID",
terminal_safe(view.profile_id.unwrap_or("")).into_owned(),
),
("Name", terminal_safe(view.profile_name).into_owned()),
("Status", status_word(out, view.active)),
];
if let Some(revision) = view.revision {
pairs.push(("Revision", revision_text(revision)));
}
for (key, value) in [
("Created", view.created_by),
("Updated", view.updated_by),
("Modified", view.last_modified_ts),
] {
if let Some(value) = non_empty(value) {
pairs.push((key, value));
}
}
if let Some(policy) = view.policy {
pairs.push(("Policy", policy_block(policy)?));
}
pretty::key_value(out, &pairs);
out.data("\n");
Ok(())
}
}
}
const TOPIC_LIST_KEYS: [&str; 4] = ["topicId", "topicName", "revision", "description"];
const TOPIC_LIST_LABELS: [&str; 4] = ["ID", "Name", "Revision", "Description"];
const TOPIC_DETAIL_KEYS: [&str; 10] = [
"topicId",
"topicName",
"revision",
"active",
"description",
"examples",
"createdBy",
"updatedBy",
"lastModifiedTs",
"createdTs",
];
const DESCRIPTION_PREVIEW_UNITS: usize = 80;
pub fn render_topic_list(
out: &mut Output,
topics: &[CustomTopic],
format: Format,
) -> Result<(), CliError> {
let views = topic_views(topics);
match format {
Format::Json => out.json(&views),
Format::Table => {
let rows = table_rows(&views, &TOPIC_LIST_KEYS)?;
emit_list_table(out, &TOPIC_LIST_LABELS, &rows);
Ok(())
}
Format::Pretty => {
runtime_banner(out);
if views.is_empty() {
pretty::empty_list(out, "topics");
return Ok(());
}
pretty::section(out, "Custom Topics:");
for view in &views {
id_line(out, view.topic_id);
let revision = out.paint(
Style::Dim,
&format!(" rev:{}", revision_text(view.revision)),
);
let description = if view.description.is_empty() {
String::new()
} else {
let (preview, _) = utf16_prefix(view.description, DESCRIPTION_PREVIEW_UNITS);
out.paint(Style::Dim, &format!(" — {}", terminal_safe(preview)))
};
out.data_line(&format!(
" {}{revision}{description}",
terminal_safe(view.topic_name)
));
}
out.data("\n");
Ok(())
}
}
}
pub fn render_topic_detail(
out: &mut Output,
topic: &CustomTopic,
format: Format,
) -> Result<(), CliError> {
let view = TopicView::from(topic);
match format {
Format::Json => out.json(&view),
Format::Table => {
let text = detail_table(&view, &TOPIC_DETAIL_KEYS)?;
out.data_line(&text);
Ok(())
}
Format::Pretty => {
runtime_banner(out);
pretty::section(out, "Topic Detail:");
let mut pairs = vec![
(
"ID",
terminal_safe(view.topic_id.unwrap_or("")).into_owned(),
),
("Name", terminal_safe(view.topic_name).into_owned()),
("Revision", revision_text(view.revision)),
];
if let Some(description) = non_empty(Some(view.description)) {
pairs.push(("Description", description));
}
pretty::key_value(out, &pairs);
if !view.examples.is_empty() {
out.data_line(" Examples:");
for example in view.examples {
pretty::bullet(out, &terminal_safe(example), pretty::Bullet::Neutral);
}
}
let meta: Vec<(&str, String)> = [
("Created", view.created_by),
("Updated", view.updated_by),
("Modified", view.last_modified_ts),
]
.into_iter()
.filter_map(|(key, value)| non_empty(value).map(|value| (key, value)))
.collect();
if !meta.is_empty() {
pretty::key_value(out, &meta);
}
out.data("\n");
Ok(())
}
}
}
pub fn non_empty_id(id: Option<&String>) -> Option<&str> {
id.map(String::as_str).filter(|id| !id.is_empty())
}
pub fn render_record<T: Serialize>(
out: &mut Output,
record: &T,
pairs: &[(&str, String)],
format: Format,
) -> Result<(), CliError> {
match format {
Format::Json => out.json(record),
Format::Table => {
let value = ordered(record)?;
let rows = value
.as_object()
.unwrap_or_default()
.iter()
.map(|(key, value)| (key.clone(), cell_text(Some(value))))
.collect();
out.data_line(&table::key_value_table(rows));
Ok(())
}
Format::Pretty => {
pretty::key_value(out, pairs);
Ok(())
}
}
}
pub trait Revisioned {
fn id(&self) -> Option<&str>;
fn name(&self) -> &str;
fn revision(&self) -> Option<&FiniteNumber>;
}
impl Revisioned for SecurityProfile {
fn id(&self) -> Option<&str> {
self.profile_id.as_ref().into_option().map(String::as_str)
}
fn name(&self) -> &str {
&self.profile_name
}
fn revision(&self) -> Option<&FiniteNumber> {
self.revision.as_ref().into_option()
}
}
impl Revisioned for CustomTopic {
fn id(&self) -> Option<&str> {
self.topic_id.as_ref().into_option().map(String::as_str)
}
fn name(&self) -> &str {
&self.topic_name
}
fn revision(&self) -> Option<&FiniteNumber> {
Some(&self.revision)
}
}
pub fn revision_value<T: Revisioned>(row: &T) -> f64 {
row.revision().and_then(FiniteNumber::as_f64).unwrap_or(0.0)
}
pub fn revision_is<T: Revisioned>(row: &T, wanted: u64) -> bool {
row.revision()
.is_some_and(|revision| match revision.as_u64() {
Some(exact) => exact == wanted,
None => revision.as_f64() == Some(wanted as f64),
})
}
pub fn is_named<T: Revisioned>(row: &T, value: &str, by_id: bool) -> bool {
if by_id {
row.id() == Some(value)
} else {
row.name() == value
}
}
pub fn matching_versions<T: Revisioned>(
rows: Vec<T>,
value: &str,
by_id: bool,
revision: Option<u64>,
) -> Vec<T> {
rows.into_iter()
.filter(|row| is_named(row, value, by_id))
.filter(|row| revision.is_none_or(|wanted| revision_is(row, wanted)))
.collect()
}
pub fn latest_per_name<T: Revisioned>(rows: Vec<T>) -> Vec<T> {
let mut positions = std::collections::HashMap::<String, usize>::new();
let mut latest: Vec<T> = Vec::new();
for row in rows {
match positions.get(row.name()) {
Some(&index) => {
if revision_value(&row) > revision_value(&latest[index]) {
latest[index] = row;
}
}
None => {
positions.insert(row.name().to_owned(), latest.len());
latest.push(row);
}
}
}
latest
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn profile(value: serde_json::Value) -> SecurityProfile {
serde_json::from_value(value).unwrap()
}
fn topic(value: serde_json::Value) -> CustomTopic {
serde_json::from_value(value).unwrap()
}
fn topic_rev(name: &str, revision: serde_json::Value, id: &str) -> CustomTopic {
topic(json!({"topic_id": id, "topic_name": name, "revision": revision, "description": "d"}))
}
#[test]
fn profile_projection_order_omission_and_dropping() {
let row = profile(json!({
"zzz": 1,
"policy": {"zeta": {"b": 1, "a": 2}, "dlp-data-profiles": [], "ai-security-profiles": [{"model-type": "default"}]},
"last_modified_ts": "2026-02-01T00:00:00Z",
"tsg_id": "100",
"csp_id": "c",
"active": true,
"revision": 2.0,
"profile_name": "alpha",
"profile_id": "00000000-0000-4000-8000-000000000002",
}));
assert_eq!(
crate::json::to_pretty(&ProfileView::from(&row)).unwrap(),
r#"{
"profileId": "00000000-0000-4000-8000-000000000002",
"profileName": "alpha",
"revision": 2,
"active": true,
"lastModifiedTs": "2026-02-01T00:00:00Z",
"policy": {
"ai-security-profiles": [
{
"model-type": "default"
}
],
"dlp-data-profiles": [],
"zeta": {
"a": 2,
"b": 1
}
}
}"#
);
let bare = profile(json!({"profile_name": "p", "created_by": "a", "updated_by": "b"}));
assert_eq!(
crate::json::to_pretty(&profile_views(&[bare])).unwrap(),
"[\n {\n \"profileName\": \"p\",\n \"createdBy\": \"a\",\n \"updatedBy\": \"b\"\n }\n]"
);
}
#[test]
fn topic_projection_order_and_examples_default() {
let row = topic(json!({
"created_ts": "2025-12-01T00:00:00Z",
"unknown": {"x": 1},
"tsg_id": "1",
"description": "old",
"revision": 1,
"topic_name": "fin",
"active": true,
"created_by": "a@example.com",
"last_modified_ts": "2026-01-01T00:00:00Z",
"examples": ["e1"],
"topic_id": "00000000-0000-4000-8000-000000000011",
}));
assert_eq!(
crate::json::to_pretty(&TopicView::from(&row)).unwrap(),
r#"{
"topicId": "00000000-0000-4000-8000-000000000011",
"topicName": "fin",
"revision": 1,
"active": true,
"description": "old",
"examples": [
"e1"
],
"createdBy": "a@example.com",
"lastModifiedTs": "2026-01-01T00:00:00Z",
"createdTs": "2025-12-01T00:00:00Z"
}"#
);
let bare = topic(json!({"topic_name": "t", "revision": 3.0, "description": ""}));
assert_eq!(
crate::json::to_pretty(&topic_views(&[bare])).unwrap(),
"[\n {\n \"topicName\": \"t\",\n \"revision\": 3,\n \"description\": \"\",\n \"examples\": []\n }\n]"
);
}
#[test]
fn revisions_compare_numerically() {
let unset = profile(json!({"profile_name": "p"}));
let float = profile(json!({"profile_name": "p", "revision": 2.0}));
let int = profile(json!({"profile_name": "p", "revision": 2}));
let frac = profile(json!({"profile_name": "p", "revision": 2.5}));
assert_eq!(revision_value(&unset), 0.0);
assert!(!revision_is(&unset, 0), "unset never equals --revision 0");
assert!(revision_is(&float, 2) && revision_is(&int, 2));
assert!(!revision_is(&frac, 2) && !revision_is(&int, 3));
assert!(revision_value(&frac) > revision_value(&float));
}
#[test]
fn latest_per_name_keeps_first_seen_order_and_first_tie() {
let rows = vec![
topic_rev("a", json!(1), "a1"),
topic_rev("b", json!(2), "b2"),
topic_rev("a", json!(3), "a3"),
topic_rev("a", json!(3.0), "a3-tie"),
topic_rev("c", json!(0), "c0"),
topic_rev("b", json!(1), "b1"),
];
let ids: Vec<_> = latest_per_name(rows)
.iter()
.map(|row| row.id().unwrap().to_owned())
.collect();
assert_eq!(ids, ["a3", "b2", "c0"]);
}
#[test]
fn unset_profile_revisions_count_as_zero_and_ties_keep_the_first() {
let rows = vec![
profile(json!({"profile_name": "p", "profile_id": "first"})),
profile(json!({"profile_name": "p", "profile_id": "zero", "revision": 0})),
profile(json!({"profile_name": "q", "profile_id": "q"})),
profile(json!({"profile_name": "p", "profile_id": "one", "revision": 1})),
profile(json!({"profile_name": "p", "profile_id": "one-tie", "revision": 1.0})),
];
let ids: Vec<_> = latest_per_name(rows)
.iter()
.map(|row| row.id().unwrap().to_owned())
.collect();
assert_eq!(ids, ["one", "q"]);
let rows = vec![
profile(json!({"profile_name": "p", "profile_id": "first"})),
profile(json!({"profile_name": "p", "profile_id": "zero", "revision": 0})),
];
assert_eq!(latest_per_name(rows)[0].id(), Some("first"));
}
#[test]
fn matching_versions_filter_by_id_or_name_then_revision() {
let id = "0000abcd-0000-4000-8000-00000000000a";
let rows = || {
vec![
topic_rev("a", json!(1), id),
topic_rev("a", json!(2), "00000000-0000-4000-8000-000000000002"),
topic_rev("b", json!(2), id),
]
};
let names = |rows: Vec<CustomTopic>| -> Vec<(String, f64)> {
rows.iter()
.map(|row| (row.name().to_owned(), revision_value(row)))
.collect()
};
assert_eq!(
names(matching_versions(rows(), "a", false, None)),
[("a".into(), 1.0), ("a".into(), 2.0)]
);
assert_eq!(
names(matching_versions(rows(), "a", false, Some(2))),
[("a".into(), 2.0)]
);
assert_eq!(
names(matching_versions(rows(), id, true, None)),
[("a".into(), 1.0), ("b".into(), 2.0)]
);
assert!(matching_versions(rows(), &id.to_uppercase(), true, None).is_empty());
assert!(matching_versions(rows(), "a", false, Some(9)).is_empty());
}
}