use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64;
use clap::ValueEnum;
use minijinja::Environment;
use serde::Serialize;
use super::Comment;
use crate::submit::SubmitError;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, clap::ValueEnum, serde::Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum StackPlacement {
#[default]
Comment,
Body,
None,
Ignore,
}
impl std::fmt::Display for StackPlacement {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let pv = self
.to_possible_value()
.expect("all variants have possible values");
f.write_str(pv.get_name())
}
}
const BODY_FENCE_START: &str = "<!-- STAKK_BODY_START -->";
const BODY_FENCE_END: &str = "<!-- STAKK_BODY_END -->";
const COMMENT_DATA_PREFIX: &str = "<!--- STAKK_STACK: ";
const COMMENT_DATA_POSTFIX: &str = " --->";
const DEFAULT_TEMPLATE: &str = include_str!("default_comment.md.jinja");
pub const STAKK_REPO_URL: &str = "https://github.com/glennib/stakk";
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct StackCommentData {
pub version: u32,
pub stack: Vec<StackEntry>,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct StackEntry {
pub bookmark_name: String,
pub pr_url: String,
pub pr_number: u64,
}
#[derive(Debug, Clone, Serialize)]
pub struct StackCommentContext {
pub stack: Vec<StackEntryContext>,
pub stack_size: usize,
pub default_branch: String,
pub current_bookmark: String,
pub stakk_url: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct StackEntryContext {
pub bookmark_name: String,
pub pr_url: String,
pub pr_number: u64,
pub title: String,
pub base: String,
pub is_draft: bool,
pub position: usize,
pub is_current: bool,
}
pub fn build_comment_env(
custom_template: Option<&str>,
) -> Result<Environment<'static>, SubmitError> {
let mut env = Environment::new();
let source = match custom_template {
Some(s) => s.to_string(),
None => DEFAULT_TEMPLATE.to_string(),
};
env.add_template("stack_comment", Box::leak(source.into_boxed_str()))
.map_err(|e| SubmitError::TemplateRenderFailed {
message: format!("failed to compile template: {e}"),
})?;
Ok(env)
}
pub fn format_stack_comment(
data: &StackCommentData,
context: &StackCommentContext,
template: &minijinja::Template<'_, '_>,
) -> Result<String, SubmitError> {
let encoded = BASE64.encode(serde_json::to_string(data).expect("serialization cannot fail"));
let metadata_line = format!("{COMMENT_DATA_PREFIX}{encoded}{COMMENT_DATA_POSTFIX}");
let rendered = template
.render(context)
.map_err(|e| SubmitError::TemplateRenderFailed {
message: e.to_string(),
})?;
Ok(format!("{metadata_line}\n{rendered}"))
}
pub const COMMENT_WARNING: &str =
"<!-- This comment is managed by stakk. Manual edits will be overwritten. -->";
pub const BODY_WARNING: &str =
"<!-- This section is managed by stakk. Manual edits will be overwritten. -->";
pub fn with_comment_preamble(formatted: &str) -> String {
let mut lines = formatted.splitn(2, '\n');
let metadata = lines.next().unwrap_or("");
let rest = lines.next().unwrap_or("");
format!("{metadata}\n{COMMENT_WARNING}\n<!-- {STAKK_REPO_URL} -->\n{rest}")
}
pub fn find_stack_comment(comments: &[Comment]) -> Option<&Comment> {
comments
.iter()
.find(|c| c.body.contains(COMMENT_DATA_PREFIX))
}
pub fn find_stack_in_body(body: &str) -> Option<(usize, usize)> {
let start = body.find(BODY_FENCE_START)?;
let end_marker_start = body[start..].find(BODY_FENCE_END)? + start;
let mut end = end_marker_start + BODY_FENCE_END.len();
if body[end..].starts_with('\n') {
end += 1;
}
Some((start, end))
}
pub fn splice_stack_into_body(existing_body: &str, stack_content: &str) -> String {
let fenced = format!(
"{BODY_FENCE_START}\n{BODY_WARNING}\n<!-- {STAKK_REPO_URL} \
-->\n\n---\n\n{stack_content}\n{BODY_FENCE_END}\n"
);
if let Some((start, end)) = find_stack_in_body(existing_body) {
let mut result = String::with_capacity(existing_body.len() + fenced.len());
result.push_str(&existing_body[..start]);
result.push_str(&fenced);
result.push_str(&existing_body[end..]);
result
} else if existing_body.is_empty() {
fenced
} else {
format!("{existing_body}\n\n{fenced}")
}
}
pub fn strip_stack_from_body(body: &str) -> String {
if let Some((start, end)) = find_stack_in_body(body) {
let mut result = String::with_capacity(body.len());
result.push_str(&body[..start]);
result.push_str(&body[end..]);
result.trim_end().to_string()
} else {
body.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_data() -> StackCommentData {
StackCommentData {
version: 0,
stack: vec![
StackEntry {
bookmark_name: "feat-a".to_string(),
pr_url: "https://github.com/owner/repo/pull/1".to_string(),
pr_number: 1,
},
StackEntry {
bookmark_name: "feat-b".to_string(),
pr_url: "https://github.com/owner/repo/pull/2".to_string(),
pr_number: 2,
},
],
}
}
fn sample_context(current_index: usize) -> StackCommentContext {
let entries = vec![
StackEntryContext {
bookmark_name: "feat-a".to_string(),
pr_url: "https://github.com/owner/repo/pull/1".to_string(),
pr_number: 1,
title: "feature a".to_string(),
base: "main".to_string(),
is_draft: false,
position: 1,
is_current: current_index == 0,
},
StackEntryContext {
bookmark_name: "feat-b".to_string(),
pr_url: "https://github.com/owner/repo/pull/2".to_string(),
pr_number: 2,
title: "feature b".to_string(),
base: "feat-a".to_string(),
is_draft: false,
position: 2,
is_current: current_index == 1,
},
];
StackCommentContext {
stack_size: entries.len(),
current_bookmark: entries[current_index].bookmark_name.clone(),
stack: entries,
default_branch: "main".to_string(),
stakk_url: STAKK_REPO_URL.to_string(),
}
}
fn default_env() -> Environment<'static> {
build_comment_env(None).unwrap()
}
#[test]
fn format_highlights_current_pr() {
let data = sample_data();
let ctx = sample_context(1);
let env = default_env();
let tmpl = env.get_template("stack_comment").unwrap();
let body = format_stack_comment(&data, &ctx, &tmpl).unwrap();
assert!(
body.contains("- https://github.com/owner/repo/pull/2 \u{1f448} **this PR**"),
"expected current entry to be marked: {body}"
);
assert!(
body.contains("- https://github.com/owner/repo/pull/1\n"),
"expected non-current entry to be a bare list item: {body}"
);
assert_eq!(
body.matches("**this PR**").count(),
1,
"exactly one entry is the current one: {body}"
);
}
#[test]
fn format_orders_leaf_first_with_trunk_anchor() {
let data = sample_data();
let ctx = sample_context(0);
let env = default_env();
let tmpl = env.get_template("stack_comment").unwrap();
let body = format_stack_comment(&data, &ctx, &tmpl).unwrap();
let leaf = body.find("pull/2").expect("leaf entry rendered");
let root = body.find("pull/1").expect("root entry rendered");
let trunk = body.find("- `main`").expect("trunk anchor rendered");
assert!(
leaf < root && root < trunk,
"entries run leaf-first with trunk at the bottom: {body}"
);
}
#[test]
fn format_includes_default_branch() {
let data = sample_data();
let ctx = sample_context(0);
let env = default_env();
let tmpl = env.get_template("stack_comment").unwrap();
let body = format_stack_comment(&data, &ctx, &tmpl).unwrap();
assert!(body.contains("`main`"));
}
#[test]
fn find_stack_comment_matches() {
let data = sample_data();
let ctx = sample_context(0);
let env = default_env();
let tmpl = env.get_template("stack_comment").unwrap();
let comments = vec![
Comment {
id: 1,
body: "Some unrelated comment".to_string(),
},
Comment {
id: 2,
body: format_stack_comment(&data, &ctx, &tmpl).unwrap(),
},
];
let found = find_stack_comment(&comments);
assert_eq!(found.unwrap().id, 2);
}
#[test]
fn find_stack_comment_none_when_absent() {
let comments = vec![Comment {
id: 1,
body: "Nothing here".to_string(),
}];
assert!(find_stack_comment(&comments).is_none());
}
#[test]
fn find_stack_in_body_present() {
let body =
format!("Some PR description\n\n{BODY_FENCE_START}\nstack content\n{BODY_FENCE_END}\n");
let (start, end) = find_stack_in_body(&body).unwrap();
assert_eq!(
&body[start..end],
format!("{BODY_FENCE_START}\nstack content\n{BODY_FENCE_END}\n")
);
}
#[test]
fn find_stack_in_body_absent() {
assert!(find_stack_in_body("just a normal body").is_none());
}
#[test]
fn splice_into_empty_body() {
let result = splice_stack_into_body("", "stack content");
assert!(result.contains(BODY_FENCE_START));
assert!(result.contains("stack content"));
assert!(result.contains(BODY_FENCE_END));
}
#[test]
fn splice_appends_to_nonempty_body() {
let result = splice_stack_into_body("Existing body", "stack content");
assert!(result.starts_with("Existing body\n\n"));
assert!(result.contains(BODY_FENCE_START));
assert!(result.contains("stack content"));
}
#[test]
fn splice_replaces_existing_fence() {
let body = format!("Before\n\n{BODY_FENCE_START}\nold content\n{BODY_FENCE_END}\nAfter");
let result = splice_stack_into_body(&body, "new content");
assert!(result.contains("new content"));
assert!(!result.contains("old content"));
assert!(result.starts_with("Before\n\n"));
assert!(result.contains("After"));
}
#[test]
fn splice_roundtrip() {
let body = "My PR description\n\nSome details here.";
let spliced = splice_stack_into_body(body, "first version");
let spliced_again = splice_stack_into_body(&spliced, "second version");
assert!(spliced_again.contains("second version"));
assert!(!spliced_again.contains("first version"));
assert!(spliced_again.contains("My PR description"));
}
#[test]
fn strip_removes_fence() {
let body = format!("Before\n\n{BODY_FENCE_START}\nstack content\n{BODY_FENCE_END}\n");
let result = strip_stack_from_body(&body);
assert_eq!(result, "Before");
}
#[test]
fn strip_no_fence_is_noop() {
let body = "Just a body";
assert_eq!(strip_stack_from_body(body), body);
}
#[test]
fn format_single_entry_list() {
let data = StackCommentData {
version: 0,
stack: vec![StackEntry {
bookmark_name: "solo".to_string(),
pr_url: "https://github.com/o/r/pull/1".to_string(),
pr_number: 1,
}],
};
let ctx = StackCommentContext {
stack: vec![StackEntryContext {
bookmark_name: "solo".to_string(),
pr_url: "https://github.com/o/r/pull/1".to_string(),
pr_number: 1,
title: "solo feature".to_string(),
base: "main".to_string(),
is_draft: false,
position: 1,
is_current: true,
}],
stack_size: 1,
default_branch: "main".to_string(),
current_bookmark: "solo".to_string(),
stakk_url: STAKK_REPO_URL.to_string(),
};
let env = default_env();
let tmpl = env.get_template("stack_comment").unwrap();
let body = format_stack_comment(&data, &ctx, &tmpl).unwrap();
assert!(
body.contains("- https://github.com/o/r/pull/1 \u{1f448} **this PR**"),
"expected list entry: {body}"
);
}
#[test]
fn format_includes_footer() {
let data = sample_data();
let ctx = sample_context(0);
let env = default_env();
let tmpl = env.get_template("stack_comment").unwrap();
let body = format_stack_comment(&data, &ctx, &tmpl).unwrap();
assert!(body.contains("stakk"));
}
#[test]
fn format_header_mentions_merge_target() {
let data = sample_data();
let ctx = sample_context(0);
let env = default_env();
let tmpl = env.get_template("stack_comment").unwrap();
let body = format_stack_comment(&data, &ctx, &tmpl).unwrap();
assert!(
body.contains("merging into `main`"),
"expected merge target in header: {body}"
);
}
#[test]
fn custom_template_renders() {
let data = sample_data();
let ctx = sample_context(0);
let custom = "Custom: {{ stack_size }} PRs for {{ current_bookmark }}";
let env = build_comment_env(Some(custom)).unwrap();
let tmpl = env.get_template("stack_comment").unwrap();
let body = format_stack_comment(&data, &ctx, &tmpl).unwrap();
assert!(body.contains("Custom: 2 PRs for feat-a"));
}
#[test]
fn invalid_template_returns_error() {
let result = build_comment_env(Some("{{ unclosed"));
assert!(result.is_err());
}
#[test]
fn format_renders_every_entry() {
let data = sample_data();
let ctx = sample_context(0);
let env = default_env();
let tmpl = env.get_template("stack_comment").unwrap();
let body = format_stack_comment(&data, &ctx, &tmpl).unwrap();
assert!(
body.contains("https://github.com/owner/repo/pull/1"),
"expected entry 1: {body}"
);
assert!(
body.contains("https://github.com/owner/repo/pull/2"),
"expected entry 2: {body}"
);
assert!(
body.contains("**Stack of 2 PRs**"),
"expected stack size in header: {body}"
);
}
fn snapshot_context() -> StackCommentContext {
let names = ["feat-auth", "feat-api", "feat-ui"];
let entries: Vec<StackEntryContext> = names
.iter()
.enumerate()
.map(|(i, name)| StackEntryContext {
bookmark_name: (*name).to_string(),
pr_url: format!("https://github.com/owner/repo/pull/{}", i + 1),
pr_number: (i + 1) as u64,
title: format!("add {name}"),
base: if i == 0 {
"main".to_string()
} else {
names[i - 1].to_string()
},
is_draft: false,
position: i + 1,
is_current: i == 1,
})
.collect();
StackCommentContext {
stack_size: entries.len(),
current_bookmark: "feat-api".to_string(),
stack: entries,
default_branch: "main".to_string(),
stakk_url: STAKK_REPO_URL.to_string(),
}
}
fn snapshot_data() -> StackCommentData {
StackCommentData {
version: 0,
stack: snapshot_context()
.stack
.iter()
.map(|e| StackEntry {
bookmark_name: e.bookmark_name.clone(),
pr_url: e.pr_url.clone(),
pr_number: e.pr_number,
})
.collect(),
}
}
#[test]
fn default_template_comment_snapshot() {
let env = default_env();
let tmpl = env.get_template("stack_comment").unwrap();
let body = format_stack_comment(&snapshot_data(), &snapshot_context(), &tmpl).unwrap();
insta::assert_snapshot!(with_comment_preamble(&body));
}
#[test]
fn default_template_body_snapshot() {
let env = default_env();
let tmpl = env.get_template("stack_comment").unwrap();
let body = format_stack_comment(&snapshot_data(), &snapshot_context(), &tmpl).unwrap();
insta::assert_snapshot!(splice_stack_into_body("My PR description.", &body));
}
#[test]
fn with_comment_preamble_inserts_warning() {
let data = sample_data();
let ctx = sample_context(0);
let env = default_env();
let tmpl = env.get_template("stack_comment").unwrap();
let formatted = format_stack_comment(&data, &ctx, &tmpl).unwrap();
let body = with_comment_preamble(&formatted);
assert!(
body.contains(COMMENT_WARNING),
"expected warning line in comment: {body}"
);
assert!(
body.contains(&format!("<!-- {STAKK_REPO_URL} -->")),
"expected repo URL line in comment: {body}"
);
assert!(body.starts_with(COMMENT_DATA_PREFIX));
}
#[test]
fn format_stack_comment_has_no_warning() {
let data = sample_data();
let ctx = sample_context(0);
let env = default_env();
let tmpl = env.get_template("stack_comment").unwrap();
let body = format_stack_comment(&data, &ctx, &tmpl).unwrap();
assert!(
!body.contains(COMMENT_WARNING),
"format_stack_comment should not include comment warning: {body}"
);
assert!(
!body.contains(BODY_WARNING),
"format_stack_comment should not include body warning: {body}"
);
}
#[test]
fn splice_includes_warning_preamble() {
let result = splice_stack_into_body("", "stack content");
assert!(
result.contains(BODY_WARNING),
"expected warning line in fenced block: {result}"
);
assert!(
result.contains(&format!("<!-- {STAKK_REPO_URL} -->")),
"expected repo URL line in fenced block: {result}"
);
}
}