Skip to main content

joy_core/
commit_msg.rs

1// Copyright (c) 2026 Joydev GmbH (joydev.com)
2// SPDX-License-Identifier: MIT
3
4//! Commit-message suggestion for the `prepare-commit-msg` hook (JOY-01B1-FF).
5//!
6//! Pure logic: given the staged item IDs, the current in-progress items, the
7//! changed code crates, and the acting identity, build the text Joy pre-fills
8//! into the commit editor. The CLI side gathers these inputs from git and the
9//! project; this module never touches git or the filesystem so it stays fully
10//! unit-testable.
11//!
12//! Design contract (see the item): produce either a *complete* subject the
13//! user can just save, or an *empty* subject with candidates offered as
14//! commented-out ready lines. Never a half-filled placeholder that has to be
15//! cursored over and deleted. Comment (`#`) lines are stripped by git's
16//! default editor cleanup, so they never reach the stored commit.
17
18use crate::ai_templates::coauthor_line_for_member;
19use crate::model::item::ItemType;
20
21/// A candidate item for the message (id + type + title).
22#[derive(Debug, Clone, PartialEq)]
23pub struct ItemRef {
24    pub id: String,
25    pub item_type: ItemType,
26    pub title: String,
27}
28
29/// Who is committing, as resolved by joy-core identity.
30#[derive(Debug, Clone, PartialEq)]
31pub struct Committer {
32    /// Member id (email or `ai:tool@joy`).
33    pub member: String,
34    /// The delegating human, present only for an authenticated AI session.
35    pub delegated_by: Option<String>,
36}
37
38/// Inputs for [`build_suggestion`].
39#[derive(Debug, Clone)]
40pub struct Inputs {
41    /// Items whose `.joy/items/*.yaml` files are staged in this commit.
42    /// These are exact matches (the id comes from the filename).
43    pub staged_items: Vec<ItemRef>,
44    /// All items currently in `in-progress` (used only when nothing is staged).
45    pub in_progress: Vec<ItemRef>,
46    /// Distinct crate names among the staged *code* changes (e.g. "joy-cli").
47    /// A scope is only emitted when there is exactly one.
48    pub changed_crates: Vec<String>,
49    /// The acting committer, for trailers.
50    pub committer: Committer,
51}
52
53/// Conventional-commit type for an item type.
54fn 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
63/// Map a crate name to a conventional scope (`joy-cli` -> `cli`,
64/// `joy-core` -> `core`, otherwise the crate name unchanged).
65fn scope_for_crate(krate: &str) -> String {
66    krate.strip_prefix("joy-").unwrap_or(krate).to_string()
67}
68
69/// The trailing block: trailers only under an AI delegation, never for a plain
70/// human commit (where Delegated-By would be meaningless and Co-Authored-By
71/// wrong). Returns lines without a leading blank line.
72fn trailer_lines(c: &Committer) -> Vec<String> {
73    let mut out = Vec::new();
74    // Trailers only under an AI delegation (ai:* member with a delegating
75    // operator). A plain human commit gets none.
76    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
85/// Build a complete subject line for one or more items.
86fn subject_for(items: &[ItemRef], changed_crates: &[String]) -> String {
87    // Type from the first item (when several are staged they usually share a
88    // change); conventional commits allow only one type.
89    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    // Title from the first item; extra IDs still get referenced.
101    format!("{ty}{scope}: {} {ids}", items[0].title)
102}
103
104/// Assemble the final message text given a subject (may be empty) and any
105/// comment lines. `subject` is line 1; trailers follow after a blank line;
106/// comments go last (git strips them).
107fn 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
129/// Build the suggested commit-message text.
130///
131/// Priority (staged-before-status):
132/// 1. staged item file(s) -> complete subject with their id(s).
133/// 2. else exactly one in-progress item -> complete subject with its id.
134/// 3. else ambiguous/none -> empty subject + candidates as commented lines.
135pub fn build_suggestion(inp: &Inputs) -> String {
136    let trailers = trailer_lines(&inp.committer);
137
138    // 1. staged item file(s): exact.
139    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    // 2. exactly one in-progress item.
145    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    // 3. ambiguous or none: empty subject + candidate comment lines.
151    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        // human commit: no trailers
208        assert!(!msg.contains("Delegated-By"));
209        assert!(!msg.contains("Co-Authored-By"));
210        // no placeholder text to delete
211        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        // subject line is empty
243        assert_eq!(msg.lines().next().unwrap(), "");
244        // candidates present as comment lines (stripped by git on save)
245        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}