| 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 crate::localization::{Locale, MessageId, tr}; |
| 14 | use crate::tui::app::{App, AppAction}; |
| 15 | |
| 16 | use super::CommandResult; |
| 17 | |
| 18 | /// Maximum length of the changelog excerpt we'll show inline (characters). |
| 19 | /// If the changelog section exceeds this, we truncate and show a notice. |
| 20 | /// 4096 chars is large enough for most version entries. |
| 21 | const MAX_INLINE_CHANGELOG_CHARS: usize = 4096; |
| 22 | const CODEWHALE_CHANGELOG: &str = include_str!("../../../../CHANGELOG.md"); |
| 23 | |
| 24 | /// Execute the `/change` command. |
| 25 | /// |
| 26 | /// If `version` is `None`, shows the latest non-empty version section. |
| 27 | /// If `version` is `Some(v)`, shows the section for that version. |
| 28 | pub fn change(app: &mut App, version: Option<&str>) -> CommandResult { |
| 29 | let section = if let Some(ver) = version { |
| 30 | let ver = ver.trim(); |
| 31 | if ver.is_empty() { |
| 32 | extract_latest_changelog_section(CODEWHALE_CHANGELOG) |
| 33 | } else { |
| 34 | extract_changelog_section_by_version(CODEWHALE_CHANGELOG, ver) |
| 35 | } |
| 36 | } else { |
| 37 | extract_latest_changelog_section(CODEWHALE_CHANGELOG) |
| 38 | }; |
| 39 | |
| 40 | let latest_section = match section { |
| 41 | Some(s) => s, |
| 42 | None => { |
| 43 | let msg = if let Some(ver) = version { |
| 44 | let ver = ver.trim(); |
| 45 | if ver.is_empty() { |
| 46 | "Could not find a version section in the bundled Codewhale changelog. \ |
| 47 | Expected a line starting with `## [`." |
| 48 | .to_string() |
| 49 | } else { |
| 50 | format!("Could not find version \"{ver}\" in the bundled Codewhale changelog.") |
| 51 | } |
| 52 | } else { |
| 53 | "Could not find a version section in the bundled Codewhale changelog. \ |
| 54 | Expected a line starting with `## [`." |
| 55 | .to_string() |
| 56 | }; |
| 57 | return CommandResult::error(msg); |
| 58 | } |
| 59 | }; |
| 60 | |
| 61 | let locale = app.ui_locale; |
| 62 | let header = tr(locale, MessageId::CmdChangeHeader); |
| 63 | |
| 64 | let prev_hint = if let Some(prev_ver) = previous_version_hint(CODEWHALE_CHANGELOG, version) { |
| 65 | let template = tr(locale, MessageId::CmdChangePreviousVersion); |
| 66 | format!("\n\n{}", template.replace("{version}", &prev_ver)) |
| 67 | } else { |
| 68 | String::new() |
| 69 | }; |
| 70 | |
| 71 | let section_text = inline_changelog_section(&latest_section); |
| 72 | |
| 73 | // If the user's locale is English, just display. |
| 74 | // Otherwise, also ask the model to translate. |
| 75 | if locale == Locale::En { |
| 76 | CommandResult::message(format!( |
| 77 | "{header}\n─────────────────────────────\n{section_text}{prev_hint}" |
| 78 | )) |
| 79 | } else if app.offline_mode || app.onboarding_needs_api_key { |
| 80 | let fallback = tr(locale, MessageId::CmdChangeTranslationUnavailable); |
| 81 | CommandResult::message(format!( |
| 82 | "{header}\n\ |
| 83 | ─────────────────────────────\n\ |
| 84 | {fallback}\n\n\ |
| 85 | {section_text}{prev_hint}" |
| 86 | )) |
| 87 | } else { |
| 88 | let queued = tr(locale, MessageId::CmdChangeTranslationQueued); |
| 89 | let display_text = format!( |
| 90 | "{header}\n\ |
| 91 | ─────────────────────────────\n\ |
| 92 | {queued}\n\n\ |
| 93 | {section_text}{prev_hint}" |
| 94 | ); |
| 95 | let translation_source = format!("{latest_section}{prev_hint}"); |
| 96 | let lang_name = locale.translation_target_name(); |
| 97 | |
| 98 | let translation_prompt = format!( |
| 99 | "Translate the following changelog into {lang_name}. \ |
| 100 | Keep all markdown formatting, version numbers, dates, \ |
| 101 | contributor names, and code references intact. \ |
| 102 | Output ONLY the translated changelog, no preamble or commentary.\n\n\ |
| 103 | {translation_source}" |
| 104 | ); |
| 105 | |
| 106 | CommandResult::with_message_and_action( |
| 107 | display_text, |
| 108 | AppAction::SendMessage(translation_prompt), |
| 109 | ) |
| 110 | } |
| 111 | } |
| 112 | |
| 113 | fn inline_changelog_section(section: &str) -> String { |
| 114 | if section.len() <= MAX_INLINE_CHANGELOG_CHARS { |
| 115 | return section.to_string(); |
| 116 | } |
| 117 | |
| 118 | let truncated: String = section.chars().take(MAX_INLINE_CHANGELOG_CHARS).collect(); |
| 119 | format!( |
| 120 | "{truncated}\n\ |
| 121 | \n\ |
| 122 | [... {} characters omitted from the bundled Codewhale changelog]", |
| 123 | section.len() - MAX_INLINE_CHANGELOG_CHARS |
| 124 | ) |
| 125 | } |
| 126 | |
| 127 | /// Extract the latest version section from CHANGELOG.md content. |
| 128 | /// |
| 129 | /// Looks for the first `## [version] - date` heading and returns all lines |
| 130 | /// from that heading up to the next `## [` heading (or end of file). |
| 131 | /// Leading and trailing whitespace is trimmed. |
| 132 | /// |
| 133 | /// Skips empty sections (e.g. `## [Unreleased]` with no content) to find |
| 134 | /// the first section that actually has content. |
| 135 | fn extract_latest_changelog_section(content: &str) -> Option<String> { |
| 136 | let lines: Vec<&str> = content.lines().collect(); |
| 137 | |
| 138 | // Find the first `## [` heading index |
| 139 | let first_idx = { |
| 140 | let mut idx = None; |
| 141 | for (i, line) in lines.iter().enumerate() { |
| 142 | if line.trim().starts_with("## [") { |
| 143 | idx = Some(i); |
| 144 | break; |
| 145 | } |
| 146 | } |
| 147 | idx? |
| 148 | }; |
| 149 | |
| 150 | // Starting from `first_idx`, walk through headings until we find a |
| 151 | // section with non-empty content. |
| 152 | let mut pos = first_idx; |
| 153 | loop { |
| 154 | let end = lines |
| 155 | .iter() |
| 156 | .enumerate() |
| 157 | .skip(pos + 1) |
| 158 | .find(|(_, line)| line.trim().starts_with("## [")) |
| 159 | .map_or(lines.len(), |(i, _)| i); |
| 160 | |
| 161 | if section_has_body_content(&lines[pos + 1..end]) { |
| 162 | return Some(lines[pos..end].join("\n").trim().to_string()); |
| 163 | } |
| 164 | |
| 165 | // Empty section — try the next heading (if any) |
| 166 | if end >= lines.len() { |
| 167 | return None; |
| 168 | } |
| 169 | pos = end; |
| 170 | } |
| 171 | } |
| 172 | |
| 173 | /// Extract a specific version section from CHANGELOG.md content. |
| 174 | /// |
| 175 | /// Looks for `## [<version>]` or `## [<version> - date]` and returns all |
| 176 | /// lines from that heading up to the next `## [` heading (or end of file). |
| 177 | fn extract_changelog_section_by_version(content: &str, version: &str) -> Option<String> { |
| 178 | let lines: Vec<&str> = content.lines().collect(); |
| 179 | let mut start_idx: Option<usize> = None; |
| 180 | |
| 181 | for (i, line) in lines.iter().enumerate() { |
| 182 | let trimmed = line.trim(); |
| 183 | if trimmed.starts_with("## [") { |
| 184 | // Check if this heading matches the requested version. |
| 185 | // Format: `## [0.8.32] - 2026-05-12` or `## [0.8.32]` |
| 186 | let bracket_end = trimmed.find(']')?; |
| 187 | let heading_ver = &trimmed[4..bracket_end]; // skip "## [" |
| 188 | if heading_ver == version { |
| 189 | start_idx = Some(i); |
| 190 | break; |
| 191 | } |
| 192 | } |
| 193 | } |
| 194 | |
| 195 | let start = start_idx?; |
| 196 | |
| 197 | let end = lines |
| 198 | .iter() |
| 199 | .enumerate() |
| 200 | .skip(start + 1) |
| 201 | .find(|(_, line)| line.trim().starts_with("## [")) |
| 202 | .map_or(lines.len(), |(i, _)| i); |
| 203 | |
| 204 | if !section_has_body_content(&lines[start + 1..end]) { |
| 205 | return None; |
| 206 | } |
| 207 | |
| 208 | Some(lines[start..end].join("\n").trim().to_string()) |
| 209 | } |
| 210 | |
| 211 | /// Extract the version number of the section immediately preceding the latest |
| 212 | /// non-empty section in the changelog. |
| 213 | /// |
| 214 | /// Walks past empty sections (e.g. `## [Unreleased]`) the same way |
| 215 | /// [`extract_latest_changelog_section`] does, then returns the version from |
| 216 | /// the next `## [version]` heading after the first contentful section. |
| 217 | fn extract_previous_version_number(content: &str) -> Option<String> { |
| 218 | let lines: Vec<&str> = content.lines().collect(); |
| 219 | let first_idx = lines.iter().position(|l| l.trim().starts_with("## ["))?; |
| 220 | |
| 221 | let mut pos = first_idx; |
| 222 | loop { |
| 223 | let end = lines |
| 224 | .iter() |
| 225 | .enumerate() |
| 226 | .skip(pos + 1) |
| 227 | .find(|(_, l)| l.trim().starts_with("## [")) |
| 228 | .map_or(lines.len(), |(i, _)| i); |
| 229 | |
| 230 | if section_has_body_content(&lines[pos + 1..end]) { |
| 231 | // Found the latest contentful section heading at `pos`. |
| 232 | return next_contentful_version_after(&lines, end); |
| 233 | } |
| 234 | |
| 235 | if end >= lines.len() { |
| 236 | return None; |
| 237 | } |
| 238 | pos = end; |
| 239 | } |
| 240 | } |
| 241 | |
| 242 | fn section_has_body_content(lines: &[&str]) -> bool { |
| 243 | lines.iter().any(|line| !line.trim().is_empty()) |
| 244 | } |
| 245 | |
| 246 | fn previous_version_hint(content: &str, version: Option<&str>) -> Option<String> { |
| 247 | match version.map(str::trim).filter(|v| !v.is_empty()) { |
| 248 | Some(version) => extract_previous_version_number_after_version(content, version), |
| 249 | None => extract_previous_version_number(content), |
| 250 | } |
| 251 | } |
| 252 | |
| 253 | fn extract_previous_version_number_after_version(content: &str, version: &str) -> Option<String> { |
| 254 | let lines: Vec<&str> = content.lines().collect(); |
| 255 | let current_start = lines.iter().position(|line| { |
| 256 | let trimmed = line.trim(); |
| 257 | trimmed |
| 258 | .strip_prefix("## [") |
| 259 | .and_then(|rest| rest.split_once(']')) |
| 260 | .is_some_and(|(heading_ver, _)| heading_ver == version) |
| 261 | })?; |
| 262 | |
| 263 | let current_end = lines |
| 264 | .iter() |
| 265 | .enumerate() |
| 266 | .skip(current_start + 1) |
| 267 | .find(|(_, line)| line.trim().starts_with("## [")) |
| 268 | .map_or(lines.len(), |(i, _)| i); |
| 269 | |
| 270 | next_contentful_version_after(&lines, current_end) |
| 271 | } |
| 272 | |
| 273 | fn next_contentful_version_after(lines: &[&str], mut pos: usize) -> Option<String> { |
| 274 | while pos < lines.len() { |
| 275 | let heading = lines[pos].trim(); |
| 276 | if !heading.starts_with("## [") { |
| 277 | pos += 1; |
| 278 | continue; |
| 279 | } |
| 280 | |
| 281 | let end = lines |
| 282 | .iter() |
| 283 | .enumerate() |
| 284 | .skip(pos + 1) |
| 285 | .find(|(_, line)| line.trim().starts_with("## [")) |
| 286 | .map_or(lines.len(), |(i, _)| i); |
| 287 | |
| 288 | if section_has_body_content(&lines[pos + 1..end]) { |
| 289 | let bracket_end = heading.find(']')?; |
| 290 | return Some(heading[4..bracket_end].to_string()); |
| 291 | } |
| 292 | |
| 293 | pos = end; |
| 294 | } |
| 295 | |
| 296 | None |
| 297 | } |
| 298 | |
| 299 | #[cfg(test)] |
| 300 | mod tests { |
| 301 | use super::*; |
| 302 | use crate::config::Config; |
| 303 | use crate::localization::Locale; |
| 304 | use crate::test_support::{EnvVarGuard, lock_test_env}; |
| 305 | use crate::tui::app::{App, TuiOptions}; |
| 306 | fn make_app(tmpdir: &tempfile::TempDir, locale: Locale, has_api_key: bool) -> App { |
| 307 | let mut config = Config::default(); |
| 308 | if has_api_key { |
| 309 | config.api_key = Some("test-key".to_string()); |
| 310 | } |
| 311 | let mut app = App::new( |
| 312 | TuiOptions { |
| 313 | skills_dir: tmpdir.path().join("skills"), |
| 314 | memory_path: tmpdir.path().join("memory.md"), |
| 315 | notes_path: tmpdir.path().join("notes.txt"), |
| 316 | mcp_config_path: tmpdir.path().join("mcp.json"), |
| 317 | ..crate::test_support::test_tui_options(tmpdir.path()) |
| 318 | }, |
| 319 | &config, |
| 320 | ); |
| 321 | app.ui_locale = locale; |
| 322 | app.api_provider = crate::config::ApiProvider::Deepseek; |
| 323 | app.model_ids_passthrough = false; |
| 324 | app.onboarding_needs_api_key = !has_api_key; |
| 325 | app |
| 326 | } |
| 327 | |
| 328 | #[test] |
| 329 | fn extract_latest_section_finds_first_version() { |
| 330 | let content = "\n\ |
| 331 | ## [0.8.26] - 2026-05-09\n\ |
| 332 | \n\ |
| 333 | A security + polish release.\n\ |
| 334 | \n\ |
| 335 | ### Fixed\n\ |
| 336 | \n\ |
| 337 | - Fixed something\n\ |
| 338 | \n\ |
| 339 | ## [0.8.25] - 2026-05-09\n\ |
| 340 | \n\ |
| 341 | A stabilization release.\n"; |
| 342 | let section = extract_latest_changelog_section(content).expect("should find a section"); |
| 343 | assert!(section.contains("0.8.26")); |
| 344 | assert!(section.contains("Fixed something")); |
| 345 | assert!(!section.contains("0.8.25")); |
| 346 | } |
| 347 | |
| 348 | #[test] |
| 349 | fn extract_latest_section_handles_0_8_29_style_fixture() { |
| 350 | let content = "\n\ |
| 351 | # Changelog\n\ |
| 352 | \n\ |
| 353 | ## [0.8.29] - 2026-05-11\n\ |
| 354 | \n\ |
| 355 | Release candidate polish.\n\ |
| 356 | \n\ |
| 357 | ### Added\n\ |
| 358 | - New note-management command.\n\ |
| 359 | \n\ |
| 360 | ## [0.8.28] - 2026-05-10\n\ |
| 361 | \n\ |
| 362 | Previous release.\n"; |
| 363 | let section = extract_latest_changelog_section(content).expect("should find a section"); |
| 364 | assert!(section.contains("0.8.29")); |
| 365 | assert!(section.contains("2026-05-11")); |
| 366 | assert!(section.contains("New note-management command")); |
| 367 | assert!(!section.contains("0.8.28")); |
| 368 | } |
| 369 | |
| 370 | #[test] |
| 371 | fn extract_latest_section_returns_none_for_empty_content() { |
| 372 | assert!(extract_latest_changelog_section("").is_none()); |
| 373 | } |
| 374 | |
| 375 | #[test] |
| 376 | fn extract_latest_section_returns_none_for_no_version_headers() { |
| 377 | let content = "# Just a heading\n\nSome text\n"; |
| 378 | assert!(extract_latest_changelog_section(content).is_none()); |
| 379 | } |
| 380 | |
| 381 | #[test] |
| 382 | fn extract_latest_section_handles_single_version() { |
| 383 | let content = "\n## [0.8.26] - 2026-05-09\n\nOnly one version.\n"; |
| 384 | let section = extract_latest_changelog_section(content).expect("should find a section"); |
| 385 | assert!(section.contains("0.8.26")); |
| 386 | assert!(section.contains("Only one version")); |
| 387 | } |
| 388 | |
| 389 | #[test] |
| 390 | fn extract_latest_section_handles_subheadings() { |
| 391 | let content = "\n\ |
| 392 | ## [0.8.26] - 2026-05-09\n\ |
| 393 | \n\ |
| 394 | ### Added\n\ |
| 395 | - New feature A\n\ |
| 396 | \n\ |
| 397 | ### Fixed\n\ |
| 398 | - Fixed bug B\n\ |
| 399 | \n\ |
| 400 | ## [0.8.25] - 2026-05-09\n\ |
| 401 | "; |
| 402 | let section = extract_latest_changelog_section(content).expect("should find a section"); |
| 403 | assert!(section.contains("New feature A")); |
| 404 | assert!(section.contains("Fixed bug B")); |
| 405 | assert!(!section.contains("0.8.25")); |
| 406 | } |
| 407 | |
| 408 | #[test] |
| 409 | fn change_uses_bundled_release_notes_without_workspace_changelog() { |
| 410 | let tmp = tempfile::TempDir::new().unwrap(); |
| 411 | let mut app = make_app(&tmp, Locale::En, false); |
| 412 | let result = change(&mut app, None); |
| 413 | assert!(!result.is_error); |
| 414 | let msg = result.message.expect("should have a message"); |
| 415 | let expected = extract_latest_changelog_section(CODEWHALE_CHANGELOG) |
| 416 | .expect("bundled changelog should have a release section"); |
| 417 | assert!(msg.contains(expected.lines().next().unwrap())); |
| 418 | } |
| 419 | |
| 420 | #[test] |
| 421 | fn change_ignores_workspace_changelog() { |
| 422 | let tmp = tempfile::TempDir::new().unwrap(); |
| 423 | std::fs::write( |
| 424 | tmp.path().join("CHANGELOG.md"), |
| 425 | "\n## [9.9.9] - 2099-01-01\n\nWorkspace changelog.\n", |
| 426 | ) |
| 427 | .unwrap(); |
| 428 | let mut app = make_app(&tmp, Locale::En, false); |
| 429 | let result = change(&mut app, None); |
| 430 | assert!(!result.is_error); |
| 431 | let msg = result.message.expect("should have a message"); |
| 432 | assert!(!msg.contains("9.9.9")); |
| 433 | assert!(!msg.contains("Workspace changelog")); |
| 434 | } |
| 435 | |
| 436 | #[test] |
| 437 | fn change_in_english_returns_message_without_action() { |
| 438 | let tmp = tempfile::TempDir::new().unwrap(); |
| 439 | let mut app = make_app(&tmp, Locale::En, true); |
| 440 | let result = change(&mut app, None); |
| 441 | assert!(!result.is_error); |
| 442 | let msg = result.message.expect("should have a message"); |
| 443 | let expected = extract_latest_changelog_section(CODEWHALE_CHANGELOG) |
| 444 | .expect("bundled changelog should have a release section"); |
| 445 | assert!(msg.contains(expected.lines().next().unwrap())); |
| 446 | assert!( |
| 447 | result.action.is_none(), |
| 448 | "English locale should not send translation" |
| 449 | ); |
| 450 | } |
| 451 | |
| 452 | #[test] |
| 453 | fn change_in_non_english_also_sends_translation_action() { |
| 454 | for (locale, _label) in [ |
| 455 | (Locale::ZhHans, "zh-Hans"), |
| 456 | (Locale::Ja, "ja"), |
| 457 | (Locale::PtBr, "pt-BR"), |
| 458 | ] { |
| 459 | let tmp = tempfile::TempDir::new().unwrap(); |
| 460 | let mut app = make_app(&tmp, locale, true); |
| 461 | let result = change(&mut app, None); |
| 462 | assert!(!result.is_error, "Failed for locale {locale:?}"); |
| 463 | let msg = result.message.expect("should have a message"); |
| 464 | assert!(msg.contains(&*tr(locale, MessageId::CmdChangeTranslationQueued))); |
| 465 | assert!( |
| 466 | matches!(result.action, Some(AppAction::SendMessage(_))), |
| 467 | "Non-English locale should send translation, got {:?}", |
| 468 | result.action |
| 469 | ); |
| 470 | if let Some(AppAction::SendMessage(prompt)) = &result.action { |
| 471 | let expected = extract_latest_changelog_section(CODEWHALE_CHANGELOG) |
| 472 | .expect("bundled changelog should have a release section"); |
| 473 | assert!(prompt.contains(expected.lines().next().unwrap())); |
| 474 | let prev_ver = extract_previous_version_number(CODEWHALE_CHANGELOG) |
| 475 | .expect("bundled changelog should have a previous release"); |
| 476 | assert!( |
| 477 | prompt.contains(&prev_ver), |
| 478 | "translation prompt should include previous-version hint: {prompt}" |
| 479 | ); |
| 480 | } |
| 481 | } |
| 482 | } |
| 483 | |
| 484 | #[test] |
| 485 | fn change_in_non_english_without_api_key_uses_explicit_fallback() { |
| 486 | let tmp = tempfile::TempDir::new().unwrap(); |
| 487 | let _lock = lock_test_env(); |
| 488 | let _config_path = EnvVarGuard::set("DEEPSEEK_CONFIG_PATH", tmp.path().join("config.toml")); |
| 489 | let _deepseek_key = EnvVarGuard::remove("DEEPSEEK_API_KEY"); |
| 490 | let _deepseek_provider = EnvVarGuard::remove("DEEPSEEK_PROVIDER"); |
| 491 | let _codewhale_provider = EnvVarGuard::remove("CODEWHALE_PROVIDER"); |
| 492 | let mut app = make_app(&tmp, Locale::ZhHans, false); |
| 493 | let result = change(&mut app, None); |
| 494 | assert!(!result.is_error); |
| 495 | let msg = result.message.expect("should have a message"); |
| 496 | assert!(msg.contains(&*tr( |
| 497 | Locale::ZhHans, |
| 498 | MessageId::CmdChangeTranslationUnavailable |
| 499 | ))); |
| 500 | assert!( |
| 501 | result.action.is_none(), |
| 502 | "missing API key should not send translation" |
| 503 | ); |
| 504 | } |
| 505 | |
| 506 | #[test] |
| 507 | fn change_in_non_english_offline_uses_explicit_fallback() { |
| 508 | let tmp = tempfile::TempDir::new().unwrap(); |
| 509 | let mut app = make_app(&tmp, Locale::Ja, true); |
| 510 | app.offline_mode = true; |
| 511 | let result = change(&mut app, None); |
| 512 | assert!(!result.is_error); |
| 513 | let msg = result.message.expect("should have a message"); |
| 514 | assert!(msg.contains(&*tr(Locale::Ja, MessageId::CmdChangeTranslationUnavailable))); |
| 515 | assert!( |
| 516 | result.action.is_none(), |
| 517 | "offline mode should not send translation" |
| 518 | ); |
| 519 | } |
| 520 | |
| 521 | #[test] |
| 522 | fn extract_latest_ignores_lines_before_first_version() { |
| 523 | let content = "\n\ |
| 524 | # Changelog\n\ |
| 525 | \n\ |
| 526 | Some intro text.\n\ |
| 527 | \n\ |
| 528 | ## [0.8.26] - 2026-05-09\n\ |
| 529 | \n\ |
| 530 | Content\n\ |
| 531 | "; |
| 532 | let section = extract_latest_changelog_section(content).expect("should find a section"); |
| 533 | assert!(section.contains("0.8.26")); |
| 534 | assert!(!section.contains("Changelog")); |
| 535 | assert!(!section.contains("intro text")); |
| 536 | } |
| 537 | |
| 538 | #[test] |
| 539 | fn extract_latest_skips_empty_unreleased_section() { |
| 540 | let content = "\n\ |
| 541 | ## [Unreleased]\n\ |
| 542 | \n\ |
| 543 | ## [0.8.32] - 2026-05-12\n\ |
| 544 | \n\ |
| 545 | A release with content.\n\ |
| 546 | \n\ |
| 547 | ### Fixed\n\ |
| 548 | - Something fixed\n\ |
| 549 | \n\ |
| 550 | ## [0.8.31] - 2026-05-11\n\ |
| 551 | \n\ |
| 552 | Previous release.\n"; |
| 553 | let section = extract_latest_changelog_section(content).expect("should skip Unreleased"); |
| 554 | assert!(section.contains("0.8.32")); |
| 555 | assert!(section.contains("Something fixed")); |
| 556 | assert!(!section.contains("Unreleased")); |
| 557 | assert!(!section.contains("0.8.31")); |
| 558 | } |
| 559 | |
| 560 | #[test] |
| 561 | fn extract_latest_skips_entirely_empty_unreleased() { |
| 562 | // `## [Unreleased]` followed immediately by the next version heading. |
| 563 | let content = "\n\ |
| 564 | ## [Unreleased]\n\ |
| 565 | ## [0.8.32] - 2026-05-12\n\ |
| 566 | \n\ |
| 567 | Content here.\n"; |
| 568 | let section = extract_latest_changelog_section(content).expect("should find 0.8.32"); |
| 569 | assert!(section.contains("0.8.32")); |
| 570 | assert!(!section.contains("Unreleased")); |
| 571 | } |
| 572 | |
| 573 | #[test] |
| 574 | fn extract_latest_returns_none_when_all_sections_empty() { |
| 575 | let content = "\n\ |
| 576 | ## [Unreleased]\n\ |
| 577 | ## [Future]\n"; |
| 578 | assert!(extract_latest_changelog_section(content).is_none()); |
| 579 | } |
| 580 | |
| 581 | #[test] |
| 582 | fn extract_latest_skips_multiple_empty_sections() { |
| 583 | let content = "\n\ |
| 584 | ## [Unreleased]\n\ |
| 585 | \n\ |
| 586 | ## [Next]\n\ |
| 587 | \n\ |
| 588 | ## [0.8.32] - 2026-05-12\n\ |
| 589 | \n\ |
| 590 | Real content.\n"; |
| 591 | let section = extract_latest_changelog_section(content).expect("should find 0.8.32"); |
| 592 | assert!(section.contains("0.8.32")); |
| 593 | assert!(section.contains("Real content")); |
| 594 | } |
| 595 | |
| 596 | #[test] |
| 597 | fn extract_by_version_finds_exact_version() { |
| 598 | let content = "\n\ |
| 599 | ## [0.8.32] - 2026-05-12\n\ |
| 600 | \n\ |
| 601 | Release content.\n\ |
| 602 | \n\ |
| 603 | ## [0.8.31] - 2026-05-11\n\ |
| 604 | \n\ |
| 605 | Earlier release.\n"; |
| 606 | let section = |
| 607 | extract_changelog_section_by_version(content, "0.8.31").expect("should find 0.8.31"); |
| 608 | assert!(section.contains("0.8.31")); |
| 609 | assert!(section.contains("Earlier release")); |
| 610 | assert!(!section.contains("0.8.32")); |
| 611 | } |
| 612 | |
| 613 | #[test] |
| 614 | fn extract_by_version_returns_none_for_missing_version() { |
| 615 | let content = "\n\ |
| 616 | ## [0.8.32] - 2026-05-12\n\ |
| 617 | \n\ |
| 618 | Content.\n"; |
| 619 | assert!(extract_changelog_section_by_version(content, "9.9.9").is_none()); |
| 620 | } |
| 621 | |
| 622 | #[test] |
| 623 | fn extract_by_version_finds_version_without_date() { |
| 624 | let content = "\n\ |
| 625 | ## [Unreleased]\n\ |
| 626 | \n\ |
| 627 | Nothing.\n"; |
| 628 | let section = extract_changelog_section_by_version(content, "Unreleased") |
| 629 | .expect("should find Unreleased"); |
| 630 | assert!(section.contains("Unreleased")); |
| 631 | assert!(section.contains("Nothing")); |
| 632 | } |
| 633 | |
| 634 | #[test] |
| 635 | fn extract_by_version_respects_empty_sections() { |
| 636 | // `## [0.8.32]` is empty, should return None for it |
| 637 | let content = "\n\ |
| 638 | ## [0.8.32] - 2026-05-12\n\ |
| 639 | ## [0.8.31] - 2026-05-11\n\ |
| 640 | \n\ |
| 641 | Content.\n"; |
| 642 | assert!(extract_changelog_section_by_version(content, "0.8.32").is_none()); |
| 643 | } |
| 644 | |
| 645 | #[test] |
| 646 | fn change_with_version_arg_shows_older_release() { |
| 647 | let tmp = tempfile::TempDir::new().unwrap(); |
| 648 | let mut app = make_app(&tmp, Locale::En, false); |
| 649 | let result = change(&mut app, Some("0.8.1")); |
| 650 | // 0.8.1 is a very old release; if it exists, the result should not be an error. |
| 651 | // If that exact version doesn't exist in the bundled changelog, we still |
| 652 | // expect a proper error message referencing the version. |
| 653 | if result.is_error { |
| 654 | let msg = result.message.as_deref().unwrap_or(""); |
| 655 | assert!(msg.contains("0.8.1"), "error should mention version: {msg}"); |
| 656 | } else { |
| 657 | let msg = result.message.expect("should have a message"); |
| 658 | assert!(msg.contains("0.8.1")); |
| 659 | } |
| 660 | } |
| 661 | |
| 662 | #[test] |
| 663 | fn change_with_empty_version_arg_acts_as_default() { |
| 664 | let tmp = tempfile::TempDir::new().unwrap(); |
| 665 | let mut app = make_app(&tmp, Locale::En, false); |
| 666 | let result_default = change(&mut app, None); |
| 667 | assert!(!result_default.is_error); |
| 668 | |
| 669 | let mut app2 = make_app(&tmp, Locale::En, false); |
| 670 | let result_empty = change(&mut app2, Some("")); |
| 671 | assert!(!result_empty.is_error); |
| 672 | |
| 673 | // Both should have the same message content |
| 674 | let msg_default = result_default.message.as_deref().unwrap_or(""); |
| 675 | let msg_empty = result_empty.message.as_deref().unwrap_or(""); |
| 676 | assert_eq!(msg_default, msg_empty); |
| 677 | } |
| 678 | |
| 679 | #[test] |
| 680 | fn change_with_nonexistent_version_returns_error() { |
| 681 | let tmp = tempfile::TempDir::new().unwrap(); |
| 682 | let mut app = make_app(&tmp, Locale::En, false); |
| 683 | let result = change(&mut app, Some("99.99.99")); |
| 684 | assert!(result.is_error); |
| 685 | let msg = result.message.as_deref().unwrap_or(""); |
| 686 | assert!( |
| 687 | msg.contains("99.99.99"), |
| 688 | "error should mention version: {msg}" |
| 689 | ); |
| 690 | } |
| 691 | |
| 692 | #[test] |
| 693 | fn extract_by_version_ignores_substring_matches() { |
| 694 | let content = |
| 695 | "\n## [0.8.1] - 2026-01-01\n\nContent A.\n\n## [0.8.10] - 2026-01-10\n\nContent B.\n"; |
| 696 | let section = |
| 697 | extract_changelog_section_by_version(content, "0.8.1").expect("should find 0.8.1"); |
| 698 | assert!(section.contains("Content A")); |
| 699 | assert!(!section.contains("Content B")); |
| 700 | } |
| 701 | |
| 702 | // --- extract_previous_version_number tests --- |
| 703 | |
| 704 | #[test] |
| 705 | fn prev_version_finds_second_heading() { |
| 706 | let content = "\n\ |
| 707 | ## [0.8.32] - 2026-05-12\n\ |
| 708 | \n\ |
| 709 | Release content.\n\ |
| 710 | \n\ |
| 711 | ## [0.8.31] - 2026-05-11\n\ |
| 712 | \n\ |
| 713 | Earlier release.\n"; |
| 714 | let prev = extract_previous_version_number(content).expect("should find 0.8.31"); |
| 715 | assert_eq!(prev, "0.8.31"); |
| 716 | } |
| 717 | |
| 718 | #[test] |
| 719 | fn prev_version_skips_empty_unreleased_section() { |
| 720 | let content = "\n\ |
| 721 | ## [Unreleased]\n\ |
| 722 | \n\ |
| 723 | ## [0.8.32] - 2026-05-12\n\ |
| 724 | \n\ |
| 725 | Actual release.\n\ |
| 726 | \n\ |
| 727 | ## [0.8.31] - 2026-05-11\n\ |
| 728 | \n\ |
| 729 | Older release.\n"; |
| 730 | let prev = extract_previous_version_number(content) |
| 731 | .expect("should skip Unreleased and find 0.8.31"); |
| 732 | assert_eq!(prev, "0.8.31"); |
| 733 | } |
| 734 | |
| 735 | #[test] |
| 736 | fn prev_version_returns_none_for_single_version() { |
| 737 | let content = "\n## [0.8.32] - 2026-05-12\n\nOnly one version.\n"; |
| 738 | assert!(extract_previous_version_number(content).is_none()); |
| 739 | } |
| 740 | |
| 741 | #[test] |
| 742 | fn prev_version_returns_none_for_empty_content() { |
| 743 | assert!(extract_previous_version_number("").is_none()); |
| 744 | } |
| 745 | |
| 746 | #[test] |
| 747 | fn prev_version_returns_none_for_no_version_headers() { |
| 748 | let content = "# Just a heading\n\nNo versions here.\n"; |
| 749 | assert!(extract_previous_version_number(content).is_none()); |
| 750 | } |
| 751 | |
| 752 | #[test] |
| 753 | fn prev_version_handles_adjacent_headings() { |
| 754 | let content = "\n\ |
| 755 | ## [0.8.32] - 2026-05-12\n\ |
| 756 | \n\ |
| 757 | Content.\n\ |
| 758 | ## [0.8.31] - 2026-05-11\n\ |
| 759 | \n\ |
| 760 | Older content.\n"; |
| 761 | let prev = extract_previous_version_number(content) |
| 762 | .expect("should find 0.8.31 even with no blank line after section"); |
| 763 | assert_eq!(prev, "0.8.31"); |
| 764 | } |
| 765 | |
| 766 | #[test] |
| 767 | fn prev_version_skips_multiple_empty_sections() { |
| 768 | let content = "\n\ |
| 769 | ## [Unreleased]\n\ |
| 770 | \n\ |
| 771 | ## [Future]\n\ |
| 772 | \n\ |
| 773 | ## [0.8.32] - 2026-05-12\n\ |
| 774 | \n\ |
| 775 | Real release.\n\ |
| 776 | \n\ |
| 777 | ## [0.8.31] - 2026-05-11\n\ |
| 778 | \n\ |
| 779 | Older release.\n"; |
| 780 | let prev = extract_previous_version_number(content) |
| 781 | .expect("should skip Unreleased and Future, find 0.8.31"); |
| 782 | assert_eq!(prev, "0.8.31"); |
| 783 | } |
| 784 | |
| 785 | #[test] |
| 786 | fn prev_version_after_explicit_version_finds_next_older_release() { |
| 787 | let content = "\n\ |
| 788 | ## [0.8.32] - 2026-05-12\n\ |
| 789 | \n\ |
| 790 | Current release.\n\ |
| 791 | \n\ |
| 792 | ## [0.8.31] - 2026-05-11\n\ |
| 793 | \n\ |
| 794 | Requested release.\n\ |
| 795 | \n\ |
| 796 | ## [0.8.30] - 2026-05-10\n\ |
| 797 | \n\ |
| 798 | Older release.\n"; |
| 799 | let prev = extract_previous_version_number_after_version(content, "0.8.31") |
| 800 | .expect("should find 0.8.30"); |
| 801 | assert_eq!(prev, "0.8.30"); |
| 802 | } |
| 803 | |
| 804 | #[test] |
| 805 | fn prev_version_after_explicit_version_skips_empty_sections() { |
| 806 | let content = "\n\ |
| 807 | ## [0.8.32] - 2026-05-12\n\ |
| 808 | \n\ |
| 809 | Current release.\n\ |
| 810 | \n\ |
| 811 | ## [0.8.31] - 2026-05-11\n\ |
| 812 | \n\ |
| 813 | Requested release.\n\ |
| 814 | \n\ |
| 815 | ## [Future]\n\ |
| 816 | \n\ |
| 817 | ## [0.8.30] - 2026-05-10\n\ |
| 818 | \n\ |
| 819 | Older release.\n"; |
| 820 | let prev = extract_previous_version_number_after_version(content, "0.8.31") |
| 821 | .expect("should skip Future and find 0.8.30"); |
| 822 | assert_eq!(prev, "0.8.30"); |
| 823 | } |
| 824 | |
| 825 | // --- change() output hint tests --- |
| 826 | |
| 827 | #[test] |
| 828 | fn change_without_args_includes_previous_version_hint() { |
| 829 | let tmp = tempfile::TempDir::new().unwrap(); |
| 830 | let mut app = make_app(&tmp, Locale::En, false); |
| 831 | let result = change(&mut app, None); |
| 832 | assert!(!result.is_error); |
| 833 | let msg = result.message.expect("should have a message"); |
| 834 | // The previous version hint should be part of the output. |
| 835 | // We can't assert an exact version number since the changelog changes, |
| 836 | // but the hint message key should appear. |
| 837 | assert!( |
| 838 | msg.contains("Previous version:") || msg.contains("run `/change"), |
| 839 | "expected previous-version hint in output, got: {msg}" |
| 840 | ); |
| 841 | } |
| 842 | |
| 843 | #[test] |
| 844 | fn change_with_explicit_version_includes_previous_hint() { |
| 845 | let tmp = tempfile::TempDir::new().unwrap(); |
| 846 | let mut app = make_app(&tmp, Locale::En, false); |
| 847 | // Derive versions from the bundled changelog: it only embeds a recent |
| 848 | // slice of releases, so hardcoded versions would age out of it. |
| 849 | let explicit = extract_previous_version_number(CODEWHALE_CHANGELOG) |
| 850 | .expect("bundled changelog should have a previous release"); |
| 851 | let expected_prev = |
| 852 | extract_previous_version_number_after_version(CODEWHALE_CHANGELOG, &explicit) |
| 853 | .expect("bundled changelog should have at least three releases"); |
| 854 | let result = change(&mut app, Some(&explicit)); |
| 855 | assert!(!result.is_error); |
| 856 | let msg = result.message.as_deref().unwrap_or(""); |
| 857 | assert!( |
| 858 | msg.contains("Previous version:") && msg.contains(&expected_prev), |
| 859 | "explicit version should show previous-version hint: {msg}" |
| 860 | ); |
| 861 | } |
| 862 | |
| 863 | #[test] |
| 864 | fn change_hint_uses_localized_template() { |
| 865 | let tmp = tempfile::TempDir::new().unwrap(); |
| 866 | let mut app = make_app(&tmp, Locale::ZhHans, true); |
| 867 | let result = change(&mut app, None); |
| 868 | assert!(!result.is_error); |
| 869 | let msg = result.message.expect("should have a message"); |
| 870 | // zh-Hans template: "上一个版本:" |
| 871 | assert!( |
| 872 | msg.contains("上一个版本"), |
| 873 | "zh-Hans output should contain localized hint: {msg}" |
| 874 | ); |
| 875 | } |
| 876 | |
| 877 | #[test] |
| 878 | fn change_hint_in_japanese() { |
| 879 | let tmp = tempfile::TempDir::new().unwrap(); |
| 880 | let mut app = make_app(&tmp, Locale::Ja, true); |
| 881 | let result = change(&mut app, None); |
| 882 | assert!(!result.is_error); |
| 883 | let msg = result.message.expect("should have a message"); |
| 884 | assert!( |
| 885 | msg.contains("前のバージョン"), |
| 886 | "ja output should contain localized hint: {msg}" |
| 887 | ); |
| 888 | } |
| 889 | |
| 890 | #[test] |
| 891 | fn change_hint_in_portuguese() { |
| 892 | let tmp = tempfile::TempDir::new().unwrap(); |
| 893 | let mut app = make_app(&tmp, Locale::PtBr, true); |
| 894 | let result = change(&mut app, None); |
| 895 | assert!(!result.is_error); |
| 896 | let msg = result.message.expect("should have a message"); |
| 897 | assert!( |
| 898 | msg.contains("Versão anterior"), |
| 899 | "pt-BR output should contain localized hint: {msg}" |
| 900 | ); |
| 901 | } |
| 902 | } |
| 903 |