use anyhow::{Context, Result};
use clap::{Parser, Subcommand, ValueEnum};
use crate::atlassian::adf::AdfDocument;
use crate::atlassian::adf_validated::ValidatedAdfDocument;
use crate::atlassian::client::{AtlassianClient, JiraComment, JiraVisibility, JiraVisibilityType};
use crate::atlassian::convert::{adf_to_markdown, markdown_to_adf};
use crate::atlassian::document::JfmDocument;
use crate::cli::atlassian::format::{output_as, ContentFormat, OutputFormat};
use crate::cli::atlassian::helpers::{create_client, read_input};
#[derive(Parser)]
pub struct CommentCommand {
#[command(subcommand)]
pub command: CommentSubcommands,
}
#[derive(Subcommand)]
pub enum CommentSubcommands {
List(ListCommand),
Add(AddCommand),
Edit(EditCommand),
}
impl CommentCommand {
pub async fn execute(self) -> Result<()> {
match self.command {
CommentSubcommands::List(cmd) => cmd.execute().await,
CommentSubcommands::Add(cmd) => cmd.execute().await,
CommentSubcommands::Edit(cmd) => cmd.execute().await,
}
}
}
#[derive(Parser)]
pub struct ListCommand {
pub key: String,
#[arg(short = 'o', long, value_enum, default_value_t = OutputFormat::Table)]
pub output: OutputFormat,
#[arg(long, default_value_t = 0)]
pub limit: u32,
}
impl ListCommand {
pub async fn execute(self) -> Result<()> {
let (client, _instance_url) = create_client()?;
run_list_comments(&client, &self.key, self.limit, &self.output).await
}
}
#[derive(Parser)]
pub struct AddCommand {
pub key: String,
pub file: Option<String>,
#[arg(long, value_enum, default_value_t = ContentFormat::Jfm)]
pub format: ContentFormat,
}
impl AddCommand {
pub async fn execute(self) -> Result<()> {
let adf = parse_comment_input(self.file.as_deref(), self.format)?;
let (client, _instance_url) = create_client()?;
run_add_comment(&client, &self.key, &adf).await
}
}
#[derive(Parser)]
pub struct EditCommand {
pub key: String,
pub comment_id: String,
pub file: Option<String>,
#[arg(long, value_enum, default_value_t = ContentFormat::Jfm)]
pub format: ContentFormat,
#[arg(long, value_enum, requires = "visibility_value")]
pub visibility_type: Option<CliVisibilityType>,
#[arg(long, requires = "visibility_type")]
pub visibility_value: Option<String>,
}
#[derive(Copy, Clone, Debug, ValueEnum)]
pub enum CliVisibilityType {
Group,
Role,
}
impl From<CliVisibilityType> for JiraVisibilityType {
fn from(value: CliVisibilityType) -> Self {
match value {
CliVisibilityType::Group => Self::Group,
CliVisibilityType::Role => Self::Role,
}
}
}
impl EditCommand {
pub async fn execute(self) -> Result<()> {
let adf = parse_comment_input(self.file.as_deref(), self.format)?;
let visibility = match (self.visibility_type, self.visibility_value) {
(Some(ty), Some(value)) => Some(JiraVisibility {
ty: ty.into(),
value,
}),
_ => None,
};
let (client, _instance_url) = create_client()?;
run_edit_comment(
&client,
&self.key,
&self.comment_id,
&adf,
visibility.as_ref(),
)
.await
}
}
fn parse_comment_input(file: Option<&str>, format: ContentFormat) -> Result<ValidatedAdfDocument> {
let input = read_input(file)?;
let adf: AdfDocument = match format {
ContentFormat::Jfm => {
if input.starts_with("---\n") {
let doc = JfmDocument::parse(&input)?;
markdown_to_adf(&doc.body)?
} else {
markdown_to_adf(&input)?
}
}
ContentFormat::Adf => {
serde_json::from_str(&input).context("Failed to parse ADF JSON input")?
}
};
Ok(ValidatedAdfDocument::try_new(adf)?)
}
async fn run_list_comments(
client: &AtlassianClient,
key: &str,
limit: u32,
output: &OutputFormat,
) -> Result<()> {
let comments = client.get_comments(key, limit).await?;
if output_as(&comments, output)? {
return Ok(());
}
print_comments(&comments);
Ok(())
}
async fn run_add_comment(
client: &AtlassianClient,
key: &str,
adf: &ValidatedAdfDocument,
) -> Result<()> {
client.add_comment(key, adf).await?;
println!("Comment added to {key}.");
Ok(())
}
async fn run_edit_comment(
client: &AtlassianClient,
key: &str,
comment_id: &str,
adf: &ValidatedAdfDocument,
visibility: Option<&JiraVisibility>,
) -> Result<()> {
let updated = client
.update_comment(key, comment_id, adf, visibility)
.await?;
println!("Comment {comment_id} updated on {key}.");
let yaml =
serde_yaml::to_string(&updated).context("Failed to serialize updated comment as YAML")?;
print!("{yaml}");
Ok(())
}
fn print_comments(comments: &[JiraComment]) {
if comments.is_empty() {
println!("No comments.");
return;
}
for (i, comment) in comments.iter().enumerate() {
if i > 0 {
println!();
}
let timestamp = format_timestamp(&comment.created);
println!("--- {} | {} ---", comment.author, timestamp);
println!("{}", format_comment_body(&comment.body_adf));
}
}
fn format_comment_body(body_adf: &Option<serde_json::Value>) -> String {
let Some(adf_value) = body_adf else {
return "[empty]".to_string();
};
let Ok(adf) = serde_json::from_value::<AdfDocument>(adf_value.clone()) else {
return "[ADF content]".to_string();
};
let Ok(md) = adf_to_markdown(&adf) else {
return "[ADF content]".to_string();
};
let trimmed = md.trim();
if trimmed.is_empty() {
"[empty]".to_string()
} else {
trimmed.to_string()
}
}
fn format_timestamp(ts: &str) -> &str {
ts.split('.').next().unwrap_or(ts)
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use std::fs;
fn sample_comment(id: &str, author: &str, body_adf: Option<serde_json::Value>) -> JiraComment {
JiraComment {
id: id.to_string(),
author: author.to_string(),
body_adf,
created: "2026-04-01T10:30:00.000+0000".to_string(),
updated: None,
}
}
#[test]
fn print_comments_empty() {
print_comments(&[]);
}
#[test]
fn print_comments_with_adf_body() {
let adf = serde_json::json!({
"version": 1,
"type": "doc",
"content": [{"type": "paragraph", "content": [{"type": "text", "text": "Hello world"}]}]
});
let comments = vec![sample_comment("1", "Alice", Some(adf))];
print_comments(&comments);
}
#[test]
fn print_comments_with_null_body() {
let comments = vec![sample_comment("1", "Bob", None)];
print_comments(&comments);
}
#[test]
fn print_comments_with_invalid_adf() {
let invalid = serde_json::json!({"not": "adf"});
let comments = vec![sample_comment("1", "Carol", Some(invalid))];
print_comments(&comments);
}
#[test]
fn print_comments_multiple() {
let adf = serde_json::json!({
"version": 1,
"type": "doc",
"content": [{"type": "paragraph", "content": [{"type": "text", "text": "First"}]}]
});
let comments = vec![
sample_comment("1", "Alice", Some(adf)),
sample_comment("2", "Bob", None),
];
print_comments(&comments);
}
#[test]
fn format_body_none() {
assert_eq!(format_comment_body(&None), "[empty]");
}
#[test]
fn format_body_valid_adf_with_text() {
let adf = serde_json::json!({
"version": 1,
"type": "doc",
"content": [{"type": "paragraph", "content": [{"type": "text", "text": "Hello"}]}]
});
let result = format_comment_body(&Some(adf));
assert_eq!(result, "Hello");
}
#[test]
fn format_body_valid_adf_empty_content() {
let adf = serde_json::json!({
"version": 1,
"type": "doc",
"content": []
});
let result = format_comment_body(&Some(adf));
assert_eq!(result, "[empty]");
}
#[test]
fn format_body_invalid_adf() {
let invalid = serde_json::json!({"not": "adf"});
assert_eq!(format_comment_body(&Some(invalid)), "[ADF content]");
}
#[test]
fn format_timestamp_with_millis() {
assert_eq!(
format_timestamp("2026-04-01T10:30:00.000+0000"),
"2026-04-01T10:30:00"
);
}
#[test]
fn format_timestamp_without_millis() {
assert_eq!(
format_timestamp("2026-04-01T10:30:00"),
"2026-04-01T10:30:00"
);
}
#[test]
fn format_timestamp_empty() {
assert_eq!(format_timestamp(""), "");
}
#[test]
fn parse_input_raw_markdown() {
let temp_dir = tempfile::tempdir().unwrap();
let file_path = temp_dir.path().join("comment.md");
fs::write(&file_path, "Hello **world**\n").unwrap();
let adf =
parse_comment_input(Some(file_path.to_str().unwrap()), ContentFormat::Jfm).unwrap();
assert!(!adf.content.is_empty());
}
#[test]
fn parse_input_jfm_with_frontmatter() {
let temp_dir = tempfile::tempdir().unwrap();
let file_path = temp_dir.path().join("comment.md");
let content =
"---\ntype: jira\ninstance: https://org.atlassian.net\nkey: PROJ-1\nsummary: Test\n---\n\nComment body\n";
fs::write(&file_path, content).unwrap();
let adf =
parse_comment_input(Some(file_path.to_str().unwrap()), ContentFormat::Jfm).unwrap();
assert!(!adf.content.is_empty());
}
#[test]
fn parse_input_adf_format() {
let temp_dir = tempfile::tempdir().unwrap();
let file_path = temp_dir.path().join("comment.json");
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Hello"}]}]}"#;
fs::write(&file_path, adf_json).unwrap();
let adf =
parse_comment_input(Some(file_path.to_str().unwrap()), ContentFormat::Adf).unwrap();
assert_eq!(adf.content.len(), 1);
}
#[test]
fn parse_input_invalid_adf() {
let temp_dir = tempfile::tempdir().unwrap();
let file_path = temp_dir.path().join("bad.json");
fs::write(&file_path, "not json").unwrap();
assert!(
parse_comment_input(Some(file_path.to_str().unwrap()), ContentFormat::Adf).is_err()
);
}
#[test]
fn parse_input_jfm_rejects_invalid_adf_nesting() {
let temp_dir = tempfile::tempdir().unwrap();
let file_path = temp_dir.path().join("bad.md");
fs::write(
&file_path,
":::panel{type=info}\n:::expand{title=\"x\"}\nbody\n:::\n:::",
)
.unwrap();
let err =
parse_comment_input(Some(file_path.to_str().unwrap()), ContentFormat::Jfm).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("invalid ADF nesting"));
assert!(msg.contains("`expand` cannot be a child of `panel`"));
}
#[test]
fn comment_command_list_variant() {
let cmd = CommentCommand {
command: CommentSubcommands::List(ListCommand {
key: "PROJ-1".to_string(),
output: OutputFormat::Table,
limit: 0,
}),
};
assert!(matches!(cmd.command, CommentSubcommands::List(_)));
}
#[test]
fn comment_command_add_variant() {
let cmd = CommentCommand {
command: CommentSubcommands::Add(AddCommand {
key: "PROJ-1".to_string(),
file: None,
format: ContentFormat::Jfm,
}),
};
assert!(matches!(cmd.command, CommentSubcommands::Add(_)));
}
#[test]
fn comment_command_edit_variant() {
let cmd = CommentCommand {
command: CommentSubcommands::Edit(EditCommand {
key: "PROJ-1".to_string(),
comment_id: "100".to_string(),
file: None,
format: ContentFormat::Jfm,
visibility_type: None,
visibility_value: None,
}),
};
assert!(matches!(cmd.command, CommentSubcommands::Edit(_)));
}
#[test]
fn cli_visibility_type_into_group() {
let mapped: JiraVisibilityType = CliVisibilityType::Group.into();
assert!(matches!(mapped, JiraVisibilityType::Group));
}
#[test]
fn cli_visibility_type_into_role() {
let mapped: JiraVisibilityType = CliVisibilityType::Role.into();
assert!(matches!(mapped, JiraVisibilityType::Role));
}
fn mock_client(base_url: &str) -> AtlassianClient {
AtlassianClient::new(base_url, "user@test.com", "token").unwrap()
}
#[tokio::test]
async fn run_list_comments_success() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1/comment"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"startAt": 0,
"maxResults": 100,
"total": 1,
"comments": [{
"id": "1",
"author": {"displayName": "Alice"},
"created": "2026-04-01T10:00:00.000+0000",
"body": null
}]
})),
)
.mount(&server)
.await;
let client = mock_client(&server.uri());
assert!(
run_list_comments(&client, "PROJ-1", 0, &OutputFormat::Table)
.await
.is_ok()
);
}
#[tokio::test]
async fn run_list_comments_json_output() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1/comment"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
serde_json::json!({"startAt": 0, "maxResults": 100, "total": 0, "comments": []}),
))
.mount(&server)
.await;
let client = mock_client(&server.uri());
assert!(run_list_comments(&client, "PROJ-1", 0, &OutputFormat::Json)
.await
.is_ok());
}
#[tokio::test]
async fn run_list_comments_api_error() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/rest/api/3/issue/NOPE-1/comment"))
.respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
.mount(&server)
.await;
let client = mock_client(&server.uri());
let err = run_list_comments(&client, "NOPE-1", 0, &OutputFormat::Table)
.await
.unwrap_err();
assert!(err.to_string().contains("404"));
}
#[tokio::test]
async fn run_list_comments_respects_limit() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1/comment"))
.and(wiremock::matchers::query_param("maxResults", "2"))
.and(wiremock::matchers::query_param("startAt", "0"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"startAt": 0,
"maxResults": 2,
"total": 10,
"comments": [
{"id": "1", "author": {"displayName": "A"}, "body": null, "created": "2026-04-01T10:00:00.000+0000"},
{"id": "2", "author": {"displayName": "B"}, "body": null, "created": "2026-04-02T10:00:00.000+0000"}
]
})),
)
.expect(1)
.mount(&server)
.await;
let client = mock_client(&server.uri());
assert!(run_list_comments(&client, "PROJ-1", 2, &OutputFormat::Json)
.await
.is_ok());
}
#[tokio::test]
async fn run_add_comment_success() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1/comment"))
.respond_with(
wiremock::ResponseTemplate::new(201)
.set_body_json(serde_json::json!({"id": "100"})),
)
.mount(&server)
.await;
let client = mock_client(&server.uri());
let adf = ValidatedAdfDocument::empty();
assert!(run_add_comment(&client, "PROJ-1", &adf).await.is_ok());
}
#[tokio::test]
async fn run_add_comment_api_error() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1/comment"))
.respond_with(wiremock::ResponseTemplate::new(403).set_body_string("Forbidden"))
.mount(&server)
.await;
let client = mock_client(&server.uri());
let adf = ValidatedAdfDocument::empty();
let err = run_add_comment(&client, "PROJ-1", &adf).await.unwrap_err();
assert!(err.to_string().contains("403"));
}
#[tokio::test]
async fn run_edit_comment_success() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("PUT"))
.and(wiremock::matchers::path(
"/rest/api/3/issue/PROJ-1/comment/100",
))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "100",
"author": {"displayName": "Me"},
"created": "2026-04-01T10:00:00.000+0000",
"updated": "2026-05-10T12:00:00.000+0000",
"body": null
})),
)
.mount(&server)
.await;
let client = mock_client(&server.uri());
let adf = ValidatedAdfDocument::empty();
assert!(run_edit_comment(&client, "PROJ-1", "100", &adf, None)
.await
.is_ok());
}
#[tokio::test]
async fn run_edit_comment_with_visibility_sends_payload() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("PUT"))
.and(wiremock::matchers::path(
"/rest/api/3/issue/PROJ-1/comment/100",
))
.and(wiremock::matchers::body_partial_json(serde_json::json!({
"visibility": {"type": "role", "identifier": "Administrators"}
})))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "100",
"author": {"displayName": "Me"},
"created": "2026-04-01T10:00:00.000+0000",
"updated": "2026-05-10T12:00:00.000+0000",
"body": null
})),
)
.expect(1)
.mount(&server)
.await;
let client = mock_client(&server.uri());
let adf = ValidatedAdfDocument::empty();
let visibility = JiraVisibility {
ty: JiraVisibilityType::Role,
value: "Administrators".to_string(),
};
run_edit_comment(&client, "PROJ-1", "100", &adf, Some(&visibility))
.await
.unwrap();
}
#[tokio::test]
async fn run_edit_comment_forbidden() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("PUT"))
.and(wiremock::matchers::path(
"/rest/api/3/issue/PROJ-1/comment/100",
))
.respond_with(
wiremock::ResponseTemplate::new(403).set_body_json(serde_json::json!({
"errorMessages": ["You do not have permission to edit this comment"]
})),
)
.mount(&server)
.await;
let client = mock_client(&server.uri());
let adf = ValidatedAdfDocument::empty();
let err = run_edit_comment(&client, "PROJ-1", "100", &adf, None)
.await
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("403"));
assert!(msg.contains("permission to edit"));
}
#[tokio::test]
async fn run_edit_comment_not_found() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("PUT"))
.and(wiremock::matchers::path(
"/rest/api/3/issue/PROJ-1/comment/9999",
))
.respond_with(
wiremock::ResponseTemplate::new(404).set_body_json(serde_json::json!({
"errorMessages": ["Comment not found"]
})),
)
.mount(&server)
.await;
let client = mock_client(&server.uri());
let adf = ValidatedAdfDocument::empty();
let err = run_edit_comment(&client, "PROJ-1", "9999", &adf, None)
.await
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("404"));
assert!(msg.contains("Comment not found"));
}
}