|
|
|
@@ -1,10 +1,11 @@
|
|
|
|
|
//! `/change` command — show the latest changelog entry, translated to the
|
|
|
|
|
//! user's locale when it is not English.
|
|
|
|
|
//! `/change` command — show a changelog entry, translated to the user's
|
|
|
|
|
//! locale when it is not English.
|
|
|
|
|
//!
|
|
|
|
|
//! Usage: `/change`
|
|
|
|
|
//! Usage: `/change [version]`
|
|
|
|
|
//!
|
|
|
|
|
//! Uses the DeepSeek-TUI changelog embedded at compile time, extracts the
|
|
|
|
|
//! most recent version section, and displays it. When the UI locale is not
|
|
|
|
|
//! Uses the DeepSeek-TUI changelog embedded at compile time. With no argument,
|
|
|
|
|
//! extracts the most recent section. With a version argument like `0.8.32`,
|
|
|
|
|
//! extracts that specific version's section. When the UI locale is not
|
|
|
|
|
//! English and the current session can reach a model, the command also fires a
|
|
|
|
|
//! `SendMessage` action that asks the model to translate the changelog into
|
|
|
|
|
//! the user's language.
|
|
|
|
@@ -21,28 +22,61 @@ const MAX_INLINE_CHANGELOG_CHARS: usize = 4096;
|
|
|
|
|
const DEEPSEEK_TUI_CHANGELOG: &str = include_str!("../../CHANGELOG.md");
|
|
|
|
|
|
|
|
|
|
/// Execute the `/change` command.
|
|
|
|
|
pub fn change(app: &mut App) -> CommandResult {
|
|
|
|
|
let latest_section = match extract_latest_changelog_section(DEEPSEEK_TUI_CHANGELOG) {
|
|
|
|
|
///
|
|
|
|
|
/// If `version` is `None`, shows the latest non-empty version section.
|
|
|
|
|
/// If `version` is `Some(v)`, shows the section for that version.
|
|
|
|
|
pub fn change(app: &mut App, version: Option<&str>) -> CommandResult {
|
|
|
|
|
let section = if let Some(ver) = version {
|
|
|
|
|
let ver = ver.trim();
|
|
|
|
|
if ver.is_empty() {
|
|
|
|
|
extract_latest_changelog_section(DEEPSEEK_TUI_CHANGELOG)
|
|
|
|
|
} else {
|
|
|
|
|
extract_changelog_section_by_version(DEEPSEEK_TUI_CHANGELOG, ver)
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
extract_latest_changelog_section(DEEPSEEK_TUI_CHANGELOG)
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let latest_section = match section {
|
|
|
|
|
Some(s) => s,
|
|
|
|
|
None => {
|
|
|
|
|
return CommandResult::error(
|
|
|
|
|
let msg = if let Some(ver) = version {
|
|
|
|
|
let ver = ver.trim();
|
|
|
|
|
if ver.is_empty() {
|
|
|
|
|
"Could not find a version section in the bundled DeepSeek-TUI changelog. \
|
|
|
|
|
Expected a line starting with `## [`."
|
|
|
|
|
.to_string()
|
|
|
|
|
} else {
|
|
|
|
|
format!(
|
|
|
|
|
"Could not find version \"{ver}\" in the bundled DeepSeek-TUI changelog."
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
"Could not find a version section in the bundled DeepSeek-TUI changelog. \
|
|
|
|
|
Expected a line starting with `## [`. "
|
|
|
|
|
.to_string(),
|
|
|
|
|
);
|
|
|
|
|
Expected a line starting with `## [`."
|
|
|
|
|
.to_string()
|
|
|
|
|
};
|
|
|
|
|
return CommandResult::error(msg);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let locale = app.ui_locale;
|
|
|
|
|
let header = tr(locale, MessageId::CmdChangeHeader);
|
|
|
|
|
|
|
|
|
|
let prev_hint = if let Some(prev_ver) = previous_version_hint(DEEPSEEK_TUI_CHANGELOG, version) {
|
|
|
|
|
let template = tr(locale, MessageId::CmdChangePreviousVersion);
|
|
|
|
|
format!("\n\n{}", template.replace("{version}", &prev_ver))
|
|
|
|
|
} else {
|
|
|
|
|
String::new()
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let section_text = inline_changelog_section(&latest_section);
|
|
|
|
|
|
|
|
|
|
// If the user's locale is English, just display.
|
|
|
|
|
// Otherwise, also ask the model to translate.
|
|
|
|
|
if locale == Locale::En {
|
|
|
|
|
CommandResult::message(format!(
|
|
|
|
|
"{header}\n─────────────────────────────\n{section_text}"
|
|
|
|
|
"{header}\n─────────────────────────────\n{section_text}{prev_hint}"
|
|
|
|
|
))
|
|
|
|
|
} else if app.offline_mode || app.onboarding_needs_api_key {
|
|
|
|
|
let fallback = tr(locale, MessageId::CmdChangeTranslationUnavailable);
|
|
|
|
@@ -50,7 +84,7 @@ pub fn change(app: &mut App) -> CommandResult {
|
|
|
|
|
"{header}\n\
|
|
|
|
|
─────────────────────────────\n\
|
|
|
|
|
{fallback}\n\n\
|
|
|
|
|
{section_text}"
|
|
|
|
|
{section_text}{prev_hint}"
|
|
|
|
|
))
|
|
|
|
|
} else {
|
|
|
|
|
let queued = tr(locale, MessageId::CmdChangeTranslationQueued);
|
|
|
|
@@ -58,8 +92,9 @@ pub fn change(app: &mut App) -> CommandResult {
|
|
|
|
|
"{header}\n\
|
|
|
|
|
─────────────────────────────\n\
|
|
|
|
|
{queued}\n\n\
|
|
|
|
|
{section_text}"
|
|
|
|
|
{section_text}{prev_hint}"
|
|
|
|
|
);
|
|
|
|
|
let translation_source = format!("{latest_section}{prev_hint}");
|
|
|
|
|
let lang_name = match locale {
|
|
|
|
|
Locale::ZhHans => "Simplified Chinese (中文)",
|
|
|
|
|
Locale::ZhHant => "Traditional Chinese (繁體中文)",
|
|
|
|
@@ -74,7 +109,7 @@ pub fn change(app: &mut App) -> CommandResult {
|
|
|
|
|
Keep all markdown formatting, version numbers, dates, \
|
|
|
|
|
contributor names, and code references intact. \
|
|
|
|
|
Output ONLY the translated changelog, no preamble or commentary.\n\n\
|
|
|
|
|
{latest_section}"
|
|
|
|
|
{translation_source}"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
CommandResult::with_message_and_action(
|
|
|
|
@@ -103,21 +138,71 @@ fn inline_changelog_section(section: &str) -> String {
|
|
|
|
|
/// Looks for the first `## [version] - date` heading and returns all lines
|
|
|
|
|
/// from that heading up to the next `## [` heading (or end of file).
|
|
|
|
|
/// Leading and trailing whitespace is trimmed.
|
|
|
|
|
///
|
|
|
|
|
/// Skips empty sections (e.g. `## [Unreleased]` with no content) to find
|
|
|
|
|
/// the first section that actually has content.
|
|
|
|
|
fn extract_latest_changelog_section(content: &str) -> Option<String> {
|
|
|
|
|
let lines: Vec<&str> = content.lines().collect();
|
|
|
|
|
|
|
|
|
|
// Find the first `## [` heading index
|
|
|
|
|
let first_idx = {
|
|
|
|
|
let mut idx = None;
|
|
|
|
|
for (i, line) in lines.iter().enumerate() {
|
|
|
|
|
if line.trim().starts_with("## [") {
|
|
|
|
|
idx = Some(i);
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
idx?
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Starting from `first_idx`, walk through headings until we find a
|
|
|
|
|
// section with non-empty content.
|
|
|
|
|
let mut pos = first_idx;
|
|
|
|
|
loop {
|
|
|
|
|
let end = lines
|
|
|
|
|
.iter()
|
|
|
|
|
.enumerate()
|
|
|
|
|
.skip(pos + 1)
|
|
|
|
|
.find(|(_, line)| line.trim().starts_with("## ["))
|
|
|
|
|
.map_or(lines.len(), |(i, _)| i);
|
|
|
|
|
|
|
|
|
|
if section_has_body_content(&lines[pos + 1..end]) {
|
|
|
|
|
return Some(lines[pos..end].join("\n").trim().to_string());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Empty section — try the next heading (if any)
|
|
|
|
|
if end >= lines.len() {
|
|
|
|
|
return None;
|
|
|
|
|
}
|
|
|
|
|
pos = end;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Extract a specific version section from CHANGELOG.md content.
|
|
|
|
|
///
|
|
|
|
|
/// Looks for `## [<version>]` or `## [<version> - date]` and returns all
|
|
|
|
|
/// lines from that heading up to the next `## [` heading (or end of file).
|
|
|
|
|
fn extract_changelog_section_by_version(content: &str, version: &str) -> Option<String> {
|
|
|
|
|
let lines: Vec<&str> = content.lines().collect();
|
|
|
|
|
let mut start_idx: Option<usize> = None;
|
|
|
|
|
|
|
|
|
|
for (i, line) in lines.iter().enumerate() {
|
|
|
|
|
let trimmed = line.trim();
|
|
|
|
|
if trimmed.starts_with("## [") {
|
|
|
|
|
start_idx = Some(i);
|
|
|
|
|
break;
|
|
|
|
|
// Check if this heading matches the requested version.
|
|
|
|
|
// Format: `## [0.8.32] - 2026-05-12` or `## [0.8.32]`
|
|
|
|
|
let bracket_end = trimmed.find(']')?;
|
|
|
|
|
let heading_ver = &trimmed[4..bracket_end]; // skip "## ["
|
|
|
|
|
if heading_ver == version {
|
|
|
|
|
start_idx = Some(i);
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let start = start_idx?;
|
|
|
|
|
|
|
|
|
|
// Find the next `## [` heading (or end)
|
|
|
|
|
let end = lines
|
|
|
|
|
.iter()
|
|
|
|
|
.enumerate()
|
|
|
|
@@ -125,14 +210,99 @@ fn extract_latest_changelog_section(content: &str) -> Option<String> {
|
|
|
|
|
.find(|(_, line)| line.trim().starts_with("## ["))
|
|
|
|
|
.map_or(lines.len(), |(i, _)| i);
|
|
|
|
|
|
|
|
|
|
let section = lines[start..end].join("\n");
|
|
|
|
|
let section = section.trim().to_string();
|
|
|
|
|
|
|
|
|
|
if section.is_empty() {
|
|
|
|
|
if !section_has_body_content(&lines[start + 1..end]) {
|
|
|
|
|
return None;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Some(section)
|
|
|
|
|
Some(lines[start..end].join("\n").trim().to_string())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Extract the version number of the section immediately preceding the latest
|
|
|
|
|
/// non-empty section in the changelog.
|
|
|
|
|
///
|
|
|
|
|
/// Walks past empty sections (e.g. `## [Unreleased]`) the same way
|
|
|
|
|
/// [`extract_latest_changelog_section`] does, then returns the version from
|
|
|
|
|
/// the next `## [version]` heading after the first contentful section.
|
|
|
|
|
fn extract_previous_version_number(content: &str) -> Option<String> {
|
|
|
|
|
let lines: Vec<&str> = content.lines().collect();
|
|
|
|
|
let first_idx = lines.iter().position(|l| l.trim().starts_with("## ["))?;
|
|
|
|
|
|
|
|
|
|
let mut pos = first_idx;
|
|
|
|
|
loop {
|
|
|
|
|
let end = lines
|
|
|
|
|
.iter()
|
|
|
|
|
.enumerate()
|
|
|
|
|
.skip(pos + 1)
|
|
|
|
|
.find(|(_, l)| l.trim().starts_with("## ["))
|
|
|
|
|
.map_or(lines.len(), |(i, _)| i);
|
|
|
|
|
|
|
|
|
|
if section_has_body_content(&lines[pos + 1..end]) {
|
|
|
|
|
// Found the latest contentful section heading at `pos`.
|
|
|
|
|
return next_contentful_version_after(&lines, end);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if end >= lines.len() {
|
|
|
|
|
return None;
|
|
|
|
|
}
|
|
|
|
|
pos = end;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn section_has_body_content(lines: &[&str]) -> bool {
|
|
|
|
|
lines.iter().any(|line| !line.trim().is_empty())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn previous_version_hint(content: &str, version: Option<&str>) -> Option<String> {
|
|
|
|
|
match version.map(str::trim).filter(|v| !v.is_empty()) {
|
|
|
|
|
Some(version) => extract_previous_version_number_after_version(content, version),
|
|
|
|
|
None => extract_previous_version_number(content),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn extract_previous_version_number_after_version(content: &str, version: &str) -> Option<String> {
|
|
|
|
|
let lines: Vec<&str> = content.lines().collect();
|
|
|
|
|
let current_start = lines.iter().position(|line| {
|
|
|
|
|
let trimmed = line.trim();
|
|
|
|
|
trimmed
|
|
|
|
|
.strip_prefix("## [")
|
|
|
|
|
.and_then(|rest| rest.split_once(']'))
|
|
|
|
|
.is_some_and(|(heading_ver, _)| heading_ver == version)
|
|
|
|
|
})?;
|
|
|
|
|
|
|
|
|
|
let current_end = lines
|
|
|
|
|
.iter()
|
|
|
|
|
.enumerate()
|
|
|
|
|
.skip(current_start + 1)
|
|
|
|
|
.find(|(_, line)| line.trim().starts_with("## ["))
|
|
|
|
|
.map_or(lines.len(), |(i, _)| i);
|
|
|
|
|
|
|
|
|
|
next_contentful_version_after(&lines, current_end)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn next_contentful_version_after(lines: &[&str], mut pos: usize) -> Option<String> {
|
|
|
|
|
while pos < lines.len() {
|
|
|
|
|
let heading = lines[pos].trim();
|
|
|
|
|
if !heading.starts_with("## [") {
|
|
|
|
|
pos += 1;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let end = lines
|
|
|
|
|
.iter()
|
|
|
|
|
.enumerate()
|
|
|
|
|
.skip(pos + 1)
|
|
|
|
|
.find(|(_, line)| line.trim().starts_with("## ["))
|
|
|
|
|
.map_or(lines.len(), |(i, _)| i);
|
|
|
|
|
|
|
|
|
|
if section_has_body_content(&lines[pos + 1..end]) {
|
|
|
|
|
let bracket_end = heading.find(']')?;
|
|
|
|
|
return Some(heading[4..bracket_end].to_string());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pos = end;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
None
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
@@ -258,7 +428,7 @@ Previous release.\n";
|
|
|
|
|
fn change_uses_bundled_release_notes_without_workspace_changelog() {
|
|
|
|
|
let tmp = tempfile::TempDir::new().unwrap();
|
|
|
|
|
let mut app = make_app(&tmp, Locale::En, false);
|
|
|
|
|
let result = change(&mut app);
|
|
|
|
|
let result = change(&mut app, None);
|
|
|
|
|
assert!(!result.is_error);
|
|
|
|
|
let msg = result.message.expect("should have a message");
|
|
|
|
|
let expected = extract_latest_changelog_section(DEEPSEEK_TUI_CHANGELOG)
|
|
|
|
@@ -275,7 +445,7 @@ Previous release.\n";
|
|
|
|
|
)
|
|
|
|
|
.unwrap();
|
|
|
|
|
let mut app = make_app(&tmp, Locale::En, false);
|
|
|
|
|
let result = change(&mut app);
|
|
|
|
|
let result = change(&mut app, None);
|
|
|
|
|
assert!(!result.is_error);
|
|
|
|
|
let msg = result.message.expect("should have a message");
|
|
|
|
|
assert!(!msg.contains("9.9.9"));
|
|
|
|
@@ -286,7 +456,7 @@ Previous release.\n";
|
|
|
|
|
fn change_in_english_returns_message_without_action() {
|
|
|
|
|
let tmp = tempfile::TempDir::new().unwrap();
|
|
|
|
|
let mut app = make_app(&tmp, Locale::En, true);
|
|
|
|
|
let result = change(&mut app);
|
|
|
|
|
let result = change(&mut app, None);
|
|
|
|
|
assert!(!result.is_error);
|
|
|
|
|
let msg = result.message.expect("should have a message");
|
|
|
|
|
let expected = extract_latest_changelog_section(DEEPSEEK_TUI_CHANGELOG)
|
|
|
|
@@ -307,7 +477,7 @@ Previous release.\n";
|
|
|
|
|
] {
|
|
|
|
|
let tmp = tempfile::TempDir::new().unwrap();
|
|
|
|
|
let mut app = make_app(&tmp, locale, true);
|
|
|
|
|
let result = change(&mut app);
|
|
|
|
|
let result = change(&mut app, None);
|
|
|
|
|
assert!(!result.is_error, "Failed for locale {locale:?}");
|
|
|
|
|
let msg = result.message.expect("should have a message");
|
|
|
|
|
assert!(msg.contains(tr(locale, MessageId::CmdChangeTranslationQueued)));
|
|
|
|
@@ -320,6 +490,12 @@ Previous release.\n";
|
|
|
|
|
let expected = extract_latest_changelog_section(DEEPSEEK_TUI_CHANGELOG)
|
|
|
|
|
.expect("bundled changelog should have a release section");
|
|
|
|
|
assert!(prompt.contains(expected.lines().next().unwrap()));
|
|
|
|
|
let prev_ver = extract_previous_version_number(DEEPSEEK_TUI_CHANGELOG)
|
|
|
|
|
.expect("bundled changelog should have a previous release");
|
|
|
|
|
assert!(
|
|
|
|
|
prompt.contains(&prev_ver),
|
|
|
|
|
"translation prompt should include previous-version hint: {prompt}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
@@ -328,7 +504,7 @@ Previous release.\n";
|
|
|
|
|
fn change_in_non_english_without_api_key_uses_explicit_fallback() {
|
|
|
|
|
let tmp = tempfile::TempDir::new().unwrap();
|
|
|
|
|
let mut app = make_app(&tmp, Locale::ZhHans, false);
|
|
|
|
|
let result = change(&mut app);
|
|
|
|
|
let result = change(&mut app, None);
|
|
|
|
|
assert!(!result.is_error);
|
|
|
|
|
let msg = result.message.expect("should have a message");
|
|
|
|
|
assert!(msg.contains(tr(
|
|
|
|
@@ -346,7 +522,7 @@ Previous release.\n";
|
|
|
|
|
let tmp = tempfile::TempDir::new().unwrap();
|
|
|
|
|
let mut app = make_app(&tmp, Locale::Ja, true);
|
|
|
|
|
app.offline_mode = true;
|
|
|
|
|
let result = change(&mut app);
|
|
|
|
|
let result = change(&mut app, None);
|
|
|
|
|
assert!(!result.is_error);
|
|
|
|
|
let msg = result.message.expect("should have a message");
|
|
|
|
|
assert!(msg.contains(tr(Locale::Ja, MessageId::CmdChangeTranslationUnavailable)));
|
|
|
|
@@ -372,4 +548,362 @@ Content\n\
|
|
|
|
|
assert!(!section.contains("Changelog"));
|
|
|
|
|
assert!(!section.contains("intro text"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn extract_latest_skips_empty_unreleased_section() {
|
|
|
|
|
let content = "\n\
|
|
|
|
|
## [Unreleased]\n\
|
|
|
|
|
\n\
|
|
|
|
|
## [0.8.32] - 2026-05-12\n\
|
|
|
|
|
\n\
|
|
|
|
|
A release with content.\n\
|
|
|
|
|
\n\
|
|
|
|
|
### Fixed\n\
|
|
|
|
|
- Something fixed\n\
|
|
|
|
|
\n\
|
|
|
|
|
## [0.8.31] - 2026-05-11\n\
|
|
|
|
|
\n\
|
|
|
|
|
Previous release.\n";
|
|
|
|
|
let section = extract_latest_changelog_section(content).expect("should skip Unreleased");
|
|
|
|
|
assert!(section.contains("0.8.32"));
|
|
|
|
|
assert!(section.contains("Something fixed"));
|
|
|
|
|
assert!(!section.contains("Unreleased"));
|
|
|
|
|
assert!(!section.contains("0.8.31"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn extract_latest_skips_entirely_empty_unreleased() {
|
|
|
|
|
// `## [Unreleased]` followed immediately by the next version heading.
|
|
|
|
|
let content = "\n\
|
|
|
|
|
## [Unreleased]\n\
|
|
|
|
|
## [0.8.32] - 2026-05-12\n\
|
|
|
|
|
\n\
|
|
|
|
|
Content here.\n";
|
|
|
|
|
let section = extract_latest_changelog_section(content).expect("should find 0.8.32");
|
|
|
|
|
assert!(section.contains("0.8.32"));
|
|
|
|
|
assert!(!section.contains("Unreleased"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn extract_latest_returns_none_when_all_sections_empty() {
|
|
|
|
|
let content = "\n\
|
|
|
|
|
## [Unreleased]\n\
|
|
|
|
|
## [Future]\n";
|
|
|
|
|
assert!(extract_latest_changelog_section(content).is_none());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn extract_latest_skips_multiple_empty_sections() {
|
|
|
|
|
let content = "\n\
|
|
|
|
|
## [Unreleased]\n\
|
|
|
|
|
\n\
|
|
|
|
|
## [Next]\n\
|
|
|
|
|
\n\
|
|
|
|
|
## [0.8.32] - 2026-05-12\n\
|
|
|
|
|
\n\
|
|
|
|
|
Real content.\n";
|
|
|
|
|
let section = extract_latest_changelog_section(content).expect("should find 0.8.32");
|
|
|
|
|
assert!(section.contains("0.8.32"));
|
|
|
|
|
assert!(section.contains("Real content"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn extract_by_version_finds_exact_version() {
|
|
|
|
|
let content = "\n\
|
|
|
|
|
## [0.8.32] - 2026-05-12\n\
|
|
|
|
|
\n\
|
|
|
|
|
Release content.\n\
|
|
|
|
|
\n\
|
|
|
|
|
## [0.8.31] - 2026-05-11\n\
|
|
|
|
|
\n\
|
|
|
|
|
Earlier release.\n";
|
|
|
|
|
let section =
|
|
|
|
|
extract_changelog_section_by_version(content, "0.8.31").expect("should find 0.8.31");
|
|
|
|
|
assert!(section.contains("0.8.31"));
|
|
|
|
|
assert!(section.contains("Earlier release"));
|
|
|
|
|
assert!(!section.contains("0.8.32"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn extract_by_version_returns_none_for_missing_version() {
|
|
|
|
|
let content = "\n\
|
|
|
|
|
## [0.8.32] - 2026-05-12\n\
|
|
|
|
|
\n\
|
|
|
|
|
Content.\n";
|
|
|
|
|
assert!(extract_changelog_section_by_version(content, "9.9.9").is_none());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn extract_by_version_finds_version_without_date() {
|
|
|
|
|
let content = "\n\
|
|
|
|
|
## [Unreleased]\n\
|
|
|
|
|
\n\
|
|
|
|
|
Nothing.\n";
|
|
|
|
|
let section = extract_changelog_section_by_version(content, "Unreleased")
|
|
|
|
|
.expect("should find Unreleased");
|
|
|
|
|
assert!(section.contains("Unreleased"));
|
|
|
|
|
assert!(section.contains("Nothing"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn extract_by_version_respects_empty_sections() {
|
|
|
|
|
// `## [0.8.32]` is empty, should return None for it
|
|
|
|
|
let content = "\n\
|
|
|
|
|
## [0.8.32] - 2026-05-12\n\
|
|
|
|
|
## [0.8.31] - 2026-05-11\n\
|
|
|
|
|
\n\
|
|
|
|
|
Content.\n";
|
|
|
|
|
assert!(extract_changelog_section_by_version(content, "0.8.32").is_none());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn change_with_version_arg_shows_older_release() {
|
|
|
|
|
let tmp = tempfile::TempDir::new().unwrap();
|
|
|
|
|
let mut app = make_app(&tmp, Locale::En, false);
|
|
|
|
|
let result = change(&mut app, Some("0.8.1"));
|
|
|
|
|
// 0.8.1 is a very old release; if it exists, the result should not be an error.
|
|
|
|
|
// If that exact version doesn't exist in the bundled changelog, we still
|
|
|
|
|
// expect a proper error message referencing the version.
|
|
|
|
|
if result.is_error {
|
|
|
|
|
let msg = result.message.as_deref().unwrap_or("");
|
|
|
|
|
assert!(msg.contains("0.8.1"), "error should mention version: {msg}");
|
|
|
|
|
} else {
|
|
|
|
|
let msg = result.message.expect("should have a message");
|
|
|
|
|
assert!(msg.contains("0.8.1"));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn change_with_empty_version_arg_acts_as_default() {
|
|
|
|
|
let tmp = tempfile::TempDir::new().unwrap();
|
|
|
|
|
let mut app = make_app(&tmp, Locale::En, false);
|
|
|
|
|
let result_default = change(&mut app, None);
|
|
|
|
|
assert!(!result_default.is_error);
|
|
|
|
|
|
|
|
|
|
let mut app2 = make_app(&tmp, Locale::En, false);
|
|
|
|
|
let result_empty = change(&mut app2, Some(""));
|
|
|
|
|
assert!(!result_empty.is_error);
|
|
|
|
|
|
|
|
|
|
// Both should have the same message content
|
|
|
|
|
let msg_default = result_default.message.as_deref().unwrap_or("");
|
|
|
|
|
let msg_empty = result_empty.message.as_deref().unwrap_or("");
|
|
|
|
|
assert_eq!(msg_default, msg_empty);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn change_with_nonexistent_version_returns_error() {
|
|
|
|
|
let tmp = tempfile::TempDir::new().unwrap();
|
|
|
|
|
let mut app = make_app(&tmp, Locale::En, false);
|
|
|
|
|
let result = change(&mut app, Some("99.99.99"));
|
|
|
|
|
assert!(result.is_error);
|
|
|
|
|
let msg = result.message.as_deref().unwrap_or("");
|
|
|
|
|
assert!(
|
|
|
|
|
msg.contains("99.99.99"),
|
|
|
|
|
"error should mention version: {msg}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn extract_by_version_ignores_substring_matches() {
|
|
|
|
|
let content =
|
|
|
|
|
"\n## [0.8.1] - 2026-01-01\n\nContent A.\n\n## [0.8.10] - 2026-01-10\n\nContent B.\n";
|
|
|
|
|
let section =
|
|
|
|
|
extract_changelog_section_by_version(content, "0.8.1").expect("should find 0.8.1");
|
|
|
|
|
assert!(section.contains("Content A"));
|
|
|
|
|
assert!(!section.contains("Content B"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- extract_previous_version_number tests ---
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn prev_version_finds_second_heading() {
|
|
|
|
|
let content = "\n\
|
|
|
|
|
## [0.8.32] - 2026-05-12\n\
|
|
|
|
|
\n\
|
|
|
|
|
Release content.\n\
|
|
|
|
|
\n\
|
|
|
|
|
## [0.8.31] - 2026-05-11\n\
|
|
|
|
|
\n\
|
|
|
|
|
Earlier release.\n";
|
|
|
|
|
let prev = extract_previous_version_number(content).expect("should find 0.8.31");
|
|
|
|
|
assert_eq!(prev, "0.8.31");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn prev_version_skips_empty_unreleased_section() {
|
|
|
|
|
let content = "\n\
|
|
|
|
|
## [Unreleased]\n\
|
|
|
|
|
\n\
|
|
|
|
|
## [0.8.32] - 2026-05-12\n\
|
|
|
|
|
\n\
|
|
|
|
|
Actual release.\n\
|
|
|
|
|
\n\
|
|
|
|
|
## [0.8.31] - 2026-05-11\n\
|
|
|
|
|
\n\
|
|
|
|
|
Older release.\n";
|
|
|
|
|
let prev = extract_previous_version_number(content)
|
|
|
|
|
.expect("should skip Unreleased and find 0.8.31");
|
|
|
|
|
assert_eq!(prev, "0.8.31");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn prev_version_returns_none_for_single_version() {
|
|
|
|
|
let content = "\n## [0.8.32] - 2026-05-12\n\nOnly one version.\n";
|
|
|
|
|
assert!(extract_previous_version_number(content).is_none());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn prev_version_returns_none_for_empty_content() {
|
|
|
|
|
assert!(extract_previous_version_number("").is_none());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn prev_version_returns_none_for_no_version_headers() {
|
|
|
|
|
let content = "# Just a heading\n\nNo versions here.\n";
|
|
|
|
|
assert!(extract_previous_version_number(content).is_none());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn prev_version_handles_adjacent_headings() {
|
|
|
|
|
let content = "\n\
|
|
|
|
|
## [0.8.32] - 2026-05-12\n\
|
|
|
|
|
\n\
|
|
|
|
|
Content.\n\
|
|
|
|
|
## [0.8.31] - 2026-05-11\n\
|
|
|
|
|
\n\
|
|
|
|
|
Older content.\n";
|
|
|
|
|
let prev = extract_previous_version_number(content)
|
|
|
|
|
.expect("should find 0.8.31 even with no blank line after section");
|
|
|
|
|
assert_eq!(prev, "0.8.31");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn prev_version_skips_multiple_empty_sections() {
|
|
|
|
|
let content = "\n\
|
|
|
|
|
## [Unreleased]\n\
|
|
|
|
|
\n\
|
|
|
|
|
## [Future]\n\
|
|
|
|
|
\n\
|
|
|
|
|
## [0.8.32] - 2026-05-12\n\
|
|
|
|
|
\n\
|
|
|
|
|
Real release.\n\
|
|
|
|
|
\n\
|
|
|
|
|
## [0.8.31] - 2026-05-11\n\
|
|
|
|
|
\n\
|
|
|
|
|
Older release.\n";
|
|
|
|
|
let prev = extract_previous_version_number(content)
|
|
|
|
|
.expect("should skip Unreleased and Future, find 0.8.31");
|
|
|
|
|
assert_eq!(prev, "0.8.31");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn prev_version_after_explicit_version_finds_next_older_release() {
|
|
|
|
|
let content = "\n\
|
|
|
|
|
## [0.8.32] - 2026-05-12\n\
|
|
|
|
|
\n\
|
|
|
|
|
Current release.\n\
|
|
|
|
|
\n\
|
|
|
|
|
## [0.8.31] - 2026-05-11\n\
|
|
|
|
|
\n\
|
|
|
|
|
Requested release.\n\
|
|
|
|
|
\n\
|
|
|
|
|
## [0.8.30] - 2026-05-10\n\
|
|
|
|
|
\n\
|
|
|
|
|
Older release.\n";
|
|
|
|
|
let prev = extract_previous_version_number_after_version(content, "0.8.31")
|
|
|
|
|
.expect("should find 0.8.30");
|
|
|
|
|
assert_eq!(prev, "0.8.30");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn prev_version_after_explicit_version_skips_empty_sections() {
|
|
|
|
|
let content = "\n\
|
|
|
|
|
## [0.8.32] - 2026-05-12\n\
|
|
|
|
|
\n\
|
|
|
|
|
Current release.\n\
|
|
|
|
|
\n\
|
|
|
|
|
## [0.8.31] - 2026-05-11\n\
|
|
|
|
|
\n\
|
|
|
|
|
Requested release.\n\
|
|
|
|
|
\n\
|
|
|
|
|
## [Future]\n\
|
|
|
|
|
\n\
|
|
|
|
|
## [0.8.30] - 2026-05-10\n\
|
|
|
|
|
\n\
|
|
|
|
|
Older release.\n";
|
|
|
|
|
let prev = extract_previous_version_number_after_version(content, "0.8.31")
|
|
|
|
|
.expect("should skip Future and find 0.8.30");
|
|
|
|
|
assert_eq!(prev, "0.8.30");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- change() output hint tests ---
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn change_without_args_includes_previous_version_hint() {
|
|
|
|
|
let tmp = tempfile::TempDir::new().unwrap();
|
|
|
|
|
let mut app = make_app(&tmp, Locale::En, false);
|
|
|
|
|
let result = change(&mut app, None);
|
|
|
|
|
assert!(!result.is_error);
|
|
|
|
|
let msg = result.message.expect("should have a message");
|
|
|
|
|
// The previous version hint should be part of the output.
|
|
|
|
|
// We can't assert an exact version number since the changelog changes,
|
|
|
|
|
// but the hint message key should appear.
|
|
|
|
|
assert!(
|
|
|
|
|
msg.contains("Previous version:") || msg.contains("run `/change"),
|
|
|
|
|
"expected previous-version hint in output, got: {msg}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn change_with_explicit_version_includes_previous_hint() {
|
|
|
|
|
let tmp = tempfile::TempDir::new().unwrap();
|
|
|
|
|
let mut app = make_app(&tmp, Locale::En, false);
|
|
|
|
|
let result = change(&mut app, Some("0.8.32"));
|
|
|
|
|
assert!(!result.is_error);
|
|
|
|
|
let msg = result.message.as_deref().unwrap_or("");
|
|
|
|
|
assert!(
|
|
|
|
|
msg.contains("Previous version:") && msg.contains("0.8.31"),
|
|
|
|
|
"explicit version should show previous-version hint: {msg}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn change_hint_uses_localized_template() {
|
|
|
|
|
let tmp = tempfile::TempDir::new().unwrap();
|
|
|
|
|
let mut app = make_app(&tmp, Locale::ZhHans, true);
|
|
|
|
|
let result = change(&mut app, None);
|
|
|
|
|
assert!(!result.is_error);
|
|
|
|
|
let msg = result.message.expect("should have a message");
|
|
|
|
|
// zh-Hans template: "上一个版本:"
|
|
|
|
|
assert!(
|
|
|
|
|
msg.contains("上一个版本"),
|
|
|
|
|
"zh-Hans output should contain localized hint: {msg}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn change_hint_in_japanese() {
|
|
|
|
|
let tmp = tempfile::TempDir::new().unwrap();
|
|
|
|
|
let mut app = make_app(&tmp, Locale::Ja, true);
|
|
|
|
|
let result = change(&mut app, None);
|
|
|
|
|
assert!(!result.is_error);
|
|
|
|
|
let msg = result.message.expect("should have a message");
|
|
|
|
|
assert!(
|
|
|
|
|
msg.contains("前のバージョン"),
|
|
|
|
|
"ja output should contain localized hint: {msg}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn change_hint_in_portuguese() {
|
|
|
|
|
let tmp = tempfile::TempDir::new().unwrap();
|
|
|
|
|
let mut app = make_app(&tmp, Locale::PtBr, true);
|
|
|
|
|
let result = change(&mut app, None);
|
|
|
|
|
assert!(!result.is_error);
|
|
|
|
|
let msg = result.message.expect("should have a message");
|
|
|
|
|
assert!(
|
|
|
|
|
msg.contains("Versão anterior"),
|
|
|
|
|
"pt-BR output should contain localized hint: {msg}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|