Skip to main content

apm_core/
context.rs

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
10/// Build an epic context bundle for a spec worker prompt.
11///
12/// Returns a Markdown string prepended to the worker prompt when the ticket
13/// belongs to an epic.  Returns an empty string only when the epic branch or
14/// EPIC.md cannot be found (so callers can always prepend safely).
15pub 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 siblings sorted by state then id for deterministic grouping.
49    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 siblings: newest first so the most-recent ones are retained when capped.
54    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    // Apply byte cap: truncate at a safe character boundary.
113    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    // Trim leading and trailing blank lines from body.
143    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    // One-line Problem summary.
162    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    // Full "Out of scope" section if present.
168    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
180/// Build a dependency context bundle for a worker prompt.
181///
182/// Returns a Markdown string to prepend to the worker prompt when the ticket
183/// has `depends_on` set.  Returns an empty string when `depends_on` is empty.
184///
185/// Direct dependencies include: ticket id + title, full Approach section, and
186/// a capped commit-subject list.  If a dependency is not yet in a terminal
187/// state, a warning is appended.
188///
189/// Transitive dependencies (deps-of-deps, one level deep) include only
190/// title + one-line Problem summary.
191pub 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        // Full Approach section.
230        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        // Commit subjects landed on the dep branch.
241        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        // Transitive dependencies — one level deep, title + one-line Problem.
261        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
295/// Return commit subjects on `dep_branch` not reachable from `target`.
296/// Caps at `max_count`.  Returns an empty vec on any git error.
297fn 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        // No epic branch exists → bundle is just the header/footer with minimal content
394        // (epic_id used as fallback title)
395        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}