use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64;
use clap::ValueEnum;
use minijinja::Environment;
use serde::Deserialize;
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,
}
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, Deserialize, PartialEq, Eq)]
pub struct StackCommentData {
pub version: u32,
pub stack: Vec<StackEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize, 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))
}
#[cfg_attr(
not(test),
expect(
dead_code,
reason = "needed when submission reads existing stack data (e.g. detecting merged PRs)"
)
)]
pub fn parse_stack_comment(body: &str) -> Option<StackCommentData> {
let first_line = body.lines().next()?;
let start = first_line.find(COMMENT_DATA_PREFIX)? + COMMENT_DATA_PREFIX.len();
let end = first_line[start..].find(COMMENT_DATA_POSTFIX)? + start;
let encoded = &first_line[start..end];
let decoded = BASE64.decode(encoded).ok()?;
let json_str = std::str::from_utf8(&decoded).ok()?;
serde_json::from_str(json_str).ok()
}
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_and_parse_roundtrip() {
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 parsed = parse_stack_comment(&body).unwrap();
assert_eq!(parsed, data);
}
#[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("\u{1f448}"),
"expected pointing finger emoji in body: {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 parse_with_different_body_text() {
let data = sample_data();
let encoded = BASE64.encode(serde_json::to_string(&data).unwrap());
let body = format!(
"{COMMENT_DATA_PREFIX}{encoded}{COMMENT_DATA_POSTFIX}\nSome different body \
text\n\n---\n*Some other footer*"
);
let parsed = parse_stack_comment(&body).unwrap();
assert_eq!(parsed, data);
}
#[test]
fn parse_invalid_base64_returns_none() {
let body = format!("{COMMENT_DATA_PREFIX}not-valid-base64!!!{COMMENT_DATA_POSTFIX}\nstuff");
assert!(parse_stack_comment(&body).is_none());
}
#[test]
fn parse_no_metadata_returns_none() {
assert!(parse_stack_comment("just a regular comment").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_numbered_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("1. https://github.com/o/r/pull/1"),
"expected numbered 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_merges_into() {
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("merges 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_numbered_entries() {
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("1. https://github.com/owner/repo/pull/1"),
"expected entry 1: {body}"
);
assert!(
body.contains("2. https://github.com/owner/repo/pull/2"),
"expected entry 2: {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}"
);
}
}