use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use crate::config::IconStyle;
#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct UpdateLog {
#[serde(default)]
pub runs: Vec<RunRecord>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RunRecord {
pub timestamp: String,
pub command: String,
pub changes: Vec<ChangeRecord>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ChangeRecord {
pub name: String,
pub url: String,
pub from: Option<String>,
pub to: String,
pub subjects: Vec<String>,
pub breaking_subjects: Vec<String>,
pub doc_files_changed: Vec<String>,
}
pub const MAX_RUNS: usize = 20;
pub const DEFAULT_LAST: usize = 1;
pub fn load_log(path: &Path) -> UpdateLog {
let content = match std::fs::read_to_string(path) {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return UpdateLog::default(),
Err(e) => {
eprintln!(
"\u{26a0} update_log: failed to read {}: {} (treating as empty)",
path.display(),
e
);
return UpdateLog::default();
}
};
match serde_json::from_str::<UpdateLog>(&content) {
Ok(mut log) => {
cap_runs(&mut log);
log
}
Err(e) => {
eprintln!(
"\u{26a0} update_log: failed to parse {}: {} (treating as empty)",
path.display(),
e
);
UpdateLog::default()
}
}
}
pub fn cap_runs(log: &mut UpdateLog) {
if log.runs.len() > MAX_RUNS {
let drop = log.runs.len() - MAX_RUNS;
log.runs.drain(0..drop);
}
}
pub fn record_run(path: &Path, command: &str, changes: Vec<ChangeRecord>) -> Result<()> {
if changes.is_empty() {
return Ok(());
}
let mut log = load_log(path);
let timestamp = format_rfc3339_utc(SystemTime::now());
log.runs.push(RunRecord {
timestamp,
command: command.to_string(),
changes,
});
cap_runs(&mut log);
save_log(path, &log)
}
pub fn save_log(path: &Path, log: &UpdateLog) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("create_dir_all {}", parent.display()))?;
}
let json = serde_json::to_string_pretty(log).context("serialize update_log")?;
let parent = path.parent().unwrap_or(Path::new("."));
let tmp = tempfile::Builder::new()
.prefix(".rvpm-update-log-")
.suffix(".tmp")
.tempfile_in(parent)
.with_context(|| format!("create tempfile in {}", parent.display()))?;
std::fs::write(tmp.path(), json.as_bytes())
.with_context(|| format!("write tempfile {}", tmp.path().display()))?;
tmp.persist(path)
.map_err(|e| anyhow::anyhow!("rename tempfile to {}: {}", path.display(), e))?;
Ok(())
}
pub fn format_rfc3339_utc(t: SystemTime) -> String {
let secs = t
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let (y, mo, d, h, mi, s) = civil_from_unix_secs(secs);
format!("{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z", y, mo, d, h, mi, s)
}
pub fn parse_rfc3339_utc(s: &str) -> Option<SystemTime> {
let s = s.trim();
let core = s.strip_suffix('Z').or_else(|| s.strip_suffix("+00:00"))?;
if core.len() != 19 || !core.is_ascii() || core.as_bytes()[10] != b'T' {
return None;
}
let y: i64 = core[0..4].parse().ok()?;
let mo: u32 = core[5..7].parse().ok()?;
let d: u32 = core[8..10].parse().ok()?;
let h: u32 = core[11..13].parse().ok()?;
let mi: u32 = core[14..16].parse().ok()?;
let s_: u32 = core[17..19].parse().ok()?;
let secs = unix_secs_from_civil(y, mo, d, h, mi, s_)?;
if secs < 0 {
return None;
}
Some(UNIX_EPOCH + Duration::from_secs(secs as u64))
}
pub fn format_relative(then: SystemTime, now: SystemTime) -> String {
let delta = now.duration_since(then).unwrap_or(Duration::ZERO);
let secs = delta.as_secs();
if secs < 60 {
return "Just now".to_string();
}
let mins = secs / 60;
if mins < 60 {
return format!("{} minute{} ago", mins, if mins == 1 { "" } else { "s" });
}
let hours = mins / 60;
if hours < 24 {
return format!("{} hour{} ago", hours, if hours == 1 { "" } else { "s" });
}
let days = hours / 24;
if days <= 7 {
return format!("{} day{} ago", days, if days == 1 { "" } else { "s" });
}
let secs_i64 = then
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let (y, mo, d, _, _, _) = civil_from_unix_secs(secs_i64);
format!("{:04}-{:02}-{:02}", y, mo, d)
}
fn civil_from_unix_secs(secs: i64) -> (i64, u32, u32, u32, u32, u32) {
let days = secs.div_euclid(86_400);
let time = secs.rem_euclid(86_400) as u32;
let h = time / 3600;
let mi = (time % 3600) / 60;
let s = time % 60;
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097) as u64;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
let y = yoe as i64 + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
let mo = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
let y = if mo <= 2 { y + 1 } else { y };
(y, mo, d, h, mi, s)
}
fn unix_secs_from_civil(y: i64, mo: u32, d: u32, h: u32, mi: u32, s: u32) -> Option<i64> {
if !(1..=12).contains(&mo) || !(1..=31).contains(&d) || h > 23 || mi > 59 || s > 59 {
return None;
}
let y = if mo <= 2 { y - 1 } else { y };
let era = y.div_euclid(400);
let yoe = y.rem_euclid(400) as u64;
let m = mo as u64;
let d_u = d as u64;
let doy = (153 * if m > 2 { m - 3 } else { m + 9 } + 2) / 5 + d_u - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
let days = era * 146_097 + doe as i64 - 719_468;
Some(days * 86_400 + h as i64 * 3600 + mi as i64 * 60 + s as i64)
}
pub fn is_breaking(subject: &str, body: &str) -> bool {
if subject_indicates_breaking(subject) {
return true;
}
body_indicates_breaking(body)
}
fn subject_indicates_breaking(subject: &str) -> bool {
let bytes = subject.as_bytes();
let mut i = 0;
while i < bytes.len() && bytes[i].is_ascii_alphabetic() {
i += 1;
}
if i == 0 {
return false;
}
if i < bytes.len() && bytes[i] == b'(' {
let start = i;
i += 1;
while i < bytes.len() && bytes[i] != b')' {
i += 1;
}
if i >= bytes.len() {
return false; }
i += 1; if i - start <= 2 {
return false;
}
}
i + 1 < bytes.len() && bytes[i] == b'!' && bytes[i + 1] == b':'
}
fn body_indicates_breaking(body: &str) -> bool {
for line in body.lines() {
let trimmed = line.trim_start();
if let Some(head) = trimmed.get(..16)
&& (head.eq_ignore_ascii_case("breaking change:")
|| head.eq_ignore_ascii_case("breaking-change:"))
{
return true;
}
}
false
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct DiffKey {
pub url: String,
pub from: String,
pub to: String,
pub file: String,
}
#[derive(Debug, Clone)]
pub struct LogRenderOptions<'a> {
pub last: usize,
pub query: Option<&'a str>,
#[allow(dead_code)]
pub full: bool,
pub diff: bool,
pub diffs: std::collections::HashMap<DiffKey, String>,
pub icons: IconStyle,
pub now: SystemTime,
}
pub const PLUGIN_NAME_PAD: usize = 26;
pub fn short_hash(h: &str) -> String {
let take = 7.min(h.len());
h[..take].to_string()
}
struct LogGlyphs {
dash: &'static str,
arrow: &'static str,
hbar: &'static str,
}
fn glyphs_for(icons: IconStyle) -> LogGlyphs {
if matches!(icons, IconStyle::Ascii) {
LogGlyphs {
dash: "-",
arrow: "->",
hbar: "--",
}
} else {
LogGlyphs {
dash: "\u{2014}", arrow: "\u{2192}", hbar: "\u{2500}\u{2500}", }
}
}
pub fn render_log(log: &UpdateLog, opts: &LogRenderOptions<'_>) -> String {
let g = glyphs_for(opts.icons);
let mut out = String::new();
out.push_str(&format!("rvpm log {} recent updates\n\n", g.dash));
if log.runs.is_empty() {
out.push_str("(no runs recorded yet)\n");
return out;
}
let breaking_marker = breaking_marker_for(opts.icons);
let query_lower: Option<String> = opts.query.map(|q| q.to_lowercase());
let mut shown = 0;
for run in log.runs.iter().rev() {
if shown >= opts.last {
break;
}
let filtered: Vec<&ChangeRecord> = run
.changes
.iter()
.filter(|c| match &query_lower {
Some(q) => c.name.to_lowercase().contains(q.as_str()),
None => true,
})
.collect();
if filtered.is_empty() {
continue;
}
shown += 1;
let when = parse_rfc3339_utc(&run.timestamp).unwrap_or(opts.now);
let rel = format_relative(when, opts.now);
out.push_str(&format!(
"# {} {} {} ({} {})\n",
rel,
g.dash,
run.command,
filtered.len(),
if filtered.len() == 1 {
"plugin"
} else {
"plugins"
},
));
for change in filtered {
render_change(&mut out, change, opts, breaking_marker, &g);
out.push('\n');
}
}
if shown == 0 {
out.push_str("(no matching runs)\n");
}
out
}
fn render_change(
out: &mut String,
change: &ChangeRecord,
opts: &LogRenderOptions<'_>,
breaking_marker: &str,
g: &LogGlyphs,
) {
let name_pad = format!("{:<width$}", change.name, width = PLUGIN_NAME_PAD);
match &change.from {
None => {
out.push_str(&format!(
" {} (new install) {} {}\n",
name_pad,
g.arrow,
short_hash(&change.to)
));
}
Some(from) => {
let n = change.subjects.len();
out.push_str(&format!(
" {} {}..{} ({} commit{})\n",
name_pad,
short_hash(from),
short_hash(&change.to),
n,
if n == 1 { "" } else { "s" }
));
for subj in &change.subjects {
if change.breaking_subjects.iter().any(|b| b == subj) {
out.push_str(&format!(" {} {}\n", breaking_marker, subj));
} else {
out.push_str(&format!(" {}\n", subj));
}
}
if !change.doc_files_changed.is_empty() {
if opts.diff {
for f in &change.doc_files_changed {
out.push_str(&format!(" {} diff: {}\n", g.hbar, f));
let key = DiffKey {
url: change.url.clone(),
from: from.clone(),
to: change.to.clone(),
file: f.clone(),
};
if let Some(patch) = opts.diffs.get(&key) {
for line in patch.lines() {
out.push_str(" ");
out.push_str(line);
out.push('\n');
}
}
}
} else {
out.push_str(&format!(
" docs changed: {} (use --diff to view)\n",
change.doc_files_changed.join(", ")
));
}
}
}
}
}
pub fn breaking_marker_for(icons: IconStyle) -> &'static str {
match icons {
IconStyle::Nerd | IconStyle::Unicode => "\x1b[33m\u{26a0} BREAKING\x1b[0m",
IconStyle::Ascii => "BREAKING",
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
use tempfile::TempDir;
#[test]
fn test_is_breaking_subject_bang_simple() {
assert!(is_breaking("feat!: rewrite api", ""));
}
#[test]
fn test_is_breaking_subject_bang_with_scope() {
assert!(is_breaking("fix(parser)!: drop legacy field", ""));
}
#[test]
fn test_is_breaking_subject_no_bang_is_not_breaking() {
assert!(!is_breaking("feat: add new flag", ""));
assert!(!is_breaking("fix(parser): handle empty", ""));
}
#[test]
fn test_is_breaking_subject_uppercase_type() {
assert!(is_breaking("Feat!: redo API", ""));
}
#[test]
fn test_is_breaking_subject_empty_scope_rejected() {
assert!(!is_breaking("feat()!: bad", ""));
}
#[test]
fn test_is_breaking_body_breaking_change() {
let body = "Some body text\n\nBREAKING CHANGE: removes API\n";
assert!(is_breaking("fix: small fix", body));
}
#[test]
fn test_is_breaking_body_breaking_dash_change() {
let body = "BREAKING-CHANGE: gone\n";
assert!(is_breaking("docs: x", body));
}
#[test]
fn test_is_breaking_body_case_insensitive() {
let body = "breaking change: lowercase form\n";
assert!(is_breaking("docs: x", body));
}
#[test]
fn test_is_breaking_body_indented_line_still_counts() {
let body = " BREAKING CHANGE: indented\n";
assert!(is_breaking("fix: x", body));
}
#[test]
fn test_is_breaking_body_no_marker() {
assert!(!is_breaking(
"fix: ok",
"Just a regular body talking about breaking things abstractly\n"
));
}
#[test]
fn test_is_breaking_subject_only_alpha_no_colon_rejected() {
assert!(!is_breaking("feat! whatever", ""));
}
#[test]
fn test_serde_roundtrip_minimal() {
let log = UpdateLog {
runs: vec![RunRecord {
timestamp: "2026-01-02T03:04:05Z".into(),
command: "sync".into(),
changes: vec![ChangeRecord {
name: "snacks.nvim".into(),
url: "folke/snacks.nvim".into(),
from: Some("a".repeat(40)),
to: "b".repeat(40),
subjects: vec!["fix: x".into()],
breaking_subjects: vec![],
doc_files_changed: vec!["README.md".into()],
}],
}],
};
let json = serde_json::to_string(&log).unwrap();
let back: UpdateLog = serde_json::from_str(&json).unwrap();
assert_eq!(log, back);
}
#[test]
fn test_serde_roundtrip_fresh_clone() {
let log = UpdateLog {
runs: vec![RunRecord {
timestamp: "2026-04-19T00:00:00Z".into(),
command: "add".into(),
changes: vec![ChangeRecord {
name: "flash.nvim".into(),
url: "folke/flash.nvim".into(),
from: None,
to: "c".repeat(40),
subjects: vec![],
breaking_subjects: vec![],
doc_files_changed: vec![],
}],
}],
};
let json = serde_json::to_string_pretty(&log).unwrap();
let back: UpdateLog = serde_json::from_str(&json).unwrap();
assert_eq!(log, back);
assert!(back.runs[0].changes[0].from.is_none());
}
#[test]
fn test_load_log_missing_file_returns_default() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("nope.json");
let log = load_log(&path);
assert_eq!(log, UpdateLog::default());
}
#[test]
fn test_load_log_malformed_returns_default() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("bad.json");
std::fs::write(&path, b"{not valid json").unwrap();
let log = load_log(&path);
assert_eq!(log, UpdateLog::default());
}
#[test]
fn test_cap_runs_truncates_oldest() {
let mut log = UpdateLog::default();
for i in 0..(MAX_RUNS + 5) {
log.runs.push(RunRecord {
timestamp: format!("2026-01-{:02}T00:00:00Z", (i % 28) + 1),
command: "sync".into(),
changes: vec![],
});
}
cap_runs(&mut log);
assert_eq!(log.runs.len(), MAX_RUNS);
assert!(
log.runs
.first()
.unwrap()
.timestamp
.starts_with("2026-01-06"),
"got {:?}",
log.runs.first().map(|r| &r.timestamp)
);
}
#[test]
fn test_cap_runs_no_op_when_under_limit() {
let mut log = UpdateLog {
runs: vec![RunRecord {
timestamp: "2026-01-01T00:00:00Z".into(),
command: "sync".into(),
changes: vec![],
}],
};
cap_runs(&mut log);
assert_eq!(log.runs.len(), 1);
}
fn sample_change(name: &str) -> ChangeRecord {
ChangeRecord {
name: name.to_string(),
url: format!("owner/{}", name),
from: Some("a".into()),
to: "b".into(),
subjects: vec!["fix: x".into()],
breaking_subjects: vec![],
doc_files_changed: vec![],
}
}
#[test]
fn test_record_run_creates_file_and_persists() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("update_log.json");
record_run(&path, "sync", vec![sample_change("a")]).unwrap();
assert!(path.exists());
let log = load_log(&path);
assert_eq!(log.runs.len(), 1);
assert_eq!(log.runs[0].command, "sync");
}
#[test]
fn test_record_run_skips_empty_changes() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("update_log.json");
record_run(&path, "sync", vec![]).unwrap();
assert!(
!path.exists(),
"update_log.json should not be created for empty run"
);
}
#[test]
fn test_record_run_skips_empty_but_preserves_existing() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("update_log.json");
record_run(&path, "sync", vec![sample_change("a")]).unwrap();
record_run(&path, "sync", vec![]).unwrap();
let log = load_log(&path);
assert_eq!(log.runs.len(), 1, "empty run should not append");
}
#[test]
fn test_record_run_appends_existing() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("update_log.json");
record_run(&path, "sync", vec![sample_change("a")]).unwrap();
record_run(&path, "update", vec![sample_change("b")]).unwrap();
let log = load_log(&path);
assert_eq!(log.runs.len(), 2);
assert_eq!(log.runs[0].command, "sync");
assert_eq!(log.runs[1].command, "update");
}
#[test]
fn test_record_run_caps_at_max_runs() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("update_log.json");
for i in 0..(MAX_RUNS + 3) {
record_run(&path, "sync", vec![sample_change(&format!("p{}", i))]).unwrap();
}
let log = load_log(&path);
assert_eq!(log.runs.len(), MAX_RUNS);
}
#[test]
fn test_format_rfc3339_utc_unix_epoch() {
let s = format_rfc3339_utc(UNIX_EPOCH);
assert_eq!(s, "1970-01-01T00:00:00Z");
}
#[test]
fn test_format_rfc3339_utc_known_date() {
let secs = unix_secs_from_civil(2026, 4, 19, 12, 34, 56).unwrap();
let t = UNIX_EPOCH + Duration::from_secs(secs as u64);
assert_eq!(format_rfc3339_utc(t), "2026-04-19T12:34:56Z");
}
#[test]
fn test_parse_rfc3339_round_trip() {
let original = "2024-02-29T23:59:59Z";
let t = parse_rfc3339_utc(original).unwrap();
assert_eq!(format_rfc3339_utc(t), original);
}
#[test]
fn test_parse_rfc3339_accepts_plus_zero() {
let a = parse_rfc3339_utc("2026-04-19T00:00:00Z").unwrap();
let b = parse_rfc3339_utc("2026-04-19T00:00:00+00:00").unwrap();
assert_eq!(a, b);
}
#[test]
fn test_parse_rfc3339_rejects_garbage() {
assert!(parse_rfc3339_utc("not a date").is_none());
assert!(parse_rfc3339_utc("2026-13-40T99:99:99Z").is_none());
}
#[test]
fn test_format_relative_just_now() {
let now = UNIX_EPOCH + Duration::from_secs(1_000_000);
let then = now - Duration::from_secs(30);
assert_eq!(format_relative(then, now), "Just now");
}
#[test]
fn test_format_relative_minutes() {
let now = UNIX_EPOCH + Duration::from_secs(1_000_000);
let then = now - Duration::from_secs(5 * 60);
assert_eq!(format_relative(then, now), "5 minutes ago");
let then1 = now - Duration::from_secs(60);
assert_eq!(format_relative(then1, now), "1 minute ago");
}
#[test]
fn test_format_relative_hours() {
let now = UNIX_EPOCH + Duration::from_secs(1_000_000);
let then = now - Duration::from_secs(3 * 3600);
assert_eq!(format_relative(then, now), "3 hours ago");
}
#[test]
fn test_format_relative_days() {
let now = UNIX_EPOCH + Duration::from_secs(1_000_000);
let then = now - Duration::from_secs(2 * 86_400);
assert_eq!(format_relative(then, now), "2 days ago");
}
#[test]
fn test_format_relative_absolute_after_week() {
let now_secs = unix_secs_from_civil(2026, 4, 19, 12, 0, 0).unwrap();
let now = UNIX_EPOCH + Duration::from_secs(now_secs as u64);
let then = now - Duration::from_secs(30 * 86_400);
let rendered = format_relative(then, now);
assert!(
rendered.starts_with("2026-03-"),
"expected 2026-03-XX, got {}",
rendered
);
}
fn sample_log() -> UpdateLog {
UpdateLog {
runs: vec![RunRecord {
timestamp: "2026-04-19T00:00:00Z".into(),
command: "update".into(),
changes: vec![
ChangeRecord {
name: "snacks.nvim".into(),
url: "folke/snacks.nvim".into(),
from: Some("abc1234aaaa".into()),
to: "def5678bbbb".into(),
subjects: vec![
"feat!: rewrite picker API".into(),
"fix: handle empty buffer".into(),
],
breaking_subjects: vec!["feat!: rewrite picker API".into()],
doc_files_changed: vec!["README.md".into(), "doc/snacks.txt".into()],
},
ChangeRecord {
name: "flash.nvim".into(),
url: "folke/flash.nvim".into(),
from: None,
to: "999aaaa".into(),
subjects: vec![],
breaking_subjects: vec![],
doc_files_changed: vec![],
},
],
}],
}
}
fn render_with(opts: LogRenderOptions<'_>) -> String {
render_log(&sample_log(), &opts)
}
fn now_for_log() -> SystemTime {
UNIX_EPOCH
+ Duration::from_secs(unix_secs_from_civil(2026, 4, 19, 0, 10, 0).unwrap() as u64)
}
#[test]
fn test_render_log_basic_no_query() {
let s = render_with(LogRenderOptions {
last: 1,
query: None,
full: false,
diff: false,
diffs: HashMap::new(),
icons: IconStyle::Ascii,
now: now_for_log(),
});
assert!(s.contains("10 minutes ago"), "got:\n{}", s);
assert!(s.contains("update (2 plugins)"), "got:\n{}", s);
assert!(s.contains("snacks.nvim"), "got:\n{}", s);
assert!(s.contains("abc1234..def5678"), "got:\n{}", s);
assert!(s.contains("(2 commits)"), "got:\n{}", s);
assert!(s.contains("BREAKING feat!: rewrite picker API"));
assert!(s.contains("(new install)"));
assert!(s.contains("999aaaa"));
assert!(s.contains("docs changed: README.md, doc/snacks.txt"));
assert!(s.contains("(use --diff to view)"));
}
#[test]
fn test_render_log_query_filters_plugins() {
let s = render_with(LogRenderOptions {
last: 1,
query: Some("flash"),
full: false,
diff: false,
diffs: HashMap::new(),
icons: IconStyle::Ascii,
now: now_for_log(),
});
assert!(s.contains("flash.nvim"), "got:\n{}", s);
assert!(!s.contains("snacks.nvim"), "got:\n{}", s);
assert!(s.contains("(1 plugin)"), "got:\n{}", s);
}
#[test]
fn test_render_log_query_no_match_omits_run() {
let s = render_with(LogRenderOptions {
last: 1,
query: Some("definitely-nope"),
full: false,
diff: false,
diffs: HashMap::new(),
icons: IconStyle::Ascii,
now: now_for_log(),
});
assert!(s.contains("(no matching runs)"), "got:\n{}", s);
}
#[test]
fn test_render_log_diff_embeds_patch() {
let mut diffs = HashMap::new();
diffs.insert(
DiffKey {
url: "folke/snacks.nvim".into(),
from: "abc1234aaaa".into(),
to: "def5678bbbb".into(),
file: "README.md".into(),
},
"diff --git a/README.md b/README.md\n+ added line\n".to_string(),
);
let s = render_with(LogRenderOptions {
last: 1,
query: None,
full: false,
diff: true,
diffs,
icons: IconStyle::Ascii,
now: now_for_log(),
});
assert!(s.contains("diff: README.md"), "got:\n{}", s);
assert!(s.contains("+ added line"), "got:\n{}", s);
assert!(!s.contains("(use --diff to view)"));
}
#[test]
fn test_render_log_empty_log_message() {
let log = UpdateLog::default();
let opts = LogRenderOptions {
last: 1,
query: None,
full: false,
diff: false,
diffs: HashMap::new(),
icons: IconStyle::Ascii,
now: now_for_log(),
};
let s = render_log(&log, &opts);
assert!(s.contains("(no runs recorded yet)"));
}
#[test]
fn test_render_log_breaking_marker_unicode_has_warn() {
let s = render_with(LogRenderOptions {
last: 1,
query: None,
full: false,
diff: false,
diffs: HashMap::new(),
icons: IconStyle::Unicode,
now: now_for_log(),
});
assert!(
s.contains("\u{26a0} BREAKING"),
"expected warn icon in BREAKING marker, got:\n{}",
s
);
}
#[test]
fn test_render_log_ascii_is_pure_ascii() {
let mut diffs = HashMap::new();
diffs.insert(
DiffKey {
url: "folke/snacks.nvim".into(),
from: "abc1234aaaa".into(),
to: "def5678bbbb".into(),
file: "README.md".into(),
},
"diff --git a/README.md b/README.md\n+ new\n".to_string(),
);
let s = render_with(LogRenderOptions {
last: 1,
query: None,
full: false,
diff: true,
diffs,
icons: IconStyle::Ascii,
now: now_for_log(),
});
for ch in s.chars() {
assert!(
ch.is_ascii(),
"non-ASCII char {:?} (U+{:04X}) found in ASCII output:\n{}",
ch,
ch as u32,
s
);
}
assert!(s.contains(" - "), "expected ' - ' in ASCII title");
assert!(
s.contains("(new install) -> "),
"expected '->' for new install"
);
assert!(s.contains("-- diff:"), "expected '--' for diff header");
}
#[test]
fn test_render_log_unicode_keeps_special_chars() {
let mut diffs = HashMap::new();
diffs.insert(
DiffKey {
url: "folke/snacks.nvim".into(),
from: "abc1234aaaa".into(),
to: "def5678bbbb".into(),
file: "README.md".into(),
},
"diff --git a/README.md b/README.md\n+ new\n".to_string(),
);
let s = render_with(LogRenderOptions {
last: 1,
query: None,
full: false,
diff: true,
diffs,
icons: IconStyle::Unicode,
now: now_for_log(),
});
assert!(s.contains("\u{2014}"), "expected em-dash"); assert!(s.contains("\u{2192}"), "expected arrow"); assert!(s.contains("\u{2500}\u{2500}"), "expected ── horizontal bar");
}
#[test]
fn test_short_hash_truncates_to_7() {
assert_eq!(short_hash("0123456789abcdef"), "0123456");
}
#[test]
fn test_short_hash_handles_short_input() {
assert_eq!(short_hash("abc"), "abc");
assert_eq!(short_hash(""), "");
}
}