1use std::collections::HashMap;
2use std::fmt::Write as _;
3use std::fs;
4use std::io;
5use std::io::Write as _;
6use std::path::Path;
7use std::path::PathBuf;
8use std::process::ExitStatus;
9
10use bstr::ByteVec as _;
11use indexmap::IndexMap;
12use indoc::indoc;
13use itertools::FoldWhile;
14use itertools::Itertools as _;
15use jj_lib::backend::CommitId;
16use jj_lib::commit::Commit;
17use jj_lib::commit_builder::DetachedCommitBuilder;
18use jj_lib::config::ConfigGetError;
19use jj_lib::file_util::IoResultExt as _;
20use jj_lib::file_util::PathError;
21use jj_lib::settings::UserSettings;
22use jj_lib::trailer::parse_description_trailers;
23use jj_lib::trailer::parse_trailers;
24use thiserror::Error;
25
26use crate::cli_util::WorkspaceCommandTransaction;
27use crate::cli_util::short_commit_hash;
28use crate::command_error::CommandError;
29use crate::command_error::user_error;
30use crate::config::CommandNameAndArgs;
31use crate::formatter::PlainTextFormatter;
32use crate::templater::TemplateRenderer;
33use crate::text_util;
34use crate::ui::Ui;
35
36#[derive(Debug, Error)]
37pub enum TextEditError {
38 #[error("Failed to run editor '{name}'")]
39 FailedToRun { name: String, source: io::Error },
40 #[error("Editor '{command}' exited with {status}")]
41 ExitStatus { command: String, status: ExitStatus },
42}
43
44#[derive(Debug, Error)]
45#[error("Failed to edit {name}", name = name.as_deref().unwrap_or("file"))]
46pub struct TempTextEditError {
47 #[source]
48 pub error: Box<dyn std::error::Error + Send + Sync>,
49 pub name: Option<String>,
51 pub path: Option<PathBuf>,
53}
54
55impl TempTextEditError {
56 fn new(error: Box<dyn std::error::Error + Send + Sync>, path: Option<PathBuf>) -> Self {
57 Self {
58 error,
59 name: None,
60 path,
61 }
62 }
63
64 pub fn with_name(mut self, name: impl Into<String>) -> Self {
66 self.name = Some(name.into());
67 self
68 }
69}
70
71#[derive(Clone, Debug)]
73pub struct TextEditor {
74 editor: CommandNameAndArgs,
75}
76
77impl TextEditor {
78 pub fn from_settings(settings: &UserSettings) -> Result<Self, ConfigGetError> {
79 let editor = settings.get("ui.editor")?;
80 Ok(Self { editor })
81 }
82
83 pub fn edit_file(&self, path: impl AsRef<Path>) -> Result<(), TextEditError> {
85 let mut cmd = self.editor.to_command();
86 cmd.arg(path.as_ref());
87 tracing::info!(?cmd, "running editor");
88 let status = cmd.status().map_err(|source| TextEditError::FailedToRun {
89 name: self.editor.split_name().into_owned(),
90 source,
91 })?;
92 if status.success() {
93 Ok(())
94 } else {
95 let command = self.editor.to_string();
96 Err(TextEditError::ExitStatus { command, status })
97 }
98 }
99
100 pub fn edit_str(
102 &self,
103 content: impl AsRef<[u8]>,
104 suffix: Option<&str>,
105 ) -> Result<String, TempTextEditError> {
106 let path = self
107 .write_temp_file(content.as_ref(), suffix)
108 .map_err(|err| TempTextEditError::new(err.into(), None))?;
109 self.edit_file(&path)
110 .map_err(|err| TempTextEditError::new(err.into(), Some(path.clone())))?;
111 let edited = fs::read_to_string(&path)
112 .context(&path)
113 .map_err(|err| TempTextEditError::new(err.into(), Some(path.clone())))?;
114 fs::remove_file(path).ok();
116 Ok(edited)
117 }
118
119 fn write_temp_file(&self, content: &[u8], suffix: Option<&str>) -> Result<PathBuf, PathError> {
120 let dir = tempfile::env::temp_dir();
121 let mut file = tempfile::Builder::new()
122 .prefix("editor-")
123 .suffix(suffix.unwrap_or(""))
124 .tempfile_in(&dir)
125 .context(&dir)?;
126 file.write_all(content).context(file.path())?;
127 let (_, path) = file
128 .keep()
129 .or_else(|err| Err(err.error).context(err.file.path()))?;
130 Ok(path)
131 }
132}
133
134fn append_blank_line(text: &mut String) {
135 if !text.is_empty() && !text.ends_with('\n') {
136 text.push('\n');
137 }
138 let last_line = text.lines().next_back();
139 if last_line.is_some_and(|line| line.starts_with("JJ:")) {
140 text.push_str("JJ:\n");
141 } else {
142 text.push('\n');
143 }
144}
145
146fn cleanup_description_lines<I>(lines: I) -> String
149where
150 I: IntoIterator,
151 I::Item: AsRef<str>,
152{
153 let description = lines
154 .into_iter()
155 .fold_while(String::new(), |acc, line| {
156 let line = line.as_ref();
157 if line.strip_prefix("JJ: ignore-rest").is_some() {
158 FoldWhile::Done(acc)
159 } else if line.starts_with("JJ:") {
160 FoldWhile::Continue(acc)
161 } else {
162 FoldWhile::Continue(acc + line + "\n")
163 }
164 })
165 .into_inner();
166 text_util::complete_newline(description.trim_matches('\n'))
167}
168
169pub fn edit_description(editor: &TextEditor, description: &str) -> Result<String, CommandError> {
170 let mut description = description.to_owned();
171 append_blank_line(&mut description);
172 description.push_str("JJ: Lines starting with \"JJ:\" (like this one) will be removed.\n");
173
174 let description = editor
175 .edit_str(description, Some(".jjdescription"))
176 .map_err(|err| err.with_name("description"))?;
177
178 Ok(cleanup_description_lines(description.lines()))
179}
180
181pub fn edit_multiple_descriptions(
183 ui: &Ui,
184 editor: &TextEditor,
185 tx: &WorkspaceCommandTransaction,
186 commits: &[(&CommitId, Commit)],
187) -> Result<ParsedBulkEditMessage<CommitId>, CommandError> {
188 let mut commits_map = IndexMap::new();
189 let mut bulk_message = String::new();
190
191 bulk_message.push_str(indoc! {r#"
192 JJ: Enter or edit commit descriptions after the `JJ: describe` lines.
193 JJ: Warning:
194 JJ: - The text you enter will be lost on a syntax error.
195 JJ: - The syntax of the separator lines may change in the future.
196 JJ:
197 "#});
198 for (commit_id, temp_commit) in commits {
199 let commit_hash = short_commit_hash(commit_id);
200 bulk_message.push_str("JJ: describe ");
201 bulk_message.push_str(&commit_hash);
202 bulk_message.push_str(" -------\n");
203 commits_map.insert(commit_hash, *commit_id);
204 let intro = "";
205 let template = description_template(ui, tx, intro, temp_commit)?;
206 bulk_message.push_str(&template);
207 append_blank_line(&mut bulk_message);
208 }
209 bulk_message.push_str("JJ: Lines starting with \"JJ:\" (like this one) will be removed.\n");
210
211 let bulk_message = editor
212 .edit_str(bulk_message, Some(".jjdescription"))
213 .map_err(|err| err.with_name("description"))?;
214
215 Ok(parse_bulk_edit_message(&bulk_message, &commits_map)?)
216}
217
218#[derive(Debug)]
219pub struct ParsedBulkEditMessage<T> {
220 pub descriptions: HashMap<T, String>,
222 pub missing: Vec<String>,
225 pub duplicates: Vec<String>,
228 pub unexpected: Vec<String>,
231}
232
233#[derive(Debug, Error, PartialEq)]
234pub enum ParseBulkEditMessageError {
235 #[error(r#"Found the following line without a commit header: "{0}""#)]
236 LineWithoutCommitHeader(String),
237}
238
239fn parse_bulk_edit_message<T>(
241 message: &str,
242 commit_ids_map: &IndexMap<String, &T>,
243) -> Result<ParsedBulkEditMessage<T>, ParseBulkEditMessageError>
244where
245 T: Eq + std::hash::Hash + Clone,
246{
247 let mut descriptions = HashMap::new();
248 let mut duplicates = Vec::new();
249 let mut unexpected = Vec::new();
250
251 let mut messages: Vec<(&str, Vec<&str>)> = vec![];
252 for line in message.lines() {
253 if let Some(commit_id_prefix) = line.strip_prefix("JJ: describe ") {
254 let commit_id_prefix =
255 commit_id_prefix.trim_end_matches(|c: char| c.is_ascii_whitespace() || c == '-');
256 messages.push((commit_id_prefix, vec![]));
257 } else if let Some((_, lines)) = messages.last_mut() {
258 lines.push(line);
259 }
260 else if !line.trim().is_empty() && !line.starts_with("JJ:") {
262 return Err(ParseBulkEditMessageError::LineWithoutCommitHeader(
263 line.to_owned(),
264 ));
265 }
266 }
267
268 for (commit_id_prefix, description_lines) in messages {
269 let Some(&commit_id) = commit_ids_map.get(commit_id_prefix) else {
270 unexpected.push(commit_id_prefix.to_string());
271 continue;
272 };
273 if descriptions.contains_key(commit_id) {
274 duplicates.push(commit_id_prefix.to_string());
275 continue;
276 }
277 descriptions.insert(
278 commit_id.clone(),
279 cleanup_description_lines(&description_lines),
280 );
281 }
282
283 let missing: Vec<_> = commit_ids_map
284 .iter()
285 .filter(|(_, commit_id)| !descriptions.contains_key(*commit_id))
286 .map(|(commit_id_prefix, _)| commit_id_prefix.clone())
287 .collect();
288
289 Ok(ParsedBulkEditMessage {
290 descriptions,
291 missing,
292 duplicates,
293 unexpected,
294 })
295}
296
297pub fn try_combine_messages(sources: &[Commit], destination: &Commit) -> Option<String> {
300 let non_empty = sources
301 .iter()
302 .chain(std::iter::once(destination))
303 .filter(|c| !c.description().is_empty())
304 .take(2)
305 .collect_vec();
306 match *non_empty.as_slice() {
307 [] => Some(String::new()),
308 [commit] => Some(commit.description().to_owned()),
309 [_, _, ..] => None,
310 }
311}
312
313pub async fn combine_messages_for_editing(
318 ui: &Ui,
319 tx: &WorkspaceCommandTransaction<'_>,
320 sources: &[Commit],
321 destination: Option<&Commit>,
322 commit_builder: &DetachedCommitBuilder,
323) -> Result<String, CommandError> {
324 let mut combined = String::new();
325 if let Some(destination) = destination {
326 combined.push_str("JJ: Description from the destination commit:\n");
327 combined.push_str(destination.description());
328 }
329 for commit in sources {
330 combined.push_str("\nJJ: Description from source commit:\n");
331 combined.push_str(commit.description());
332 }
333
334 if let Some(template) = parse_trailers_template(ui, tx)? {
335 let old_trailers: Vec<_> = sources
337 .iter()
338 .chain(destination)
339 .flat_map(|commit| parse_description_trailers(commit.description()))
340 .collect();
341 let commit = commit_builder.write_hidden().await?;
342 let trailer_lines = template
343 .format_plain_text(&commit)
344 .into_string()
345 .map_err(|_| user_error("Trailers should be valid utf-8"))?;
346 let new_trailers = parse_trailers(&trailer_lines)?;
347 let mut trailers = new_trailers
348 .iter()
349 .filter(|&t| !old_trailers.contains(t))
350 .peekable();
351 if trailers.peek().is_some() {
352 combined.push_str("\nJJ: Trailers not found in the squashed commits:\n");
353 combined.extend(trailers.flat_map(|t| [&t.key, ": ", &t.value, "\n"]));
354 }
355 }
356
357 Ok(combined)
358}
359
360pub fn join_message_paragraphs(paragraphs: &[String]) -> String {
365 paragraphs
368 .iter()
369 .map(|p| text_util::complete_newline(p.as_str()))
370 .join("\n")
371}
372
373pub fn parse_trailers_template<'a>(
377 ui: &Ui,
378 tx: &'a WorkspaceCommandTransaction,
379) -> Result<Option<TemplateRenderer<'a, Commit>>, CommandError> {
380 let trailer_template = tx.settings().get_string("templates.commit_trailers")?;
381 if trailer_template.is_empty() {
382 Ok(None)
383 } else {
384 tx.parse_commit_template(ui, &trailer_template).map(Some)
385 }
386}
387
388pub fn add_trailers_with_template(
393 template: &TemplateRenderer<'_, Commit>,
394 commit: &Commit,
395) -> Result<String, CommandError> {
396 let trailers = parse_description_trailers(commit.description());
397 let trailer_lines = template
398 .format_plain_text(commit)
399 .into_string()
400 .map_err(|_| user_error("Trailers should be valid utf-8"))?;
401 let new_trailers = parse_trailers(&trailer_lines)?;
402 let mut description = commit.description().to_owned();
403 if trailers.is_empty() && !new_trailers.is_empty() {
404 if description.is_empty() {
405 description.push('\n');
407 }
408 description.push('\n');
410 }
411 for new_trailer in new_trailers {
412 if !trailers.contains(&new_trailer) {
413 writeln!(description, "{}: {}", new_trailer.key, new_trailer.value).unwrap();
414 }
415 }
416 Ok(description)
417}
418
419pub async fn add_trailers(
424 ui: &Ui,
425 tx: &WorkspaceCommandTransaction<'_>,
426 commit_builder: &DetachedCommitBuilder,
427) -> Result<String, CommandError> {
428 if let Some(renderer) = parse_trailers_template(ui, tx)? {
429 let commit = commit_builder.write_hidden().await?;
430 add_trailers_with_template(&renderer, &commit)
431 } else {
432 Ok(commit_builder.description().to_owned())
433 }
434}
435
436pub fn description_template(
438 ui: &Ui,
439 tx: &WorkspaceCommandTransaction,
440 intro: &str,
441 commit: &Commit,
442) -> Result<String, CommandError> {
443 let template_key = "templates.draft_commit_description";
445 let template_text = tx.settings().get_string(template_key)?;
446 let template = tx.parse_commit_template(ui, &template_text)?;
447
448 let mut output = Vec::new();
449 if !intro.is_empty() {
450 writeln!(output, "JJ: {intro}").unwrap();
451 }
452 template
453 .format(commit, &mut PlainTextFormatter::new(&mut output))
454 .expect("write() to vec backed formatter should never fail");
455 Ok(output.into_string_lossy())
457}
458
459#[cfg(test)]
460mod tests {
461 use indexmap::indexmap;
462 use indoc::indoc;
463 use maplit::hashmap;
464
465 use super::parse_bulk_edit_message;
466 use crate::description_util::ParseBulkEditMessageError;
467
468 #[test]
469 fn test_parse_complete_bulk_edit_message() {
470 let result = parse_bulk_edit_message(
471 indoc! {"
472 JJ: describe 1 -------
473 Description 1
474
475 JJ: describe 2
476 Description 2
477
478 JJ: describe 3 --
479 Description 3
480 "},
481 &indexmap! {
482 "1".to_string() => &1,
483 "2".to_string() => &2,
484 "3".to_string() => &3,
485 },
486 )
487 .unwrap();
488 assert_eq!(
489 result.descriptions,
490 hashmap! {
491 1 => "Description 1\n".to_string(),
492 2 => "Description 2\n".to_string(),
493 3 => "Description 3\n".to_string(),
494 }
495 );
496 assert!(result.missing.is_empty());
497 assert!(result.duplicates.is_empty());
498 assert!(result.unexpected.is_empty());
499 }
500
501 #[test]
502 fn test_parse_bulk_edit_message_with_missing_descriptions() {
503 let result = parse_bulk_edit_message(
504 indoc! {"
505 JJ: describe 1 -------
506 Description 1
507 "},
508 &indexmap! {
509 "1".to_string() => &1,
510 "2".to_string() => &2,
511 },
512 )
513 .unwrap();
514 assert_eq!(
515 result.descriptions,
516 hashmap! {
517 1 => "Description 1\n".to_string(),
518 }
519 );
520 assert_eq!(result.missing, vec!["2".to_string()]);
521 assert!(result.duplicates.is_empty());
522 assert!(result.unexpected.is_empty());
523 }
524
525 #[test]
526 fn test_parse_bulk_edit_message_with_duplicate_descriptions() {
527 let result = parse_bulk_edit_message(
528 indoc! {"
529 JJ: describe 1 -------
530 Description 1
531
532 JJ: describe 1 -------
533 Description 1 (repeated)
534 "},
535 &indexmap! {
536 "1".to_string() => &1,
537 },
538 )
539 .unwrap();
540 assert_eq!(
541 result.descriptions,
542 hashmap! {
543 1 => "Description 1\n".to_string(),
544 }
545 );
546 assert!(result.missing.is_empty());
547 assert_eq!(result.duplicates, vec!["1".to_string()]);
548 assert!(result.unexpected.is_empty());
549 }
550
551 #[test]
552 fn test_parse_bulk_edit_message_with_unexpected_descriptions() {
553 let result = parse_bulk_edit_message(
554 indoc! {"
555 JJ: describe 1 -------
556 Description 1
557
558 JJ: describe 3 -------
559 Description 3 (unexpected)
560 "},
561 &indexmap! {
562 "1".to_string() => &1,
563 },
564 )
565 .unwrap();
566 assert_eq!(
567 result.descriptions,
568 hashmap! {
569 1 => "Description 1\n".to_string(),
570 }
571 );
572 assert!(result.missing.is_empty());
573 assert!(result.duplicates.is_empty());
574 assert_eq!(result.unexpected, vec!["3".to_string()]);
575 }
576
577 #[test]
578 fn test_parse_bulk_edit_message_with_no_header() {
579 let result = parse_bulk_edit_message(
580 indoc! {"
581 Description 1
582 "},
583 &indexmap! {
584 "1".to_string() => &1,
585 },
586 );
587 assert_eq!(
588 result.unwrap_err(),
589 ParseBulkEditMessageError::LineWithoutCommitHeader("Description 1".to_string())
590 );
591 }
592
593 #[test]
594 fn test_parse_bulk_edit_message_with_comment_before_header() {
595 let result = parse_bulk_edit_message(
596 indoc! {"
597 JJ: Custom comment and empty lines below should be accepted
598
599
600 JJ: describe 1 -------
601 Description 1
602 "},
603 &indexmap! {
604 "1".to_string() => &1,
605 },
606 )
607 .unwrap();
608 assert_eq!(
609 result.descriptions,
610 hashmap! {
611 1 => "Description 1\n".to_string(),
612 }
613 );
614 assert!(result.missing.is_empty());
615 assert!(result.duplicates.is_empty());
616 assert!(result.unexpected.is_empty());
617 }
618
619 #[test]
620 fn test_parse_bulk_edit_message_with_ignored_content() {
621 let result = parse_bulk_edit_message(
622 indoc! {"
623 JJ: describe 1 -------
624 Description 1
625 JJ: directive ignored
626 JJ: foo bar baz
627 JJ: describe 2
628 Description 2
629
630 JJ: ignore-rest
631 ignored content
632
633 JJ: describe 3 --
634 Description 3
635 JJ: ignore-rest still-ignored
636 additional ignored content
637 "},
638 &indexmap! {
639 "1".to_string() => &1,
640 "2".to_string() => &2,
641 "3".to_string() => &3,
642 },
643 )
644 .unwrap();
645 assert_eq!(
646 result.descriptions,
647 hashmap! {
648 1 => "Description 1\n".to_string(),
649 2 => "Description 2\n".to_string(),
650 3 => "Description 3\n".to_string(),
651 }
652 );
653 assert!(result.missing.is_empty());
654 assert!(result.duplicates.is_empty());
655 assert!(result.unexpected.is_empty());
656 }
657}