use super::{Invocation, confirm, nothing_changed, walk_options};
use crate::cli::{TopicCreateArgs, TopicDeleteArgs, TopicUpdateArgs};
use crate::clients::WALK_BUDGET;
use crate::error::CliError;
use crate::output::{Format, terminal_safe};
use crate::render::js_json_number;
use crate::views;
use crate::writes::{self, Change};
use scm::ErrorKind;
use scm::airs::management::models::{CreateCustomTopicRequest, CustomTopic, FiniteNumber};
use scm::validation::Mode;
use serde::Serialize;
use std::ffi::OsString;
const MAX_NAME: usize = 100;
const MAX_DESCRIPTION: usize = 250;
const MAX_EXAMPLE: usize = 250;
const MIN_EXAMPLES: usize = 2;
const MAX_EXAMPLES: usize = 5;
const MAX_COMBINED: usize = 1000;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CreatePlan {
pub name: String,
pub description: String,
pub examples: Vec<String>,
pub format: Format,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct UpdatePlan {
pub topic_id: String,
pub config: OsString,
pub format: Format,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DeletePlan {
pub topic_id: String,
pub force: Option<String>,
}
pub fn constraint_errors(name: &str, description: &str, examples: &[String]) -> Vec<String> {
let mut errors = Vec::new();
if name.is_empty() {
errors.push("name: Name is required".to_owned());
} else if name.len() > MAX_NAME {
errors.push(format!("name: Name must be at most {MAX_NAME} bytes"));
}
if description.is_empty() {
errors.push("description: Description is required".to_owned());
} else if description.len() > MAX_DESCRIPTION {
errors.push(format!(
"description: Description must be at most {MAX_DESCRIPTION} bytes"
));
}
if examples.len() < MIN_EXAMPLES {
errors.push(format!(
"examples: At least {MIN_EXAMPLES} examples required"
));
}
if examples.len() > MAX_EXAMPLES {
errors.push(format!("examples: At most {MAX_EXAMPLES} examples allowed"));
}
for (index, example) in examples.iter().enumerate() {
if example.is_empty() {
errors.push(format!("examples[{index}]: Example {index} is required"));
} else if example.len() > MAX_EXAMPLE {
errors.push(format!(
"examples[{index}]: Example {index} must be at most {MAX_EXAMPLE} bytes"
));
}
}
let combined = name.len() + description.len() + examples.iter().map(String::len).sum::<usize>();
if combined > MAX_COMBINED {
errors.push(format!(
"topic: Combined length ({combined}) exceeds {MAX_COMBINED} bytes"
));
}
errors
}
pub fn plan_create(args: &TopicCreateArgs, format: Format) -> Result<CreatePlan, CliError> {
let errors = constraint_errors(&args.name, &args.description, &args.examples);
if !errors.is_empty() {
return Err(CliError::usage(errors.join("; ")));
}
Ok(CreatePlan {
name: args.name.clone(),
description: args.description.clone(),
examples: args.examples.clone(),
format,
})
}
fn invalid_topic_id(id: &str) -> String {
format!("AISEC_USER_REQUEST_PAYLOAD_ERROR:Invalid topic_id: {id}")
}
pub fn plan_update(args: &TopicUpdateArgs, format: Format) -> Result<UpdatePlan, CliError> {
if !crate::pagination::is_uuid(&args.topic_id) {
return Err(CliError::usage(invalid_topic_id(&args.topic_id)));
}
Ok(UpdatePlan {
topic_id: args.topic_id.clone(),
config: args.config.clone(),
format,
})
}
pub const UPDATED_BY_CONTROL: &str = "--updated-by <email> must not contain control characters";
pub fn has_control(value: &str) -> bool {
value.chars().any(char::is_control)
}
pub fn force_audit(updated_by: Option<&str>) -> Result<String, CliError> {
let value = updated_by
.filter(|value| !crate::tenant::config::js_trim(value).is_empty())
.ok_or_else(|| CliError::usage("--updated-by <email> is required with --force"))?;
if has_control(value) {
return Err(CliError::usage(UPDATED_BY_CONTROL));
}
Ok(value.to_owned())
}
pub fn plan_delete(args: &TopicDeleteArgs, interactive: bool) -> Result<DeletePlan, CliError> {
if !args.force && !interactive {
return Err(CliError::usage(format!(
"refusing to delete topic {} without --force in non-interactive mode",
args.topic_id
)));
}
if !crate::pagination::is_uuid(&args.topic_id) {
return Err(CliError::usage(invalid_topic_id(&args.topic_id)));
}
let force = if args.force {
Some(force_audit(args.updated_by.as_deref())?)
} else {
None
};
Ok(DeletePlan {
topic_id: args.topic_id.clone(),
force,
})
}
fn topic_body(
value: serde_json::Value,
method: &str,
path: &str,
) -> Result<CreateCustomTopicRequest, CliError> {
CreateCustomTopicRequest::parse(value, Mode::Request)
.map_err(|_| writes::invalid_body(method, path))
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct CreateRecord<'a> {
topic_id: &'a str,
topic_name: &'a str,
revision: &'a FiniteNumber,
created: bool,
}
pub async fn create(invocation: &mut Invocation, plan: &CreatePlan) -> Result<(), CliError> {
let options = walk_options()?;
let (management, format) = invocation
.connect_management(Some(plan.format), Some(&options))
.await
.map_err(nothing_changed)?;
let topics = management.topics();
let existing = match topics.get_by_name(&plan.name, Some(&options)).await {
Ok(topic) => Some(topic),
Err(error) if error.kind() == ErrorKind::Validation => None,
Err(error) => {
return Err(nothing_changed(
invocation.management_error(&error, WALK_BUDGET),
));
}
};
let value = serde_json::json!({
"topic_name": plan.name,
"description": plan.description,
"examples": plan.examples,
});
let secrets = invocation.secrets.clone();
let (change, result) = match &existing {
Some(topic) => {
let id =
views::non_empty_id(topic.topic_id.as_ref().into_option()).ok_or_else(|| {
CliError::operational(format!("Existing topic '{}' has no topic_id", plan.name))
})?;
if !crate::pagination::is_uuid(id) {
return Err(CliError::operational(invalid_topic_id(id)));
}
let body = topic_body(value, "PUT", &format!("/v1/mgmt/topic/uuid/{id}"))?;
let change = Change::TopicUpdated;
let result = writes::send(&secrets, change, async || {
topics.update(id, &body, None).await
})
.await;
(change, result)
}
None => {
let body = topic_body(value, "POST", "/v1/mgmt/topic")?;
let change = Change::TopicCreated;
let result =
writes::send(&secrets, change, async || topics.create(&body, None).await).await;
(change, result)
}
};
let topic = result?;
invocation.committed = true;
let Some(topic_id) = views::non_empty_id(topic.topic_id.as_ref().into_option()) else {
let (_, done) = change.words();
return Err(CliError::Operational(crate::error::Failure {
message: "API response missing topic_id".into(),
status: None,
details: vec![format!(
"Outcome: the topic was {done}, but the response carried no topic_id."
)],
}));
};
let record = CreateRecord {
topic_id,
topic_name: &topic.topic_name,
revision: &topic.revision,
created: existing.is_none(),
};
let (_, done) = change.words();
invocation
.output
.success(&format!("Topic {done}: {}", topic.topic_name));
let pairs = [
("ID", terminal_safe(topic_id).into_owned()),
("Revision", js_json_number(topic.revision.as_number())),
];
views::render_record(&mut invocation.output, &record, &pairs, format)
}
pub async fn update(invocation: &mut Invocation, plan: &UpdatePlan) -> Result<(), CliError> {
let config = crate::files::read_config(&plan.config)?;
let body = topic_body(
serde_json::Value::Object(config),
"PUT",
&format!("/v1/mgmt/topic/uuid/{}", plan.topic_id),
)?;
let (management, format) = invocation
.connect_management(Some(plan.format), None)
.await
.map_err(nothing_changed)?;
let topics = management.topics();
let secrets = invocation.secrets.clone();
let topic: CustomTopic = writes::send(&secrets, Change::TopicUpdated, async || {
topics.update(&plan.topic_id, &body, None).await
})
.await?;
invocation.committed = true;
let id = views::non_empty_id(topic.topic_id.as_ref().into_option()).unwrap_or(&plan.topic_id);
invocation.output.success(&format!("Topic updated: {id}"));
views::render_topic_detail(&mut invocation.output, &topic, format)
}
pub async fn delete(invocation: &mut Invocation, plan: &DeletePlan) -> Result<(), CliError> {
let clients = invocation.management_clients()?;
if plan.force.is_none() && !confirm(invocation, &format!("Delete topic {}?", plan.topic_id))? {
invocation.output.info("Aborted");
return Ok(());
}
clients
.authenticate(None, crate::clients::TOKEN_BUDGET)
.await
.map_err(nothing_changed)?;
let topics = clients.management.topics();
let secrets = invocation.secrets.clone();
let id = plan.topic_id.as_str();
writes::send(&secrets, Change::TopicDeleted, async || match &plan.force {
Some(updated_by) => topics.force_delete_with_audit(id, updated_by, None).await,
None => topics.delete(id, None).await,
})
.await?;
invocation.committed = true;
let line = if plan.force.is_some() {
format!("Topic {id} force-deleted.")
} else {
format!("Topic {id} deleted.")
};
invocation.output.success(&line);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn strings(values: &[&str]) -> Vec<String> {
values.iter().map(|value| (*value).to_owned()).collect()
}
#[test]
fn constraints_follow_the_typescript_order_and_byte_lengths() {
assert!(constraint_errors("n", "d", &strings(&["a", "b"])).is_empty());
assert_eq!(
constraint_errors("", "", &strings(&["a"])),
[
"name: Name is required",
"description: Description is required",
"examples: At least 2 examples required"
]
);
let long = "é".repeat(51); assert_eq!(
constraint_errors(&long, "d", &strings(&["a", "", "c", "d", "e", "f"])),
[
"name: Name must be at most 100 bytes",
"examples: At most 5 examples allowed",
"examples[1]: Example 1 is required"
]
);
let example = "x".repeat(251);
assert_eq!(
constraint_errors("n", &"d".repeat(251), &[example.clone(), "y".into()]),
[
"description: Description must be at most 250 bytes",
"examples[0]: Example 0 must be at most 250 bytes"
]
);
let full = "x".repeat(250);
assert_eq!(
constraint_errors(
&"n".repeat(100),
&"d".repeat(250),
&[full.clone(), full.clone(), full.clone()]
),
["topic: Combined length (1100) exceeds 1000 bytes"]
);
assert!(
constraint_errors(
&"n".repeat(100),
&"d".repeat(150),
&[full.clone(), full.clone(), full.clone()]
)
.is_empty(),
"exactly 1000 bytes"
);
}
#[test]
fn force_needs_a_usable_audit_email() {
for bad in [None, Some(""), Some(" \u{a0}\t")] {
assert_eq!(
force_audit(bad),
Err(CliError::usage(
"--updated-by <email> is required with --force"
)),
"{bad:?}"
);
}
for bad in ["a\u{7}b", "ops\tteam@x", "a@b\u{9b}"] {
assert_eq!(
force_audit(Some(bad)),
Err(CliError::usage(UPDATED_BY_CONTROL)),
"{bad:?}"
);
}
assert_eq!(force_audit(Some(" a@b ")).unwrap(), " a@b ");
}
}