personal activity index (bluesky, leaflet, substack) pai.desertthunder.dev
rss bluesky
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at cde71d02b2d701b87d9829f9814ff3f60b5d40b2 711 lines 23 kB view raw
1use pai_core::{Item, ListFilter, SourceKind}; 2use serde::{Deserialize, Serialize}; 3use wasm_bindgen::JsValue; 4use worker::*; 5 6#[derive(Deserialize)] 7struct SyncConfig { 8 substack: Option<SubstackConfig>, 9 bluesky: Option<BlueskyConfig>, 10 leaflet: Vec<LeafletConfig>, 11 bearblog: Vec<BearBlogConfig>, 12} 13 14#[derive(Deserialize)] 15struct SubstackConfig { 16 base_url: String, 17} 18 19#[derive(Deserialize)] 20struct BlueskyConfig { 21 handle: String, 22} 23 24#[derive(Deserialize)] 25struct LeafletConfig { 26 id: String, 27 base_url: String, 28} 29 30#[derive(Deserialize)] 31struct BearBlogConfig { 32 id: String, 33 base_url: String, 34} 35 36#[derive(Deserialize)] 37struct FeedParams { 38 source_kind: Option<SourceKind>, 39 source_id: Option<String>, 40 limit: Option<usize>, 41 since: Option<String>, 42 q: Option<String>, 43} 44 45#[derive(Serialize)] 46struct FeedResponse { 47 items: Vec<Item>, 48} 49 50#[derive(Serialize)] 51struct StatusResponse { 52 status: &'static str, 53 version: &'static str, 54} 55 56#[event(fetch)] 57async fn fetch(req: Request, env: Env, _ctx: Context) -> Result<Response> { 58 let router = Router::new(); 59 router 60 .get_async("/api/feed", |req, ctx| async move { handle_feed(req, ctx).await }) 61 .get_async("/api/item/:id", |_req, ctx| async move { 62 let id = ctx 63 .param("id") 64 .ok_or_else(|| Error::RustError("Missing id parameter".into()))?; 65 handle_item(id, &ctx).await 66 }) 67 .get("/status", |_req, _ctx| { 68 let version = env!("CARGO_PKG_VERSION"); 69 let status = StatusResponse { status: "ok", version }; 70 Response::from_json(&status) 71 }) 72 .run(req, env) 73 .await 74} 75 76#[event(scheduled)] 77async fn scheduled(_event: ScheduledEvent, env: Env, _ctx: ScheduleContext) { 78 if let Err(e) = run_sync(&env).await { 79 console_error!("Scheduled sync failed: {}", e); 80 } 81} 82 83async fn handle_feed(req: Request, ctx: RouteContext<()>) -> Result<Response> { 84 let url = req.url()?; 85 let params: FeedParams = serde_urlencoded::from_str(url.query().unwrap_or("")) 86 .map_err(|e| Error::RustError(format!("Invalid query parameters: {e}")))?; 87 88 let filter = ListFilter { 89 source_kind: params.source_kind, 90 source_id: params.source_id, 91 limit: Some(params.limit.unwrap_or(20)), 92 since: params.since, 93 query: params.q, 94 }; 95 96 let db = ctx.env.d1("DB")?; 97 let items = query_items(&db, &filter).await?; 98 99 let response = FeedResponse { items }; 100 Response::from_json(&response) 101} 102 103async fn handle_item(id: &str, ctx: &RouteContext<()>) -> Result<Response> { 104 let db = ctx.env.d1("DB")?; 105 let stmt = db.prepare("SELECT * FROM items WHERE id = ?1").bind(&[id.into()])?; 106 107 let result = stmt.first::<Item>(None).await?; 108 109 match result { 110 Some(item) => Response::from_json(&item), 111 None => Response::error("Item not found", 404), 112 } 113} 114 115async fn query_items(db: &D1Database, filter: &ListFilter) -> Result<Vec<Item>> { 116 let mut query = String::from( 117 "SELECT id, source_kind, source_id, author, title, summary, url, content_html, published_at, created_at FROM items WHERE 1=1" 118 ); 119 let mut bindings = vec![]; 120 121 if let Some(kind) = filter.source_kind { 122 query.push_str(" AND source_kind = ?"); 123 bindings.push(kind.to_string().into()); 124 } 125 126 if let Some(ref source_id) = filter.source_id { 127 query.push_str(" AND source_id = ?"); 128 bindings.push(source_id.clone().into()); 129 } 130 131 if let Some(ref since) = filter.since { 132 query.push_str(" AND published_at >= ?"); 133 bindings.push(since.clone().into()); 134 } 135 136 if let Some(ref q) = filter.query { 137 query.push_str(" AND (title LIKE ? OR summary LIKE ?)"); 138 let pattern = format!("%{q}%"); 139 bindings.push(pattern.clone().into()); 140 bindings.push(pattern.into()); 141 } 142 143 query.push_str(" ORDER BY published_at DESC"); 144 145 if let Some(limit) = filter.limit { 146 query.push_str(" LIMIT ?"); 147 bindings.push((limit as i64).into()); 148 } 149 150 let mut stmt = db.prepare(&query); 151 for binding in bindings { 152 stmt = stmt.bind(&[binding])?; 153 } 154 155 let results = stmt.all().await?; 156 let items: Vec<Item> = results.results()?; 157 158 Ok(items) 159} 160 161async fn run_sync(env: &Env) -> Result<()> { 162 let config = load_sync_config(env)?; 163 164 let db = env.d1("DB")?; 165 let mut synced = 0; 166 167 if let Some(substack_config) = config.substack { 168 match sync_substack(&substack_config, &db).await { 169 Ok(count) => { 170 console_log!("Synced {} items from Substack", count); 171 synced += count; 172 } 173 Err(e) => console_error!("Substack sync failed: {}", e), 174 } 175 } 176 177 if let Some(bluesky_config) = config.bluesky { 178 match sync_bluesky(&bluesky_config, &db).await { 179 Ok(count) => { 180 console_log!("Synced {} items from Bluesky", count); 181 synced += count; 182 } 183 Err(e) => console_error!("Bluesky sync failed: {}", e), 184 } 185 } 186 187 for leaflet_config in config.leaflet { 188 match sync_leaflet(&leaflet_config, &db).await { 189 Ok(count) => { 190 console_log!("Synced {} items from Leaflet ({})", count, leaflet_config.id); 191 synced += count; 192 } 193 Err(e) => console_error!("Leaflet sync failed for {}: {}", leaflet_config.id, e), 194 } 195 } 196 197 for bearblog_config in config.bearblog { 198 match sync_bearblog(&bearblog_config, &db).await { 199 Ok(count) => { 200 console_log!("Synced {} items from BearBlog ({})", count, bearblog_config.id); 201 synced += count; 202 } 203 Err(e) => console_error!("BearBlog sync failed for {}: {}", bearblog_config.id, e), 204 } 205 } 206 207 console_log!("Sync completed: {} total items", synced); 208 Ok(()) 209} 210 211fn load_sync_config(env: &Env) -> Result<SyncConfig> { 212 let substack = env 213 .var("SUBSTACK_URL") 214 .ok() 215 .map(|url| SubstackConfig { base_url: url.to_string() }); 216 217 let bluesky = env 218 .var("BLUESKY_HANDLE") 219 .ok() 220 .map(|handle| BlueskyConfig { handle: handle.to_string() }); 221 222 let leaflet = if let Ok(urls) = env.var("LEAFLET_URLS") { 223 urls.to_string() 224 .split(',') 225 .filter_map(|entry| { 226 let parts: Vec<&str> = entry.trim().splitn(2, ':').collect(); 227 if parts.len() == 2 { 228 Some(LeafletConfig { id: parts[0].to_string(), base_url: parts[1].to_string() }) 229 } else { 230 None 231 } 232 }) 233 .collect() 234 } else { 235 Vec::new() 236 }; 237 238 let bearblog = if let Ok(urls) = env.var("BEARBLOG_URLS") { 239 urls.to_string() 240 .split(',') 241 .filter_map(|entry| { 242 let parts: Vec<&str> = entry.trim().splitn(2, ':').collect(); 243 if parts.len() == 2 { 244 Some(BearBlogConfig { id: parts[0].to_string(), base_url: parts[1].to_string() }) 245 } else { 246 None 247 } 248 }) 249 .collect() 250 } else { 251 Vec::new() 252 }; 253 254 Ok(SyncConfig { substack, bluesky, leaflet, bearblog }) 255} 256 257async fn sync_substack(config: &SubstackConfig, db: &D1Database) -> Result<usize> { 258 let feed_url = format!("{}/feed", config.base_url); 259 260 let mut req = Request::new(&feed_url, Method::Get)?; 261 req.headers_mut()?.set("User-Agent", "pai-worker/0.1.0")?; 262 263 let mut resp = Fetch::Request(req).send().await?; 264 let body = resp.text().await?; 265 266 let channel = 267 rss::Channel::read_from(body.as_bytes()).map_err(|e| Error::RustError(format!("Failed to parse RSS: {e}")))?; 268 269 let source_id = normalize_source_id(&config.base_url); 270 let mut count = 0; 271 272 for item in channel.items() { 273 let id = item.guid().map(|g| g.value()).unwrap_or(item.link().unwrap_or("")); 274 let url = item.link().unwrap_or(id); 275 let title = item.title(); 276 let summary = item.description(); 277 let author = item.author(); 278 let content_html = item.content(); 279 280 let published_at = item 281 .pub_date() 282 .and_then(|s| chrono::DateTime::parse_from_rfc2822(s).ok()) 283 .map(|dt| dt.to_rfc3339()) 284 .unwrap_or_else(|| chrono::Utc::now().to_rfc3339()); 285 286 let created_at = chrono::Utc::now().to_rfc3339(); 287 288 let stmt = db.prepare( 289 "INSERT OR REPLACE INTO items (id, source_kind, source_id, author, title, summary, url, content_html, published_at, created_at) 290 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)" 291 ); 292 293 stmt.bind(&[ 294 id.into(), 295 "substack".into(), 296 source_id.clone().into(), 297 author.map(|s| s.into()).unwrap_or(JsValue::NULL), 298 title.map(|s| s.into()).unwrap_or(JsValue::NULL), 299 summary.map(|s| s.into()).unwrap_or(JsValue::NULL), 300 url.into(), 301 content_html.map(|s| s.into()).unwrap_or(JsValue::NULL), 302 published_at.into(), 303 created_at.into(), 304 ])? 305 .run() 306 .await?; 307 308 count += 1; 309 } 310 311 Ok(count) 312} 313 314async fn sync_bluesky(config: &BlueskyConfig, db: &D1Database) -> Result<usize> { 315 let api_url = format!( 316 "https://public.api.bsky.app/xrpc/app.bsky.feed.getAuthorFeed?actor={}&limit=50", 317 config.handle 318 ); 319 320 let mut req = Request::new(&api_url, Method::Get)?; 321 req.headers_mut()?.set("User-Agent", "pai-worker/0.1.0")?; 322 323 let mut resp = Fetch::Request(req).send().await?; 324 let json: serde_json::Value = resp.json().await?; 325 326 let feed = json["feed"] 327 .as_array() 328 .ok_or_else(|| Error::RustError("Invalid Bluesky response".into()))?; 329 330 let mut count = 0; 331 332 for item in feed { 333 let post = &item["post"]; 334 335 if item.get("reason").is_some() { 336 continue; 337 } 338 339 let uri = post["uri"] 340 .as_str() 341 .ok_or_else(|| Error::RustError("Missing URI".into()))?; 342 let record = &post["record"]; 343 let text = record["text"].as_str().unwrap_or(""); 344 345 let post_id = uri.split('/').next_back().unwrap_or(""); 346 let url = format!("https://bsky.app/profile/{}/post/{}", config.handle, post_id); 347 348 let title = if text.len() > 100 { format!("{}...", &text[..97]) } else { text.to_string() }; 349 350 let published_at = record["createdAt"].as_str().unwrap_or("").to_string(); 351 let created_at = chrono::Utc::now().to_rfc3339(); 352 353 let stmt = db.prepare( 354 "INSERT OR REPLACE INTO items (id, source_kind, source_id, author, title, summary, url, content_html, published_at, created_at) 355 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)" 356 ); 357 358 stmt.bind(&[ 359 uri.into(), 360 "bluesky".into(), 361 config.handle.clone().into(), 362 config.handle.clone().into(), 363 title.into(), 364 text.into(), 365 url.into(), 366 JsValue::NULL, 367 published_at.into(), 368 created_at.into(), 369 ])? 370 .run() 371 .await?; 372 373 count += 1; 374 } 375 376 Ok(count) 377} 378 379async fn sync_leaflet(config: &LeafletConfig, db: &D1Database) -> Result<usize> { 380 let host = normalize_source_id(&config.base_url); 381 let subdomain = host.split('.').next().unwrap_or(&host); 382 let did = format!("{subdomain}.bsky.social"); 383 384 let api_url = format!( 385 "https://public.api.bsky.app/xrpc/com.atproto.repo.listRecords?repo={did}&collection=pub.leaflet.post&limit=50" 386 ); 387 388 let mut req = Request::new(&api_url, Method::Get)?; 389 req.headers_mut()?.set("User-Agent", "pai-worker/0.1.0")?; 390 391 let mut resp = Fetch::Request(req).send().await?; 392 let json: serde_json::Value = resp.json().await?; 393 394 let records = json["records"] 395 .as_array() 396 .ok_or_else(|| Error::RustError("Invalid Leaflet response".into()))?; 397 398 let mut count = 0; 399 400 for record in records { 401 let uri = record["uri"] 402 .as_str() 403 .ok_or_else(|| Error::RustError("Missing URI".into()))?; 404 let value = &record["value"]; 405 406 let title = value["title"].as_str().unwrap_or("Untitled"); 407 let summary = value["summary"].as_str().or(value["content"].as_str()).unwrap_or(""); 408 let slug = value["slug"].as_str().unwrap_or(""); 409 410 let url = if !slug.is_empty() { 411 format!("{}/{}", config.base_url, slug) 412 } else { 413 format!("{}/post/{}", config.base_url, uri.split('/').next_back().unwrap_or("")) 414 }; 415 416 let published_at = value["publishedAt"] 417 .as_str() 418 .or(value["createdAt"].as_str()) 419 .unwrap_or("") 420 .to_string(); 421 422 let created_at = chrono::Utc::now().to_rfc3339(); 423 424 let stmt = db.prepare( 425 "INSERT OR REPLACE INTO items (id, source_kind, source_id, author, title, summary, url, content_html, published_at, created_at) 426 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)" 427 ); 428 429 stmt.bind(&[ 430 uri.into(), 431 "leaflet".into(), 432 config.id.clone().into(), 433 JsValue::NULL, 434 title.into(), 435 summary.into(), 436 url.into(), 437 JsValue::NULL, 438 published_at.into(), 439 created_at.into(), 440 ])? 441 .run() 442 .await?; 443 444 count += 1; 445 } 446 447 Ok(count) 448} 449 450async fn sync_bearblog(config: &BearBlogConfig, db: &D1Database) -> Result<usize> { 451 let feed_url = format!("{}/feed/", config.base_url.trim_end_matches('/')); 452 453 let mut req = Request::new(&feed_url, Method::Get)?; 454 req.headers_mut()?.set("User-Agent", "pai-worker/0.1.0")?; 455 456 let mut resp = Fetch::Request(req).send().await?; 457 let body = resp.text().await?; 458 459 let channel = 460 rss::Channel::read_from(body.as_bytes()).map_err(|e| Error::RustError(format!("Failed to parse RSS: {e}")))?; 461 462 let mut count = 0; 463 464 for item in channel.items() { 465 let id = item.guid().map(|g| g.value()).unwrap_or(item.link().unwrap_or("")); 466 let url = item.link().unwrap_or(id); 467 let title = item.title(); 468 let summary = item.description(); 469 let author = item.author(); 470 let content_html = item.content(); 471 472 let published_at = item 473 .pub_date() 474 .and_then(|s| chrono::DateTime::parse_from_rfc2822(s).ok()) 475 .map(|dt| dt.to_rfc3339()) 476 .unwrap_or_else(|| chrono::Utc::now().to_rfc3339()); 477 478 let created_at = chrono::Utc::now().to_rfc3339(); 479 480 let stmt = db.prepare( 481 "INSERT OR REPLACE INTO items (id, source_kind, source_id, author, title, summary, url, content_html, published_at, created_at) 482 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)" 483 ); 484 485 stmt.bind(&[ 486 id.into(), 487 "bearblog".into(), 488 config.id.clone().into(), 489 author.map(|s| s.into()).unwrap_or(JsValue::NULL), 490 title.map(|s| s.into()).unwrap_or(JsValue::NULL), 491 summary.map(|s| s.into()).unwrap_or(JsValue::NULL), 492 url.into(), 493 content_html.map(|s| s.into()).unwrap_or(JsValue::NULL), 494 published_at.into(), 495 created_at.into(), 496 ])? 497 .run() 498 .await?; 499 500 count += 1; 501 } 502 503 Ok(count) 504} 505 506fn normalize_source_id(base_url: &str) -> String { 507 base_url 508 .trim_start_matches("https://") 509 .trim_start_matches("http://") 510 .trim_end_matches('/') 511 .to_string() 512} 513 514#[cfg(test)] 515mod tests { 516 use super::*; 517 518 #[test] 519 fn test_normalize_source_id_https() { 520 assert_eq!( 521 normalize_source_id("https://patternmatched.substack.com"), 522 "patternmatched.substack.com" 523 ); 524 } 525 526 #[test] 527 fn test_normalize_source_id_http() { 528 assert_eq!(normalize_source_id("http://example.com/"), "example.com"); 529 } 530 531 #[test] 532 fn test_normalize_source_id_trailing_slash() { 533 assert_eq!(normalize_source_id("https://test.leaflet.pub/"), "test.leaflet.pub"); 534 } 535 536 #[test] 537 fn test_normalize_source_id_no_protocol() { 538 assert_eq!(normalize_source_id("example.com"), "example.com"); 539 } 540 541 #[test] 542 fn test_bluesky_title_truncation_short() { 543 let text = "Short post"; 544 let title = if text.len() > 100 { format!("{}...", &text[..97]) } else { text.to_string() }; 545 assert_eq!(title, "Short post"); 546 } 547 548 #[test] 549 fn test_bluesky_title_truncation_long() { 550 let text = "a".repeat(150); 551 let title = if text.len() > 100 { format!("{}...", &text[..97]) } else { text.to_string() }; 552 assert_eq!(title.len(), 100); 553 assert!(title.ends_with("...")); 554 } 555 556 #[test] 557 fn test_bluesky_title_truncation_boundary() { 558 let text = "a".repeat(100); 559 let title = if text.len() > 100 { format!("{}...", &text[..97]) } else { text.to_string() }; 560 assert_eq!(title, text); 561 } 562 563 #[test] 564 fn test_leaflet_url_with_slug() { 565 let base_url = "https://test.leaflet.pub"; 566 let slug = "my-post"; 567 let url = if !slug.is_empty() { 568 format!("{base_url}/{slug}") 569 } else { 570 format!("{}/post/{}", base_url, "fallback") 571 }; 572 assert_eq!(url, "https://test.leaflet.pub/my-post"); 573 } 574 575 #[test] 576 fn test_leaflet_url_without_slug() { 577 let base_url = "https://test.leaflet.pub"; 578 let slug = ""; 579 let uri = "at://did:plc:abc123/pub.leaflet.post/xyz789"; 580 let post_id = uri.split('/').next_back().unwrap_or(""); 581 let url = if !slug.is_empty() { format!("{base_url}/{slug}") } else { format!("{base_url}/post/{post_id}") }; 582 assert_eq!(url, "https://test.leaflet.pub/post/xyz789"); 583 } 584 585 #[test] 586 fn test_bluesky_post_id_extraction() { 587 let uri = "at://did:plc:abc123/app.bsky.feed.post/3ld7xyqnvqk2a"; 588 let post_id = uri.split('/').next_back().unwrap_or(""); 589 assert_eq!(post_id, "3ld7xyqnvqk2a"); 590 } 591 592 #[test] 593 fn test_bluesky_url_construction() { 594 let handle = "desertthunder.dev"; 595 let post_id = "3ld7xyqnvqk2a"; 596 let url = format!("https://bsky.app/profile/{handle}/post/{post_id}"); 597 assert_eq!(url, "https://bsky.app/profile/desertthunder.dev/post/3ld7xyqnvqk2a"); 598 } 599 600 #[test] 601 fn test_leaflet_config_parsing() { 602 let entry = "desertthunder:https://desertthunder.leaflet.pub"; 603 let parts: Vec<&str> = entry.trim().splitn(2, ':').collect(); 604 assert_eq!(parts.len(), 2); 605 assert_eq!(parts[0], "desertthunder"); 606 assert_eq!(parts[1], "https://desertthunder.leaflet.pub"); 607 } 608 609 #[test] 610 fn test_leaflet_config_parsing_invalid() { 611 let entry = "invalid-entry-no-colon"; 612 let parts: Vec<&str> = entry.trim().splitn(2, ':').collect(); 613 assert_ne!(parts.len(), 2); 614 } 615 616 #[test] 617 fn test_leaflet_config_parsing_multiple() { 618 let urls = "id1:https://pub1.leaflet.pub,id2:https://pub2.leaflet.pub"; 619 let configs: Vec<_> = urls 620 .split(',') 621 .filter_map(|entry| { 622 let parts: Vec<&str> = entry.trim().splitn(2, ':').collect(); 623 if parts.len() == 2 { 624 Some((parts[0].to_string(), parts[1].to_string())) 625 } else { 626 None 627 } 628 }) 629 .collect(); 630 631 assert_eq!(configs.len(), 2); 632 assert_eq!(configs[0].0, "id1"); 633 assert_eq!(configs[0].1, "https://pub1.leaflet.pub"); 634 assert_eq!(configs[1].0, "id2"); 635 assert_eq!(configs[1].1, "https://pub2.leaflet.pub"); 636 } 637 638 #[test] 639 fn test_substack_feed_url_construction() { 640 let base_url = "https://patternmatched.substack.com"; 641 let feed_url = format!("{base_url}/feed"); 642 assert_eq!(feed_url, "https://patternmatched.substack.com/feed"); 643 } 644 645 #[test] 646 fn test_bluesky_api_url_construction() { 647 let handle = "desertthunder.dev"; 648 let api_url = format!("https://public.api.bsky.app/xrpc/app.bsky.feed.getAuthorFeed?actor={handle}&limit=50"); 649 assert_eq!( 650 api_url, 651 "https://public.api.bsky.app/xrpc/app.bsky.feed.getAuthorFeed?actor=desertthunder.dev&limit=50" 652 ); 653 } 654 655 #[test] 656 fn test_leaflet_did_construction() { 657 let subdomain = "desertthunder"; 658 let did = format!("{subdomain}.bsky.social"); 659 assert_eq!(did, "desertthunder.bsky.social"); 660 } 661 662 #[test] 663 fn test_leaflet_api_url_construction() { 664 let did = "desertthunder.bsky.social"; 665 let api_url = format!( 666 "https://public.api.bsky.app/xrpc/com.atproto.repo.listRecords?repo={did}&collection=pub.leaflet.post&limit=50" 667 ); 668 assert_eq!( 669 api_url, 670 "https://public.api.bsky.app/xrpc/com.atproto.repo.listRecords?repo=desertthunder.bsky.social&collection=pub.leaflet.post&limit=50" 671 ); 672 } 673 674 #[test] 675 fn test_bearblog_feed_url_construction() { 676 let base_url = "https://desertthunder.bearblog.dev"; 677 let feed_url = format!("{}/feed/", base_url.trim_end_matches('/')); 678 assert_eq!(feed_url, "https://desertthunder.bearblog.dev/feed/"); 679 } 680 681 #[test] 682 fn test_bearblog_config_parsing() { 683 let entry = "desertthunder:https://desertthunder.bearblog.dev"; 684 let parts: Vec<&str> = entry.trim().splitn(2, ':').collect(); 685 assert_eq!(parts.len(), 2); 686 assert_eq!(parts[0], "desertthunder"); 687 assert_eq!(parts[1], "https://desertthunder.bearblog.dev"); 688 } 689 690 #[test] 691 fn test_bearblog_config_parsing_multiple() { 692 let urls = "id1:https://blog1.bearblog.dev,id2:https://blog2.bearblog.dev"; 693 let configs: Vec<_> = urls 694 .split(',') 695 .filter_map(|entry| { 696 let parts: Vec<&str> = entry.trim().splitn(2, ':').collect(); 697 if parts.len() == 2 { 698 Some((parts[0].to_string(), parts[1].to_string())) 699 } else { 700 None 701 } 702 }) 703 .collect(); 704 705 assert_eq!(configs.len(), 2); 706 assert_eq!(configs[0].0, "id1"); 707 assert_eq!(configs[0].1, "https://blog1.bearblog.dev"); 708 assert_eq!(configs[1].0, "id2"); 709 assert_eq!(configs[1].1, "https://blog2.bearblog.dev"); 710 } 711}