use super::{
auto_import_storage_ctx_if_stale, resolve_issue_id, retry_mutation_with_jsonl_recovery,
};
use crate::cli::{CommentAddArgs, CommentCommands, CommentsArgs};
use crate::config;
use crate::error::{BeadsError, Result};
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)
}
}
}
fn execute_add(
args: &CommentAddArgs,
cli: &config::CliOverrides,
ctx: &OutputContext,
beads_dir: &Path,
) -> Result<()> {
let (mut storage_ctx, route_cli, auto_flush_external) =
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() {
tracing::debug!(
beads_dir = %storage_ctx.paths.beads_dir.display(),
error = %error,
"Routed auto-flush failed (non-fatal)"
);
}
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 to {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, _) = 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 open_routed_storage_for_input(
local_beads_dir: &Path,
cli: &config::CliOverrides,
issue_input: &str,
) -> Result<(config::OpenStorageResult, config::CliOverrides, bool)> {
let route = config::routing::resolve_route(issue_input, local_beads_dir)?;
let mut route_cli = cli.clone();
if route.is_external {
route_cli.db = None;
}
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))
}
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 for {issue_id}.");
return Ok(());
}
println!("Comments for {issue_id}:");
for comment in comments {
let timestamp = comment.created_at.format("%Y-%m-%d %H:%M UTC");
println!("[{}] at {}", comment.author, timestamp);
println!("{}", 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(
&format!("No comments for {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!("@{}", 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(comment.body.trim_end_matches('\n'));
content.append("\n\n");
}
let title = format!("Comments: {} ({})", 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(issue_id, theme.issue_id.clone());
console.print_renderable(&text);
console.print("");
let mut comment_text = Text::new("");
comment_text.append_styled(&format!("@{}", comment.author), theme.username.clone());
comment_text.append_styled(" \u{2022} just now", theme.timestamp.clone());
comment_text.append("\n");
comment_text.append(comment.body.trim_end_matches('\n'));
console.print_renderable(&comment_text);
}
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 = String::new();
reader.take(max_bytes).read_to_string(&mut buffer)?;
if buffer.len() > byte_limit {
return Err(BeadsError::validation(
field,
format!("stdin input exceeds maximum size of {byte_limit} bytes"),
));
}
Ok(buffer)
}
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 metadata = fs::metadata(path)?;
if metadata.len() > MAX_STDIN_COMMENT_BYTES as u64 {
return Err(BeadsError::validation(
"file",
format!(
"file exceeds maximum comment size of {} bytes",
MAX_STDIN_COMMENT_BYTES
),
));
}
return Ok(fs::read_to_string(path)?);
}
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_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");
}
}