返回 CodeWhale
change.rs
根目录 / crates / tui / src / commands / groups / debug / change.rs
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
903 lines RUST