Format the Rust code with rustfmt

The code was formatted by hand, and `cargo fmt --check` differed in 866
places. rustfmt.toml keeps the house's long lines (120) and short
expressions on one line, which halves what changes; this commit is only
what rustfmt does with it, and is named in .git-blame-ignore-revs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-17 21:55:41 +00:00
parent 2c4ba7896e
commit 692a510217
26 changed files with 1710 additions and 1114 deletions

View File

@@ -1,8 +1,11 @@
//! The database, through SeaORM: SQLite today, Postgres to come (issue #18). Replaces the
//! per-feed .ipxd plists, history.dat and qmcache.dat.
use crate::entity::{
api_tokens, blocklists, catalogue, deadair_prefs, enclosures, entries, feeds, hidden, sessions, settings,
signal_verdicts, signals, subscriptions, users,
};
use anyhow::{Context, Result};
use crate::entity::{api_tokens, blocklists, catalogue, deadair_prefs, enclosures, entries, feeds, hidden, sessions, settings, signal_verdicts, signals, subscriptions, users};
use sea_orm::sea_query::{Expr, Func};
use sea_orm::{
ActiveModelTrait, ColumnTrait, ConnectionTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, Set,
@@ -94,10 +97,9 @@ async fn create_missing(orm: &sea_orm::DatabaseConnection) -> Result<()> {
// A feed is read whole only when it has changed since the last read, so each one's
// description would wait for its next item. Forgetting how each was last seen makes
// its next read, when it is due anyway, a whole one.
for sql in [
"ALTER TABLE feeds ADD COLUMN description TEXT",
"UPDATE feeds SET etag = NULL, last_modified = NULL",
] {
for sql in
["ALTER TABLE feeds ADD COLUMN description TEXT", "UPDATE feeds SET etag = NULL, last_modified = NULL"]
{
orm.execute_unprepared(sql).await.with_context(|| sql.to_owned())?;
}
}
@@ -125,7 +127,10 @@ async fn create_missing(orm: &sea_orm::DatabaseConnection) -> Result<()> {
// Postgres guessed a third of entries were recent and read them all, entries_recent unused
// (#274). Analysed once when they are missing; autovacuum keeps them after.
if orm.get_database_backend() == sea_orm::DbBackend::Postgres {
let has = Statement::from_string(sea_orm::DbBackend::Postgres, "SELECT 1 FROM pg_stats WHERE tablename = 'entries_recent'");
let has = Statement::from_string(
sea_orm::DbBackend::Postgres,
"SELECT 1 FROM pg_stats WHERE tablename = 'entries_recent'",
);
if orm.query_one_raw(has).await?.is_none() {
orm.execute_unprepared("ANALYZE entries").await.context("analysing entries")?;
}
@@ -222,9 +227,7 @@ async fn connect(location: &str, keep_open: bool) -> Result<sea_orm::DatabaseCon
if keep_open && is_postgres(location) {
opts.min_connections(20);
}
sea_orm::Database::connect(opts)
.await
.with_context(|| format!("opening {}", redact(location)))
sea_orm::Database::connect(opts).await.with_context(|| format!("opening {}", redact(location)))
}
/// The URL `connect` hands sqlx for a location: a SQLite file, created if missing, or a Postgres
@@ -244,7 +247,6 @@ fn url_for(location: &str) -> String {
}
}
/// One person's wants for one feed. `None` in a field means the feed's own setting stands.
#[derive(Debug, Clone, Default)]
pub struct Sub {
@@ -330,7 +332,6 @@ pub struct Managed {
pub group_id: String,
}
/// What `ipx list` shows next to each configured feed.
#[derive(Debug, Default)]
pub struct FeedSummary {
@@ -414,9 +415,11 @@ impl Db {
admin.execute_unprepared(&format!("CREATE SCHEMA {schema}")).await?;
// Every connection in the pool starts in it, so each test sees only its own tables.
let sep = if url.contains('?') { '&' } else { '?' };
let orm =
connect(&format!("{url}{sep}options=-c%20search_path%3D{schema}%20-c%20client_min_messages%3Dwarning"), false)
.await?;
let orm = connect(
&format!("{url}{sep}options=-c%20search_path%3D{schema}%20-c%20client_min_messages%3Dwarning"),
false,
)
.await?;
create_missing(&orm).await?;
return Ok(Self { orm, tmp: None });
}
@@ -530,9 +533,9 @@ impl Db {
step("blocked"),
)
.await?
.into_iter()
.map(|b| (b.feed_id, keywords(Some(b.words)).unwrap_or_default()))
.collect();
.into_iter()
.map(|b| (b.feed_id, keywords(Some(b.words)).unwrap_or_default()))
.collect();
let mut rows = feeds::Entity::find();
if let Some(id) = only {
rows = rows.filter(feeds::Column::Id.eq(id));
@@ -574,7 +577,6 @@ pub struct FeedListing {
pub unread: i64,
}
/// Everything `fetch` needs to decide whether to poll a feed, and how.
#[derive(Debug, Default)]
pub struct HttpState {
@@ -611,7 +613,12 @@ impl Db {
/// Every feed's, in one query: a scan, and the daemon working out when the next is due, asked
/// feed by feed, 180 queries a minute (#114).
pub async fn http_states(&self) -> Result<std::collections::HashMap<String, HttpState>> {
Ok(feeds::Entity::find().all(&self.orm).await?.into_iter().map(|f| (f.id.clone(), HttpState::from(f))).collect())
Ok(feeds::Entity::find()
.all(&self.orm)
.await?
.into_iter()
.map(|f| (f.id.clone(), HttpState::from(f)))
.collect())
}
/// Upsert after a successful poll. Clears any previous error.
@@ -786,30 +793,18 @@ impl Db {
Ok(())
}
pub async fn record_enclosure(
&self,
feed_id: &str,
guid: &str,
enc: &crate::feed::Enclosure,
) -> Result<bool> {
pub async fn record_enclosure(&self, feed_id: &str, guid: &str, enc: &crate::feed::Enclosure) -> Result<bool> {
let inserted = self
.exec(
"INSERT INTO enclosures (feed_id, guid, url, mime, length, state)
VALUES ($1, $2, $3, $4, $5, 'pending') ON CONFLICT DO NOTHING",
vec![
feed_id.into(),
guid.into(),
enc.url.clone().into(),
enc.mime.clone().into(),
enc.length.into(),
],
vec![feed_id.into(), guid.into(), enc.url.clone().into(), enc.mime.clone().into(), enc.length.into()],
)
.await?;
Ok(inserted == 1)
}
}
/// An enclosure waiting to be downloaded.
#[derive(Debug)]
pub struct Pending {
@@ -848,7 +843,9 @@ impl Db {
.await?;
// And back, when a limit is raised or a feed downloads again.
self.exec(
&format!("UPDATE enclosures SET state = 'pending' WHERE feed_id = $1 AND state = 'held' AND guid IN ({newest})"),
&format!(
"UPDATE enclosures SET state = 'pending' WHERE feed_id = $1 AND state = 'held' AND guid IN ({newest})"
),
vec![feed_id.into(), limit],
)
.await?;
@@ -881,7 +878,6 @@ impl Db {
}
}
/// A downloaded file the reaper may consider.
#[derive(Debug, Clone, PartialEq)]
pub struct Candidate {
@@ -1019,7 +1015,9 @@ impl Db {
for table in ["entries", "feeds"] {
let col = if table == "feeds" { "id" } else { "feed_id" };
self.exec(
&format!("UPDATE {table} SET image = 'https://' || substr(image, 8) WHERE {col} = $1 AND image LIKE $2"),
&format!(
"UPDATE {table} SET image = 'https://' || substr(image, 8) WHERE {col} = $1 AND image LIKE $2"
),
vec![feed_id.into(), like.clone().into()],
)
.await?;
@@ -1108,19 +1106,14 @@ impl Db {
/// (pending, downloaded) across all feeds, for the status command.
pub async fn counts(&self) -> Result<(i64, i64)> {
let pending = enclosures::Entity::find()
.filter(enclosures::Column::State.eq("pending"))
.count(&self.orm)
.await?;
let downloaded = enclosures::Entity::find()
.filter(enclosures::Column::Path.is_not_null())
.count(&self.orm)
.await?;
let pending =
enclosures::Entity::find().filter(enclosures::Column::State.eq("pending")).count(&self.orm).await?;
let downloaded =
enclosures::Entity::find().filter(enclosures::Column::Path.is_not_null()).count(&self.orm).await?;
Ok((pending as i64, downloaded as i64))
}
}
/// An entry plus its enclosures, for the web UI.
#[derive(Debug, serde::Serialize)]
pub struct EntryRow {
@@ -1182,9 +1175,7 @@ fn entries_from(
let search = match search.map(|q| q.trim().to_lowercase()).filter(|q| !q.is_empty()) {
Some(q) => {
let like = a.p(format!("%{q}%"));
format!(
"(lower(coalesce(e.title, '')) LIKE {like} OR lower(coalesce(e.description, '')) LIKE {like})"
)
format!("(lower(coalesce(e.title, '')) LIKE {like} OR lower(coalesce(e.description, '')) LIKE {like})")
}
None => "true".into(),
};
@@ -1340,7 +1331,8 @@ impl Db {
pub async fn signal_count(&self, user_id: i64, signal: i64, filter: Filter, search: Option<&str>) -> Result<i64> {
let mut a = Args::default();
let sql = format!("SELECT count(*) AS n {}", entries_from(&mut a, user_id, None, filter, search, None, Some(signal)));
let sql =
format!("SELECT count(*) AS n {}", entries_from(&mut a, user_id, None, filter, search, None, Some(signal)));
let r = self.rows(&sql, a.0).await?;
Ok(r.first().context("count(*) always returns a row")?.try_get("", "n")?)
}
@@ -1432,7 +1424,8 @@ impl Db {
search: Option<&str>,
) -> Result<i64> {
let mut a = Args::default();
let sql = format!("SELECT count(*) AS n {}", entries_from(&mut a, user_id, feed_id, filter, search, None, None));
let sql =
format!("SELECT count(*) AS n {}", entries_from(&mut a, user_id, feed_id, filter, search, None, None));
let r = self.rows(&sql, a.0).await?;
Ok(r.first().context("count(*) always returns a row")?.try_get("", "n")?)
}
@@ -1457,13 +1450,7 @@ impl Db {
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (user_id, feed_id, guid) DO UPDATE SET position = excluded.position,
duration = coalesce(excluded.duration, entry_state.duration)",
vec![
user_id.into(),
feed_id.into(),
guid.into(),
secs.max(0).into(),
duration.filter(|d| *d > 0).into(),
],
vec![user_id.into(), feed_id.into(), guid.into(), secs.max(0).into(), duration.filter(|d| *d > 0).into()],
)
.await?;
Ok(())
@@ -1508,9 +1495,7 @@ impl Db {
return Ok(false); // dropped, so rolled back
}
for (id, feed) in &cfg.feeds {
catalogue::ActiveModel { id: Set(id.clone()), spec: Set(serde_json::to_string(feed)?) }
.insert(&tx)
.await?;
catalogue::ActiveModel { id: Set(id.clone()), spec: Set(serde_json::to_string(feed)?) }.insert(&tx).await?;
}
tx.commit().await?;
Ok(true)
@@ -1531,9 +1516,7 @@ impl Db {
.await?;
catalogue::Entity::delete_many().exec(&tx).await?;
for (id, feed) in &cfg.feeds {
catalogue::ActiveModel { id: Set(id.clone()), spec: Set(serde_json::to_string(feed)?) }
.insert(&tx)
.await?;
catalogue::ActiveModel { id: Set(id.clone()), spec: Set(serde_json::to_string(feed)?) }.insert(&tx).await?;
}
tx.commit().await?;
Ok(())
@@ -1583,10 +1566,7 @@ impl Db {
/// What this person wants from a feed. Absent means they do not subscribe at all.
pub async fn subscription(&self, user_id: i64, feed_id: &str) -> Result<Option<Sub>> {
Ok(subscriptions::Entity::find_by_id((user_id, feed_id.to_owned()))
.one(&self.orm)
.await?
.map(Sub::from))
Ok(subscriptions::Entity::find_by_id((user_id, feed_id.to_owned())).one(&self.orm).await?.map(Sub::from))
}
/// Every feed this person subscribes to, with their settings.
@@ -1651,14 +1631,11 @@ impl Db {
/// Feeds with any audio or video enclosure: the Directory's Podcasts, with the rest Blogs.
/// Reaped files keep their rows, so a show whose files have all been purged still counts.
pub async fn media_feeds(&self) -> Result<std::collections::HashSet<String>> {
self.rows(
"SELECT DISTINCT feed_id FROM enclosures WHERE mime LIKE 'audio/%' OR mime LIKE 'video/%'",
vec![],
)
.await?
.iter()
.map(|r| Ok(r.try_get("", "feed_id")?))
.collect()
self.rows("SELECT DISTINCT feed_id FROM enclosures WHERE mime LIKE 'audio/%' OR mime LIKE 'video/%'", vec![])
.await?
.iter()
.map(|r| Ok(r.try_get("", "feed_id")?))
.collect()
}
/// Who else would miss this file: subscribers other than `user_id` who have starred
@@ -1769,14 +1746,13 @@ impl Db {
.iter()
.flat_map(|e| {
let hay = [e.title.as_deref().unwrap_or(""), e.description.as_deref().unwrap_or("")];
lists
.iter()
.filter(move |(_, words)| crate::download::blocked(words, &hay))
.map(|(user, _)| hidden::ActiveModel {
lists.iter().filter(move |(_, words)| crate::download::blocked(words, &hay)).map(|(user, _)| {
hidden::ActiveModel {
user_id: Set(*user),
feed_id: Set(feed_id.to_owned()),
guid: Set(e.guid.clone()),
})
}
})
})
.collect();
if !rows.is_empty() {
@@ -1925,17 +1901,12 @@ impl Db {
/// Sets some of one person's columns, whatever else is in their row.
async fn update_user(&self, id: i64, m: users::ActiveModel) -> Result<()> {
users::Entity::update_many()
.set(m)
.filter(users::Column::Id.eq(id))
.exec(&self.orm)
.await?;
users::Entity::update_many().set(m).filter(users::Column::Id.eq(id)).exec(&self.orm).await?;
Ok(())
}
pub async fn set_password(&self, id: i64, hash: &str) -> Result<()> {
self.update_user(id, users::ActiveModel { pass_hash: Set(Some(hash.to_owned())), ..Default::default() })
.await
self.update_user(id, users::ActiveModel { pass_hash: Set(Some(hash.to_owned())), ..Default::default() }).await
}
pub async fn set_admin(&self, id: i64, admin: bool) -> Result<()> {
@@ -2233,11 +2204,8 @@ impl Db {
#[tracing::instrument(skip_all)]
pub async fn adopt(&self, parent: &str, child: &str, listed: &[(&str, &str)]) -> Result<()> {
use sea_orm::TransactionTrait;
let holds = enclosures::Entity::find()
.filter(enclosures::Column::FeedId.eq(parent))
.count(&self.orm)
.await?
> 0;
let holds =
enclosures::Entity::find().filter(enclosures::Column::FeedId.eq(parent)).count(&self.orm).await? > 0;
if !holds {
return Ok(()); // An OPML, or a creator already shared out.
}
@@ -2432,7 +2400,12 @@ impl Db {
.await?
.iter()
.map(|r| {
Ok((r.try_get("", "user_id")?, r.try_get("", "feed_id")?, r.try_get("", "wanted")?, r.try_get("", "pause_ms")?))
Ok((
r.try_get("", "user_id")?,
r.try_get("", "feed_id")?,
r.try_get("", "wanted")?,
r.try_get("", "pause_ms")?,
))
})
.collect()
}
@@ -2646,7 +2619,6 @@ pub struct Unjudged {
pub signals: Vec<Ask>,
}
impl Db {
pub async fn signals(&self, user_id: i64) -> Result<Vec<SignalRow>> {
self.signal_rows(user_id, None).await
@@ -2695,7 +2667,13 @@ impl Db {
.collect()
}
pub async fn add_signal(&self, user_id: i64, name: &str, words: &[String], description: Option<&str>) -> Result<i64> {
pub async fn add_signal(
&self,
user_id: i64,
name: &str,
words: &[String],
description: Option<&str>,
) -> Result<i64> {
let m = signals::ActiveModel {
user_id: Set(user_id),
name: Set(name.to_owned()),
@@ -2760,8 +2738,11 @@ impl Db {
}
pub async fn set_typesafe_key(&self, user_id: i64, key: Option<&str>) -> Result<()> {
self.update_user(user_id, users::ActiveModel { typesafe_key: Set(key.map(str::to_owned)), ..Default::default() })
.await
self.update_user(
user_id,
users::ActiveModel { typesafe_key: Set(key.map(str::to_owned)), ..Default::default() },
)
.await
}
/// Up to `limit` items, newest first, that someone with a key has a Signal to ask about: not
@@ -2804,7 +2785,9 @@ impl Db {
"JOIN signal_verdicts v ON v.signal_id = g.id AND v.feed_id = s.feed_id AND v.recheck
JOIN entries e ON e.feed_id = v.feed_id AND e.guid = v.guid",
);
let sql = format!("SELECT * FROM ({recent} UNION ALL {again}) q ORDER BY at DESC, feed_id, guid, user_id LIMIT {limit}");
let sql = format!(
"SELECT * FROM ({recent} UNION ALL {again}) q ORDER BY at DESC, feed_id, guid, user_id LIMIT {limit}"
);
let mut out: Vec<Unjudged> = Vec::new();
for r in self.rows(&sql, a.0).await? {
let (user_id, feed_id, guid): (i64, String, String) =
@@ -2853,10 +2836,7 @@ impl Db {
}
pub fn now() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0)
}
#[cfg(test)]
@@ -2889,9 +2869,15 @@ mod tests {
db.store_plans(1, r#"{"300": {"cuts": [[1000,3000]], "pieces": [[0,10,1.0]]}}"#).await.unwrap();
assert!(db.deadair_todo().await.unwrap().is_empty());
let p = db.plans(&[1, 2]).await.unwrap();
assert_eq!((p.len(), p[&1][&300].cuts.as_slice(), p[&1][&300].saved_ms()), (1, &[crate::deadair::Cut(1000, 3000)][..], 2000));
assert_eq!(
(p.len(), p[&1][&300].cuts.as_slice(), p[&1][&300].saved_ms()),
(1, &[crate::deadair::Cut(1000, 3000)][..], 2000)
);
// Downloaded again since: looked at again before it plays shortened.
db.orm.execute_unprepared(&format!("UPDATE enclosures SET downloaded_at = {} WHERE id = 1", now() + 60)).await.unwrap();
db.orm
.execute_unprepared(&format!("UPDATE enclosures SET downloaded_at = {} WHERE id = 1", now() + 60))
.await
.unwrap();
assert_eq!(db.deadair_todo().await.unwrap().len(), 1);
assert!(db.plans(&[1]).await.unwrap().is_empty());
@@ -2929,7 +2915,9 @@ mod tests {
// And writers at once all get their turn, not a refusal.
let writes = (0..16).map(|i| {
let db = &db;
async move { db.record_feed(&format!("f{i}"), "http://x/f", Some("F"), None, None, None, None, None, None).await }
async move {
db.record_feed(&format!("f{i}"), "http://x/f", Some("F"), None, None, None, None, None, None).await
}
});
for r in futures_util::future::join_all(writes).await {
r.unwrap();
@@ -2942,10 +2930,18 @@ mod tests {
let backend = db.orm.get_database_backend();
if backend == sea_orm::DbBackend::Sqlite {
let row = db.orm.query_one_raw(Statement::from_string(backend, "PRAGMA synchronous")).await.unwrap();
assert_eq!(row.unwrap().try_get_by_index::<i32>(0).unwrap(), 1, "NORMAL, on every connection in the pool (#135)");
assert_eq!(
row.unwrap().try_get_by_index::<i32>(0).unwrap(),
1,
"NORMAL, on every connection in the pool (#135)"
);
} else {
let row = db.orm.query_one_raw(Statement::from_string(backend, "SHOW synchronous_commit")).await.unwrap();
assert_eq!(row.unwrap().try_get_by_index::<String>(0).unwrap(), "off", "with the test's own search_path as well (#140)");
assert_eq!(
row.unwrap().try_get_by_index::<String>(0).unwrap(),
"off",
"with the test's own search_path as well (#140)"
);
}
}
@@ -2954,7 +2950,11 @@ mod tests {
let db = Db::memory().await.unwrap();
let record = |etag: &'static str, about: Option<&'static str>| {
let db = &db;
async move { db.record_feed("f", "https://x/f", Some("F"), Some(etag), Some("then"), None, None, None, about).await.unwrap() }
async move {
db.record_feed("f", "https://x/f", Some("F"), Some(etag), Some("then"), None, None, None, about)
.await
.unwrap()
}
};
record("\"a\"", Some("About F")).await;
assert_eq!(db.feed_description("f").await.unwrap().as_deref(), Some("About F"));
@@ -2965,7 +2965,10 @@ mod tests {
create_missing(&db.orm).await.unwrap();
assert_eq!(db.feed_description("f").await.unwrap(), None);
let st = db.http_state("f").await.unwrap();
assert!(st.etag.is_none() && st.last_modified.is_none(), "every feed is read whole next time, for its description");
assert!(
st.etag.is_none() && st.last_modified.is_none(),
"every feed is read whole next time, for its description"
);
// With the column there, opening the database again writes nothing.
record("\"b\"", Some("About F")).await;
create_missing(&db.orm).await.unwrap();
@@ -3001,10 +3004,12 @@ mod tests {
INSERT INTO enclosures (id, feed_id, guid, url, mime, length, state) VALUES
(1,'f','a','u1','audio/mpeg',300,'pending'),(2,'g','b','u2','image/png',10,'pending'),
(3,'f','c','u3','video/mp4',2000,'pending');",
).await
)
.await
.unwrap();
let order = async |col: &str, dir: &str| -> Vec<String> {
db.entries_in(1, None, Filter::All, None, 0, 50, &order_sql(col, dir)).await
db.entries_in(1, None, Filter::All, None, 0, 50, &order_sql(col, dir))
.await
.unwrap()
.into_iter()
.map(|e| e.guid)
@@ -3025,8 +3030,13 @@ mod tests {
db.exec_for_test("DELETE FROM entries WHERE guid = 'n';").await.unwrap();
assert_eq!(order("published", "desc").await, ["c", "b", "a"]);
// A page from the middle: the keys are paged before the rows are read (#188).
let page: Vec<String> = db.entries_in(1, None, Filter::All, None, 1, 1, &order_sql("published", "desc")).await
.unwrap().into_iter().map(|e| e.guid).collect();
let page: Vec<String> = db
.entries_in(1, None, Filter::All, None, 1, 1, &order_sql("published", "desc"))
.await
.unwrap()
.into_iter()
.map(|e| e.guid)
.collect();
assert_eq!(page, ["b"]);
db.set_entry_flag(1, "f", "a", EntryFlag::Flagged, true).await.unwrap();
assert_eq!(order("kept", "desc").await[0], "a");
@@ -3055,7 +3065,10 @@ mod tests {
let list = db.feed_list(1, None).await.unwrap();
let g = db.feed_list(1, Some("g")).await.unwrap();
assert_eq!(g.keys().collect::<Vec<_>>(), ["g"]);
assert_eq!((g["g"].unread, g["g"].summary.entries, g["g"].blocked.clone()), (1, 1, vec!["spoiler".to_string()]));
assert_eq!(
(g["g"].unread, g["g"].summary.entries, g["g"].blocked.clone()),
(1, 1, vec!["spoiler".to_string()])
);
for id in ["f", "g"] {
let one = &list[id];
let s = db.feed_summary(id).await.unwrap();
@@ -3084,8 +3097,8 @@ mod tests {
db.secure_images("f", "a.example").await.unwrap();
assert_eq!(db.feed_summary("f").await.unwrap().image.as_deref(), Some("https://a.example/logo.png"));
assert!(db.names_image("https://a.example/1.jpg").await.unwrap());
assert!(db.names_image("http://b.example/2.jpg").await.unwrap()); // another host
assert!(db.names_image("http://a.example/3.jpg").await.unwrap()); // another feed
assert!(db.names_image("http://b.example/2.jpg").await.unwrap()); // another host
assert!(db.names_image("http://a.example/3.jpg").await.unwrap()); // another feed
assert_eq!(db.feed_summary("g").await.unwrap().image.as_deref(), Some("http://a.example/g.png"));
}
@@ -3102,7 +3115,8 @@ mod tests {
INSERT INTO entries (feed_id, guid, first_seen, published) VALUES
('quiet','q',0,100),('news','n',0,950),('dead','d',0,950),('kept','k',0,100);
INSERT INTO enclosures (id, feed_id, guid, url, path, state) VALUES (1,'kept','k','u','/tmp/k','done');",
).await
)
.await
.unwrap();
// Dead before 500, or nothing newer than 500: dead fails since 100, quiet's newest is 100.
// news published lately, new has no items yet, wanted has a subscriber, kept a file,
@@ -3128,7 +3142,8 @@ mod tests {
INSERT INTO entries (feed_id, guid, first_seen) VALUES ('gone','a',0),('kept','b',0);
INSERT INTO enclosures (id, feed_id, guid, url, path, state) VALUES
(1,'gone','a','u1',null,'pending'),(2,'kept','b','u2','/tmp/b','done');",
).await
)
.await
.unwrap();
// Subscribed, holding a file, working, or in the catalogue: all stay.
assert_eq!(db.prune_abandoned_failures().await.unwrap(), vec!["gone".to_string()]);
@@ -3147,21 +3162,27 @@ mod tests {
INSERT INTO enclosures (id, feed_id, guid, url, state, path) VALUES
(1,'f','e1','u1','pending',null),(2,'f','e2','u2','pending',null),
(3,'f','e3','u3','done','/tmp/3'),(4,'f','e4','u4','pending',null);",
).await
)
.await
.unwrap();
let states = || async {
let mut v: Vec<(i64, String)> = db.rows("SELECT id, state FROM enclosures ORDER BY id", vec![]).await.unwrap()
.iter().map(|r| (r.try_get("", "id").unwrap(), r.try_get("", "state").unwrap())).collect();
let mut v: Vec<(i64, String)> = db
.rows("SELECT id, state FROM enclosures ORDER BY id", vec![])
.await
.unwrap()
.iter()
.map(|r| (r.try_get("", "id").unwrap(), r.try_get("", "state").unwrap()))
.collect();
v.sort();
v.into_iter().map(|(_, s)| s).collect::<Vec<_>>()
};
db.hold_back("f", 2).await.unwrap(); // newest two: e4, e3
db.hold_back("f", 2).await.unwrap(); // newest two: e4, e3
assert_eq!(states().await, ["held", "held", "done", "pending"]);
db.hold_back("f", 3).await.unwrap(); // a limit raised brings e2 back
db.hold_back("f", 3).await.unwrap(); // a limit raised brings e2 back
assert_eq!(states().await, ["held", "pending", "done", "pending"]);
db.hold_back("f", 0).await.unwrap(); // a feed nobody downloads: all held
db.hold_back("f", 0).await.unwrap(); // a feed nobody downloads: all held
assert_eq!(states().await, ["held", "held", "done", "held"]);
db.hold_back("f", usize::MAX).await.unwrap(); // unlimited, an archive: all queued
db.hold_back("f", usize::MAX).await.unwrap(); // unlimited, an archive: all queued
assert_eq!(states().await, ["pending", "pending", "done", "pending"]);
}
@@ -3175,7 +3196,8 @@ mod tests {
(1,'f','e1','u1','pending',null),(2,'f','e2','u2','pending',null),
(3,'f','e3','u3','done','/tmp/3'),(4,'f','e4','u4','done','/tmp/4'),
(5,'f','e5','u5','skipped',null);",
).await
)
.await
.unwrap();
let ids = |p: Vec<Pending>| p.into_iter().map(|p| p.id).collect::<Vec<_>>();
// The newest three with a file worth having are e4, e3 and e2 (e5's was skipped): only
@@ -3225,12 +3247,15 @@ mod tests {
db.record_entry("f", &item("b", "B", Some("the next review"))).await.unwrap();
db.record_entry("f", &item("c", "C", Some("as it was"))).await.unwrap();
let changed = db
.changed_items("f", &[
item("a", "A", Some("the review, fixed")), // the text corrected
item("b", "B renamed", Some("the next review")), // retitled
item("c", "C", None), // says nothing of its text: what is stored stands
item("d", "D", Some("new")), // not stored: the insert's, not this
])
.changed_items(
"f",
&[
item("a", "A", Some("the review, fixed")), // the text corrected
item("b", "B renamed", Some("the next review")), // retitled
item("c", "C", None), // says nothing of its text: what is stored stands
item("d", "D", Some("new")), // not stored: the insert's, not this
],
)
.await
.unwrap();
assert_eq!(changed, ["a", "b"].map(String::from).into());
@@ -3243,7 +3268,8 @@ mod tests {
db.exec_for_test(
"INSERT INTO feeds (id, url, image) VALUES ('f','u','http://cdn.example/show.jpg');
INSERT INTO entries (feed_id, guid, first_seen, image) VALUES ('f','a',0,'http://cdn.example/ep.jpg');",
).await
)
.await
.unwrap();
assert!(db.names_image("http://cdn.example/show.jpg").await.unwrap());
assert!(db.names_image("http://cdn.example/ep.jpg").await.unwrap());
@@ -3259,7 +3285,8 @@ mod tests {
INSERT INTO entries (feed_id, guid, first_seen) VALUES ('f','a',0);
INSERT INTO enclosures (id, feed_id, guid, url, path, state) VALUES
(1,'f','a','u1','/tmp/a','done');",
).await
)
.await
.unwrap();
// Nobody has touched it: both others still have it unplayed.
@@ -3283,7 +3310,8 @@ mod tests {
"INSERT INTO users (id, name, is_admin) VALUES (1,'ray',true),(2,'sam',false);
INSERT INTO entries (feed_id, guid, title, first_seen) VALUES
('f','a','One',100),('f','b','Two',200);",
).await
)
.await
.unwrap();
assert_eq!(db.unread_count(1, "f").await.unwrap(), 2);
@@ -3362,8 +3390,7 @@ mod tests {
// Cutting this along with the dead read columns left the browser suite's admin with an
// empty sidebar: it is how a fresh install's first account gets config.toml's feeds.
let db = Db::memory().await.unwrap();
db.exec_for_test("INSERT INTO users (id, name, is_admin) VALUES (1,'admin',true);").await
.unwrap();
db.exec_for_test("INSERT INTO users (id, name, is_admin) VALUES (1,'admin',true);").await.unwrap();
let subs = async || db.i64s_for_test("SELECT count(*) FROM subscriptions").await[0];
let catalogue = ["a".to_string(), "b".to_string()];
assert_eq!(db.adopt_catalogue(1, &catalogue).await.unwrap(), 2);
@@ -3405,7 +3432,8 @@ mod tests {
-- Opened, and so read, but 42 of 900 seconds in: still Currently Listening.
-- Filtering on read hid exactly these (issue #14).
(7,'f','h',true,false,42);",
).await
)
.await
.unwrap();
for f in [Filter::All, Filter::Unread, Filter::Downloaded, Filter::Flagged, Filter::InProgress] {
@@ -3425,8 +3453,11 @@ mod tests {
assert_eq!(db.count_in(7, Some("f"), Filter::Downloaded, None).await.unwrap(), 1);
assert_eq!(db.count_in(7, Some("f"), Filter::Flagged, None).await.unwrap(), 1);
assert_eq!(db.count_in(7, Some("f"), Filter::All, Some("dive")).await.unwrap(), 2);
assert_eq!(db.count_in(7, Some("f"), Filter::All, Some("NOTES two")).await.unwrap(), 1,
"search is case-insensitive and covers the description");
assert_eq!(
db.count_in(7, Some("f"), Filter::All, Some("NOTES two")).await.unwrap(),
1,
"search is case-insensitive and covers the description"
);
// Currently Listening: started, not finished, and not just an accidental tap.
let order = order_sql("published", "desc");
@@ -3473,14 +3504,26 @@ mod tests {
let everyone = db.unjudged(&[], 50, true, 30).await.unwrap();
let mut seen: Vec<_> = everyone.iter().map(|u| (u.user_id, u.guid.as_str(), u.key.is_none())).collect();
seen.sort();
assert_eq!(seen, [(1, "x", true), (1, "y", true), (2, "x", true)], "on the server's key, everyone's Signals, no key of their own needed");
assert_eq!(
seen,
[(1, "x", true), (1, "y", true), (2, "x", true)],
"on the server's key, everyone's Signals, no key of their own needed"
);
db.set_typesafe_key(1, Some("k")).await.unwrap();
let queue = db.unjudged(&[], 50, false, 30).await.unwrap();
let mut seen: Vec<_> = queue.iter().map(|u| (u.guid.as_str(), u.signals.len())).collect();
seen.sort();
assert_eq!(seen, [("x", 2), ("y", 2)], "each recent subscribed item once, with both Signals; old and unsubscribed left out");
assert_eq!(
seen,
[("x", 2), ("y", 2)],
"each recent subscribed item once, with both Signals; old and unsubscribed left out"
);
let ask = queue[0].signals.iter().find(|a| a.id == food).unwrap();
assert_eq!((ask.name.as_str(), ask.description.as_deref()), ("Food", Some("Bread and cakes")), "Jev is asked about the name and the sentence");
assert_eq!(
(ask.name.as_str(), ask.description.as_deref()),
("Food", Some("Bread and cakes")),
"Jev is asked about the name and the sentence"
);
assert!(db.unjudged(&[1], 50, false, 30).await.unwrap().is_empty(), "a refused key is passed over");
db.record_verdicts("f", "x", &[(f1, true), (food, false)]).await.unwrap();
@@ -3491,10 +3534,15 @@ mod tests {
let page = db.signal_entries(1, f1, Filter::All, None, 0, 50, &order).await.unwrap();
assert_eq!(page.iter().map(|e| e.guid.as_str()).collect::<Vec<_>>(), ["x"]);
assert_eq!(db.signal_count(1, food, Filter::All, None).await.unwrap(), 1);
assert_eq!(db.signals(1).await.unwrap().iter().map(|s| (s.items, s.unread)).collect::<Vec<_>>(), [(1, 1), (1, 1)]);
db.exec_for_test("INSERT INTO entry_state (user_id, feed_id, guid, read, flagged, position) VALUES (1,'f','x',true,false,0)")
.await
.unwrap();
assert_eq!(
db.signals(1).await.unwrap().iter().map(|s| (s.items, s.unread)).collect::<Vec<_>>(),
[(1, 1), (1, 1)]
);
db.exec_for_test(
"INSERT INTO entry_state (user_id, feed_id, guid, read, flagged, position) VALUES (1,'f','x',true,false,0)",
)
.await
.unwrap();
let f = db.signal(1, f1).await.unwrap().unwrap();
assert_eq!((f.items, f.unread), (1, 0), "read in its feed is read in the Signal, and still one of its items");
@@ -3505,11 +3553,22 @@ mod tests {
// An edit asks again only what was picked out, however old, and leaves what was passed
// over (#276). Until answered, what was picked out stays in the list.
db.record_verdicts("g", "old", &[(f1, true)]).await.unwrap();
db.set_signal(1, f1, SignalChange { description: Some(Some("Grand prix racing")), ..Default::default() }).await.unwrap();
db.set_signal(1, f1, SignalChange { description: Some(Some("Grand prix racing")), ..Default::default() })
.await
.unwrap();
assert_eq!(db.signal(1, f1).await.unwrap().unwrap().description.as_deref(), Some("Grand prix racing"));
let again: Vec<_> = db.unjudged(&[], 50, false, 30).await.unwrap().iter()
.map(|u| (u.guid.clone(), u.signals.iter().map(|a| a.id).collect::<Vec<_>>())).collect();
assert_eq!(again, [("x".to_string(), vec![f1]), ("old".to_string(), vec![f1])], "y was passed over, and stays so");
let again: Vec<_> = db
.unjudged(&[], 50, false, 30)
.await
.unwrap()
.iter()
.map(|u| (u.guid.clone(), u.signals.iter().map(|a| a.id).collect::<Vec<_>>()))
.collect();
assert_eq!(
again,
[("x".to_string(), vec![f1]), ("old".to_string(), vec![f1])],
"y was passed over, and stays so"
);
assert_eq!(db.signal_count(1, f1, Filter::All, None).await.unwrap(), 2);
db.record_verdicts("f", "x", &[(f1, false)]).await.unwrap();
db.record_verdicts("g", "old", &[(f1, true)]).await.unwrap();
@@ -3518,7 +3577,9 @@ mod tests {
assert!(db.unjudged(&[], 50, false, 1).await.unwrap().is_empty());
db.record_verdicts("f", "x", &[(f1, true)]).await.unwrap();
db.set_signal(1, f1, SignalChange { icon: Some(Some("solid/flag-checkered")), ..Default::default() }).await.unwrap();
db.set_signal(1, f1, SignalChange { icon: Some(Some("solid/flag-checkered")), ..Default::default() })
.await
.unwrap();
assert_eq!(db.signal_count(1, f1, Filter::All, None).await.unwrap(), 2, "x and old, kept");
assert!(db.unjudged(&[], 50, false, 30).await.unwrap().is_empty(), "a new icon asks nothing again");
assert_eq!(db.signal(1, f1).await.unwrap().unwrap().icon.as_deref(), Some("solid/flag-checkered"));
@@ -3574,7 +3635,8 @@ mod tests {
(1,'f','old','u-old','pending'),
(2,'f','mid','u-mid','pending'),
(3,'f','new','u-new','pending');",
).await
)
.await
.unwrap();
let got: Vec<String> = db.pending("f", 2).await.unwrap().into_iter().map(|p| p.url).collect();
assert_eq!(got, vec!["u-new", "u-mid"], "newest first, oldest left for later");
@@ -3589,7 +3651,8 @@ mod tests {
(2,'f','b','u2','pending',NULL),
(3,'f','c','u3','downloading','/tmp/already-here'),
(4,'f','d','u4','done','/tmp/x');",
).await
)
.await
.unwrap();
assert_eq!(db.requeue_interrupted().await.unwrap(), 1, "only the in-flight, fileless one");
let state = async |id: i64| db.strings_for_test(&format!("SELECT state FROM enclosures WHERE id = {id}")).await;
@@ -3609,10 +3672,12 @@ mod tests {
(3,'creator','c','u3','skipped',NULL,'explicit'),
(4,'other','d','u4','skipped',NULL,'torrents disabled');
INSERT INTO entry_state (user_id, feed_id, guid, read) VALUES (1,'creator','a',true);",
).await
)
.await
.unwrap();
db.adopt("creator", "show", &[("a", "u1"), ("b", "u2"), ("d", "u4")]).await.unwrap();
let owner = async |id: i64| db.strings_for_test(&format!("SELECT feed_id FROM enclosures WHERE id = {id}")).await;
let owner =
async |id: i64| db.strings_for_test(&format!("SELECT feed_id FROM enclosures WHERE id = {id}")).await;
assert_eq!(owner(1).await, ["show"], "a downloaded file moves with its item");
assert_eq!(owner(2).await, ["show"]);
assert_eq!(owner(3).await, ["creator"], "this show does not list it");
@@ -3637,7 +3702,8 @@ mod tests {
"INSERT INTO users (id, name, is_admin) VALUES (1,'ray',true),(2,'sam',false);
INSERT INTO subscriptions (user_id, feed_id, allow_explicit) VALUES
(1,'group',true),(1,'show',NULL),(2,'group',true),(2,'show',false);",
).await
)
.await
.unwrap();
let explicit = async |group| -> Vec<Option<bool>> {
let mut v: Vec<_> =
@@ -3645,7 +3711,11 @@ mod tests {
v.sort();
v
};
assert_eq!(explicit(Some("group")).await, [Some(false), Some(true)], "ray inherits; sam's own choice on the show wins");
assert_eq!(
explicit(Some("group")).await,
[Some(false), Some(true)],
"ray inherits; sam's own choice on the show wins"
);
assert_eq!(explicit(None).await, [None, Some(false)], "outside a group nothing is inherited");
}
@@ -3677,7 +3747,8 @@ mod tests {
db.subscribe(me, "f").await.unwrap();
assert!(db.set_pinned(me, "f", true).await.unwrap());
// set_subscription writes the rest of the row; it must leave the pin alone.
db.set_subscription(me, &Sub { feed_id: "f".into(), auto_download: Some(false), ..Default::default() }).await
db.set_subscription(me, &Sub { feed_id: "f".into(), auto_download: Some(false), ..Default::default() })
.await
.unwrap();
assert!(db.pinned_feeds(me).await.unwrap().contains("f"));
db.set_pinned(me, "f", false).await.unwrap();
@@ -3687,8 +3758,15 @@ mod tests {
#[test]
fn postgres_is_asked_for_no_notices_and_a_password_never_reaches_a_log() {
assert_eq!(url_for("postgres://u:p@h/d"), format!("postgres://u:p@h/d?{QUIET}"));
assert_eq!(url_for("postgres://u:p@h/d?sslmode=disable"), format!("postgres://u:p@h/d?sslmode=disable&{QUIET}"));
assert_eq!(url_for("postgres://u:p@h/d?options=-c%20x%3Dy"), "postgres://u:p@h/d?options=-c%20x%3Dy", "theirs stands");
assert_eq!(
url_for("postgres://u:p@h/d?sslmode=disable"),
format!("postgres://u:p@h/d?sslmode=disable&{QUIET}")
);
assert_eq!(
url_for("postgres://u:p@h/d?options=-c%20x%3Dy"),
"postgres://u:p@h/d?options=-c%20x%3Dy",
"theirs stands"
);
assert_eq!(url_for("/data/state.db"), "sqlite:///data/state.db?mode=rwc");
assert_eq!(redact("postgres://ipodderx:s3cret@h:5433/ipodderx"), "postgres://ipodderx:***@h:5433/ipodderx");
}
@@ -3732,7 +3810,8 @@ mod tests {
#[tokio::test]
async fn enclosure_url_is_the_dedupe_key() {
let db = Db::memory().await.unwrap();
let insert = "INSERT INTO enclosures (feed_id, guid, url, state) VALUES ('f', 'g', 'http://x/a.mp3', 'pending')";
let insert =
"INSERT INTO enclosures (feed_id, guid, url, state) VALUES ('f', 'g', 'http://x/a.mp3', 'pending')";
db.exec_for_test(insert).await.unwrap();
assert!(db.exec_for_test(insert).await.is_err(), "duplicate url must be rejected");
}