use owo_colors::OwoColorize;
use crate::api::{ApiError, Issue, IssueLink, JiraClient, escape_jql};
use crate::output::{OutputConfig, use_color};
#[allow(clippy::too_many_arguments)]
pub async fn list(
client: &JiraClient,
out: &OutputConfig,
project: Option<&str>,
status: Option<&str>,
assignee: Option<&str>,
issue_type: Option<&str>,
sprint: Option<&str>,
jql_extra: Option<&str>,
limit: usize,
offset: usize,
all: bool,
) -> Result<(), ApiError> {
let jql = build_list_jql(project, status, assignee, issue_type, sprint, jql_extra);
if all {
let issues = fetch_all_issues(client, &jql).await?;
render_results(out, &issues, issues.len(), 0, issues.len(), client, false);
} else {
let resp = client.search(&jql, limit, offset).await?;
let more = resp.total > resp.start_at + resp.issues.len();
render_results(
out,
&resp.issues,
resp.total,
resp.start_at,
resp.max_results,
client,
more,
);
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub async fn mine(
client: &JiraClient,
out: &OutputConfig,
project: Option<&str>,
status: Option<&str>,
issue_type: Option<&str>,
sprint: Option<&str>,
limit: usize,
all: bool,
) -> Result<(), ApiError> {
list(
client,
out,
project,
status,
Some("me"),
issue_type,
sprint,
None,
limit,
0,
all,
)
.await
}
pub async fn comments(client: &JiraClient, out: &OutputConfig, key: &str) -> Result<(), ApiError> {
let issue = client.get_issue(key).await?;
let comment_list = issue.fields.comment.as_ref();
if out.json {
let comments_json: Vec<serde_json::Value> = comment_list
.map(|cl| {
cl.comments
.iter()
.map(|c| {
serde_json::json!({
"id": c.id,
"author": {
"displayName": c.author.display_name,
"accountId": c.author.account_id,
},
"body": c.body_text(),
"created": c.created,
"updated": c.updated,
})
})
.collect()
})
.unwrap_or_default();
let total = comment_list.map(|cl| cl.total).unwrap_or(0);
out.print_data(
&serde_json::to_string_pretty(&serde_json::json!({
"issue": key,
"total": total,
"comments": comments_json,
}))
.expect("failed to serialize JSON"),
);
} else {
match comment_list {
None => {
out.print_message(&format!("No comments on {key}."));
}
Some(cl) if cl.comments.is_empty() => {
out.print_message(&format!("No comments on {key}."));
}
Some(cl) => {
let color = use_color();
out.print_message(&format!("Comments on {key} ({}):", cl.total));
for c in &cl.comments {
println!();
let author = if color {
c.author.display_name.bold().to_string()
} else {
c.author.display_name.clone()
};
println!(" {} — {}", author, format_date(&c.created));
for line in c.body_text().lines() {
println!(" {line}");
}
}
}
}
}
Ok(())
}
pub async fn fetch_all_issues(client: &JiraClient, jql: &str) -> Result<Vec<Issue>, ApiError> {
const PAGE_SIZE: usize = 100;
let mut all: Vec<Issue> = Vec::new();
let mut offset = 0;
loop {
let resp = client.search(jql, PAGE_SIZE, offset).await?;
let fetched = resp.issues.len();
all.extend(resp.issues);
offset += fetched;
if offset >= resp.total || fetched == 0 {
break;
}
}
Ok(all)
}
fn render_results(
out: &OutputConfig,
issues: &[Issue],
total: usize,
start_at: usize,
max_results: usize,
client: &JiraClient,
more: bool,
) {
if out.json {
out.print_data(
&serde_json::to_string_pretty(&serde_json::json!({
"total": total,
"startAt": start_at,
"maxResults": max_results,
"issues": issues.iter().map(|i| issue_to_json(i, client)).collect::<Vec<_>>(),
}))
.expect("failed to serialize JSON"),
);
} else {
render_issue_table(issues, out);
if more {
out.print_message(&format!(
"Showing {}-{} of {} issues — use --limit/--offset or --all to paginate",
start_at + 1,
start_at + issues.len(),
total
));
} else {
out.print_message(&format!("{} issues", issues.len()));
}
}
}
pub async fn show(
client: &JiraClient,
out: &OutputConfig,
key: &str,
open: bool,
) -> Result<(), ApiError> {
let issue = client.get_issue(key).await?;
if open {
open_in_browser(&client.browse_url(&issue.key));
}
if out.json {
out.print_data(
&serde_json::to_string_pretty(&issue_detail_to_json(&issue, client))
.expect("failed to serialize JSON"),
);
} else {
render_issue_detail(&issue);
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub async fn create(
client: &JiraClient,
out: &OutputConfig,
project: &str,
issue_type: &str,
summary: &str,
description: Option<&str>,
priority: Option<&str>,
labels: Option<&[&str]>,
assignee: Option<&str>,
sprint: Option<&str>,
parent: Option<&str>,
custom_fields: &[(String, serde_json::Value)],
) -> Result<(), ApiError> {
let resp = client
.create_issue(
project,
issue_type,
summary,
description,
priority,
labels,
assignee,
parent,
custom_fields,
)
.await?;
let url = client.browse_url(&resp.key);
let mut result = serde_json::json!({ "key": resp.key, "id": resp.id, "url": url });
if let Some(p) = parent {
result["parent"] = serde_json::json!(p);
}
if let Some(s) = sprint {
let resolved = client.resolve_sprint(s).await?;
client.move_issue_to_sprint(&resp.key, resolved.id).await?;
result["sprintId"] = serde_json::json!(resolved.id);
result["sprintName"] = serde_json::json!(resolved.name);
}
out.print_result(&result, &resp.key);
Ok(())
}
pub async fn update(
client: &JiraClient,
out: &OutputConfig,
key: &str,
summary: Option<&str>,
description: Option<&str>,
priority: Option<&str>,
custom_fields: &[(String, serde_json::Value)],
) -> Result<(), ApiError> {
client
.update_issue(key, summary, description, priority, custom_fields)
.await?;
out.print_result(
&serde_json::json!({ "key": key, "updated": true }),
&format!("Updated {key}"),
);
Ok(())
}
pub async fn move_to_sprint(
client: &JiraClient,
out: &OutputConfig,
key: &str,
sprint: &str,
) -> Result<(), ApiError> {
let resolved = client.resolve_sprint(sprint).await?;
client.move_issue_to_sprint(key, resolved.id).await?;
out.print_result(
&serde_json::json!({
"issue": key,
"sprintId": resolved.id,
"sprintName": resolved.name,
}),
&format!("Moved {key} to {} ({})", resolved.name, resolved.id),
);
Ok(())
}
pub async fn comment(
client: &JiraClient,
out: &OutputConfig,
key: &str,
body: &str,
) -> Result<(), ApiError> {
let c = client.add_comment(key, body).await?;
let url = client.browse_url(key);
out.print_result(
&serde_json::json!({
"id": c.id,
"issue": key,
"url": url,
"author": c.author.display_name,
"created": c.created,
}),
&format!("Comment added to {key}"),
);
Ok(())
}
pub async fn transition(
client: &JiraClient,
out: &OutputConfig,
key: &str,
to: &str,
) -> Result<(), ApiError> {
let transitions = client.get_transitions(key).await?;
let matched = transitions
.iter()
.find(|t| t.name.to_lowercase() == to.to_lowercase() || t.id == to);
match matched {
Some(t) => {
let name = t.name.clone();
let id = t.id.clone();
let status =
t.to.as_ref()
.map(|tt| tt.name.clone())
.unwrap_or_else(|| name.clone());
client.do_transition(key, &id).await?;
out.print_result(
&serde_json::json!({ "issue": key, "transition": name, "status": status, "id": id }),
&format!("Transitioned {key} → {status}"),
);
}
None => {
let hint = transitions
.iter()
.map(|t| format!(" {} ({})", t.name, t.id))
.collect::<Vec<_>>()
.join("\n");
out.print_message(&format!(
"Transition '{to}' not found for {key}. Available:\n{hint}"
));
out.print_message(&format!(
"Tip: `jira issues list-transitions {key}` shows transitions as JSON."
));
return Err(ApiError::NotFound(format!(
"Transition '{to}' not found for {key}"
)));
}
}
Ok(())
}
pub async fn list_transitions(
client: &JiraClient,
out: &OutputConfig,
key: &str,
) -> Result<(), ApiError> {
let ts = client.get_transitions(key).await?;
if out.json {
out.print_data(&serde_json::to_string_pretty(&ts).expect("failed to serialize JSON"));
} else {
let color = use_color();
let header = format!("{:<6} {}", "ID", "Name");
if color {
println!("{}", header.bold());
} else {
println!("{header}");
}
for t in &ts {
println!("{:<6} {}", t.id, t.name);
}
}
Ok(())
}
pub async fn assign(
client: &JiraClient,
out: &OutputConfig,
key: &str,
assignee: &str,
) -> Result<(), ApiError> {
let account_id = if assignee == "me" {
let me = client.get_myself().await?;
me.account_id
} else if assignee == "none" || assignee == "unassign" {
client.assign_issue(key, None).await?;
out.print_result(
&serde_json::json!({ "issue": key, "assignee": null }),
&format!("Unassigned {key}"),
);
return Ok(());
} else {
assignee.to_string()
};
client.assign_issue(key, Some(&account_id)).await?;
out.print_result(
&serde_json::json!({ "issue": key, "accountId": account_id }),
&format!("Assigned {key} to {assignee}"),
);
Ok(())
}
pub async fn link_types(client: &JiraClient, out: &OutputConfig) -> Result<(), ApiError> {
let types = client.get_link_types().await?;
if out.json {
out.print_data(
&serde_json::to_string_pretty(&serde_json::json!(
types
.iter()
.map(|t| serde_json::json!({
"id": t.id,
"name": t.name,
"inward": t.inward,
"outward": t.outward,
}))
.collect::<Vec<_>>()
))
.expect("failed to serialize JSON"),
);
return Ok(());
}
for t in &types {
println!(
"{:<20} outward: {} / inward: {}",
t.name, t.outward, t.inward
);
}
Ok(())
}
pub async fn link(
client: &JiraClient,
out: &OutputConfig,
from_key: &str,
to_key: &str,
link_type: &str,
) -> Result<(), ApiError> {
client.link_issues(from_key, to_key, link_type).await?;
out.print_result(
&serde_json::json!({
"from": from_key,
"to": to_key,
"type": link_type,
}),
&format!("Linked {from_key} → {to_key} ({link_type})"),
);
Ok(())
}
pub async fn unlink(
client: &JiraClient,
out: &OutputConfig,
link_id: &str,
) -> Result<(), ApiError> {
client.unlink_issues(link_id).await?;
out.print_result(
&serde_json::json!({ "linkId": link_id }),
&format!("Removed link {link_id}"),
);
Ok(())
}
pub async fn log_work(
client: &JiraClient,
out: &OutputConfig,
key: &str,
time_spent: &str,
comment: Option<&str>,
started: Option<&str>,
) -> Result<(), ApiError> {
let entry = client.log_work(key, time_spent, comment, started).await?;
out.print_result(
&serde_json::json!({
"id": entry.id,
"issue": key,
"timeSpent": entry.time_spent,
"timeSpentSeconds": entry.time_spent_seconds,
"author": entry.author.display_name,
"started": entry.started,
"created": entry.created,
}),
&format!("Logged {} on {key}", entry.time_spent),
);
Ok(())
}
pub async fn bulk_transition(
client: &JiraClient,
out: &OutputConfig,
jql: &str,
to: &str,
dry_run: bool,
) -> Result<(), ApiError> {
let issues = fetch_all_issues(client, jql).await?;
if issues.is_empty() {
out.print_message("No issues matched the query.");
return Ok(());
}
let mut results: Vec<serde_json::Value> = Vec::new();
let mut succeeded = 0usize;
let mut failed = 0usize;
for issue in &issues {
if dry_run {
results.push(serde_json::json!({
"key": issue.key,
"status": issue.status(),
"action": "would transition",
"to": to,
}));
continue;
}
let transitions = client.get_transitions(&issue.key).await?;
let matched = transitions.iter().find(|t| {
t.name.eq_ignore_ascii_case(to)
|| t.to
.as_ref()
.is_some_and(|tt| tt.name.eq_ignore_ascii_case(to))
|| t.id == to
});
match matched {
Some(t) => match client.do_transition(&issue.key, &t.id).await {
Ok(()) => {
succeeded += 1;
results.push(serde_json::json!({
"key": issue.key,
"from": issue.status(),
"to": to,
"ok": true,
}));
}
Err(e) => {
failed += 1;
results.push(serde_json::json!({
"key": issue.key,
"ok": false,
"error": e.to_string(),
}));
}
},
None => {
failed += 1;
results.push(serde_json::json!({
"key": issue.key,
"ok": false,
"error": format!("transition '{to}' not available"),
}));
}
}
}
if out.json {
out.print_data(
&serde_json::to_string_pretty(&serde_json::json!({
"dryRun": dry_run,
"total": issues.len(),
"succeeded": succeeded,
"failed": failed,
"issues": results,
}))
.expect("failed to serialize JSON"),
);
} else if dry_run {
render_issue_table(&issues, out);
out.print_message(&format!(
"Dry run: {} issues would be transitioned to '{to}'",
issues.len()
));
} else {
out.print_message(&format!(
"Transitioned {succeeded}/{} issues to '{to}'{}",
issues.len(),
if failed > 0 {
format!(" ({failed} failed)")
} else {
String::new()
}
));
}
Ok(())
}
pub async fn bulk_assign(
client: &JiraClient,
out: &OutputConfig,
jql: &str,
assignee: &str,
dry_run: bool,
) -> Result<(), ApiError> {
let account_id: Option<String> = match assignee {
"me" => {
let me = client.get_myself().await?;
Some(me.account_id)
}
"none" | "unassign" => None,
id => Some(id.to_string()),
};
let issues = fetch_all_issues(client, jql).await?;
if issues.is_empty() {
out.print_message("No issues matched the query.");
return Ok(());
}
let mut results: Vec<serde_json::Value> = Vec::new();
let mut succeeded = 0usize;
let mut failed = 0usize;
for issue in &issues {
if dry_run {
results.push(serde_json::json!({
"key": issue.key,
"currentAssignee": issue.assignee(),
"action": "would assign",
"to": assignee,
}));
continue;
}
match client.assign_issue(&issue.key, account_id.as_deref()).await {
Ok(()) => {
succeeded += 1;
results.push(serde_json::json!({
"key": issue.key,
"assignee": assignee,
"ok": true,
}));
}
Err(e) => {
failed += 1;
results.push(serde_json::json!({
"key": issue.key,
"ok": false,
"error": e.to_string(),
}));
}
}
}
if out.json {
out.print_data(
&serde_json::to_string_pretty(&serde_json::json!({
"dryRun": dry_run,
"total": issues.len(),
"succeeded": succeeded,
"failed": failed,
"issues": results,
}))
.expect("failed to serialize JSON"),
);
} else if dry_run {
render_issue_table(&issues, out);
out.print_message(&format!(
"Dry run: {} issues would be assigned to '{assignee}'",
issues.len()
));
} else {
out.print_message(&format!(
"Assigned {succeeded}/{} issues to '{assignee}'{}",
issues.len(),
if failed > 0 {
format!(" ({failed} failed)")
} else {
String::new()
}
));
}
Ok(())
}
pub(crate) fn render_issue_table(issues: &[Issue], out: &OutputConfig) {
if issues.is_empty() {
out.print_message("No issues found.");
return;
}
let color = use_color();
let term_width = terminal_width();
let key_w = issues.iter().map(|i| i.key.len()).max().unwrap_or(4).max(4) + 1;
let status_w = issues
.iter()
.map(|i| i.status().len())
.max()
.unwrap_or(6)
.clamp(6, 14)
+ 2;
let assignee_w = issues
.iter()
.map(|i| i.assignee().len())
.max()
.unwrap_or(8)
.clamp(8, 18)
+ 2;
let type_w = issues
.iter()
.map(|i| i.issue_type().len())
.max()
.unwrap_or(4)
.clamp(4, 12)
+ 2;
let fixed = key_w + 1 + status_w + 1 + assignee_w + 1 + type_w + 1;
let summary_w = term_width.saturating_sub(fixed).max(20);
let header = format!(
"{:<key_w$} {:<status_w$} {:<assignee_w$} {:<type_w$} {}",
"Key", "Status", "Assignee", "Type", "Summary"
);
if color {
println!("{}", header.bold());
} else {
println!("{header}");
}
for issue in issues {
let key = if color {
format!("{:<key_w$}", issue.key).yellow().to_string()
} else {
format!("{:<key_w$}", issue.key)
};
let status_val = truncate(issue.status(), status_w - 2);
let status = if color {
colorize_status(issue.status(), &format!("{:<status_w$}", status_val))
} else {
format!("{:<status_w$}", status_val)
};
println!(
"{key} {status} {:<assignee_w$} {:<type_w$} {}",
truncate(issue.assignee(), assignee_w - 2),
truncate(issue.issue_type(), type_w - 2),
truncate(issue.summary(), summary_w),
);
}
}
fn render_issue_detail(issue: &Issue) {
let color = use_color();
let key = if color {
issue.key.yellow().bold().to_string()
} else {
issue.key.clone()
};
println!("{key} {}", issue.summary());
println!();
println!(" Type: {}", issue.issue_type());
let status_str = if color {
colorize_status(issue.status(), issue.status())
} else {
issue.status().to_string()
};
println!(" Status: {status_str}");
println!(" Priority: {}", issue.priority());
println!(" Assignee: {}", issue.assignee());
if let Some(ref reporter) = issue.fields.reporter {
println!(" Reporter: {}", reporter.display_name);
}
if let Some(ref labels) = issue.fields.labels
&& !labels.is_empty()
{
println!(" Labels: {}", labels.join(", "));
}
if let Some(ref created) = issue.fields.created {
println!(" Created: {}", format_date(created));
}
if let Some(ref updated) = issue.fields.updated {
println!(" Updated: {}", format_date(updated));
}
let desc = issue.description_text();
if !desc.is_empty() {
println!();
println!("Description:");
for line in desc.lines() {
println!(" {line}");
}
}
if let Some(ref links) = issue.fields.issue_links
&& !links.is_empty()
{
println!();
println!("Links:");
for link in links {
render_issue_link(link);
}
}
if let Some(ref comment_list) = issue.fields.comment
&& !comment_list.comments.is_empty()
{
println!();
println!("Comments ({}):", comment_list.total);
for c in &comment_list.comments {
println!();
let author = if color {
c.author.display_name.bold().to_string()
} else {
c.author.display_name.clone()
};
println!(" {} — {}", author, format_date(&c.created));
let body = c.body_text();
for line in body.lines() {
println!(" {line}");
}
}
}
}
fn render_issue_link(link: &IssueLink) {
if let Some(ref out_issue) = link.outward_issue {
println!(
" [{}] {} {} — {}",
link.id, link.link_type.outward, out_issue.key, out_issue.fields.summary
);
}
if let Some(ref in_issue) = link.inward_issue {
println!(
" [{}] {} {} — {}",
link.id, link.link_type.inward, in_issue.key, in_issue.fields.summary
);
}
}
pub(crate) fn issue_to_json(issue: &Issue, client: &JiraClient) -> serde_json::Value {
serde_json::json!({
"key": issue.key,
"id": issue.id,
"url": client.browse_url(&issue.key),
"summary": issue.summary(),
"status": issue.status(),
"assignee": {
"displayName": issue.assignee(),
"accountId": issue.fields.assignee.as_ref().and_then(|a| a.account_id.as_deref()),
},
"priority": issue.priority(),
"type": issue.issue_type(),
"created": issue.fields.created,
"updated": issue.fields.updated,
})
}
fn issue_detail_to_json(issue: &Issue, client: &JiraClient) -> serde_json::Value {
let comments: Vec<serde_json::Value> = issue
.fields
.comment
.as_ref()
.map(|cl| {
cl.comments
.iter()
.map(|c| {
serde_json::json!({
"id": c.id,
"author": {
"displayName": c.author.display_name,
"accountId": c.author.account_id,
},
"body": c.body_text(),
"created": c.created,
"updated": c.updated,
})
})
.collect()
})
.unwrap_or_default();
let issue_links: Vec<serde_json::Value> = issue
.fields
.issue_links
.as_deref()
.unwrap_or_default()
.iter()
.map(|link| {
let sentence = if let Some(ref out_issue) = link.outward_issue {
format!("{} {} {}", issue.key, link.link_type.outward, out_issue.key)
} else if let Some(ref in_issue) = link.inward_issue {
format!("{} {} {}", issue.key, link.link_type.inward, in_issue.key)
} else {
String::new()
};
serde_json::json!({
"id": link.id,
"sentence": sentence,
"type": {
"id": link.link_type.id,
"name": link.link_type.name,
"inward": link.link_type.inward,
"outward": link.link_type.outward,
},
"outwardIssue": link.outward_issue.as_ref().map(|i| serde_json::json!({
"key": i.key,
"summary": i.fields.summary,
"status": i.fields.status.name,
})),
"inwardIssue": link.inward_issue.as_ref().map(|i| serde_json::json!({
"key": i.key,
"summary": i.fields.summary,
"status": i.fields.status.name,
})),
})
})
.collect();
serde_json::json!({
"key": issue.key,
"id": issue.id,
"url": client.browse_url(&issue.key),
"summary": issue.summary(),
"status": issue.status(),
"type": issue.issue_type(),
"priority": issue.priority(),
"assignee": {
"displayName": issue.assignee(),
"accountId": issue.fields.assignee.as_ref().and_then(|a| a.account_id.as_deref()),
},
"reporter": issue.fields.reporter.as_ref().map(|r| serde_json::json!({
"displayName": r.display_name,
"accountId": r.account_id,
})),
"labels": issue.fields.labels,
"description": issue.description_text(),
"created": issue.fields.created,
"updated": issue.fields.updated,
"comments": comments,
"issueLinks": issue_links,
})
}
fn build_list_jql(
project: Option<&str>,
status: Option<&str>,
assignee: Option<&str>,
issue_type: Option<&str>,
sprint: Option<&str>,
extra: Option<&str>,
) -> String {
let mut parts: Vec<String> = Vec::new();
if let Some(p) = project {
parts.push(format!(r#"project = "{}""#, escape_jql(p)));
}
if let Some(s) = status {
parts.push(format!(r#"status = "{}""#, escape_jql(s)));
}
if let Some(a) = assignee {
if a == "me" {
parts.push("assignee = currentUser()".into());
} else {
parts.push(format!(r#"assignee = "{}""#, escape_jql(a)));
}
}
if let Some(t) = issue_type {
parts.push(format!(r#"issuetype = "{}""#, escape_jql(t)));
}
if let Some(s) = sprint {
if s == "active" || s == "open" {
parts.push("sprint in openSprints()".into());
} else {
parts.push(format!(r#"sprint = "{}""#, escape_jql(s)));
}
}
if let Some(e) = extra {
parts.push(format!("({e})"));
}
if parts.is_empty() {
"ORDER BY updated DESC".into()
} else {
format!("{} ORDER BY updated DESC", parts.join(" AND "))
}
}
fn colorize_status(status: &str, display: &str) -> String {
let lower = status.to_lowercase();
if lower.contains("done") || lower.contains("closed") || lower.contains("resolved") {
display.green().to_string()
} else if lower.contains("progress") || lower.contains("review") || lower.contains("testing") {
display.yellow().to_string()
} else if lower.contains("blocked") || lower.contains("impediment") {
display.red().to_string()
} else {
display.to_string()
}
}
fn open_in_browser(url: &str) {
#[cfg(target_os = "macos")]
let result = std::process::Command::new("open").arg(url).status();
#[cfg(target_os = "linux")]
let result = std::process::Command::new("xdg-open").arg(url).status();
#[cfg(target_os = "windows")]
let result = std::process::Command::new("cmd")
.args(["/c", "start", url])
.status();
#[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))]
if let Err(e) = result {
eprintln!("Warning: could not open browser: {e}");
}
}
fn truncate(s: &str, max: usize) -> String {
let mut chars = s.chars();
let mut result: String = chars.by_ref().take(max).collect();
if chars.next().is_some() {
result.push('…');
}
result
}
fn format_date(s: &str) -> String {
s.chars().take(10).collect()
}
fn terminal_width() -> usize {
std::env::var("COLUMNS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(120)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn truncate_short_string() {
assert_eq!(truncate("hello", 10), "hello");
}
#[test]
fn truncate_exact_length() {
assert_eq!(truncate("hello", 5), "hello");
}
#[test]
fn truncate_long_string() {
assert_eq!(truncate("hello world", 5), "hello…");
}
#[test]
fn truncate_multibyte_safe() {
let result = truncate("日本語テスト", 3);
assert_eq!(result, "日本語…");
}
#[test]
fn build_list_jql_empty() {
assert_eq!(
build_list_jql(None, None, None, None, None, None),
"ORDER BY updated DESC"
);
}
#[test]
fn build_list_jql_escapes_quotes() {
let jql = build_list_jql(None, Some(r#"Done" OR 1=1"#), None, None, None, None);
assert!(jql.contains(r#"\""#), "double quote must be escaped");
assert!(
jql.contains(r#"status = "Done\""#),
"escaped quote must remain inside the status value string"
);
}
#[test]
fn build_list_jql_project_and_status() {
let jql = build_list_jql(Some("PROJ"), Some("In Progress"), None, None, None, None);
assert!(jql.contains(r#"project = "PROJ""#));
assert!(jql.contains(r#"status = "In Progress""#));
}
#[test]
fn build_list_jql_assignee_me() {
let jql = build_list_jql(None, None, Some("me"), None, None, None);
assert!(jql.contains("currentUser()"));
}
#[test]
fn build_list_jql_issue_type() {
let jql = build_list_jql(None, None, None, Some("Bug"), None, None);
assert!(jql.contains(r#"issuetype = "Bug""#));
}
#[test]
fn build_list_jql_sprint_active() {
let jql = build_list_jql(None, None, None, None, Some("active"), None);
assert!(jql.contains("sprint in openSprints()"));
}
#[test]
fn build_list_jql_sprint_named() {
let jql = build_list_jql(None, None, None, None, Some("Sprint 42"), None);
assert!(jql.contains(r#"sprint = "Sprint 42""#));
}
#[test]
fn colorize_status_done_is_green() {
let result = colorize_status("Done", "Done");
assert!(result.contains("Done"));
assert!(result.contains("\x1b["));
}
#[test]
fn colorize_status_unknown_unchanged() {
let result = colorize_status("Backlog", "Backlog");
assert_eq!(result, "Backlog");
}
struct EnvVarGuard(&'static str);
impl Drop for EnvVarGuard {
fn drop(&mut self) {
unsafe { std::env::remove_var(self.0) }
}
}
#[test]
fn terminal_width_fallback_parses_columns() {
unsafe { std::env::set_var("COLUMNS", "200") };
let _guard = EnvVarGuard("COLUMNS");
assert_eq!(terminal_width(), 200);
}
}