返回 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 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
348 lines RUST