//! Show command - display detailed entity information from local database. use console::style; use rusqlite::Connection; use serde::Serialize; use crate::Config; use crate::core::db::create_connection; use crate::core::error::{LoreError, Result}; use crate::core::paths::get_db_path; use crate::core::project::resolve_project; use crate::core::time::ms_to_iso; /// Merge request metadata for display. #[derive(Debug, Serialize)] pub struct MrDetail { pub id: i64, pub iid: i64, pub title: String, pub description: Option, pub state: String, pub draft: bool, pub author_username: String, pub source_branch: String, pub target_branch: String, pub created_at: i64, pub updated_at: i64, pub merged_at: Option, pub closed_at: Option, pub web_url: Option, pub project_path: String, pub labels: Vec, pub assignees: Vec, pub reviewers: Vec, pub discussions: Vec, } /// MR discussion detail for display. #[derive(Debug, Serialize)] pub struct MrDiscussionDetail { pub notes: Vec, pub individual_note: bool, } /// MR note detail for display (includes DiffNote position). #[derive(Debug, Serialize)] pub struct MrNoteDetail { pub author_username: String, pub body: String, pub created_at: i64, pub is_system: bool, pub position: Option, } /// DiffNote position context for display. #[derive(Debug, Clone, Serialize)] pub struct DiffNotePosition { pub old_path: Option, pub new_path: Option, pub old_line: Option, pub new_line: Option, pub position_type: Option, } /// Issue metadata for display. #[derive(Debug, Serialize)] pub struct IssueDetail { pub id: i64, pub iid: i64, pub title: String, pub description: Option, pub state: String, pub author_username: String, pub created_at: i64, pub updated_at: i64, pub web_url: Option, pub project_path: String, pub labels: Vec, pub discussions: Vec, } /// Discussion detail for display. #[derive(Debug, Serialize)] pub struct DiscussionDetail { pub notes: Vec, pub individual_note: bool, } /// Note detail for display. #[derive(Debug, Serialize)] pub struct NoteDetail { pub author_username: String, pub body: String, pub created_at: i64, pub is_system: bool, } /// Run the show issue command. pub fn run_show_issue( config: &Config, iid: i64, project_filter: Option<&str>, ) -> Result { let db_path = get_db_path(config.storage.db_path.as_deref()); let conn = create_connection(&db_path)?; // Find the issue let issue = find_issue(&conn, iid, project_filter)?; // Load labels let labels = get_issue_labels(&conn, issue.id)?; // Load discussions with notes let discussions = get_issue_discussions(&conn, issue.id)?; Ok(IssueDetail { id: issue.id, iid: issue.iid, title: issue.title, description: issue.description, state: issue.state, author_username: issue.author_username, created_at: issue.created_at, updated_at: issue.updated_at, web_url: issue.web_url, project_path: issue.project_path, labels, discussions, }) } /// Internal issue row from query. struct IssueRow { id: i64, iid: i64, title: String, description: Option, state: String, author_username: String, created_at: i64, updated_at: i64, web_url: Option, project_path: String, } /// Find issue by iid, optionally filtered by project. fn find_issue(conn: &Connection, iid: i64, project_filter: Option<&str>) -> Result { let (sql, params): (&str, Vec>) = match project_filter { Some(project) => { let project_id = resolve_project(conn, project)?; ( "SELECT i.id, i.iid, i.title, i.description, i.state, i.author_username, i.created_at, i.updated_at, i.web_url, p.path_with_namespace FROM issues i JOIN projects p ON i.project_id = p.id WHERE i.iid = ? AND i.project_id = ?", vec![Box::new(iid), Box::new(project_id)], ) } None => ( "SELECT i.id, i.iid, i.title, i.description, i.state, i.author_username, i.created_at, i.updated_at, i.web_url, p.path_with_namespace FROM issues i JOIN projects p ON i.project_id = p.id WHERE i.iid = ?", vec![Box::new(iid)], ), }; let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect(); let mut stmt = conn.prepare(sql)?; let issues: Vec = stmt .query_map(param_refs.as_slice(), |row| { Ok(IssueRow { id: row.get(0)?, iid: row.get(1)?, title: row.get(2)?, description: row.get(3)?, state: row.get(4)?, author_username: row.get(5)?, created_at: row.get(6)?, updated_at: row.get(7)?, web_url: row.get(8)?, project_path: row.get(9)?, }) })? .collect::, _>>()?; match issues.len() { 0 => Err(LoreError::NotFound(format!("Issue #{} not found", iid))), 1 => Ok(issues.into_iter().next().unwrap()), _ => { let projects: Vec = issues.iter().map(|i| i.project_path.clone()).collect(); Err(LoreError::Ambiguous(format!( "Issue #{} exists in multiple projects: {}. Use --project to specify.", iid, projects.join(", ") ))) } } } /// Get labels for an issue. fn get_issue_labels(conn: &Connection, issue_id: i64) -> Result> { let mut stmt = conn.prepare( "SELECT l.name FROM labels l JOIN issue_labels il ON l.id = il.label_id WHERE il.issue_id = ? ORDER BY l.name", )?; let labels: Vec = stmt .query_map([issue_id], |row| row.get(0))? .collect::, _>>()?; Ok(labels) } /// Get discussions with notes for an issue. fn get_issue_discussions(conn: &Connection, issue_id: i64) -> Result> { // First get all discussions let mut disc_stmt = conn.prepare( "SELECT id, individual_note FROM discussions WHERE issue_id = ? ORDER BY first_note_at", )?; let disc_rows: Vec<(i64, bool)> = disc_stmt .query_map([issue_id], |row| { let individual: i64 = row.get(1)?; Ok((row.get(0)?, individual == 1)) })? .collect::, _>>()?; // Then get notes for each discussion let mut note_stmt = conn.prepare( "SELECT author_username, body, created_at, is_system FROM notes WHERE discussion_id = ? ORDER BY position", )?; let mut discussions = Vec::new(); for (disc_id, individual_note) in disc_rows { let notes: Vec = note_stmt .query_map([disc_id], |row| { let is_system: i64 = row.get(3)?; Ok(NoteDetail { author_username: row.get(0)?, body: row.get(1)?, created_at: row.get(2)?, is_system: is_system == 1, }) })? .collect::, _>>()?; // Filter out discussions with only system notes let has_user_notes = notes.iter().any(|n| !n.is_system); if has_user_notes || notes.is_empty() { discussions.push(DiscussionDetail { notes, individual_note, }); } } Ok(discussions) } /// Run the show MR command. pub fn run_show_mr(config: &Config, iid: i64, project_filter: Option<&str>) -> Result { let db_path = get_db_path(config.storage.db_path.as_deref()); let conn = create_connection(&db_path)?; // Find the MR let mr = find_mr(&conn, iid, project_filter)?; // Load labels let labels = get_mr_labels(&conn, mr.id)?; // Load assignees let assignees = get_mr_assignees(&conn, mr.id)?; // Load reviewers let reviewers = get_mr_reviewers(&conn, mr.id)?; // Load discussions with notes let discussions = get_mr_discussions(&conn, mr.id)?; Ok(MrDetail { id: mr.id, iid: mr.iid, title: mr.title, description: mr.description, state: mr.state, draft: mr.draft, author_username: mr.author_username, source_branch: mr.source_branch, target_branch: mr.target_branch, created_at: mr.created_at, updated_at: mr.updated_at, merged_at: mr.merged_at, closed_at: mr.closed_at, web_url: mr.web_url, project_path: mr.project_path, labels, assignees, reviewers, discussions, }) } /// Internal MR row from query. struct MrRow { id: i64, iid: i64, title: String, description: Option, state: String, draft: bool, author_username: String, source_branch: String, target_branch: String, created_at: i64, updated_at: i64, merged_at: Option, closed_at: Option, web_url: Option, project_path: String, } /// Find MR by iid, optionally filtered by project. fn find_mr(conn: &Connection, iid: i64, project_filter: Option<&str>) -> Result { let (sql, params): (&str, Vec>) = match project_filter { Some(project) => { let project_id = resolve_project(conn, project)?; ( "SELECT m.id, m.iid, m.title, m.description, m.state, m.draft, m.author_username, m.source_branch, m.target_branch, m.created_at, m.updated_at, m.merged_at, m.closed_at, m.web_url, p.path_with_namespace FROM merge_requests m JOIN projects p ON m.project_id = p.id WHERE m.iid = ? AND m.project_id = ?", vec![Box::new(iid), Box::new(project_id)], ) } None => ( "SELECT m.id, m.iid, m.title, m.description, m.state, m.draft, m.author_username, m.source_branch, m.target_branch, m.created_at, m.updated_at, m.merged_at, m.closed_at, m.web_url, p.path_with_namespace FROM merge_requests m JOIN projects p ON m.project_id = p.id WHERE m.iid = ?", vec![Box::new(iid)], ), }; let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect(); let mut stmt = conn.prepare(sql)?; let mrs: Vec = stmt .query_map(param_refs.as_slice(), |row| { let draft_val: i64 = row.get(5)?; Ok(MrRow { id: row.get(0)?, iid: row.get(1)?, title: row.get(2)?, description: row.get(3)?, state: row.get(4)?, draft: draft_val == 1, author_username: row.get(6)?, source_branch: row.get(7)?, target_branch: row.get(8)?, created_at: row.get(9)?, updated_at: row.get(10)?, merged_at: row.get(11)?, closed_at: row.get(12)?, web_url: row.get(13)?, project_path: row.get(14)?, }) })? .collect::, _>>()?; match mrs.len() { 0 => Err(LoreError::NotFound(format!("MR !{} not found", iid))), 1 => Ok(mrs.into_iter().next().unwrap()), _ => { let projects: Vec = mrs.iter().map(|m| m.project_path.clone()).collect(); Err(LoreError::Ambiguous(format!( "MR !{} exists in multiple projects: {}. Use --project to specify.", iid, projects.join(", ") ))) } } } /// Get labels for an MR. fn get_mr_labels(conn: &Connection, mr_id: i64) -> Result> { let mut stmt = conn.prepare( "SELECT l.name FROM labels l JOIN mr_labels ml ON l.id = ml.label_id WHERE ml.merge_request_id = ? ORDER BY l.name", )?; let labels: Vec = stmt .query_map([mr_id], |row| row.get(0))? .collect::, _>>()?; Ok(labels) } /// Get assignees for an MR. fn get_mr_assignees(conn: &Connection, mr_id: i64) -> Result> { let mut stmt = conn.prepare( "SELECT username FROM mr_assignees WHERE merge_request_id = ? ORDER BY username", )?; let assignees: Vec = stmt .query_map([mr_id], |row| row.get(0))? .collect::, _>>()?; Ok(assignees) } /// Get reviewers for an MR. fn get_mr_reviewers(conn: &Connection, mr_id: i64) -> Result> { let mut stmt = conn.prepare( "SELECT username FROM mr_reviewers WHERE merge_request_id = ? ORDER BY username", )?; let reviewers: Vec = stmt .query_map([mr_id], |row| row.get(0))? .collect::, _>>()?; Ok(reviewers) } /// Get discussions with notes for an MR. fn get_mr_discussions(conn: &Connection, mr_id: i64) -> Result> { // First get all discussions let mut disc_stmt = conn.prepare( "SELECT id, individual_note FROM discussions WHERE merge_request_id = ? ORDER BY first_note_at", )?; let disc_rows: Vec<(i64, bool)> = disc_stmt .query_map([mr_id], |row| { let individual: i64 = row.get(1)?; Ok((row.get(0)?, individual == 1)) })? .collect::, _>>()?; // Then get notes for each discussion (with DiffNote position fields) let mut note_stmt = conn.prepare( "SELECT author_username, body, created_at, is_system, position_old_path, position_new_path, position_old_line, position_new_line, position_type FROM notes WHERE discussion_id = ? ORDER BY position", )?; let mut discussions = Vec::new(); for (disc_id, individual_note) in disc_rows { let notes: Vec = note_stmt .query_map([disc_id], |row| { let is_system: i64 = row.get(3)?; let old_path: Option = row.get(4)?; let new_path: Option = row.get(5)?; let old_line: Option = row.get(6)?; let new_line: Option = row.get(7)?; let position_type: Option = row.get(8)?; let position = if old_path.is_some() || new_path.is_some() || old_line.is_some() || new_line.is_some() { Some(DiffNotePosition { old_path, new_path, old_line, new_line, position_type, }) } else { None }; Ok(MrNoteDetail { author_username: row.get(0)?, body: row.get(1)?, created_at: row.get(2)?, is_system: is_system == 1, position, }) })? .collect::, _>>()?; // Filter out discussions with only system notes let has_user_notes = notes.iter().any(|n| !n.is_system); if has_user_notes || notes.is_empty() { discussions.push(MrDiscussionDetail { notes, individual_note, }); } } Ok(discussions) } /// Format date from ms epoch. fn format_date(ms: i64) -> String { let iso = ms_to_iso(ms); // Extract just the date part (YYYY-MM-DD) iso.split('T').next().unwrap_or(&iso).to_string() } /// Truncate text with ellipsis (character-safe for UTF-8). fn truncate(s: &str, max_len: usize) -> String { if s.chars().count() <= max_len { s.to_string() } else { let truncated: String = s.chars().take(max_len.saturating_sub(3)).collect(); format!("{truncated}...") } } /// Wrap text to width, with indent prefix on continuation lines. fn wrap_text(text: &str, width: usize, indent: &str) -> String { let mut result = String::new(); let mut current_line = String::new(); for word in text.split_whitespace() { if current_line.is_empty() { current_line = word.to_string(); } else if current_line.len() + 1 + word.len() <= width { current_line.push(' '); current_line.push_str(word); } else { if !result.is_empty() { result.push('\n'); result.push_str(indent); } result.push_str(¤t_line); current_line = word.to_string(); } } if !current_line.is_empty() { if !result.is_empty() { result.push('\n'); result.push_str(indent); } result.push_str(¤t_line); } result } /// Print issue detail. pub fn print_show_issue(issue: &IssueDetail) { // Header let header = format!("Issue #{}: {}", issue.iid, issue.title); println!("{}", style(&header).bold()); println!("{}", "━".repeat(header.len().min(80))); println!(); // Metadata println!("Project: {}", style(&issue.project_path).cyan()); let state_styled = if issue.state == "opened" { style(&issue.state).green() } else { style(&issue.state).dim() }; println!("State: {}", state_styled); println!("Author: @{}", issue.author_username); println!("Created: {}", format_date(issue.created_at)); println!("Updated: {}", format_date(issue.updated_at)); if issue.labels.is_empty() { println!("Labels: {}", style("(none)").dim()); } else { println!("Labels: {}", issue.labels.join(", ")); } if let Some(url) = &issue.web_url { println!("URL: {}", style(url).dim()); } println!(); // Description println!("{}", style("Description:").bold()); if let Some(desc) = &issue.description { let truncated = truncate(desc, 500); let wrapped = wrap_text(&truncated, 76, " "); println!(" {}", wrapped); } else { println!(" {}", style("(no description)").dim()); } println!(); // Discussions let user_discussions: Vec<&DiscussionDetail> = issue .discussions .iter() .filter(|d| d.notes.iter().any(|n| !n.is_system)) .collect(); if user_discussions.is_empty() { println!("{}", style("Discussions: (none)").dim()); } else { println!( "{}", style(format!("Discussions ({}):", user_discussions.len())).bold() ); println!(); for discussion in user_discussions { let user_notes: Vec<&NoteDetail> = discussion.notes.iter().filter(|n| !n.is_system).collect(); if let Some(first_note) = user_notes.first() { // First note of discussion (not indented) println!( " {} ({}):", style(format!("@{}", first_note.author_username)).cyan(), format_date(first_note.created_at) ); let wrapped = wrap_text(&truncate(&first_note.body, 300), 72, " "); println!(" {}", wrapped); println!(); // Replies (indented) for reply in user_notes.iter().skip(1) { println!( " {} ({}):", style(format!("@{}", reply.author_username)).cyan(), format_date(reply.created_at) ); let wrapped = wrap_text(&truncate(&reply.body, 300), 68, " "); println!(" {}", wrapped); println!(); } } } } } /// Print MR detail. pub fn print_show_mr(mr: &MrDetail) { // Header with draft indicator let draft_prefix = if mr.draft { "[Draft] " } else { "" }; let header = format!("MR !{}: {}{}", mr.iid, draft_prefix, mr.title); println!("{}", style(&header).bold()); println!("{}", "━".repeat(header.len().min(80))); println!(); // Metadata println!("Project: {}", style(&mr.project_path).cyan()); let state_styled = match mr.state.as_str() { "opened" => style(&mr.state).green(), "merged" => style(&mr.state).magenta(), "closed" => style(&mr.state).red(), _ => style(&mr.state).dim(), }; println!("State: {}", state_styled); println!( "Branches: {} -> {}", style(&mr.source_branch).cyan(), style(&mr.target_branch).yellow() ); println!("Author: @{}", mr.author_username); if !mr.assignees.is_empty() { println!( "Assignees: {}", mr.assignees .iter() .map(|a| format!("@{}", a)) .collect::>() .join(", ") ); } if !mr.reviewers.is_empty() { println!( "Reviewers: {}", mr.reviewers .iter() .map(|r| format!("@{}", r)) .collect::>() .join(", ") ); } println!("Created: {}", format_date(mr.created_at)); println!("Updated: {}", format_date(mr.updated_at)); if let Some(merged_at) = mr.merged_at { println!("Merged: {}", format_date(merged_at)); } if let Some(closed_at) = mr.closed_at { println!("Closed: {}", format_date(closed_at)); } if mr.labels.is_empty() { println!("Labels: {}", style("(none)").dim()); } else { println!("Labels: {}", mr.labels.join(", ")); } if let Some(url) = &mr.web_url { println!("URL: {}", style(url).dim()); } println!(); // Description println!("{}", style("Description:").bold()); if let Some(desc) = &mr.description { let truncated = truncate(desc, 500); let wrapped = wrap_text(&truncated, 76, " "); println!(" {}", wrapped); } else { println!(" {}", style("(no description)").dim()); } println!(); // Discussions let user_discussions: Vec<&MrDiscussionDetail> = mr .discussions .iter() .filter(|d| d.notes.iter().any(|n| !n.is_system)) .collect(); if user_discussions.is_empty() { println!("{}", style("Discussions: (none)").dim()); } else { println!( "{}", style(format!("Discussions ({}):", user_discussions.len())).bold() ); println!(); for discussion in user_discussions { let user_notes: Vec<&MrNoteDetail> = discussion.notes.iter().filter(|n| !n.is_system).collect(); if let Some(first_note) = user_notes.first() { // Print DiffNote position context if present if let Some(pos) = &first_note.position { print_diff_position(pos); } // First note of discussion (not indented) println!( " {} ({}):", style(format!("@{}", first_note.author_username)).cyan(), format_date(first_note.created_at) ); let wrapped = wrap_text(&truncate(&first_note.body, 300), 72, " "); println!(" {}", wrapped); println!(); // Replies (indented) for reply in user_notes.iter().skip(1) { println!( " {} ({}):", style(format!("@{}", reply.author_username)).cyan(), format_date(reply.created_at) ); let wrapped = wrap_text(&truncate(&reply.body, 300), 68, " "); println!(" {}", wrapped); println!(); } } } } } /// Print DiffNote position context. fn print_diff_position(pos: &DiffNotePosition) { let file = pos.new_path.as_ref().or(pos.old_path.as_ref()); if let Some(file_path) = file { let line_str = match (pos.old_line, pos.new_line) { (Some(old), Some(new)) if old == new => format!(":{}", new), (Some(old), Some(new)) => format!(":{}→{}", old, new), (None, Some(new)) => format!(":+{}", new), (Some(old), None) => format!(":-{}", old), (None, None) => String::new(), }; println!( " {} {}{}", style("📍").dim(), style(file_path).yellow(), style(line_str).dim() ); } } // ============================================================================ // JSON Output Structs (with ISO timestamps for machine consumption) // ============================================================================ /// JSON output for issue detail. #[derive(Serialize)] pub struct IssueDetailJson { pub id: i64, pub iid: i64, pub title: String, pub description: Option, pub state: String, pub author_username: String, pub created_at: String, pub updated_at: String, pub web_url: Option, pub project_path: String, pub labels: Vec, pub discussions: Vec, } /// JSON output for discussion detail. #[derive(Serialize)] pub struct DiscussionDetailJson { pub notes: Vec, pub individual_note: bool, } /// JSON output for note detail. #[derive(Serialize)] pub struct NoteDetailJson { pub author_username: String, pub body: String, pub created_at: String, pub is_system: bool, } impl From<&IssueDetail> for IssueDetailJson { fn from(issue: &IssueDetail) -> Self { Self { id: issue.id, iid: issue.iid, title: issue.title.clone(), description: issue.description.clone(), state: issue.state.clone(), author_username: issue.author_username.clone(), created_at: ms_to_iso(issue.created_at), updated_at: ms_to_iso(issue.updated_at), web_url: issue.web_url.clone(), project_path: issue.project_path.clone(), labels: issue.labels.clone(), discussions: issue.discussions.iter().map(|d| d.into()).collect(), } } } impl From<&DiscussionDetail> for DiscussionDetailJson { fn from(disc: &DiscussionDetail) -> Self { Self { notes: disc.notes.iter().map(|n| n.into()).collect(), individual_note: disc.individual_note, } } } impl From<&NoteDetail> for NoteDetailJson { fn from(note: &NoteDetail) -> Self { Self { author_username: note.author_username.clone(), body: note.body.clone(), created_at: ms_to_iso(note.created_at), is_system: note.is_system, } } } /// JSON output for MR detail. #[derive(Serialize)] pub struct MrDetailJson { pub id: i64, pub iid: i64, pub title: String, pub description: Option, pub state: String, pub draft: bool, pub author_username: String, pub source_branch: String, pub target_branch: String, pub created_at: String, pub updated_at: String, pub merged_at: Option, pub closed_at: Option, pub web_url: Option, pub project_path: String, pub labels: Vec, pub assignees: Vec, pub reviewers: Vec, pub discussions: Vec, } /// JSON output for MR discussion detail. #[derive(Serialize)] pub struct MrDiscussionDetailJson { pub notes: Vec, pub individual_note: bool, } /// JSON output for MR note detail. #[derive(Serialize)] pub struct MrNoteDetailJson { pub author_username: String, pub body: String, pub created_at: String, pub is_system: bool, pub position: Option, } impl From<&MrDetail> for MrDetailJson { fn from(mr: &MrDetail) -> Self { Self { id: mr.id, iid: mr.iid, title: mr.title.clone(), description: mr.description.clone(), state: mr.state.clone(), draft: mr.draft, author_username: mr.author_username.clone(), source_branch: mr.source_branch.clone(), target_branch: mr.target_branch.clone(), created_at: ms_to_iso(mr.created_at), updated_at: ms_to_iso(mr.updated_at), merged_at: mr.merged_at.map(ms_to_iso), closed_at: mr.closed_at.map(ms_to_iso), web_url: mr.web_url.clone(), project_path: mr.project_path.clone(), labels: mr.labels.clone(), assignees: mr.assignees.clone(), reviewers: mr.reviewers.clone(), discussions: mr.discussions.iter().map(|d| d.into()).collect(), } } } impl From<&MrDiscussionDetail> for MrDiscussionDetailJson { fn from(disc: &MrDiscussionDetail) -> Self { Self { notes: disc.notes.iter().map(|n| n.into()).collect(), individual_note: disc.individual_note, } } } impl From<&MrNoteDetail> for MrNoteDetailJson { fn from(note: &MrNoteDetail) -> Self { Self { author_username: note.author_username.clone(), body: note.body.clone(), created_at: ms_to_iso(note.created_at), is_system: note.is_system, position: note.position.clone(), } } } /// Print issue detail as JSON. pub fn print_show_issue_json(issue: &IssueDetail) { let json_result = IssueDetailJson::from(issue); match serde_json::to_string_pretty(&json_result) { Ok(json) => println!("{json}"), Err(e) => eprintln!("Error serializing to JSON: {e}"), } } /// Print MR detail as JSON. pub fn print_show_mr_json(mr: &MrDetail) { let json_result = MrDetailJson::from(mr); match serde_json::to_string_pretty(&json_result) { Ok(json) => println!("{json}"), Err(e) => eprintln!("Error serializing to JSON: {e}"), } } #[cfg(test)] mod tests { use super::*; #[test] fn truncate_leaves_short_strings() { assert_eq!(truncate("short", 10), "short"); } #[test] fn truncate_adds_ellipsis() { assert_eq!(truncate("this is a long string", 10), "this is..."); } #[test] fn wrap_text_single_line() { assert_eq!(wrap_text("hello world", 80, " "), "hello world"); } #[test] fn wrap_text_multiple_lines() { let result = wrap_text("one two three four five", 10, " "); assert!(result.contains('\n')); } #[test] fn format_date_extracts_date_part() { // 2024-01-15T00:00:00Z in milliseconds let ms = 1705276800000; let date = format_date(ms); assert!(date.starts_with("2024-01-15")); } }