| 1 | //! `/change` command — show a changelog entry, translated to the user's |
| 2 | //! locale when it is not English. |
| 3 | //! |
| 4 | //! Usage: `/change [version]` |
| 5 | //! |
| 6 | //! Uses the Codewhale changelog embedded at compile time. With no argument, |
| 7 | //! extracts the most recent section. With a version argument like `0.8.32`, |
| 8 | //! extracts that specific version's section. When the UI locale is not |
| 9 | //! English and the current session can reach a model, the command also fires a |
| 10 | //! `SendMessage` action that asks the model to translate the changelog into |
| 11 | //! the user's language. |
| 12 | |
| 13 | use super::DebugAction; |
| 14 | use codewhale_command_contract::facets::{CommandPresentationContext, DebugChangeProjection}; |
| 15 | use codewhale_command_contract::handler::{ |
| 16 | CommandCapabilities as Caps, CommandContexts, CommandHandler, |
| 17 | }; |
| 18 | use codewhale_command_contract::metadata::{CommandInfo, RegisterCommand}; |
| 19 | |
| 20 | use super::CommandResult; |
| 21 | |
| 22 | /// Maximum length of the changelog excerpt we'll show inline (characters). |
| 23 | /// If the changelog section exceeds this, we truncate and show a notice. |
| 24 | /// 4096 chars is large enough for most version entries. |
| 25 | const MAX_INLINE_CHANGELOG_CHARS: usize = 4096; |
| 26 | pub(in crate::commands) struct ChangeCmd; |
| 27 | impl RegisterCommand<CommandResult> for ChangeCmd { |
| 28 | fn info() -> &'static CommandInfo { |
| 29 | &CommandInfo { |
| 30 | name: "change", |
| 31 | aliases: &[], |
| 32 | usage: "/change [version]", |
| 33 | description_key: "cmd_change_description", |
| 34 | } |
| 35 | } |
| 36 | fn handler() -> CommandHandler<CommandResult> { |
| 37 | CommandHandler::Contextual { |
| 38 | capabilities: Caps::DEBUG_CHANGE.union(Caps::PRESENTATION), |
| 39 | handler: change, |
| 40 | } |
| 41 | } |
| 42 | } |
| 43 | |
| 44 | pub(super) fn change(contexts: CommandContexts<'_>, version: Option<&str>) -> CommandResult { |
| 45 | let mut parts = contexts.into_parts(); |
| 46 | let Some(change) = parts.debug_change.as_deref_mut() else { |
| 47 | return CommandResult::error("Command capability unavailable: debug_change"); |
| 48 | }; |
| 49 | let Some(presentation) = parts.presentation.as_deref_mut() else { |
| 50 | return CommandResult::error("Command capability unavailable: presentation"); |
| 51 | }; |
| 52 | match change_report(change.change_projection(), presentation, version) { |
| 53 | Ok(result) => result, |
| 54 | Err(error) => CommandResult::error(error), |
| 55 | } |
| 56 | } |
| 57 | |
| 58 | /// Execute the `/change` command. |
| 59 | /// |
| 60 | /// If `version` is `None`, shows the latest non-empty version section. |
| 61 | /// If `version` is `Some(v)`, shows the section for that version. |
| 62 | fn change_report( |
| 63 | projection: DebugChangeProjection, |
| 64 | presentation: &dyn CommandPresentationContext, |
| 65 | version: Option<&str>, |
| 66 | ) -> Result<CommandResult, String> { |
| 67 | let section = if let Some(ver) = version { |
| 68 | let ver = ver.trim(); |
| 69 | if ver.is_empty() { |
| 70 | extract_latest_changelog_section(projection.changelog) |
| 71 | } else { |
| 72 | extract_changelog_section_by_version(projection.changelog, ver) |
| 73 | } |
| 74 | } else { |
| 75 | extract_latest_changelog_section(projection.changelog) |
| 76 | }; |
| 77 | |
| 78 | let latest_section = match section { |
| 79 | Some(s) => s, |
| 80 | None => { |
| 81 | let msg = if let Some(ver) = version { |
| 82 | let ver = ver.trim(); |
| 83 | if ver.is_empty() { |
| 84 | "Could not find a version section in the bundled Codewhale changelog. \ |
| 85 | Expected a line starting with `## [`." |
| 86 | .to_string() |
| 87 | } else { |
| 88 | format!("Could not find version \"{ver}\" in the bundled Codewhale changelog.") |
| 89 | } |
| 90 | } else { |
| 91 | "Could not find a version section in the bundled Codewhale changelog. \ |
| 92 | Expected a line starting with `## [`." |
| 93 | .to_string() |
| 94 | }; |
| 95 | return Err(msg); |
| 96 | } |
| 97 | }; |
| 98 | |
| 99 | let header = presentation.translate("cmd_change_header", &[])?; |
| 100 | |
| 101 | let prev_hint = if let Some(prev_ver) = previous_version_hint(projection.changelog, version) { |
| 102 | let hint = |
| 103 | presentation.translate("cmd_change_previous_version", &[("version", &prev_ver)])?; |
| 104 | format!("\n\n{hint}") |
| 105 | } else { |
| 106 | String::new() |
| 107 | }; |
| 108 | |
| 109 | let section_text = inline_changelog_section(&latest_section); |
| 110 | |
| 111 | // If the user's locale is English, just display. |
| 112 | // Otherwise, also ask the model to translate. |
| 113 | Ok(if projection.is_english { |
| 114 | CommandResult::message(format!( |
| 115 | "{header}\n─────────────────────────────\n{section_text}{prev_hint}" |
| 116 | )) |
| 117 | } else if !projection.translation_available { |
| 118 | let fallback = presentation.translate("cmd_change_translation_unavailable", &[])?; |
| 119 | CommandResult::message(format!( |
| 120 | "{header}\n\ |
| 121 | ─────────────────────────────\n\ |
| 122 | {fallback}\n\n\ |
| 123 | {section_text}{prev_hint}" |
| 124 | )) |
| 125 | } else { |
| 126 | let queued = presentation.translate("cmd_change_translation_queued", &[])?; |
| 127 | let display_text = format!( |
| 128 | "{header}\n\ |
| 129 | ─────────────────────────────\n\ |
| 130 | {queued}\n\n\ |
| 131 | {section_text}{prev_hint}" |
| 132 | ); |
| 133 | let translation_source = format!("{latest_section}{prev_hint}"); |
| 134 | let lang_name = projection.translation_target; |
| 135 | |
| 136 | let translation_prompt = format!( |
| 137 | "Translate the following changelog into {lang_name}. \ |
| 138 | Keep all markdown formatting, version numbers, dates, \ |
| 139 | contributor names, and code references intact. \ |
| 140 | Output ONLY the translated changelog, no preamble or commentary.\n\n\ |
| 141 | {translation_source}" |
| 142 | ); |
| 143 | |
| 144 | CommandResult::with_message_and_action( |
| 145 | display_text, |
| 146 | DebugAction::SendMessage(translation_prompt), |
| 147 | ) |
| 148 | }) |
| 149 | } |
| 150 | |
| 151 | pub(in crate::commands) fn inline_changelog_section(section: &str) -> String { |
| 152 | if section.len() <= MAX_INLINE_CHANGELOG_CHARS { |
| 153 | return section.to_string(); |
| 154 | } |
| 155 | |
| 156 | let truncated: String = section.chars().take(MAX_INLINE_CHANGELOG_CHARS).collect(); |
| 157 | format!( |
| 158 | "{truncated}\n\ |
| 159 | \n\ |
| 160 | [... {} characters omitted from the bundled Codewhale changelog]", |
| 161 | section.len() - MAX_INLINE_CHANGELOG_CHARS |
| 162 | ) |
| 163 | } |
| 164 | |
| 165 | /// Extract the latest version section from CHANGELOG.md content. |
| 166 | /// |
| 167 | /// Looks for the first `## [version] - date` heading and returns all lines |
| 168 | /// from that heading up to the next `## [` heading (or end of file). |
| 169 | /// Leading and trailing whitespace is trimmed. |
| 170 | /// |
| 171 | /// Skips empty sections (e.g. `## [Unreleased]` with no content) to find |
| 172 | /// the first section that actually has content. |
| 173 | pub(in crate::commands) fn extract_latest_changelog_section(content: &str) -> Option<String> { |
| 174 | let lines: Vec<&str> = content.lines().collect(); |
| 175 | |
| 176 | // Find the first `## [` heading index |
| 177 | let first_idx = { |
| 178 | let mut idx = None; |
| 179 | for (i, line) in lines.iter().enumerate() { |
| 180 | if line.trim().starts_with("## [") { |
| 181 | idx = Some(i); |
| 182 | break; |
| 183 | } |
| 184 | } |
| 185 | idx? |
| 186 | }; |
| 187 | |
| 188 | // Starting from `first_idx`, walk through headings until we find a |
| 189 | // section with non-empty content. |
| 190 | let mut pos = first_idx; |
| 191 | loop { |
| 192 | let end = lines |
| 193 | .iter() |
| 194 | .enumerate() |
| 195 | .skip(pos + 1) |
| 196 | .find(|(_, line)| line.trim().starts_with("## [")) |
| 197 | .map_or(lines.len(), |(i, _)| i); |
| 198 | |
| 199 | if section_has_body_content(&lines[pos + 1..end]) { |
| 200 | return Some(lines[pos..end].join("\n").trim().to_string()); |
| 201 | } |
| 202 | |
| 203 | // Empty section — try the next heading (if any) |
| 204 | if end >= lines.len() { |
| 205 | return None; |
| 206 | } |
| 207 | pos = end; |
| 208 | } |
| 209 | } |
| 210 | |
| 211 | /// Extract a specific version section from CHANGELOG.md content. |
| 212 | /// |
| 213 | /// Looks for `## [<version>]` or `## [<version> - date]` and returns all |
| 214 | /// lines from that heading up to the next `## [` heading (or end of file). |
| 215 | pub(in crate::commands) fn extract_changelog_section_by_version( |
| 216 | content: &str, |
| 217 | version: &str, |
| 218 | ) -> Option<String> { |
| 219 | let lines: Vec<&str> = content.lines().collect(); |
| 220 | let mut start_idx: Option<usize> = None; |
| 221 | |
| 222 | for (i, line) in lines.iter().enumerate() { |
| 223 | let trimmed = line.trim(); |
| 224 | if trimmed.starts_with("## [") { |
| 225 | // Check if this heading matches the requested version. |
| 226 | // Format: `## [0.8.32] - 2026-05-12` or `## [0.8.32]` |
| 227 | let bracket_end = trimmed.find(']')?; |
| 228 | let heading_ver = &trimmed[4..bracket_end]; // skip "## [" |
| 229 | if heading_ver == version { |
| 230 | start_idx = Some(i); |
| 231 | break; |
| 232 | } |
| 233 | } |
| 234 | } |
| 235 | |
| 236 | let start = start_idx?; |
| 237 | |
| 238 | let end = lines |
| 239 | .iter() |
| 240 | .enumerate() |
| 241 | .skip(start + 1) |
| 242 | .find(|(_, line)| line.trim().starts_with("## [")) |
| 243 | .map_or(lines.len(), |(i, _)| i); |
| 244 | |
| 245 | if !section_has_body_content(&lines[start + 1..end]) { |
| 246 | return None; |
| 247 | } |
| 248 | |
| 249 | Some(lines[start..end].join("\n").trim().to_string()) |
| 250 | } |
| 251 | |
| 252 | /// Extract the version number of the section immediately preceding the latest |
| 253 | /// non-empty section in the changelog. |
| 254 | /// |
| 255 | /// Walks past empty sections (e.g. `## [Unreleased]`) the same way |
| 256 | /// [`extract_latest_changelog_section`] does, then returns the version from |
| 257 | /// the next `## [version]` heading after the first contentful section. |
| 258 | pub(in crate::commands) fn extract_previous_version_number(content: &str) -> Option<String> { |
| 259 | let lines: Vec<&str> = content.lines().collect(); |
| 260 | let first_idx = lines.iter().position(|l| l.trim().starts_with("## ["))?; |
| 261 | |
| 262 | let mut pos = first_idx; |
| 263 | loop { |
| 264 | let end = lines |
| 265 | .iter() |
| 266 | .enumerate() |
| 267 | .skip(pos + 1) |
| 268 | .find(|(_, l)| l.trim().starts_with("## [")) |
| 269 | .map_or(lines.len(), |(i, _)| i); |
| 270 | |
| 271 | if section_has_body_content(&lines[pos + 1..end]) { |
| 272 | // Found the latest contentful section heading at `pos`. |
| 273 | return next_contentful_version_after(&lines, end); |
| 274 | } |
| 275 | |
| 276 | if end >= lines.len() { |
| 277 | return None; |
| 278 | } |
| 279 | pos = end; |
| 280 | } |
| 281 | } |
| 282 | |
| 283 | pub(in crate::commands) fn section_has_body_content(lines: &[&str]) -> bool { |
| 284 | lines.iter().any(|line| !line.trim().is_empty()) |
| 285 | } |
| 286 | |
| 287 | pub(in crate::commands) fn previous_version_hint( |
| 288 | content: &str, |
| 289 | version: Option<&str>, |
| 290 | ) -> Option<String> { |
| 291 | match version.map(str::trim).filter(|v| !v.is_empty()) { |
| 292 | Some(version) => extract_previous_version_number_after_version(content, version), |
| 293 | None => extract_previous_version_number(content), |
| 294 | } |
| 295 | } |
| 296 | |
| 297 | pub(in crate::commands) fn extract_previous_version_number_after_version( |
| 298 | content: &str, |
| 299 | version: &str, |
| 300 | ) -> Option<String> { |
| 301 | let lines: Vec<&str> = content.lines().collect(); |
| 302 | let current_start = lines.iter().position(|line| { |
| 303 | let trimmed = line.trim(); |
| 304 | trimmed |
| 305 | .strip_prefix("## [") |
| 306 | .and_then(|rest| rest.split_once(']')) |
| 307 | .is_some_and(|(heading_ver, _)| heading_ver == version) |
| 308 | })?; |
| 309 | |
| 310 | let current_end = lines |
| 311 | .iter() |
| 312 | .enumerate() |
| 313 | .skip(current_start + 1) |
| 314 | .find(|(_, line)| line.trim().starts_with("## [")) |
| 315 | .map_or(lines.len(), |(i, _)| i); |
| 316 | |
| 317 | next_contentful_version_after(&lines, current_end) |
| 318 | } |
| 319 | |
| 320 | pub(in crate::commands) fn next_contentful_version_after( |
| 321 | lines: &[&str], |
| 322 | mut pos: usize, |
| 323 | ) -> Option<String> { |
| 324 | while pos < lines.len() { |
| 325 | let heading = lines[pos].trim(); |
| 326 | if !heading.starts_with("## [") { |
| 327 | pos += 1; |
| 328 | continue; |
| 329 | } |
| 330 | |
| 331 | let end = lines |
| 332 | .iter() |
| 333 | .enumerate() |
| 334 | .skip(pos + 1) |
| 335 | .find(|(_, line)| line.trim().starts_with("## [")) |
| 336 | .map_or(lines.len(), |(i, _)| i); |
| 337 | |
| 338 | if section_has_body_content(&lines[pos + 1..end]) { |
| 339 | let bracket_end = heading.find(']')?; |
| 340 | return Some(heading[4..bracket_end].to_string()); |
| 341 | } |
| 342 | |
| 343 | pos = end; |
| 344 | } |
| 345 | |
| 346 | None |
| 347 | } |
| 348 |