git-ents.gitmain
⌘K
foforge
compose.rs131 lines · 4.6 KB · rusthistorycomment on this file
1//! Attribute-driven `$GIT_EDITOR`/`$EDITOR` composition: an action
2//! variant marks its message-carrying fields `#[facet(ents::compose)]`,
3//! and this module — reading only the variant's [`facet::Shape`], never a
4//! per-command branch — opens the editor when those flags were omitted,
5//! mirroring `git commit`'s own editor fallback and its empty-message
6//! abort. A frontend concern, deliberately not an `ents-forge` operation
7//! (`lens.parity`).
8
9use std::io::Write as _;
10use std::process::Command;
11
12use facet::{Facet, Type, UserType};
13
14use crate::error::{Error, Result};
15
16/// Resolve `title` and `body` for a variant whose `title` and `body`
17/// fields are compose-marked: given values pass through; with no title,
18/// the editor composes both (first line title, rest body).
19///
20/// # Errors
21///
22/// [`Error::InvalidArgument`] if the variant's fields are not
23/// compose-marked (the flag is simply required) or the composed title is
24/// empty; [`Error::Io`] if the editor cannot run.
25pub fn title_body<T: Facet<'static>>(
26 variant: &str,
27 title: Option<String>,
28 body: Option<String>,
29) -> Result<(String, String)> {
30 if let Some(title) = title {
31 return Ok((title, body.unwrap_or_default()));
32 }
33 require_compose::<T>(variant, "title")?;
34 let message = editor_message(
35 "# First line is the title, the rest is the body.\n\
36 # Lines starting with '#' are stripped; an empty title aborts.",
37 )?;
38 let mut lines = message.lines();
39 let title = lines.next().unwrap_or("").trim();
40 if title.is_empty() {
41 return Err(Error::InvalidArgument("empty message, aborting".into()));
42 }
43 let body = lines.collect::<Vec<_>>().join("\n");
44 Ok((title.to_owned(), body.trim().to_owned()))
45}
46
47/// Resolve `body` for a variant whose `body` field is compose-marked:
48/// a given value passes through; with none, the editor composes it.
49///
50/// # Errors
51///
52/// [`Error::InvalidArgument`] if the field is not compose-marked (the
53/// flag is simply required) or the composed body is empty; [`Error::Io`]
54/// if the editor cannot run.
55pub fn body<T: Facet<'static>>(variant: &str, body: Option<String>) -> Result<String> {
56 if let Some(body) = body {
57 return Ok(body);
58 }
59 require_compose::<T>(variant, "body")?;
60 let message = editor_message(
61 "# Compose the body. Lines starting with '#' are stripped;\n\
62 # an empty body aborts.",
63 )?;
64 let body = message.trim();
65 if body.is_empty() {
66 return Err(Error::InvalidArgument("empty message, aborting".into()));
67 }
68 Ok(body.to_owned())
69}
70
71/// Refuse unless `T`'s variant marks `field` with `ents::compose` — the
72/// attribute on the action enum, not this module, is what licenses the
73/// editor fallback (`model.presentation`); an unmarked omitted flag is
74/// simply a missing argument.
75// @relation(model.presentation, scope=function)
76fn require_compose<T: Facet<'static>>(variant: &str, field: &str) -> Result<()> {
77 let marked = match T::SHAPE.ty {
78 Type::User(UserType::Enum(shape)) => shape
79 .variants
80 .iter()
81 .find(|candidate| candidate.name == variant)
82 .is_some_and(|found| {
83 found
84 .data
85 .fields
86 .iter()
87 .any(|f| f.name == field && f.has_attr(Some("ents"), "compose"))
88 }),
89 _ => false,
90 };
91 if marked {
92 Ok(())
93 } else {
94 Err(Error::InvalidArgument(format!("--{field} is required")))
95 }
96}
97
98/// Open `$GIT_EDITOR` (or `$EDITOR`, or `vi`) on a scratch file seeded
99/// with `instructions`, returning its content with `#` lines stripped.
100fn editor_message(instructions: &str) -> Result<String> {
101 let editor = std::env::var("GIT_EDITOR")
102 .or_else(|_| std::env::var("EDITOR"))
103 .unwrap_or_else(|_| "vi".to_owned());
104
105 let io_error = |path: &std::path::Path| {
106 let path = path.to_owned();
107 move |source| Error::Io { path, source }
108 };
109 let mut file = tempfile::NamedTempFile::new().map_err(io_error(&std::env::temp_dir()))?;
110 let path = file.path().to_owned();
111 writeln!(file, "\n{instructions}").map_err(io_error(&path))?;
112 file.flush().map_err(io_error(&path))?;
113
114 let status = Command::new(&editor)
115 .arg(&path)
116 .status()
117 .map_err(io_error(&path))?;
118 if !status.success() {
119 return Err(Error::Io {
120 path,
121 source: std::io::Error::other(format!("{editor} exited with {status}")),
122 });
123 }
124
125 let contents = std::fs::read_to_string(&path).map_err(io_error(&path))?;
126 Ok(contents
127 .lines()
128 .filter(|line| !line.starts_with('#'))
129 .collect::<Vec<_>>()
130 .join("\n"))
131}