use super::{
RoutedWorkspaceWriteLock, acquire_routed_workspace_write_lock,
auto_import_storage_ctx_if_stale, cli_for_routed_workspace, report_auto_flush_failure,
resolve_issue_id, retry_mutation_with_jsonl_recovery,
};
use crate::cli::{CommentAddArgs, CommentCommands, CommentsArgs};
use crate::config;
use crate::error::{BeadsError, Result};
use crate::format::{sanitize_terminal_inline, sanitize_terminal_text};
use crate::model::Comment;
use crate::output::{OutputContext, OutputMode};
use crate::storage::SqliteStorage;
use crate::util::id::{IdResolver, ResolverConfig};
use crate::util::time::format_relative_time;
use chrono::Utc;
use rich_rust::prelude::*;
use std::fs;
use std::io::Read;
use std::path::Path;
pub fn execute(
args: &CommentsArgs,
json: bool,
cli: &config::CliOverrides,
ctx: &OutputContext,
) -> Result<()> {
let beads_dir = config::discover_beads_dir_with_cli(cli)?;
match &args.command {
Some(CommentCommands::Add(add_args)) => execute_add(add_args, cli, ctx, &beads_dir),
Some(CommentCommands::List(list_args)) => {
execute_list(&list_args.id, json, cli, ctx, &beads_dir, list_args.wrap)
}
None => {
let id = args
.id
.as_deref()
.ok_or_else(|| BeadsError::validation("id", "missing issue id"))?;
execute_list(id, json, cli, ctx, &beads_dir, args.wrap)
}
}
}
pub fn execute_with_storage_ctx(
args: &CommentsArgs,
json: bool,
cli: &config::CliOverrides,
ctx: &OutputContext,
local_beads_dir: &Path,
storage_ctx: &config::OpenStorageResult,
) -> Result<bool> {
match &args.command {
Some(CommentCommands::Add(_)) => Ok(false),
Some(CommentCommands::List(list_args)) => execute_list_with_storage_ctx(
&list_args.id,
json,
cli,
ctx,
local_beads_dir,
list_args.wrap,
storage_ctx,
),
None => {
let id = args
.id
.as_deref()
.ok_or_else(|| BeadsError::validation("id", "missing issue id"))?;
execute_list_with_storage_ctx(
id,
json,
cli,
ctx,
local_beads_dir,
args.wrap,
storage_ctx,
)
}
}
}
fn execute_add(
args: &CommentAddArgs,
cli: &config::CliOverrides,
ctx: &OutputContext,
beads_dir: &Path,
) -> Result<()> {
let (mut storage_ctx, route_cli, auto_flush_external, _routed_write_lock) =
open_routed_storage_for_input(beads_dir, cli, &args.id)?;
let config_layer = storage_ctx.load_config(&route_cli)?;
let id_config = config::id_config_from_layer(&config_layer);
let resolver = IdResolver::new(ResolverConfig::with_prefix(id_config.prefix));
let actor = config::actor_from_layer(&config_layer);
let (issue_id, author, text) =
prepare_comment_add(args, &storage_ctx.storage, &resolver, actor.as_deref())?;
let comment = retry_mutation_with_jsonl_recovery(
&mut storage_ctx,
true,
"comment add",
Some(issue_id.as_str()),
|storage| storage.add_comment(&issue_id, &author, &text),
)?;
storage_ctx.flush_no_db_if_dirty()?;
if auto_flush_external && let Err(error) = storage_ctx.auto_flush_if_enabled() {
report_auto_flush_failure(
ctx,
&storage_ctx.paths.beads_dir,
&storage_ctx.paths.jsonl_path,
&error,
);
}
crate::util::set_last_touched_id(beads_dir, &issue_id);
if matches!(ctx.mode(), OutputMode::Quiet) {
return Ok(());
}
if ctx.is_json() {
ctx.json_pretty(&comment);
} else if ctx.is_toon() {
ctx.toon(&comment);
} else if ctx.is_rich() {
render_comment_added_rich(&issue_id, &comment, ctx);
} else {
println!("{}", comment_added_message(&issue_id));
}
Ok(())
}
fn execute_list(
issue_input: &str,
json: bool,
cli: &config::CliOverrides,
ctx: &OutputContext,
beads_dir: &Path,
wrap: bool,
) -> Result<()> {
let (storage_ctx, route_cli, _, _routed_write_lock) =
open_routed_storage_for_input(beads_dir, cli, issue_input)?;
let config_layer = storage_ctx.load_config(&route_cli)?;
let id_config = config::id_config_from_layer(&config_layer);
let resolver = IdResolver::new(ResolverConfig::with_prefix(id_config.prefix));
list_comments_by_id(
issue_input,
&storage_ctx.storage,
&resolver,
json,
ctx,
wrap,
)
}
fn execute_list_with_storage_ctx(
issue_input: &str,
json: bool,
cli: &config::CliOverrides,
ctx: &OutputContext,
local_beads_dir: &Path,
wrap: bool,
storage_ctx: &config::OpenStorageResult,
) -> Result<bool> {
let route = config::routing::resolve_route(issue_input, local_beads_dir)?;
if route.is_external {
return Ok(false);
}
let config_layer = storage_ctx.load_config(cli)?;
let id_config = config::id_config_from_layer(&config_layer);
let resolver = IdResolver::new(ResolverConfig::with_prefix(id_config.prefix));
list_comments_by_id(
issue_input,
&storage_ctx.storage,
&resolver,
json,
ctx,
wrap,
)?;
Ok(true)
}
fn open_routed_storage_for_input(
local_beads_dir: &Path,
cli: &config::CliOverrides,
issue_input: &str,
) -> Result<(
config::OpenStorageResult,
config::CliOverrides,
bool,
RoutedWorkspaceWriteLock,
)> {
let route = config::routing::resolve_route(issue_input, local_beads_dir)?;
let mut route_cli = cli_for_routed_workspace(cli, route.is_external);
let routed_write_lock = acquire_routed_workspace_write_lock(
&route.beads_dir,
route.is_external,
route_cli.lock_timeout,
)?;
routed_write_lock.mark_cli_write_lock_held(&mut route_cli);
let mut storage_ctx = config::open_storage_with_cli(&route.beads_dir, &route_cli)?;
auto_import_storage_ctx_if_stale(&mut storage_ctx, &route_cli)?;
Ok((storage_ctx, route_cli, route.is_external, routed_write_lock))
}
fn prepare_comment_add(
args: &CommentAddArgs,
storage: &SqliteStorage,
resolver: &IdResolver,
actor: Option<&str>,
) -> Result<(String, String, String)> {
let issue_id = resolve_issue_id(storage, resolver, &args.id)?;
let text = read_comment_text(args)?;
if text.trim().is_empty() {
return Err(BeadsError::validation(
"text",
"comment text cannot be empty",
));
}
let author = resolve_author(args.author.as_deref(), actor);
Ok((issue_id, author, text))
}
fn list_comments_by_id(
id: &str,
storage: &SqliteStorage,
resolver: &IdResolver,
_json: bool,
ctx: &OutputContext,
wrap: bool,
) -> Result<()> {
let issue_id = resolve_issue_id(storage, resolver, id)?;
let comments = storage.get_comments(&issue_id)?;
if matches!(ctx.mode(), OutputMode::Quiet) {
return Ok(());
}
if ctx.is_json() {
ctx.json_pretty(&comments);
return Ok(());
}
if ctx.is_toon() {
ctx.toon(&comments);
return Ok(());
}
if matches!(ctx.mode(), OutputMode::Rich) {
render_comments_list_rich(&issue_id, &comments, ctx, wrap);
return Ok(());
}
if comments.is_empty() {
println!("{}", no_comments_message(&issue_id));
return Ok(());
}
println!("{}", comments_header(&issue_id));
for comment in comments {
let timestamp = comment.created_at.format("%Y-%m-%d %H:%M UTC");
println!(
"[{}] at {}",
sanitize_terminal_inline(&comment.author),
timestamp
);
println!(
"{}",
sanitize_terminal_text(comment.body.trim_end_matches('\n'))
);
println!();
}
Ok(())
}
fn render_comments_list_rich(
issue_id: &str,
comments: &[Comment],
ctx: &OutputContext,
wrap: bool,
) {
let console = Console::default();
let theme = ctx.theme();
let width = ctx.width();
if comments.is_empty() {
let mut text = Text::new("");
text.append_styled("\u{1f4ad} ", theme.dimmed.clone());
text.append_styled(&no_comments_message(issue_id), theme.dimmed.clone());
console.print_renderable(&text);
return;
}
let mut content = Text::new("");
let now = Utc::now();
for (i, comment) in comments.iter().enumerate() {
if i > 0 {
content.append_styled(
&"\u{2500}".repeat(40.min(width.saturating_sub(4))),
theme.dimmed.clone(),
);
content.append("\n\n");
}
content.append_styled(
&format!("@{}", sanitize_terminal_inline(&comment.author)),
theme.username.clone(),
);
content.append_styled(" \u{2022} ", theme.dimmed.clone());
content.append_styled(
&format_relative_time(comment.created_at, now),
theme.timestamp.clone(),
);
content.append("\n");
content.append(sanitize_terminal_text(comment.body.trim_end_matches('\n')).as_ref());
content.append("\n\n");
}
let title = comments_panel_title(issue_id, comments.len());
let content = if wrap {
wrap_rich_text(&content, width)
} else {
content
};
let panel = Panel::from_rich_text(&content, width)
.title(Text::styled(&title, theme.panel_title.clone()))
.box_style(theme.box_style);
console.print_renderable(&panel);
}
fn wrap_rich_text(text: &Text, panel_width: usize) -> Text {
let content_width = panel_width.saturating_sub(4).max(1);
let lines = text.wrap(content_width);
let mut wrapped = Text::new("");
for (idx, line) in lines.iter().enumerate() {
if idx > 0 {
wrapped.append("\n");
}
wrapped.append_text(line);
}
wrapped
}
fn render_comment_added_rich(issue_id: &str, comment: &Comment, ctx: &OutputContext) {
let console = Console::default();
let theme = ctx.theme();
let mut text = Text::new("");
text.append_styled("\u{2713} ", theme.success.clone());
text.append_styled("Added comment to ", theme.success.clone());
text.append_styled(
sanitize_terminal_inline(issue_id).as_ref(),
theme.issue_id.clone(),
);
console.print_renderable(&text);
console.print("");
let mut comment_text = Text::new("");
comment_text.append_styled(
&format!("@{}", sanitize_terminal_inline(&comment.author)),
theme.username.clone(),
);
comment_text.append_styled(" \u{2022} just now", theme.timestamp.clone());
comment_text.append("\n");
comment_text.append(sanitize_terminal_text(comment.body.trim_end_matches('\n')).as_ref());
console.print_renderable(&comment_text);
}
fn comment_added_message(issue_id: &str) -> String {
format!("Comment added to {}", sanitize_terminal_inline(issue_id))
}
fn comments_header(issue_id: &str) -> String {
format!("Comments for {}:", sanitize_terminal_inline(issue_id))
}
fn no_comments_message(issue_id: &str) -> String {
format!("No comments for {}.", sanitize_terminal_inline(issue_id))
}
fn comments_panel_title(issue_id: &str, count: usize) -> String {
format!("Comments: {} ({count})", sanitize_terminal_inline(issue_id))
}
const MAX_STDIN_COMMENT_BYTES: usize = 10 * 1024 * 1024;
fn read_limited_string<R: Read>(reader: &mut R, byte_limit: usize, field: &str) -> Result<String> {
let max_bytes = byte_limit
.checked_add(1)
.and_then(|limit| u64::try_from(limit).ok())
.unwrap_or(u64::MAX);
let mut buffer = Vec::new();
reader.take(max_bytes).read_to_end(&mut buffer)?;
if buffer.len() > byte_limit {
return Err(BeadsError::validation(
field,
format!("{field} input exceeds maximum size of {byte_limit} bytes"),
));
}
String::from_utf8(buffer).map_err(|err| {
BeadsError::validation(field, format!("{field} input must be valid UTF-8: {err}"))
})
}
fn read_comment_text(args: &CommentAddArgs) -> Result<String> {
if let Some(path) = &args.file {
if path.as_os_str() == "-" {
let mut stdin = std::io::stdin();
return read_limited_string(&mut stdin, MAX_STDIN_COMMENT_BYTES, "text");
}
let mut file = fs::File::open(path)?;
return read_limited_string(&mut file, MAX_STDIN_COMMENT_BYTES, "file");
}
if let Some(message) = &args.message {
return Ok(message.clone());
}
if !args.text.is_empty() {
return Ok(args.text.join(" "));
}
Err(BeadsError::validation("text", "comment text required"))
}
fn resolve_author(author_override: Option<&str>, actor: Option<&str>) -> String {
if let Some(author) = author_override
&& !author.trim().is_empty()
{
return author.to_string();
}
if let Some(actor) = actor
&& !actor.trim().is_empty()
{
return actor.to_string();
}
if let Some(value) = resolve_author_from_env(|name| std::env::var(name).ok()) {
return value;
}
"unknown".to_string()
}
fn resolve_author_from_env(mut lookup: impl FnMut(&str) -> Option<String>) -> Option<String> {
for key in ["BD_ACTOR", "BEADS_ACTOR", "USER", "LOGNAME", "USERNAME"] {
if let Some(value) = lookup(key)
&& !value.trim().is_empty()
{
return Some(value);
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::logging::init_test_logging;
use std::io::Write;
use tempfile::NamedTempFile;
use tracing::info;
#[test]
fn test_resolve_author_with_override() {
init_test_logging();
info!("test_resolve_author_with_override: starting");
let result = resolve_author(Some("custom_author"), Some("actor_name"));
assert_eq!(result, "custom_author");
info!("test_resolve_author_with_override: assertions passed");
}
#[test]
fn test_resolve_author_empty_override_uses_actor() {
init_test_logging();
info!("test_resolve_author_empty_override_uses_actor: starting");
let result = resolve_author(Some(""), Some("actor_name"));
assert_eq!(result, "actor_name");
info!("test_resolve_author_empty_override_uses_actor: assertions passed");
}
#[test]
fn test_resolve_author_whitespace_override_uses_actor() {
init_test_logging();
info!("test_resolve_author_whitespace_override_uses_actor: starting");
let result = resolve_author(Some(" "), Some("actor_name"));
assert_eq!(result, "actor_name");
info!("test_resolve_author_whitespace_override_uses_actor: assertions passed");
}
#[test]
fn test_resolve_author_no_override_uses_actor() {
init_test_logging();
info!("test_resolve_author_no_override_uses_actor: starting");
let result = resolve_author(None, Some("actor_name"));
assert_eq!(result, "actor_name");
info!("test_resolve_author_no_override_uses_actor: assertions passed");
}
#[test]
fn test_resolve_author_empty_actor_falls_through() {
init_test_logging();
info!("test_resolve_author_empty_actor_falls_through: starting");
let result = resolve_author(None, Some(""));
assert!(!result.is_empty());
info!("test_resolve_author_empty_actor_falls_through: assertions passed");
}
#[test]
fn test_resolve_author_env_helper_checks_windows_username() {
init_test_logging();
info!("test_resolve_author_env_helper_checks_windows_username: starting");
let result = resolve_author_from_env(|name| match name {
"USERNAME" => Some("windows-user".to_string()),
_ => None,
});
assert_eq!(result.as_deref(), Some("windows-user"));
info!("test_resolve_author_env_helper_checks_windows_username: assertions passed");
}
#[test]
fn test_read_comment_text_from_message_flag() {
init_test_logging();
info!("test_read_comment_text_from_message_flag: starting");
let args = CommentAddArgs {
id: "test-id".to_string(),
text: vec![],
file: None,
author: None,
message: Some("message flag content".to_string()),
};
let result = read_comment_text(&args).unwrap();
assert_eq!(result, "message flag content");
info!("test_read_comment_text_from_message_flag: assertions passed");
}
#[test]
fn test_read_comment_text_from_positional_args() {
init_test_logging();
info!("test_read_comment_text_from_positional_args: starting");
let args = CommentAddArgs {
id: "test-id".to_string(),
text: vec!["hello".to_string(), "world".to_string()],
file: None,
author: None,
message: None,
};
let result = read_comment_text(&args).unwrap();
assert_eq!(result, "hello world");
info!("test_read_comment_text_from_positional_args: assertions passed");
}
#[test]
fn test_read_comment_text_from_file() {
init_test_logging();
info!("test_read_comment_text_from_file: starting");
let mut file = NamedTempFile::new().unwrap();
writeln!(file, "Comment from file").unwrap();
file.flush().unwrap();
let args = CommentAddArgs {
id: "test-id".to_string(),
text: vec![],
file: Some(file.path().to_path_buf()),
author: None,
message: None,
};
let result = read_comment_text(&args).unwrap();
assert!(result.contains("Comment from file"));
info!("test_read_comment_text_from_file: assertions passed");
}
#[test]
fn test_read_comment_text_rejects_oversized_file() {
init_test_logging();
info!("test_read_comment_text_rejects_oversized_file: starting");
let mut file = NamedTempFile::new().unwrap();
let payload = vec![b'a'; MAX_STDIN_COMMENT_BYTES + 1];
file.write_all(&payload).unwrap();
file.flush().unwrap();
let args = CommentAddArgs {
id: "test-id".to_string(),
text: vec![],
file: Some(file.path().to_path_buf()),
author: None,
message: None,
};
let err = read_comment_text(&args).expect_err("oversized file");
assert!(matches!(err, BeadsError::Validation { field, .. } if field == "file"));
info!("test_read_comment_text_rejects_oversized_file: assertions passed");
}
#[test]
fn test_read_comment_text_file_takes_precedence() {
init_test_logging();
info!("test_read_comment_text_file_takes_precedence: starting");
let mut file = NamedTempFile::new().unwrap();
writeln!(file, "File content").unwrap();
file.flush().unwrap();
let args = CommentAddArgs {
id: "test-id".to_string(),
text: vec!["text content".to_string()],
file: Some(file.path().to_path_buf()),
author: None,
message: Some("message content".to_string()),
};
let result = read_comment_text(&args).unwrap();
assert!(result.contains("File content"));
info!("test_read_comment_text_file_takes_precedence: assertions passed");
}
#[test]
fn test_read_comment_text_no_input_fails() {
init_test_logging();
info!("test_read_comment_text_no_input_fails: starting");
let args = CommentAddArgs {
id: "test-id".to_string(),
text: vec![],
file: None,
author: None,
message: None,
};
let result = read_comment_text(&args);
assert!(result.is_err());
info!("test_read_comment_text_no_input_fails: assertions passed");
}
#[test]
fn test_read_limited_string_accepts_content_within_limit() {
init_test_logging();
info!("test_read_limited_string_accepts_content_within_limit: starting");
let payload = "a".repeat(32);
let mut reader = payload.as_bytes();
let result = read_limited_string(&mut reader, 32, "text").expect("read within limit");
assert_eq!(result.len(), 32);
info!("test_read_limited_string_accepts_content_within_limit: assertions passed");
}
#[test]
fn test_read_limited_string_rejects_oversized_input() {
init_test_logging();
info!("test_read_limited_string_rejects_oversized_input: starting");
let payload = "a".repeat(33);
let mut reader = payload.as_bytes();
let err = read_limited_string(&mut reader, 32, "text").expect_err("oversized stdin");
assert!(matches!(err, BeadsError::Validation { .. }));
info!("test_read_limited_string_rejects_oversized_input: assertions passed");
}
#[test]
fn test_read_limited_string_checks_size_before_utf8_decode() {
init_test_logging();
info!("test_read_limited_string_checks_size_before_utf8_decode: starting");
let payload = "aaaaƩ";
let mut reader = payload.as_bytes();
let err = read_limited_string(&mut reader, 4, "text")
.expect_err("oversized input should be reported before UTF-8 decoding");
assert!(
matches!(&err, BeadsError::Validation { field, reason }
if field == "text" && reason.contains("exceeds maximum size")),
"unexpected error: {err:?}"
);
info!("test_read_limited_string_checks_size_before_utf8_decode: assertions passed");
}
#[test]
fn test_read_limited_string_rejects_invalid_utf8() {
init_test_logging();
info!("test_read_limited_string_rejects_invalid_utf8: starting");
let payload = [0xff];
let mut reader = payload.as_slice();
let err = read_limited_string(&mut reader, 4, "text")
.expect_err("invalid UTF-8 input should be rejected");
assert!(
matches!(&err, BeadsError::Validation { field, reason }
if field == "text" && reason.contains("valid UTF-8")),
"unexpected error: {err:?}"
);
info!("test_read_limited_string_rejects_invalid_utf8: assertions passed");
}
#[test]
fn comment_human_messages_sanitize_issue_id() {
init_test_logging();
info!("comment_human_messages_sanitize_issue_id: starting");
let issue_id = "bd-1\x1b]52;c;bad\x07\rreset";
let rendered = [
comment_added_message(issue_id),
comments_header(issue_id),
no_comments_message(issue_id),
comments_panel_title(issue_id, 2),
]
.join("\n");
assert!(!rendered.contains('\x1b'));
assert!(!rendered.contains('\x07'));
assert!(!rendered.contains('\r'));
assert!(rendered.contains("bd-1\\u{1b}]52;c;bad\\u{7}\\rreset"));
info!("comment_human_messages_sanitize_issue_id: assertions passed");
}
}