use std::path::Path;
use crate::config::Config;
use crate::ticket::Ticket;
const DEP_COMMIT_CAP: usize = 20;
const SCOPE_GUIDANCE: &str =
"Use this to scope your ticket — do not duplicate or overreach into sibling tickets' territory.";
pub fn build_epic_bundle(
root: &Path,
epic_id: &str,
current_ticket_id: &str,
config: &Config,
) -> String {
let epic_md = crate::epic::find_epic_branch(root, epic_id)
.and_then(|branch| crate::git::read_from_branch(root, &branch, "tickets/EPIC.md").ok())
.unwrap_or_default();
let (epic_title, epic_body) = parse_epic_md(&epic_md, epic_id);
let all_tickets = crate::ticket::load_all_from_git(root, &config.tickets.dir)
.unwrap_or_default();
let siblings: Vec<&Ticket> = all_tickets.iter()
.filter(|t| {
t.frontmatter.epic.as_deref() == Some(epic_id)
&& t.frontmatter.id != current_ticket_id
})
.collect();
let terminal_ids = config.terminal_state_ids();
let mut active: Vec<&Ticket> = siblings.iter()
.filter(|t| !terminal_ids.contains(&t.frontmatter.state))
.copied()
.collect();
let mut closed: Vec<&Ticket> = siblings.iter()
.filter(|t| terminal_ids.contains(&t.frontmatter.state))
.copied()
.collect();
active.sort_by(|a, b| {
a.frontmatter.state.cmp(&b.frontmatter.state)
.then(a.frontmatter.id.cmp(&b.frontmatter.id))
});
closed.sort_by(|a, b| b.frontmatter.created_at.cmp(&a.frontmatter.created_at));
let sibling_cap = config.context.epic_sibling_cap;
let byte_cap = config.context.epic_byte_cap;
let active_take = active.len().min(sibling_cap);
let included_active = &active[..active_take];
let remaining = sibling_cap.saturating_sub(active_take);
let closed_take = closed.len().min(remaining);
let included_closed = &closed[..closed_take];
let elided_count = closed.len().saturating_sub(closed_take);
let mut out = String::new();
out.push_str("# Epic Context Bundle\n\n");
out.push_str(&format!("**Epic:** {}\n", epic_title));
if !epic_body.is_empty() {
out.push('\n');
out.push_str(&epic_body);
out.push('\n');
}
out.push('\n');
out.push_str(&format!("**Scope guidance:** {SCOPE_GUIDANCE}\n"));
if !included_active.is_empty() || !included_closed.is_empty() || elided_count > 0 {
out.push_str("\n### Sibling Tickets\n");
let mut seen_states: Vec<&str> = Vec::new();
for t in included_active {
let state = t.frontmatter.state.as_str();
if !seen_states.contains(&state) {
seen_states.push(state);
out.push_str(&format!("\n#### {}\n", state));
}
append_sibling_entry(&mut out, t);
}
if !included_closed.is_empty() {
let mut seen_closed: Vec<&str> = Vec::new();
for t in included_closed {
let state = t.frontmatter.state.as_str();
if !seen_closed.contains(&state) {
seen_closed.push(state);
out.push_str(&format!("\n#### {}\n", state));
}
append_sibling_entry(&mut out, t);
}
}
if elided_count > 0 {
let plural = if elided_count == 1 { "" } else { "s" };
out.push_str(&format!(
"\n*({elided_count} older closed sibling{plural} not shown)*\n"
));
}
}
out.push_str("***\n");
if byte_cap > 0 && out.len() > byte_cap {
let truncate_at = (0..=byte_cap)
.rev()
.find(|&i| out.is_char_boundary(i))
.unwrap_or(0);
let mut truncated = out[..truncate_at].to_string();
truncated.push_str("\n*[bundle truncated at byte limit]*\n***\n");
truncated
} else {
out
}
}
fn parse_epic_md(content: &str, fallback_id: &str) -> (String, String) {
let mut title = fallback_id.to_string();
let mut body_lines: Vec<&str> = Vec::new();
let mut found_title = false;
for line in content.lines() {
if !found_title {
if let Some(t) = line.strip_prefix("# ") {
title = t.trim().to_string();
found_title = true;
}
} else {
body_lines.push(line);
}
}
while body_lines.first().map(|l| l.trim().is_empty()) == Some(true) {
body_lines.remove(0);
}
while body_lines.last().map(|l| l.trim().is_empty()) == Some(true) {
body_lines.pop();
}
(title, body_lines.join("\n"))
}
fn append_sibling_entry(out: &mut String, t: &Ticket) {
out.push_str(&format!("- **{}:** {}\n", t.frontmatter.id, t.frontmatter.title));
let doc = match t.document() {
Ok(d) => d,
Err(_) => return,
};
let problem = crate::spec::get_section(&doc, "Problem").unwrap_or_default();
if let Some(one_liner) = problem.lines().find(|l| !l.trim().is_empty()) {
out.push_str(&format!(" *Problem:* {}\n", one_liner.trim()));
}
if let Some(oos) = crate::spec::get_section(&doc, "Out of scope").filter(|s| !s.is_empty()) {
out.push_str(" *Out of scope:*\n");
for line in oos.lines() {
if line.trim().is_empty() {
out.push_str(" \n");
} else {
out.push_str(&format!(" {}\n", line));
}
}
}
}
pub fn build_dependency_bundle(root: &Path, depends_on: &[String], config: &Config) -> String {
if depends_on.is_empty() {
return String::new();
}
let all_tickets = crate::ticket::load_all_from_git(root, &config.tickets.dir)
.unwrap_or_default();
let terminal_ids = config.terminal_state_ids();
let default_branch = config.project.default_branch.clone();
let mut out = String::new();
out.push_str("# Dependency Context Bundle\n\n");
let mut any = false;
for dep_id in depends_on {
let Some(dep) = all_tickets.iter().find(|t| &t.frontmatter.id == dep_id) else {
out.push_str(&format!("### Dependency: {dep_id}\n*Ticket not found.*\n\n"));
any = true;
continue;
};
any = true;
let is_terminal = terminal_ids.contains(&dep.frontmatter.state);
out.push_str(&format!(
"### Dependency: {} — {}\n",
dep_id, dep.frontmatter.title
));
out.push_str(&format!("**State:** {}", dep.frontmatter.state));
if !is_terminal {
out.push_str(
" ⚠️ *This dependency is not yet closed — its API may still change. Tread carefully.*",
);
}
out.push('\n');
if let Ok(doc) = dep.document() {
if let Some(approach) = crate::spec::get_section(&doc, "Approach")
.filter(|s| !s.is_empty())
{
out.push_str("\n**Approach:**\n");
out.push_str(&approach);
out.push('\n');
}
}
let dep_branch = dep
.frontmatter
.branch
.clone()
.or_else(|| crate::ticket_fmt::branch_name_from_path(&dep.path))
.unwrap_or_else(|| format!("ticket/{dep_id}"));
let target = dep
.frontmatter
.target_branch
.as_deref()
.unwrap_or(default_branch.as_str());
let subjects = commit_subjects(root, target, &dep_branch, DEP_COMMIT_CAP);
if !subjects.is_empty() {
out.push_str("\n**Commits landed:**\n");
for s in &subjects {
out.push_str(&format!("- {s}\n"));
}
}
if let Some(ref trans_ids) = dep.frontmatter.depends_on {
if !trans_ids.is_empty() {
out.push_str("\n**Transitive dependencies:**\n");
for trans_id in trans_ids {
if let Some(trans) = all_tickets.iter().find(|t| &t.frontmatter.id == trans_id) {
out.push_str(&format!("- **{}:** {}", trans_id, trans.frontmatter.title));
if let Ok(doc) = trans.document() {
if let Some(problem) = crate::spec::get_section(&doc, "Problem") {
if let Some(line) =
problem.lines().find(|l| !l.trim().is_empty())
{
out.push_str(&format!(" — {}", line.trim()));
}
}
}
out.push('\n');
} else {
out.push_str(&format!("- **{trans_id}:** *(not found)*\n"));
}
}
}
}
out.push('\n');
}
if !any {
return String::new();
}
out.push_str("***\n");
out
}
fn commit_subjects(root: &Path, target: &str, dep_branch: &str, max_count: usize) -> Vec<String> {
let dep_ref = if crate::git_util::remote_branch_tip(root, dep_branch).is_some() {
format!("origin/{dep_branch}")
} else {
dep_branch.to_string()
};
let target_ref = if crate::git_util::remote_branch_tip(root, target).is_some() {
format!("origin/{target}")
} else {
target.to_string()
};
let range = format!("{target_ref}..{dep_ref}");
let max_str = max_count.to_string();
let output = crate::git_util::run(
root,
&["log", "--pretty=%s", &range, &format!("--max-count={max_str}")],
)
.unwrap_or_default();
output
.lines()
.filter(|l| !l.trim().is_empty())
.map(|l| l.to_string())
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_epic_md_extracts_title_and_body() {
let md = "# My Epic\n\n## Goal\nDo great things.\n\n## Non-goals\nNot everything.\n";
let (title, body) = parse_epic_md(md, "fallback");
assert_eq!(title, "My Epic");
assert!(body.contains("Goal"));
assert!(body.contains("Do great things"));
assert!(body.contains("Non-goals"));
}
#[test]
fn parse_epic_md_fallback_when_no_heading() {
let md = "Just some text without a heading.";
let (title, body) = parse_epic_md(md, "epic-id");
assert_eq!(title, "epic-id");
assert!(body.is_empty());
}
#[test]
fn parse_epic_md_trims_leading_blank_lines_from_body() {
let md = "# Title\n\n\nFirst non-blank line.\n";
let (_, body) = parse_epic_md(md, "id");
assert!(!body.starts_with('\n'));
assert!(body.starts_with("First"));
}
#[test]
fn build_epic_bundle_returns_empty_string_when_no_epic_branch() {
let tmp = tempfile::tempdir().unwrap();
let p = tmp.path();
std::process::Command::new("git")
.args(["-c", "init.defaultBranch=main", "init", "-q"])
.current_dir(p)
.env("GIT_AUTHOR_NAME", "t")
.env("GIT_AUTHOR_EMAIL", "t@t.com")
.env("GIT_COMMITTER_NAME", "t")
.env("GIT_COMMITTER_EMAIL", "t@t.com")
.status()
.unwrap();
std::process::Command::new("git")
.args(["config", "user.email", "t@t.com"])
.current_dir(p)
.status()
.unwrap();
std::process::Command::new("git")
.args(["config", "user.name", "t"])
.current_dir(p)
.status()
.unwrap();
std::fs::write(p.join("README.md"), "init").unwrap();
std::process::Command::new("git")
.args(["add", "README.md"])
.current_dir(p)
.status()
.unwrap();
std::process::Command::new("git")
.args(["-c", "commit.gpgsign=false", "commit", "-m", "init"])
.current_dir(p)
.status()
.unwrap();
std::fs::create_dir_all(p.join(".apm")).unwrap();
std::fs::write(
p.join(".apm/config.toml"),
"[project]\nname = \"test\"\n\n[tickets]\ndir = \"tickets\"\n",
)
.unwrap();
let config = crate::config::Config::load(p).unwrap();
let bundle = build_epic_bundle(p, "deadbeef", "aabb1234", &config);
assert!(bundle.contains("deadbeef"), "fallback title should appear");
assert!(bundle.contains("Scope guidance"), "guidance should always appear");
}
}