1use crate::ai_templates::coauthor_line_for_member;
19use crate::model::item::ItemType;
20
21#[derive(Debug, Clone, PartialEq)]
23pub struct ItemRef {
24 pub id: String,
25 pub item_type: ItemType,
26 pub title: String,
27}
28
29#[derive(Debug, Clone, PartialEq)]
31pub struct Committer {
32 pub member: String,
34 pub delegated_by: Option<String>,
36}
37
38#[derive(Debug, Clone)]
40pub struct Inputs {
41 pub staged_items: Vec<ItemRef>,
44 pub in_progress: Vec<ItemRef>,
46 pub changed_crates: Vec<String>,
49 pub committer: Committer,
51}
52
53fn conventional_type(t: &ItemType) -> &'static str {
55 match t {
56 ItemType::Bug => "fix",
57 ItemType::Rework => "rework",
58 ItemType::Story | ItemType::Epic | ItemType::Task => "feat",
59 ItemType::Decision | ItemType::Idea => "docs",
60 }
61}
62
63fn scope_for_crate(krate: &str) -> String {
66 krate.strip_prefix("joy-").unwrap_or(krate).to_string()
67}
68
69fn trailer_lines(c: &Committer) -> Vec<String> {
73 let mut out = Vec::new();
74 if let (true, Some(op)) = (c.member.starts_with("ai:"), &c.delegated_by) {
77 if let Some(coauthor) = coauthor_line_for_member(&c.member) {
78 out.push(format!("Co-Authored-By: {coauthor}"));
79 }
80 out.push(format!("Delegated-By: {op}"));
81 }
82 out
83}
84
85fn subject_for(items: &[ItemRef], changed_crates: &[String]) -> String {
87 let ty = conventional_type(&items[0].item_type);
90 let scope = if changed_crates.len() == 1 {
91 format!("({})", scope_for_crate(&changed_crates[0]))
92 } else {
93 String::new()
94 };
95 let ids: String = items
96 .iter()
97 .map(|i| format!("[{}]", i.id))
98 .collect::<Vec<_>>()
99 .join(" ");
100 format!("{ty}{scope}: {} {ids}", items[0].title)
102}
103
104fn assemble(subject: &str, trailers: &[String], comments: &[String]) -> String {
108 let mut s = String::new();
109 s.push_str(subject);
110 s.push('\n');
111 if !trailers.is_empty() {
112 s.push('\n');
113 for t in trailers {
114 s.push_str(t);
115 s.push('\n');
116 }
117 }
118 if !comments.is_empty() {
119 s.push('\n');
120 for c in comments {
121 s.push_str("# ");
122 s.push_str(c);
123 s.push('\n');
124 }
125 }
126 s
127}
128
129pub fn build_suggestion(inp: &Inputs) -> String {
136 let trailers = trailer_lines(&inp.committer);
137
138 if !inp.staged_items.is_empty() {
140 let subject = subject_for(&inp.staged_items, &inp.changed_crates);
141 return assemble(&subject, &trailers, &[]);
142 }
143
144 if inp.in_progress.len() == 1 {
146 let subject = subject_for(&inp.in_progress, &inp.changed_crates);
147 return assemble(&subject, &trailers, &[]);
148 }
149
150 let mut comments = Vec::new();
152 if inp.in_progress.is_empty() {
153 comments.push("joy: no in-progress item and no .joy/ item staged.".to_string());
154 comments.push("add a [<ID>] to the subject, or use [no-item].".to_string());
155 } else {
156 comments.push(
157 "joy: several in-progress items. Uncomment one line, or write your own:".to_string(),
158 );
159 for it in &inp.in_progress {
160 comments.push(subject_for(std::slice::from_ref(it), &inp.changed_crates));
161 }
162 }
163 assemble("", &trailers, &comments)
164}
165
166#[cfg(test)]
167mod tests {
168 use super::*;
169
170 fn item(id: &str, t: ItemType, title: &str) -> ItemRef {
171 ItemRef {
172 id: id.to_string(),
173 item_type: t,
174 title: title.to_string(),
175 }
176 }
177
178 fn human() -> Committer {
179 Committer {
180 member: "horst@joydev.com".into(),
181 delegated_by: None,
182 }
183 }
184
185 fn ai() -> Committer {
186 Committer {
187 member: "ai:claude@joy".into(),
188 delegated_by: Some("horst@joydev.com".into()),
189 }
190 }
191
192 #[test]
193 fn staged_item_gives_complete_subject_no_placeholder() {
194 let inp = Inputs {
195 staged_items: vec![item(
196 "JOY-01B1-FF",
197 ItemType::Rework,
198 "pre-fill commit message",
199 )],
200 in_progress: vec![],
201 changed_crates: vec!["joy-cli".into()],
202 committer: human(),
203 };
204 let msg = build_suggestion(&inp);
205 let first = msg.lines().next().unwrap();
206 assert_eq!(first, "rework(cli): pre-fill commit message [JOY-01B1-FF]");
207 assert!(!msg.contains("Delegated-By"));
209 assert!(!msg.contains("Co-Authored-By"));
210 assert!(!msg.contains("<type>"));
212 assert!(!msg.contains("<describe"));
213 }
214
215 #[test]
216 fn single_in_progress_used_when_nothing_staged() {
217 let inp = Inputs {
218 staged_items: vec![],
219 in_progress: vec![item("JOY-0042-AB", ItemType::Bug, "fix the thing")],
220 changed_crates: vec!["joy-core".into()],
221 committer: human(),
222 };
223 let msg = build_suggestion(&inp);
224 assert_eq!(
225 msg.lines().next().unwrap(),
226 "fix(core): fix the thing [JOY-0042-AB]"
227 );
228 }
229
230 #[test]
231 fn multiple_in_progress_offers_commented_candidates_empty_subject() {
232 let inp = Inputs {
233 staged_items: vec![],
234 in_progress: vec![
235 item("JOY-0001-AA", ItemType::Task, "task one"),
236 item("JOY-0002-BB", ItemType::Bug, "bug two"),
237 ],
238 changed_crates: vec![],
239 committer: human(),
240 };
241 let msg = build_suggestion(&inp);
242 assert_eq!(msg.lines().next().unwrap(), "");
244 assert!(msg.contains("# feat: task one [JOY-0001-AA]"));
246 assert!(msg.contains("# fix: bug two [JOY-0002-BB]"));
247 }
248
249 #[test]
250 fn no_candidate_gives_hint_only() {
251 let inp = Inputs {
252 staged_items: vec![],
253 in_progress: vec![],
254 changed_crates: vec![],
255 committer: human(),
256 };
257 let msg = build_suggestion(&inp);
258 assert_eq!(msg.lines().next().unwrap(), "");
259 assert!(msg.contains("# joy: no in-progress item"));
260 assert!(msg.contains("[no-item]"));
261 }
262
263 #[test]
264 fn scope_omitted_when_multiple_crates() {
265 let inp = Inputs {
266 staged_items: vec![item("JOY-0003-CC", ItemType::Story, "spanning change")],
267 in_progress: vec![],
268 changed_crates: vec!["joy-cli".into(), "joy-core".into()],
269 committer: human(),
270 };
271 let msg = build_suggestion(&inp);
272 assert_eq!(
273 msg.lines().next().unwrap(),
274 "feat: spanning change [JOY-0003-CC]"
275 );
276 }
277
278 #[test]
279 fn ai_delegation_adds_trailers() {
280 let inp = Inputs {
281 staged_items: vec![item("JOY-0004-DD", ItemType::Task, "do it")],
282 in_progress: vec![],
283 changed_crates: vec!["joy-cli".into()],
284 committer: ai(),
285 };
286 let msg = build_suggestion(&inp);
287 assert!(msg.contains("Co-Authored-By: Claude <noreply@anthropic.com>"));
288 assert!(msg.contains("Delegated-By: horst@joydev.com"));
289 }
290
291 #[test]
292 fn multiple_staged_items_reference_all_ids() {
293 let inp = Inputs {
294 staged_items: vec![
295 item("JOY-0005-EE", ItemType::Task, "first"),
296 item("JOY-0006-FF", ItemType::Task, "second"),
297 ],
298 in_progress: vec![],
299 changed_crates: vec!["joy-cli".into()],
300 committer: human(),
301 };
302 let msg = build_suggestion(&inp);
303 let first = msg.lines().next().unwrap();
304 assert!(first.contains("[JOY-0005-EE]"));
305 assert!(first.contains("[JOY-0006-FF]"));
306 }
307}