use std::process::Command;
use anyhow::{anyhow, bail, Context, Result};
use serde::Deserialize;
use serde_json::Value;
use crate::cmd::update::{compare_versions, VersionOrder, CURRENT_VERSION};
const RELEASES_TAG_API: &str = "https://api.github.com/repos/Alireza29675/teamctl/releases/tags/";
const RELEASES_LIST_API: &str = "https://api.github.com/repos/Alireza29675/teamctl/releases";
const FLOOR_VERSION: &str = "0.8.0";
const CHANGELOG_URL: &str = "https://teamctl.run/changelog";
const STYLE_DIM_ITALIC: &str = "\x1b[2;3m";
const STYLE_RESET: &str = "\x1b[0m";
#[derive(Debug, PartialEq, Eq)]
pub(crate) struct Entry {
pub headline: String,
pub description: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ReleaseEntry {
pub version: String,
pub body: String,
}
pub fn fetch_release_body(version: &str) -> Result<String> {
let v = version.trim_start_matches('v');
let url = format!("{RELEASES_TAG_API}v{v}");
let raw = curl_get(&url)?;
extract_body_field(&raw)
.ok_or_else(|| anyhow!("no `body` field in GitHub releases response for v{v}"))
}
#[derive(Deserialize)]
struct ReleaseResponse {
#[serde(default)]
body: Option<String>,
}
fn extract_body_field(json: &str) -> Option<String> {
let parsed: ReleaseResponse = serde_json::from_str(json).ok()?;
let body = parsed.body?;
if body.is_empty() {
None
} else {
Some(body)
}
}
pub(crate) fn fetch_releases_list() -> Result<Vec<ReleaseEntry>> {
let raw = curl_get(RELEASES_LIST_API)?;
let list = parse_releases_list(&raw);
if list.is_empty() {
bail!("no usable releases in GitHub releases response");
}
Ok(list)
}
fn parse_releases_list(json: &str) -> Vec<ReleaseEntry> {
let v: Value = match serde_json::from_str(json) {
Ok(v) => v,
Err(_) => return Vec::new(),
};
let Some(arr) = v.as_array() else {
return Vec::new();
};
arr.iter()
.filter_map(|item| {
let tag = item
.get("tag_name")?
.as_str()?
.trim_start_matches('v')
.to_string();
if tag.is_empty() {
return None;
}
let body = item
.get("body")
.and_then(|b| b.as_str())
.unwrap_or("")
.to_string();
Some(ReleaseEntry { version: tag, body })
})
.collect()
}
pub(crate) fn is_below_floor(version: &str) -> bool {
matches!(
compare_versions(version.trim_start_matches('v'), FLOOR_VERSION),
VersionOrder::Older
)
}
pub(crate) fn select_range(all: &[ReleaseEntry], from: &str, to: &str) -> Vec<ReleaseEntry> {
let from = from.trim_start_matches('v');
let to = to.trim_start_matches('v');
let mut selected: Vec<ReleaseEntry> = all
.iter()
.filter(|r| !is_below_floor(&r.version))
.filter(|r| matches!(compare_versions(&r.version, from), VersionOrder::Newer))
.filter(|r| !matches!(compare_versions(&r.version, to), VersionOrder::Newer))
.cloned()
.collect();
selected.sort_by(|a, b| match compare_versions(&a.version, &b.version) {
VersionOrder::Older => std::cmp::Ordering::Less,
VersionOrder::Equal => std::cmp::Ordering::Equal,
VersionOrder::Newer => std::cmp::Ordering::Greater,
});
selected
}
pub(crate) fn parse_release_body(body: &str) -> Vec<Entry> {
if has_non_convention_markdown(body) {
return Vec::new();
}
let mut entries: Vec<Entry> = Vec::new();
let mut current: Option<(String, Vec<String>)> = None;
for line in body.lines() {
if let Some(headline) = line.strip_prefix("# ") {
if let Some((h, desc)) = current.take() {
entries.push(Entry {
headline: h,
description: desc.join("\n").trim().to_string(),
});
}
current = Some((headline.trim().to_string(), Vec::new()));
} else if let Some((_, desc)) = current.as_mut() {
desc.push(line.to_string());
}
}
if let Some((h, desc)) = current {
entries.push(Entry {
headline: h,
description: desc.join("\n").trim().to_string(),
});
}
entries
}
fn has_non_convention_markdown(body: &str) -> bool {
body.lines()
.any(|l| l.starts_with("## ") || l.starts_with("```"))
}
fn truncate_at_cargo_dist(body: &str) -> &str {
let mut idx = 0;
for line in body.split_inclusive('\n') {
let trimmed = line.strip_suffix('\n').unwrap_or(line);
if is_cargo_dist_install_heading(trimmed) {
return &body[..idx];
}
idx += line.len();
}
body
}
fn is_cargo_dist_install_heading(line: &str) -> bool {
const KNOWN_CRATES: &[&str] = &["team-bot", "team-mcp", "teamctl", "teamctl-ui"];
let rest = match line.strip_prefix("# ") {
Some(r) => r,
None => return false,
};
for c in KNOWN_CRATES {
if let Some(after_crate) = rest.strip_prefix(c) {
if let Some(ver) = after_crate.strip_prefix(' ') {
if is_three_part_semver(ver) {
return true;
}
}
}
}
false
}
fn is_three_part_semver(s: &str) -> bool {
let parts: Vec<&str> = s.split('.').collect();
parts.len() == 3
&& parts
.iter()
.all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_digit()))
}
fn render_body(body: &str) -> String {
let body = truncate_at_cargo_dist(body);
let entries = parse_release_body(body);
if entries.is_empty() {
let trimmed = body.trim_end();
if trimmed.is_empty() {
return String::new();
}
let mut out = String::new();
out.push_str(trimmed);
out.push('\n');
return out;
}
let mut out = String::new();
for (i, e) in entries.iter().enumerate() {
if i > 0 {
out.push('\n');
}
out.push_str(&e.headline);
out.push('\n');
for line in e.description.lines() {
out.push_str(" ");
out.push_str(STYLE_DIM_ITALIC);
out.push_str(line);
out.push_str(STYLE_RESET);
out.push('\n');
}
}
out
}
fn frame_single(version: &str) -> String {
let v = version.trim_start_matches('v');
format!("✨ What's new in v{v}\n\n")
}
fn frame_range(from: &str, to: &str) -> String {
let from = from.trim_start_matches('v');
let to = to.trim_start_matches('v');
format!("✨ What's new in v{from} → v{to}\n\n")
}
fn footer_line() -> String {
format!("📖 Full changelog: {CHANGELOG_URL}")
}
pub fn render(version: &str, body: &str) -> String {
let mut out = frame_single(version);
let body_rendered = render_body(body);
if !body_rendered.is_empty() {
out.push_str(&body_rendered);
out.push('\n');
}
out.push_str(&footer_line());
out.push('\n');
out
}
pub(crate) fn render_range(from: &str, to: &str, entries: &[ReleaseEntry]) -> String {
let mut out = frame_range(from, to);
for (i, e) in entries.iter().enumerate() {
if i > 0 {
out.push('\n');
}
out.push_str(&format!("v{}\n", e.version.trim_start_matches('v')));
let body_rendered = render_body(&e.body);
if !body_rendered.is_empty() {
out.push_str(&body_rendered);
}
}
out.push('\n');
out.push_str(&footer_line());
out.push('\n');
out
}
pub fn fallback_link(version: &str) -> String {
let v = version.trim_start_matches('v');
format!(
"(release notes unavailable — check https://github.com/Alireza29675/teamctl/releases/tag/v{v})"
)
}
pub fn print_for(version: &str) {
match fetch_release_body(version) {
Ok(body) => print!("{}", render(version, &body)),
Err(_) => println!("{}", fallback_link(version)),
}
}
fn effective_from(current: &str) -> String {
if is_below_floor(current) {
FLOOR_VERSION.to_string()
} else {
current.trim_start_matches('v').to_string()
}
}
pub fn print_since(from: &str, to: &str) {
let display_from = effective_from(from);
let to_v = to.trim_start_matches('v');
if display_from == to_v {
print_target_inline(to);
return;
}
let entries = match fetch_releases_list() {
Ok(all) => select_range(&all, from, to),
Err(_) => {
print_target_inline(to);
return;
}
};
if entries.is_empty() {
print_target_inline(to);
return;
}
if entries.len() == 1 {
print_target_inline(&entries[0].version);
return;
}
print!("{}", render_range(&display_from, to, &entries));
}
fn print_target_inline(version: &str) {
match fetch_release_body(version) {
Ok(body) => print!("{}", render(version, &body)),
Err(_) => println!("{}", fallback_link(version)),
}
}
fn curl_get(url: &str) -> Result<String> {
let out = Command::new("curl")
.args([
"-sS",
"-H",
&format!("User-Agent: teamctl-cli/{CURRENT_VERSION}"),
"--max-time",
"15",
url,
])
.output()
.context("run curl (is curl installed?)")?;
if !out.status.success() {
let err = String::from_utf8_lossy(&out.stderr);
bail!("curl failed: {}", err.trim());
}
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extract_body_handles_single_line_json() {
let blob = r#"{"id":1,"body":"hello world","name":"v0.8.0"}"#;
assert_eq!(extract_body_field(blob).as_deref(), Some("hello world"));
}
#[test]
fn extract_body_unescapes_newlines_and_quotes() {
let blob = r#"{"body":"line1\nline2 \"quoted\" \\path"}"#;
assert_eq!(
extract_body_field(blob).as_deref(),
Some("line1\nline2 \"quoted\" \\path")
);
}
#[test]
fn extract_body_returns_none_for_null_body() {
let blob = r#"{"body":null,"tag_name":"v0.8.0"}"#;
assert!(extract_body_field(blob).is_none());
}
#[test]
fn extract_body_returns_none_for_empty_body() {
let blob = r#"{"body":""}"#;
assert!(extract_body_field(blob).is_none());
}
#[test]
fn extract_body_returns_none_for_missing_field() {
let blob = r#"{"tag_name":"v0.8.0"}"#;
assert!(extract_body_field(blob).is_none());
}
#[test]
fn extract_body_handles_unicode_escape() {
let blob = r#"{"body":"✨ sparkle"}"#;
assert_eq!(extract_body_field(blob).as_deref(), Some("✨ sparkle"));
}
#[test]
fn extract_body_survives_round_trip_with_realistic_release_body() {
let blob = r#"{"tag_name":"v0.8.0","body":"✨ \"What's new\" cleanup\n\nSwap `extract_body_field` to serde_json — fixes \"escaped quote\" handling and unicode (éclat) survives round-trip. Path: C:\\Users\\test.","name":"0.8.0"}"#;
let body = extract_body_field(blob).expect("body present");
assert!(body.contains("\"What's new\""));
assert!(body.contains("`extract_body_field`"));
assert!(body.contains("éclat"));
assert!(body.contains(r"C:\Users\test"));
assert!(body.starts_with("✨"));
}
#[test]
fn extract_body_returns_none_for_malformed_json() {
let blob = r#"{"body": "unterminated"#;
assert!(extract_body_field(blob).is_none());
}
#[test]
fn parse_release_body_handles_single_entry() {
let body = "# First headline\nA description line.\nAnother line.\n";
let parsed = parse_release_body(body);
assert_eq!(parsed.len(), 1);
assert_eq!(parsed[0].headline, "First headline");
assert_eq!(parsed[0].description, "A description line.\nAnother line.");
}
#[test]
fn parse_release_body_handles_multiple_entries() {
let body = "# Headline A\nDesc A.\n\n# Headline B\nDesc B line 1.\nDesc B line 2.\n";
let parsed = parse_release_body(body);
assert_eq!(parsed.len(), 2);
assert_eq!(parsed[0].headline, "Headline A");
assert_eq!(parsed[0].description, "Desc A.");
assert_eq!(parsed[1].headline, "Headline B");
assert_eq!(parsed[1].description, "Desc B line 1.\nDesc B line 2.");
}
#[test]
fn parse_release_body_returns_empty_when_no_headings() {
let body = "just a paragraph of release notes\nwith two lines";
assert!(parse_release_body(body).is_empty());
}
#[test]
fn parse_release_body_treats_h2_as_non_convention() {
let body = "# Headline\nIntro line.\n## Subsection\nMore description.\n";
assert!(parse_release_body(body).is_empty());
}
#[test]
fn parse_release_body_treats_code_fence_as_non_convention() {
let body = "# Headline\nDescription with snippet:\n```sh\nteamctl up\n```\n";
assert!(parse_release_body(body).is_empty());
}
#[test]
fn parse_release_body_treats_cargo_dist_body_as_non_convention() {
let body = "# team-bot 0.7.3\n## Install team-bot 0.7.3\n### shell\n```sh\ncurl ...\n```\n";
assert!(parse_release_body(body).is_empty());
}
#[test]
fn parse_release_body_handles_headline_without_description() {
let body = "# Only a headline\n\n# Another\nWith body.\n";
let parsed = parse_release_body(body);
assert_eq!(parsed.len(), 2);
assert_eq!(parsed[0].headline, "Only a headline");
assert!(parsed[0].description.is_empty());
assert_eq!(parsed[1].headline, "Another");
}
#[test]
fn render_frames_version_and_styles_descriptions() {
let body = "# Headline\nLine one.\nLine two.\n";
let rendered = render("0.8.0", body);
assert!(rendered.starts_with("✨ What's new in v0.8.0\n\n"));
assert!(rendered.contains("Headline\n"));
assert!(rendered.contains(" \x1b[2;3mLine one.\x1b[0m"));
assert!(rendered.contains(" \x1b[2;3mLine two.\x1b[0m"));
}
#[test]
fn render_strips_v_prefix_in_header() {
let body = "# H\nD.\n";
let rendered = render("v0.8.0", body);
assert!(rendered.starts_with("✨ What's new in v0.8.0\n\n"));
assert!(!rendered.contains("vv0.8.0"));
}
#[test]
fn render_falls_back_to_raw_for_non_conforming_body() {
let body = "Older release with no structured convention.\nJust prose.\n";
let rendered = render("0.6.0", body);
assert!(rendered.starts_with("✨ What's new in v0.6.0\n\n"));
assert!(rendered.contains("Older release with no structured convention."));
assert!(rendered.contains("Just prose."));
let body_only = rendered.replace("📖 Full changelog: https://teamctl.run/changelog\n", "");
assert!(!body_only.contains("\x1b["));
}
#[test]
fn render_separates_entries_with_blank_line() {
let body = "# A\nDesc A.\n\n# B\nDesc B.\n";
let rendered = render("0.8.0", body);
let a_idx = rendered.find("Desc A.").unwrap();
let b_idx = rendered.find("B\n").unwrap();
let between = &rendered[a_idx..b_idx];
let nl_count = between.matches('\n').count();
assert!(
nl_count >= 2,
"expected blank line between entries, got: {between:?}"
);
}
#[test]
fn render_handles_empty_body() {
let rendered = render("0.8.0", "");
assert!(rendered.starts_with("✨ What's new in v0.8.0\n\n"));
assert!(rendered.contains("📖 Full changelog"));
}
#[test]
fn render_empty_body_has_no_double_blank_before_footer() {
let rendered = render("0.8.0", "");
assert_eq!(
rendered,
"✨ What's new in v0.8.0\n\n📖 Full changelog: https://teamctl.run/changelog\n",
"expected exactly one blank line between frame and footer, got: {rendered:?}"
);
}
#[test]
fn render_install_at_top_truncated_body_has_no_double_blank() {
let body = "# teamctl 0.8.1\n## Install\n```sh\nnoise\n```\n";
let rendered = render("0.8.1", body);
assert_eq!(
rendered,
"✨ What's new in v0.8.1\n\n📖 Full changelog: https://teamctl.run/changelog\n",
"truncated-to-empty body must not double-blank, got: {rendered:?}"
);
}
#[test]
fn render_range_empty_entry_body_has_no_double_blank_between_subheaders() {
let entries = vec![
ReleaseEntry {
version: "0.8.1".into(),
body: "# A\nDesc A.".into(),
},
ReleaseEntry {
version: "0.8.2".into(),
body: "# teamctl 0.8.2\n## Install\n".into(),
},
];
let rendered = render_range("0.8.0", "0.8.2", &entries);
assert!(
rendered.contains("\n\nv0.8.2\n"),
"expected single blank line before v0.8.2 subheader, got: {rendered:?}"
);
assert!(
!rendered.contains("\n\n\nv0.8.2\n"),
"expected no double blank line before v0.8.2 subheader, got: {rendered:?}"
);
}
#[test]
fn render_appends_footer_line() {
let rendered = render("0.8.0", "# H\nD.\n");
assert!(rendered.contains("📖 Full changelog: https://teamctl.run/changelog"));
assert!(
rendered.ends_with("https://teamctl.run/changelog\n"),
"footer should be the last line: {rendered:?}"
);
}
#[test]
fn fallback_link_contains_tag_url() {
let line = fallback_link("0.8.0");
assert!(line.contains("https://github.com/Alireza29675/teamctl/releases/tag/v0.8.0"));
assert!(line.contains("release notes unavailable"));
}
#[test]
fn fallback_link_strips_v_prefix() {
assert_eq!(fallback_link("v0.8.0"), fallback_link("0.8.0"));
}
#[test]
fn is_below_floor_is_inclusive_at_floor() {
assert!(!is_below_floor("0.8.0"));
assert!(!is_below_floor("v0.8.0"));
assert!(!is_below_floor("0.8.1"));
assert!(!is_below_floor("0.9.0"));
assert!(!is_below_floor("1.0.0"));
}
#[test]
fn is_below_floor_excludes_pre_floor_versions() {
assert!(is_below_floor("0.7.3"));
assert!(is_below_floor("0.7.0"));
assert!(is_below_floor("0.6.0"));
assert!(is_below_floor("v0.7.3"));
}
#[test]
fn parse_releases_list_extracts_tag_and_body() {
let json = r##"[
{"tag_name":"v0.8.2","body":"# H2\nBody2","name":"v0.8.2"},
{"tag_name":"v0.8.1","body":"# H1\nBody1"},
{"tag_name":"v0.8.0","body":"# H0\nBody0"}
]"##;
let list = parse_releases_list(json);
assert_eq!(list.len(), 3);
assert_eq!(list[0].version, "0.8.2");
assert_eq!(list[0].body, "# H2\nBody2");
assert_eq!(list[2].version, "0.8.0");
}
#[test]
fn parse_releases_list_returns_empty_on_malformed_json() {
assert!(parse_releases_list("{not json").is_empty());
assert!(parse_releases_list(r#"{"message":"Not Found"}"#).is_empty());
}
#[test]
fn parse_releases_list_skips_items_missing_tag() {
let json = r#"[
{"tag_name":"v0.8.0","body":"keep"},
{"body":"skip me — no tag"},
{"tag_name":"","body":"skip — empty tag"}
]"#;
let list = parse_releases_list(json);
assert_eq!(list.len(), 1);
assert_eq!(list[0].version, "0.8.0");
}
#[test]
fn parse_releases_list_tolerates_null_body() {
let json = r#"[{"tag_name":"v0.8.0","body":null}]"#;
let list = parse_releases_list(json);
assert_eq!(list.len(), 1);
assert_eq!(list[0].body, "");
}
fn sample_releases() -> Vec<ReleaseEntry> {
vec![
ReleaseEntry {
version: "0.8.3".into(),
body: "# T3\nD3".into(),
},
ReleaseEntry {
version: "0.8.2".into(),
body: "# T2\nD2".into(),
},
ReleaseEntry {
version: "0.8.1".into(),
body: "# T1\nD1".into(),
},
ReleaseEntry {
version: "0.8.0".into(),
body: "# T0\nD0".into(),
},
ReleaseEntry {
version: "0.7.3".into(),
body: "old".into(),
},
]
}
#[test]
fn select_range_excludes_from_and_includes_to() {
let r = select_range(&sample_releases(), "0.8.0", "0.8.3");
let vs: Vec<&str> = r.iter().map(|e| e.version.as_str()).collect();
assert_eq!(vs, vec!["0.8.1", "0.8.2", "0.8.3"]);
}
#[test]
fn select_range_cuts_below_floor() {
let r = select_range(&sample_releases(), "0.7.0", "0.8.1");
let vs: Vec<&str> = r.iter().map(|e| e.version.as_str()).collect();
assert_eq!(vs, vec!["0.8.0", "0.8.1"]);
}
#[test]
fn select_range_returns_oldest_first() {
let r = select_range(&sample_releases(), "0.7.0", "0.8.3");
assert_eq!(r.first().unwrap().version, "0.8.0");
assert_eq!(r.last().unwrap().version, "0.8.3");
}
#[test]
fn select_range_is_empty_when_from_equals_to() {
let r = select_range(&sample_releases(), "0.8.2", "0.8.2");
assert!(r.is_empty());
}
#[test]
fn select_range_excludes_targets_below_floor() {
let r = select_range(&sample_releases(), "0.7.0", "0.7.3");
assert!(r.is_empty());
}
#[test]
fn render_range_shows_range_frame_and_per_version_subheaders() {
let entries = vec![
ReleaseEntry {
version: "0.8.1".into(),
body: "# H1\nD1.".into(),
},
ReleaseEntry {
version: "0.8.2".into(),
body: "# H2\nD2.".into(),
},
];
let rendered = render_range("0.8.0", "0.8.2", &entries);
assert!(rendered.starts_with("✨ What's new in v0.8.0 → v0.8.2\n\n"));
assert!(rendered.contains("v0.8.1\nH1\n"));
assert!(rendered.contains("v0.8.2\nH2\n"));
assert!(rendered.ends_with("https://teamctl.run/changelog\n"));
}
#[test]
fn render_range_strips_v_prefix_consistently() {
let entries = vec![ReleaseEntry {
version: "v0.8.1".into(),
body: "# H\nD.".into(),
}];
let rendered = render_range("v0.8.0", "v0.8.1", &entries);
assert!(rendered.starts_with("✨ What's new in v0.8.0 → v0.8.1\n\n"));
assert!(!rendered.contains("vv"));
}
#[test]
fn render_range_separates_versions_with_blank_line() {
let entries = vec![
ReleaseEntry {
version: "0.8.1".into(),
body: "# A\nDesc A.".into(),
},
ReleaseEntry {
version: "0.8.3".into(),
body: "# B\nDesc B.".into(),
},
];
let rendered = render_range("0.8.0", "0.8.4", &entries);
assert!(
rendered.contains("\n\nv0.8.3\n"),
"expected blank line before v0.8.3 subheader, got: {rendered:?}"
);
}
#[test]
fn footer_line_points_to_changelog() {
assert_eq!(
footer_line(),
"📖 Full changelog: https://teamctl.run/changelog"
);
}
#[test]
fn effective_from_clamps_pre_floor_current_to_floor() {
assert_eq!(effective_from("0.7.3"), "0.8.0");
assert_eq!(effective_from("v0.6.0"), "0.8.0");
}
#[test]
fn effective_from_preserves_at_or_above_floor() {
assert_eq!(effective_from("0.8.0"), "0.8.0");
assert_eq!(effective_from("0.8.5"), "0.8.5");
assert_eq!(effective_from("v0.9.0"), "0.9.0");
assert_eq!(effective_from("1.0.0"), "1.0.0");
}
#[test]
fn is_cargo_dist_install_heading_recognizes_known_crates() {
assert!(is_cargo_dist_install_heading("# team-bot 0.8.1"));
assert!(is_cargo_dist_install_heading("# team-mcp 0.8.1"));
assert!(is_cargo_dist_install_heading("# teamctl 0.8.1"));
assert!(is_cargo_dist_install_heading("# teamctl-ui 0.8.1"));
assert!(is_cargo_dist_install_heading("# teamctl 10.20.30"));
}
#[test]
fn is_cargo_dist_install_heading_rejects_other_shapes() {
assert!(!is_cargo_dist_install_heading("# Headline"));
assert!(!is_cargo_dist_install_heading("## team-bot 0.8.1"));
assert!(!is_cargo_dist_install_heading("team-bot 0.8.1"));
assert!(!is_cargo_dist_install_heading("# unknown-crate 0.8.1"));
assert!(!is_cargo_dist_install_heading("# teamctl 0.8"));
assert!(!is_cargo_dist_install_heading("# teamctl 0.8.1-rc.1"));
assert!(!is_cargo_dist_install_heading("# teamctl v0.8.1"));
assert!(!is_cargo_dist_install_heading(""));
}
#[test]
fn truncate_at_cargo_dist_drops_install_section_and_below() {
let body = "Curated prose paragraph one.\n\n\
Curated prose paragraph two.\n\n\
# team-bot 0.8.1\n## Install\n```sh\ncurl ...\n```\n";
let kept = truncate_at_cargo_dist(body);
assert!(kept.starts_with("Curated prose paragraph one."));
assert!(kept.contains("paragraph two."));
assert!(!kept.contains("team-bot 0.8.1"));
assert!(!kept.contains("Install"));
assert!(!kept.contains("```"));
}
#[test]
fn truncate_at_cargo_dist_unchanged_when_no_install_heading() {
let body = "# Curated headline\nDescription with no install boilerplate.\n";
assert_eq!(truncate_at_cargo_dist(body), body);
}
#[test]
fn truncate_at_cargo_dist_empty_result_when_install_starts_at_top() {
let body = "# teamctl 0.8.1\n## Install\n";
assert_eq!(truncate_at_cargo_dist(body), "");
}
#[test]
fn truncate_at_cargo_dist_keeps_prose_with_inline_hash() {
let body = "Fixed issue #197 by truncating before cargo-dist.\n";
assert_eq!(truncate_at_cargo_dist(body), body);
}
#[test]
fn render_body_strips_cargo_dist_install_section() {
let body = "# Curated headline\nDescription.\n\n# teamctl 0.8.1\n```sh\nnoise\n```\n";
let rendered = render_body(body);
assert!(rendered.contains("Curated headline\n"));
assert!(rendered.contains("Description."));
assert!(!rendered.contains("teamctl 0.8.1"));
assert!(!rendered.contains("```"));
}
#[test]
fn render_range_path_used_for_multi_version() {
let entries = vec![
ReleaseEntry {
version: "0.8.1".into(),
body: "# A\nDesc A.".into(),
},
ReleaseEntry {
version: "0.8.2".into(),
body: "# B\nDesc B.".into(),
},
];
let rendered = render_range("0.8.0", "0.8.2", &entries);
assert!(rendered.starts_with("✨ What's new in v0.8.0 → v0.8.2\n\n"));
assert!(rendered.contains("\nv0.8.1\n"));
assert!(rendered.contains("\n\nv0.8.2\n"));
}
}