use std::io::IsTerminal;
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_BOLD: &str = "\x1b[1m";
const STYLE_CODE: &str = "\x1b[36m";
const STYLE_RESET: &str = "\x1b[0m";
fn style_inline(line: &str, color: bool) -> String {
style_runs(line, color, "")
}
fn style_runs(line: &str, color: bool, base: &str) -> String {
let mut out = String::new();
let mut rest = line;
while !rest.is_empty() {
if let Some(after) = rest.strip_prefix("**") {
if let Some(end) = after.find("**") {
let (inner, tail) = after.split_at(end);
if color {
out.push_str(STYLE_BOLD);
out.push_str(&style_runs(inner, color, STYLE_BOLD));
out.push_str(STYLE_RESET);
out.push_str(base);
} else {
out.push_str(&style_runs(inner, color, base));
}
rest = &tail[2..]; continue;
}
}
if let Some(after) = rest.strip_prefix('`') {
if let Some(end) = after.find('`') {
let (inner, tail) = after.split_at(end);
if color {
out.push_str(STYLE_CODE);
out.push_str(inner);
out.push_str(STYLE_RESET);
out.push_str(base); } else {
out.push_str(inner);
}
rest = &tail[1..]; continue;
}
}
let ch = rest.chars().next().unwrap();
out.push(ch);
rest = &rest[ch.len_utf8()..];
}
out
}
#[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, color: bool) -> 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();
}
if has_non_convention_markdown(trimmed) {
let mut out = String::new();
out.push_str(trimmed);
out.push('\n');
return out;
}
let mut out = String::new();
for line in trimmed.lines() {
out.push_str(&style_inline(line, color));
out.push('\n');
}
return out;
}
let mut out = String::new();
for (i, e) in entries.iter().enumerate() {
if i > 0 {
out.push('\n');
}
if color {
out.push_str(STYLE_BOLD);
out.push_str(&style_inline(&e.headline, false));
out.push_str(STYLE_RESET);
} else {
out.push_str(&style_inline(&e.headline, false));
}
out.push('\n');
for line in e.description.lines() {
out.push_str(" ");
out.push_str(&style_inline(line, color));
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, color: bool) -> String {
let mut out = frame_single(version);
let body_rendered = render_body(body, color);
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], color: bool) -> 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, color);
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) {
let color = crate::term::use_color(std::io::stdout().is_terminal());
match fetch_release_body(version) {
Ok(body) => print!("{}", render(version, &body, color)),
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;
}
let color = crate::term::use_color(std::io::stdout().is_terminal());
print!("{}", render_range(&display_from, to, &entries, color));
}
fn print_target_inline(version: &str) {
let color = crate::term::use_color(std::io::stdout().is_terminal());
match fetch_release_body(version) {
Ok(body) => print!("{}", render(version, &body, color)),
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_styles_headline_and_inline_spans_when_color() {
let body = "# Headline\nUse **bold** and `code` here.\n";
let rendered = render("0.8.0", body, true);
assert!(rendered.starts_with("✨ What's new in v0.8.0\n\n"));
assert!(
rendered.contains("\x1b[1mHeadline\x1b[0m\n"),
"headline should be bold: {rendered:?}"
);
assert!(rendered.contains("\x1b[1mbold\x1b[0m"), "got: {rendered:?}");
assert!(
rendered.contains("\x1b[36mcode\x1b[0m"),
"got: {rendered:?}"
);
assert!(!rendered.contains("**bold**"));
assert!(!rendered.contains("`code`"));
assert!(rendered.contains(" Use \x1b[1mbold\x1b[0m"));
}
#[test]
fn render_plain_when_no_color_strips_markdown_punctuation() {
let body = "# Headline\nUse **bold** and `code` here.\n";
let rendered = render("0.8.0", body, false);
assert!(rendered.starts_with("✨ What's new in v0.8.0\n\n"));
assert!(
!rendered.contains('\x1b'),
"no escapes when plain: {rendered:?}"
);
assert!(rendered.contains("Headline\n"));
assert!(rendered.contains(" Use bold and code here.\n"));
assert!(!rendered.contains("**"));
assert!(!rendered.contains('`'));
}
#[test]
fn render_styles_flat_prose_inline_spans_when_color() {
let body = "🎁 **Per-agent capabilities.** Files live in `.team/` now.\n";
let rendered = render("0.9.0", body, true);
assert!(rendered.starts_with("✨ What's new in v0.9.0\n\n"));
assert!(
rendered.contains("🎁 \x1b[1mPer-agent capabilities.\x1b[0m"),
"bold lead-in should be styled: {rendered:?}"
);
assert!(
rendered.contains("\x1b[36m.team/\x1b[0m"),
"got: {rendered:?}"
);
assert!(!rendered.contains("**Per-agent"));
assert!(!rendered.contains("`.team/`"));
}
#[test]
fn render_flat_prose_plain_strips_punctuation_no_escapes() {
let body = "🎁 **Per-agent capabilities.** Files live in `.team/` now.\n";
let rendered = render("0.9.0", body, false);
assert!(rendered.contains("🎁 Per-agent capabilities. Files live in .team/ now."));
assert!(
!rendered.contains('\x1b'),
"plain path must have no escapes: {rendered:?}"
);
assert!(!rendered.contains("**"));
assert!(!rendered.contains('`'));
}
#[test]
fn render_non_convention_body_stays_verbatim() {
let body = "## Subsection\nUse **bold** and `code` literally here.\n";
let rendered = render("0.8.0", body, true);
assert!(rendered.contains("## Subsection"));
assert!(
rendered.contains("**bold** and `code`"),
"non-convention body must stay verbatim: {rendered:?}"
);
assert!(
!rendered.contains('\x1b'),
"no styling on the verbatim path: {rendered:?}"
);
}
#[test]
fn style_inline_bold_span_when_color() {
assert_eq!(style_inline("a **b** c", true), "a \x1b[1mb\x1b[0m c");
}
#[test]
fn style_inline_code_span_when_color() {
assert_eq!(
style_inline("run `cmd` now", true),
"run \x1b[36mcmd\x1b[0m now"
);
}
#[test]
fn style_inline_strips_punctuation_when_plain() {
assert_eq!(style_inline("a **b** and `c`", false), "a b and c");
}
#[test]
fn style_inline_handles_multiple_and_mixed_spans() {
assert_eq!(
style_inline("**x** y `z` **w**", true),
"\x1b[1mx\x1b[0m y \x1b[36mz\x1b[0m \x1b[1mw\x1b[0m"
);
}
#[test]
fn style_inline_leaves_unterminated_bold_verbatim() {
assert_eq!(
style_inline("see **important note", true),
"see **important note"
);
assert_eq!(
style_inline("see **important note", false),
"see **important note"
);
}
#[test]
fn style_inline_leaves_unterminated_code_verbatim() {
assert_eq!(style_inline("a `b c", true), "a `b c");
}
#[test]
fn style_inline_single_asterisk_is_not_bold() {
assert_eq!(style_inline("* item one", true), "* item one");
assert_eq!(style_inline("2 * 3 = 6", true), "2 * 3 = 6");
}
#[test]
fn style_inline_preserves_unicode_and_emoji() {
assert_eq!(
style_inline("✨ éclat **bold**", true),
"✨ éclat \x1b[1mbold\x1b[0m"
);
assert_eq!(style_inline("✨ éclat **bold**", false), "✨ éclat bold");
}
#[test]
fn style_inline_empty_bold_span_is_noop_text() {
assert_eq!(style_inline("****", true), "\x1b[1m\x1b[0m");
assert_eq!(style_inline("****", false), "");
}
#[test]
fn style_inline_plain_text_unchanged() {
assert_eq!(style_inline("just words here", true), "just words here");
assert_eq!(style_inline("", true), "");
}
#[test]
fn style_inline_code_nested_in_bold_when_color() {
let out = style_inline("**`teamctl init` is out**", true);
assert!(out.starts_with(STYLE_BOLD), "got: {out:?}");
assert!(
out.contains("\x1b[36mteamctl init\x1b[0m"),
"code styled: {out:?}"
);
assert!(
!out.contains('`'),
"no raw backtick on colored path: {out:?}"
);
assert!(
out.contains("\x1b[0m\x1b[1m is out"),
"bold resumes: {out:?}"
);
}
#[test]
fn style_inline_code_nested_in_bold_when_plain() {
assert_eq!(
style_inline("**`teamctl init` is out**", false),
"teamctl init is out"
);
}
#[test]
fn style_inline_multiple_code_spans_in_one_bold() {
let plain = style_inline("**`/teamctl:init` and `/teamctl:adjust`.**", false);
assert_eq!(plain, "/teamctl:init and /teamctl:adjust.");
let colored = style_inline("**`/teamctl:init` and `/teamctl:adjust`.**", true);
assert!(!colored.contains('`'), "no raw backtick: {colored:?}");
assert!(colored.contains("\x1b[36m/teamctl:init\x1b[0m"));
assert!(colored.contains("\x1b[36m/teamctl:adjust\x1b[0m"));
}
#[test]
fn style_inline_triple_asterisk_boundary_documented() {
assert_eq!(style_inline("***x***", false), "*x*");
}
#[test]
fn render_strips_v_prefix_in_header() {
let body = "# H\nD.\n";
let rendered = render("v0.8.0", body, false);
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, false);
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, false);
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", "", false);
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", "", false);
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, false);
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, false);
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", false);
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, false);
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, false);
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, false);
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, false);
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, false);
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"));
}
}