use std::collections::{HashMap, HashSet};
use cratefield_core::{Clock, Database, DbError, Row, Statement};
use sea_query::Value as SeaValue;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Release {
pub source: String,
pub version: String,
pub title: String,
pub body: String,
pub url: String,
pub published_at: String,
pub prerelease: bool,
pub draft: bool,
pub first_seen_at: String,
pub updated_at: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct UpstreamRelease {
pub version: String,
pub title: String,
pub body: String,
pub url: String,
pub published_at: String,
pub prerelease: bool,
pub draft: bool,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct SourceState {
pub source: String,
pub etag: String,
pub generation: String,
pub last_refreshed_at: String,
pub last_status: String,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub(crate) struct RefreshCounts {
pub inserted: u64,
pub updated: u64,
pub unchanged: u64,
pub removed: u64,
}
impl RefreshCounts {
pub(crate) fn changed(&self) -> bool {
self.inserted + self.updated + self.removed > 0
}
}
const RELEASE_COLUMNS: &str = "source, version, title, body, url, published_at, prerelease, \
draft, first_seen_at, updated_at";
fn text(value: &str) -> SeaValue {
SeaValue::String(Some(Box::new(value.to_owned())))
}
fn flag(value: bool) -> SeaValue {
SeaValue::BigInt(Some(i64::from(value)))
}
pub(crate) fn now_iso(clock: &dyn Clock) -> String {
use time::format_description::well_known::Rfc3339;
clock
.now()
.replace_nanosecond(0)
.expect("truncation stays in range")
.format(&Rfc3339)
.unwrap_or_default()
}
fn release_from(row: &Row) -> Release {
Release {
source: row.get::<String>("source").unwrap_or_default(),
version: row.get::<String>("version").unwrap_or_default(),
title: row.get::<String>("title").unwrap_or_default(),
body: row.get::<String>("body").unwrap_or_default(),
url: row.get::<String>("url").unwrap_or_default(),
published_at: row.get::<String>("published_at").unwrap_or_default(),
prerelease: row.get::<i64>("prerelease").unwrap_or_default() != 0,
draft: row.get::<i64>("draft").unwrap_or_default() != 0,
first_seen_at: row.get::<String>("first_seen_at").unwrap_or_default(),
updated_at: row.get::<String>("updated_at").unwrap_or_default(),
}
}
fn source_state_from(row: &Row) -> SourceState {
SourceState {
source: row.get::<String>("source").unwrap_or_default(),
etag: row.get::<String>("etag").unwrap_or_default(),
generation: row.get::<String>("generation").unwrap_or_default(),
last_refreshed_at: row.get::<String>("last_refreshed_at").unwrap_or_default(),
last_status: row.get::<String>("last_status").unwrap_or_default(),
}
}
pub(crate) async fn list_releases(
db: &dyn Database,
source: &str,
limit: u64,
offset: u64,
) -> Result<Vec<Release>, DbError> {
let rows = db
.query(&Statement::with_values(
format!(
"SELECT {RELEASE_COLUMNS} FROM changelog_release WHERE source = ? \
ORDER BY published_at DESC, version DESC LIMIT {limit} OFFSET {offset}"
),
vec![text(source)],
))
.await?;
Ok(rows.rows.iter().map(release_from).collect())
}
pub(crate) async fn count_releases(db: &dyn Database, source: &str) -> Result<u64, DbError> {
let rows = db
.query(&Statement::with_values(
"SELECT COUNT(*) AS total FROM changelog_release WHERE source = ?",
vec![text(source)],
))
.await?;
let total = rows
.first()
.and_then(|row| row.get::<i64>("total"))
.unwrap_or_default();
Ok(u64::try_from(total.max(0)).unwrap_or_default())
}
pub(crate) async fn find_release(
db: &dyn Database,
source: &str,
version: &str,
) -> Result<Option<Release>, DbError> {
let rows = db
.query(&Statement::with_values(
format!(
"SELECT {RELEASE_COLUMNS} FROM changelog_release \
WHERE source = ? AND version = ?"
),
vec![text(source), text(version)],
))
.await?;
Ok(rows.first().map(release_from))
}
pub(crate) async fn source_state(
db: &dyn Database,
source: &str,
) -> Result<Option<SourceState>, DbError> {
let rows = db
.query(&Statement::with_values(
"SELECT source, etag, generation, last_refreshed_at, last_status \
FROM changelog_source WHERE source = ?",
vec![text(source)],
))
.await?;
Ok(rows.first().map(source_state_from))
}
type StoredFields = (String, String, String, String, bool, bool);
fn stored_fields_of(db_rows: &[Row]) -> HashMap<String, StoredFields> {
let mut existing = HashMap::with_capacity(db_rows.len());
for row in db_rows {
let Some(version) = row.get::<String>("version") else {
continue;
};
existing.insert(
version,
(
row.get::<String>("title").unwrap_or_default(),
row.get::<String>("body").unwrap_or_default(),
row.get::<String>("url").unwrap_or_default(),
row.get::<String>("published_at").unwrap_or_default(),
row.get::<i64>("prerelease").unwrap_or_default() != 0,
row.get::<i64>("draft").unwrap_or_default() != 0,
),
);
}
existing
}
fn insert_release(source: &str, release: &UpstreamRelease, now: &str) -> Statement {
Statement::with_values(
format!(
"INSERT INTO changelog_release ({RELEASE_COLUMNS}) \
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) \
ON CONFLICT(source, version) DO UPDATE SET \
title = excluded.title, body = excluded.body, url = excluded.url, \
published_at = excluded.published_at, prerelease = excluded.prerelease, \
draft = excluded.draft, updated_at = excluded.updated_at"
),
vec![
text(source),
text(&release.version),
text(&release.title),
text(&release.body),
text(&release.url),
text(&release.published_at),
flag(release.prerelease),
flag(release.draft),
text(now),
text(now),
],
)
}
pub(crate) async fn apply_refresh(
db: &dyn Database,
source: &str,
incoming: &[UpstreamRelease],
now: &str,
prune: bool,
) -> Result<RefreshCounts, DbError> {
let rows = db
.query(&Statement::with_values(
"SELECT version, title, body, url, published_at, prerelease, draft \
FROM changelog_release WHERE source = ?",
vec![text(source)],
))
.await?;
let existing = stored_fields_of(&rows.rows);
let mut counts = RefreshCounts::default();
let mut statements: Vec<Statement> = Vec::new();
let mut seen: HashSet<&str> = HashSet::with_capacity(incoming.len());
for release in incoming {
if !seen.insert(release.version.as_str()) {
continue;
}
match existing.get(&release.version) {
Some((title, body, url, published_at, prerelease, draft))
if title == &release.title
&& body == &release.body
&& url == &release.url
&& published_at == &release.published_at
&& prerelease == &release.prerelease
&& draft == &release.draft =>
{
counts.unchanged += 1;
}
Some(_) => {
counts.updated += 1;
statements.push(Statement::with_values(
"UPDATE changelog_release \
SET title = ?, body = ?, url = ?, published_at = ?, prerelease = ?, \
draft = ?, updated_at = ? \
WHERE source = ? AND version = ?",
vec![
text(&release.title),
text(&release.body),
text(&release.url),
text(&release.published_at),
flag(release.prerelease),
flag(release.draft),
text(now),
text(source),
text(&release.version),
],
));
}
None => {
counts.inserted += 1;
statements.push(insert_release(source, release, now));
}
}
}
if prune {
for version in existing.keys() {
if !seen.contains(version.as_str()) {
counts.removed += 1;
statements.push(Statement::with_values(
"DELETE FROM changelog_release WHERE source = ? AND version = ?",
vec![text(source), text(version)],
));
}
}
}
if !statements.is_empty() {
db.batch_atomic(&statements).await?;
}
Ok(counts)
}
pub(crate) async fn record_source(
db: &dyn Database,
source: &str,
etag: &str,
generation: &str,
last_refreshed_at: &str,
last_status: &str,
) -> Result<(), DbError> {
db.execute(&Statement::with_values(
"INSERT INTO changelog_source \
(source, etag, generation, last_refreshed_at, last_status) \
VALUES (?, ?, ?, ?, ?) \
ON CONFLICT(source) DO UPDATE SET \
etag = excluded.etag, generation = excluded.generation, \
last_refreshed_at = excluded.last_refreshed_at, \
last_status = excluded.last_status",
vec![
text(source),
text(etag),
text(generation),
text(last_refreshed_at),
text(last_status),
],
))
.await?;
Ok(())
}
pub(crate) async fn record_status(
db: &dyn Database,
source: &str,
last_status: &str,
last_refreshed_at: &str,
) -> Result<(), DbError> {
db.execute(&Statement::with_values(
"INSERT INTO changelog_source \
(source, etag, generation, last_refreshed_at, last_status) \
VALUES (?, '', '', ?, ?) \
ON CONFLICT(source) DO UPDATE SET \
last_refreshed_at = excluded.last_refreshed_at, \
last_status = excluded.last_status",
vec![text(source), text(last_refreshed_at), text(last_status)],
))
.await?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Changelog;
use cratefield_testing::TestHarness;
fn release(version: &str, title: &str, body: &str) -> UpstreamRelease {
UpstreamRelease {
version: version.to_owned(),
title: title.to_owned(),
body: body.to_owned(),
url: String::new(),
published_at: String::new(),
prerelease: false,
draft: false,
}
}
#[test]
fn a_conflicting_insert_updates_in_place_and_keeps_first_seen() {
for harness in
TestHarness::all_dialects(|| vec![Box::new(Changelog::new().repo("owner/name"))])
{
let source = "github-releases:owner/name";
let first = pollster::block_on(apply_refresh(
harness.db.as_ref(),
source,
&[release("v1.0.0", "1.0.0", "- where it started")],
"2026-01-01T00:00:00Z",
true,
))
.expect("the first insert lands");
assert_eq!(first.inserted, 1, "{}", harness.dialect);
pollster::block_on(harness.db.execute(&insert_release(
source,
&release("v1.0.0", "1.0.0 rewritten", "- rewritten upstream"),
"2026-02-01T00:00:00Z",
)))
.expect("a conflicting insert is an update, not a primary-key error");
let rows = pollster::block_on(harness.db.query(&Statement::with_values(
"SELECT title, body, first_seen_at, updated_at FROM changelog_release \
WHERE source = ? AND version = ?",
vec![text(source), text("v1.0.0")],
)))
.expect("the row reads");
let row = rows.first().expect("still exactly one row");
assert_eq!(
row.get::<String>("title").as_deref(),
Some("1.0.0 rewritten"),
"{}",
harness.dialect,
);
assert_eq!(
row.get::<String>("body").as_deref(),
Some("- rewritten upstream"),
"{}",
harness.dialect,
);
assert_eq!(
row.get::<String>("first_seen_at").as_deref(),
Some("2026-01-01T00:00:00Z"),
"the conflict moved first_seen_at ({})",
harness.dialect,
);
assert_eq!(
row.get::<String>("updated_at").as_deref(),
Some("2026-02-01T00:00:00Z"),
"{}",
harness.dialect,
);
let second = pollster::block_on(apply_refresh(
harness.db.as_ref(),
source,
&[release("v1.0.0", "1.0.0 rewritten", "- rewritten upstream")],
"2026-03-01T00:00:00Z",
true,
))
.expect("the follow-up refresh runs");
assert_eq!(second.inserted, 0, "{}", harness.dialect);
assert_eq!(second.unchanged, 1, "{}", harness.dialect);
}
}
}