1use std::path::Path;
2use crate::config::Config;
3use crate::ticket::Ticket;
4
5const DEP_COMMIT_CAP: usize = 20;
6
7const SCOPE_GUIDANCE: &str =
8 "Use this to scope your ticket — do not duplicate or overreach into sibling tickets' territory.";
9
10pub fn build_epic_bundle(
16 root: &Path,
17 epic_id: &str,
18 current_ticket_id: &str,
19 config: &Config,
20) -> String {
21 let epic_md = crate::epic::find_epic_branch(root, epic_id)
22 .and_then(|branch| crate::git::read_from_branch(root, &branch, "tickets/EPIC.md").ok())
23 .unwrap_or_default();
24
25 let (epic_title, epic_body) = parse_epic_md(&epic_md, epic_id);
26
27 let all_tickets = crate::ticket::load_all_from_git(root, &config.tickets.dir)
28 .unwrap_or_default();
29
30 let siblings: Vec<&Ticket> = all_tickets.iter()
31 .filter(|t| {
32 t.frontmatter.epic.as_deref() == Some(epic_id)
33 && t.frontmatter.id != current_ticket_id
34 })
35 .collect();
36
37 let terminal_ids = config.terminal_state_ids();
38
39 let mut active: Vec<&Ticket> = siblings.iter()
40 .filter(|t| !terminal_ids.contains(&t.frontmatter.state))
41 .copied()
42 .collect();
43 let mut closed: Vec<&Ticket> = siblings.iter()
44 .filter(|t| terminal_ids.contains(&t.frontmatter.state))
45 .copied()
46 .collect();
47
48 active.sort_by(|a, b| {
50 a.frontmatter.state.cmp(&b.frontmatter.state)
51 .then(a.frontmatter.id.cmp(&b.frontmatter.id))
52 });
53 closed.sort_by(|a, b| b.frontmatter.created_at.cmp(&a.frontmatter.created_at));
55
56 let sibling_cap = config.context.epic_sibling_cap;
57 let byte_cap = config.context.epic_byte_cap;
58
59 let active_take = active.len().min(sibling_cap);
60 let included_active = &active[..active_take];
61 let remaining = sibling_cap.saturating_sub(active_take);
62 let closed_take = closed.len().min(remaining);
63 let included_closed = &closed[..closed_take];
64 let elided_count = closed.len().saturating_sub(closed_take);
65
66 let mut out = String::new();
67 out.push_str("# Epic Context Bundle\n\n");
68 out.push_str(&format!("**Epic:** {}\n", epic_title));
69 if !epic_body.is_empty() {
70 out.push('\n');
71 out.push_str(&epic_body);
72 out.push('\n');
73 }
74 out.push('\n');
75 out.push_str(&format!("**Scope guidance:** {SCOPE_GUIDANCE}\n"));
76
77 if !included_active.is_empty() || !included_closed.is_empty() || elided_count > 0 {
78 out.push_str("\n### Sibling Tickets\n");
79
80 let mut seen_states: Vec<&str> = Vec::new();
81 for t in included_active {
82 let state = t.frontmatter.state.as_str();
83 if !seen_states.contains(&state) {
84 seen_states.push(state);
85 out.push_str(&format!("\n#### {}\n", state));
86 }
87 append_sibling_entry(&mut out, t);
88 }
89
90 if !included_closed.is_empty() {
91 let mut seen_closed: Vec<&str> = Vec::new();
92 for t in included_closed {
93 let state = t.frontmatter.state.as_str();
94 if !seen_closed.contains(&state) {
95 seen_closed.push(state);
96 out.push_str(&format!("\n#### {}\n", state));
97 }
98 append_sibling_entry(&mut out, t);
99 }
100 }
101
102 if elided_count > 0 {
103 let plural = if elided_count == 1 { "" } else { "s" };
104 out.push_str(&format!(
105 "\n*({elided_count} older closed sibling{plural} not shown)*\n"
106 ));
107 }
108 }
109
110 out.push_str("***\n");
111
112 if byte_cap > 0 && out.len() > byte_cap {
114 let truncate_at = (0..=byte_cap)
115 .rev()
116 .find(|&i| out.is_char_boundary(i))
117 .unwrap_or(0);
118 let mut truncated = out[..truncate_at].to_string();
119 truncated.push_str("\n*[bundle truncated at byte limit]*\n***\n");
120 truncated
121 } else {
122 out
123 }
124}
125
126fn parse_epic_md(content: &str, fallback_id: &str) -> (String, String) {
127 let mut title = fallback_id.to_string();
128 let mut body_lines: Vec<&str> = Vec::new();
129 let mut found_title = false;
130
131 for line in content.lines() {
132 if !found_title {
133 if let Some(t) = line.strip_prefix("# ") {
134 title = t.trim().to_string();
135 found_title = true;
136 }
137 } else {
138 body_lines.push(line);
139 }
140 }
141
142 while body_lines.first().map(|l| l.trim().is_empty()) == Some(true) {
144 body_lines.remove(0);
145 }
146 while body_lines.last().map(|l| l.trim().is_empty()) == Some(true) {
147 body_lines.pop();
148 }
149
150 (title, body_lines.join("\n"))
151}
152
153fn append_sibling_entry(out: &mut String, t: &Ticket) {
154 out.push_str(&format!("- **{}:** {}\n", t.frontmatter.id, t.frontmatter.title));
155
156 let doc = match t.document() {
157 Ok(d) => d,
158 Err(_) => return,
159 };
160
161 let problem = crate::spec::get_section(&doc, "Problem").unwrap_or_default();
163 if let Some(one_liner) = problem.lines().find(|l| !l.trim().is_empty()) {
164 out.push_str(&format!(" *Problem:* {}\n", one_liner.trim()));
165 }
166
167 if let Some(oos) = crate::spec::get_section(&doc, "Out of scope").filter(|s| !s.is_empty()) {
169 out.push_str(" *Out of scope:*\n");
170 for line in oos.lines() {
171 if line.trim().is_empty() {
172 out.push_str(" \n");
173 } else {
174 out.push_str(&format!(" {}\n", line));
175 }
176 }
177 }
178}
179
180pub fn build_dependency_bundle(root: &Path, depends_on: &[String], config: &Config) -> String {
192 if depends_on.is_empty() {
193 return String::new();
194 }
195
196 let all_tickets = crate::ticket::load_all_from_git(root, &config.tickets.dir)
197 .unwrap_or_default();
198
199 let terminal_ids = config.terminal_state_ids();
200 let default_branch = config.project.default_branch.clone();
201
202 let mut out = String::new();
203 out.push_str("# Dependency Context Bundle\n\n");
204
205 let mut any = false;
206
207 for dep_id in depends_on {
208 let Some(dep) = all_tickets.iter().find(|t| &t.frontmatter.id == dep_id) else {
209 out.push_str(&format!("### Dependency: {dep_id}\n*Ticket not found.*\n\n"));
210 any = true;
211 continue;
212 };
213
214 any = true;
215 let is_terminal = terminal_ids.contains(&dep.frontmatter.state);
216
217 out.push_str(&format!(
218 "### Dependency: {} — {}\n",
219 dep_id, dep.frontmatter.title
220 ));
221 out.push_str(&format!("**State:** {}", dep.frontmatter.state));
222 if !is_terminal {
223 out.push_str(
224 " ⚠️ *This dependency is not yet closed — its API may still change. Tread carefully.*",
225 );
226 }
227 out.push('\n');
228
229 if let Ok(doc) = dep.document() {
231 if let Some(approach) = crate::spec::get_section(&doc, "Approach")
232 .filter(|s| !s.is_empty())
233 {
234 out.push_str("\n**Approach:**\n");
235 out.push_str(&approach);
236 out.push('\n');
237 }
238 }
239
240 let dep_branch = dep
242 .frontmatter
243 .branch
244 .clone()
245 .or_else(|| crate::ticket_fmt::branch_name_from_path(&dep.path))
246 .unwrap_or_else(|| format!("ticket/{dep_id}"));
247 let target = dep
248 .frontmatter
249 .target_branch
250 .as_deref()
251 .unwrap_or(default_branch.as_str());
252 let subjects = commit_subjects(root, target, &dep_branch, DEP_COMMIT_CAP);
253 if !subjects.is_empty() {
254 out.push_str("\n**Commits landed:**\n");
255 for s in &subjects {
256 out.push_str(&format!("- {s}\n"));
257 }
258 }
259
260 if let Some(ref trans_ids) = dep.frontmatter.depends_on {
262 if !trans_ids.is_empty() {
263 out.push_str("\n**Transitive dependencies:**\n");
264 for trans_id in trans_ids {
265 if let Some(trans) = all_tickets.iter().find(|t| &t.frontmatter.id == trans_id) {
266 out.push_str(&format!("- **{}:** {}", trans_id, trans.frontmatter.title));
267 if let Ok(doc) = trans.document() {
268 if let Some(problem) = crate::spec::get_section(&doc, "Problem") {
269 if let Some(line) =
270 problem.lines().find(|l| !l.trim().is_empty())
271 {
272 out.push_str(&format!(" — {}", line.trim()));
273 }
274 }
275 }
276 out.push('\n');
277 } else {
278 out.push_str(&format!("- **{trans_id}:** *(not found)*\n"));
279 }
280 }
281 }
282 }
283
284 out.push('\n');
285 }
286
287 if !any {
288 return String::new();
289 }
290
291 out.push_str("***\n");
292 out
293}
294
295fn commit_subjects(root: &Path, target: &str, dep_branch: &str, max_count: usize) -> Vec<String> {
298 let dep_ref = if crate::git_util::remote_branch_tip(root, dep_branch).is_some() {
299 format!("origin/{dep_branch}")
300 } else {
301 dep_branch.to_string()
302 };
303 let target_ref = if crate::git_util::remote_branch_tip(root, target).is_some() {
304 format!("origin/{target}")
305 } else {
306 target.to_string()
307 };
308 let range = format!("{target_ref}..{dep_ref}");
309 let max_str = max_count.to_string();
310 let output = crate::git_util::run(
311 root,
312 &["log", "--pretty=%s", &range, &format!("--max-count={max_str}")],
313 )
314 .unwrap_or_default();
315 output
316 .lines()
317 .filter(|l| !l.trim().is_empty())
318 .map(|l| l.to_string())
319 .collect()
320}
321
322#[cfg(test)]
323mod tests {
324 use super::*;
325
326 #[test]
327 fn parse_epic_md_extracts_title_and_body() {
328 let md = "# My Epic\n\n## Goal\nDo great things.\n\n## Non-goals\nNot everything.\n";
329 let (title, body) = parse_epic_md(md, "fallback");
330 assert_eq!(title, "My Epic");
331 assert!(body.contains("Goal"));
332 assert!(body.contains("Do great things"));
333 assert!(body.contains("Non-goals"));
334 }
335
336 #[test]
337 fn parse_epic_md_fallback_when_no_heading() {
338 let md = "Just some text without a heading.";
339 let (title, body) = parse_epic_md(md, "epic-id");
340 assert_eq!(title, "epic-id");
341 assert!(body.is_empty());
342 }
343
344 #[test]
345 fn parse_epic_md_trims_leading_blank_lines_from_body() {
346 let md = "# Title\n\n\nFirst non-blank line.\n";
347 let (_, body) = parse_epic_md(md, "id");
348 assert!(!body.starts_with('\n'));
349 assert!(body.starts_with("First"));
350 }
351
352 #[test]
353 fn build_epic_bundle_returns_empty_string_when_no_epic_branch() {
354 let tmp = tempfile::tempdir().unwrap();
355 let p = tmp.path();
356 std::process::Command::new("git")
357 .args(["-c", "init.defaultBranch=main", "init", "-q"])
358 .current_dir(p)
359 .env("GIT_AUTHOR_NAME", "t")
360 .env("GIT_AUTHOR_EMAIL", "t@t.com")
361 .env("GIT_COMMITTER_NAME", "t")
362 .env("GIT_COMMITTER_EMAIL", "t@t.com")
363 .status()
364 .unwrap();
365 std::process::Command::new("git")
366 .args(["config", "user.email", "t@t.com"])
367 .current_dir(p)
368 .status()
369 .unwrap();
370 std::process::Command::new("git")
371 .args(["config", "user.name", "t"])
372 .current_dir(p)
373 .status()
374 .unwrap();
375 std::fs::write(p.join("README.md"), "init").unwrap();
376 std::process::Command::new("git")
377 .args(["add", "README.md"])
378 .current_dir(p)
379 .status()
380 .unwrap();
381 std::process::Command::new("git")
382 .args(["-c", "commit.gpgsign=false", "commit", "-m", "init"])
383 .current_dir(p)
384 .status()
385 .unwrap();
386 std::fs::create_dir_all(p.join(".apm")).unwrap();
387 std::fs::write(
388 p.join(".apm/config.toml"),
389 "[project]\nname = \"test\"\n\n[tickets]\ndir = \"tickets\"\n",
390 )
391 .unwrap();
392 let config = crate::config::Config::load(p).unwrap();
393 let bundle = build_epic_bundle(p, "deadbeef", "aabb1234", &config);
396 assert!(bundle.contains("deadbeef"), "fallback title should appear");
397 assert!(bundle.contains("Scope guidance"), "guidance should always appear");
398 }
399}