1#![doc = include_str!("../README.md")]
33#![forbid(unsafe_code)]
34
35mod cache;
36mod handlers;
37mod source;
38mod store;
39
40use cratefield_core::{
41 AnyError, BoxFuture, Config, ConfigError, Migrations, Module, ModuleConfig, ModuleContext,
42 PersonalDataSet, Port, SqlMigration,
43};
44use std::sync::Arc;
45
46const MIGRATION_INIT: SqlMigration = SqlMigration::new(
49 "0001",
50 "init",
51 include_str!("../migrations/sqlite/0001_init.sql"),
52);
53
54#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
59pub enum SourceKind {
60 #[default]
62 GithubReleases,
63 ChangelogMd,
66}
67
68impl SourceKind {
69 #[must_use]
72 pub fn as_str(self) -> &'static str {
73 match self {
74 Self::GithubReleases => "github-releases",
75 Self::ChangelogMd => "changelog-md",
76 }
77 }
78
79 #[must_use]
82 pub fn parse(raw: &str) -> Option<Self> {
83 match raw.trim() {
84 "github-releases" => Some(Self::GithubReleases),
85 "changelog-md" => Some(Self::ChangelogMd),
86 _ => None,
87 }
88 }
89}
90
91impl std::fmt::Display for SourceKind {
92 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
93 f.write_str(self.as_str())
94 }
95}
96
97#[derive(Debug, Clone)]
102pub(crate) struct Settings {
103 pub(crate) repo: Option<String>,
104 pub(crate) source: SourceKind,
105 pub(crate) path: String,
106 pub(crate) git_ref: Option<String>,
107 pub(crate) token: Option<String>,
108 pub(crate) api_base: String,
109 pub(crate) include_prereleases: bool,
110 pub(crate) include_drafts: bool,
111 pub(crate) locale: String,
112 pub(crate) cache_ttl_secs: u64,
113}
114
115impl Settings {
116 pub(crate) fn repo_parts(&self) -> Option<(&str, &str)> {
119 let (owner, name) = self.repo.as_deref()?.split_once('/')?;
120 Some((owner.trim(), name.trim()))
121 }
122
123 pub(crate) fn kind(&self) -> &'static str {
125 self.source.as_str()
126 }
127
128 pub(crate) fn source_id(&self) -> String {
133 let (owner, name) = self.repo_parts().unwrap_or(("", ""));
134 match self.source {
135 SourceKind::GithubReleases => format!("{}:{owner}/{name}", self.source.as_str()),
136 SourceKind::ChangelogMd => format!(
137 "{}:{owner}/{name}:{}@{}",
138 self.source.as_str(),
139 self.path,
140 self.git_ref.as_deref().unwrap_or("default"),
141 ),
142 }
143 }
144}
145
146pub(crate) fn is_repo_segment(segment: &str) -> bool {
149 !segment.is_empty()
150 && segment.len() <= 100
151 && segment
152 .bytes()
153 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'))
154}
155
156pub(crate) fn is_repo(repo: &str) -> bool {
158 match repo.split_once('/') {
159 Some((owner, name)) => is_repo_segment(owner) && is_repo_segment(name),
160 None => false,
161 }
162}
163
164#[derive(Debug, Clone)]
167pub struct Changelog {
168 settings: Settings,
169}
170
171impl Default for Changelog {
172 fn default() -> Self {
173 Self::new()
174 }
175}
176
177impl Changelog {
178 #[must_use]
185 pub fn new() -> Self {
186 Self {
187 settings: Settings {
188 repo: None,
189 source: SourceKind::default(),
190 path: "CHANGELOG.md".to_owned(),
191 git_ref: None,
192 token: None,
193 api_base: "https://api.github.com".to_owned(),
194 include_prereleases: false,
195 include_drafts: false,
196 locale: "en".to_owned(),
197 cache_ttl_secs: 60,
198 },
199 }
200 }
201
202 #[must_use]
205 pub fn repo(mut self, repo: impl Into<String>) -> Self {
206 self.settings.repo = Some(repo.into());
207 self
208 }
209
210 #[must_use]
214 pub fn source(mut self, source: SourceKind) -> Self {
215 self.settings.source = source;
216 self
217 }
218
219 #[must_use]
222 pub fn path(mut self, path: impl Into<String>) -> Self {
223 self.settings.path = path.into();
224 self
225 }
226
227 #[must_use]
230 pub fn git_ref(mut self, git_ref: impl Into<String>) -> Self {
231 self.settings.git_ref = Some(git_ref.into());
232 self
233 }
234
235 #[must_use]
240 pub fn token(mut self, token: impl Into<String>) -> Self {
241 self.settings.token = Some(token.into());
242 self
243 }
244
245 #[must_use]
248 pub fn api_base(mut self, api_base: impl Into<String>) -> Self {
249 self.settings.api_base = api_base.into();
250 self
251 }
252
253 #[must_use]
256 pub fn include_prereleases(mut self, include: bool) -> Self {
257 self.settings.include_prereleases = include;
258 self
259 }
260
261 #[must_use]
264 pub fn include_drafts(mut self, include: bool) -> Self {
265 self.settings.include_drafts = include;
266 self
267 }
268
269 #[must_use]
272 pub fn locale(mut self, locale: impl Into<String>) -> Self {
273 self.settings.locale = locale.into();
274 self
275 }
276
277 #[must_use]
280 pub fn cache_ttl_secs(mut self, secs: u64) -> Self {
281 self.settings.cache_ttl_secs = secs;
282 self
283 }
284}
285
286impl Module for Changelog {
287 fn name(&self) -> &'static str {
288 "changelog"
289 }
290
291 fn version(&self) -> &'static str {
292 env!("CARGO_PKG_VERSION")
293 }
294
295 fn requires(&self) -> &'static [Port] {
296 &[Port::Db, Port::HttpClient, Port::Clock]
300 }
301
302 fn optional(&self) -> &'static [Port] {
303 &[Port::KeyValue]
304 }
305
306 fn tables(&self) -> &'static [&'static str] {
307 &["changelog_release", "changelog_source"]
308 }
309
310 fn personal_data(&self) -> &'static [PersonalDataSet] {
319 const SETS: &[PersonalDataSet] = &[
320 PersonalDataSet::none(
321 "changelog_release",
322 "The releases this venture mirrors from its project's repository, one row per \
323 version: the author's own title and markdown body, a link, and when it was \
324 published. It describes software, not people — no column in it names a person.",
325 ),
326 PersonalDataSet::none(
327 "changelog_source",
328 "One row per configured source, recording how the last mirror went: an etag, a \
329 cache generation, a timestamp and a status. It is bookkeeping about a fetch, \
330 and it names nobody.",
331 ),
332 ];
333 SETS
334 }
335
336 fn migrations(&self) -> Migrations {
337 const MIGRATIONS: [SqlMigration; 1] = [MIGRATION_INIT];
338 const _: () = cratefield_core::assert_migration_set(&MIGRATIONS);
341 Migrations {
342 sqlite: &MIGRATIONS,
343 postgres: &[],
344 }
345 }
346
347 fn validate_config(&self, cfg: &dyn Config) -> Result<(), ConfigError> {
348 let module = ModuleConfig::new("changelog", cfg);
349 let mut errors = ConfigError::default();
350
351 match cfg.get(&module.key("REPO")) {
352 Some(repo) if is_repo(repo.trim()) => {}
353 Some(repo) => errors.push(format!(
354 "changelog: {} must be `owner/name` (letters, digits, '.', '-', '_'), got {repo:?}",
355 module.key("REPO"),
356 )),
357 None => errors.push(format!(
358 "changelog: {} is required (the repository whose releases to mirror)",
359 module.key("REPO"),
360 )),
361 }
362
363 if let Some(raw) = cfg.get(&module.key("SOURCE"))
364 && SourceKind::parse(&raw).is_none()
365 {
366 errors.push(format!(
367 "changelog: {} must be `github-releases` or `changelog-md`, got {raw:?}",
368 module.key("SOURCE"),
369 ));
370 }
371
372 if let Some(raw) = cfg.get(&module.key("CACHE_TTL_SECONDS"))
373 && raw.trim().parse::<u64>().is_err()
374 {
375 errors.push(format!(
376 "changelog: {} must be a whole number of seconds, got {raw:?}",
377 module.key("CACHE_TTL_SECONDS"),
378 ));
379 }
380
381 if let Some(base) = cfg.get(&module.key("API_BASE"))
382 && !base.starts_with("https://")
383 && !base.starts_with("http://")
384 {
385 errors.push(format!(
386 "changelog: {} must be an http(s) URL, got {base:?}",
387 module.key("API_BASE"),
388 ));
389 }
390
391 if let Some(locale) = cfg.get(&module.key("LOCALE"))
392 && locale.trim().is_empty()
393 {
394 errors.push(format!(
395 "changelog: {} must not be empty (it is reported in `rendering.locale`)",
396 module.key("LOCALE"),
397 ));
398 }
399
400 errors.into_result()
401 }
402
403 fn self_check(&self) -> Vec<String> {
408 let mut problems = Vec::new();
409 let source = self.settings.source.as_str();
410 if let Some(repo) = &self.settings.repo
411 && !is_repo(repo.trim())
412 {
413 problems.push(format!(
414 "changelog: the composed repo is not `owner/name`, so refresh will refuse to \
415 run (source {source})"
416 ));
417 }
418 if self.settings.source == SourceKind::ChangelogMd && self.settings.path.trim().is_empty() {
419 problems.push(format!(
420 "changelog: source {source} needs a file to read — set `.path(..)` or \
421 CHANGELOG_PATH"
422 ));
423 }
424 problems
425 }
426
427 fn router(&self, ctx: ModuleContext) -> axum::Router {
428 handlers::router(Arc::new(ctx), self.settings.clone())
429 }
430
431 fn surface(&self) -> cratefield_core::Surface {
432 handlers::surface()
433 }
434
435 fn scheduled<'a>(
440 &'a self,
441 ctx: &'a ModuleContext,
442 cron: &'a str,
443 ) -> BoxFuture<'a, Result<(), AnyError>> {
444 let base = self.settings.clone();
445 Box::pin(async move {
446 let settings = source::resolved(ctx, &base);
447 match source::refresh(ctx, &settings).await {
448 Ok(report) if report.not_modified => {
449 tracing::debug!(cron = %cron, "changelog: upstream not modified");
450 }
451 Ok(report) => tracing::info!(
452 cron = %cron,
453 fetched = report.fetched,
454 inserted = report.inserted,
455 updated = report.updated,
456 removed = report.removed,
457 "changelog refreshed"
458 ),
459 Err(trouble) => {
460 tracing::warn!(cron = %cron, trouble = ?trouble, "scheduled changelog refresh failed");
461 }
462 }
463 Ok(())
464 })
465 }
466}
467
468#[cfg(test)]
469mod tests {
470 use super::*;
471 use cratefield_core::MapConfig;
472
473 fn config(pairs: &[(&str, &str)]) -> MapConfig {
474 MapConfig::from_pairs(
475 pairs
476 .iter()
477 .map(|(key, value)| ((*key).to_owned(), (*value).to_owned())),
478 )
479 }
480
481 #[test]
482 fn defaults_are_the_documented_ones() {
483 let module = Changelog::new();
484 assert_eq!(module.name(), "changelog");
485 assert_eq!(module.requires(), [Port::Db, Port::HttpClient, Port::Clock]);
486 assert_eq!(module.optional(), [Port::KeyValue]);
487 assert_eq!(module.settings.source, SourceKind::GithubReleases);
488 assert_eq!(module.settings.path, "CHANGELOG.md");
489 assert_eq!(module.settings.api_base, "https://api.github.com");
490 assert_eq!(module.settings.locale, "en");
491 assert_eq!(module.settings.cache_ttl_secs, 60);
492 assert!(!module.settings.include_prereleases);
493 assert!(!module.settings.include_drafts);
494 assert!(module.settings.repo.is_none());
495 }
496
497 #[test]
498 fn the_source_id_changes_when_the_configuration_does() {
499 let mut settings = Changelog::new().repo("owner/name").settings;
500 assert_eq!(settings.source_id(), "github-releases:owner/name");
501 settings.source = SourceKind::ChangelogMd;
502 assert_eq!(
503 settings.source_id(),
504 "changelog-md:owner/name:CHANGELOG.md@default"
505 );
506 settings.git_ref = Some("main".to_owned());
507 assert_eq!(
508 settings.source_id(),
509 "changelog-md:owner/name:CHANGELOG.md@main"
510 );
511 settings.path = "docs/CHANGES.md".to_owned();
512 assert_eq!(
513 settings.source_id(),
514 "changelog-md:owner/name:docs/CHANGES.md@main"
515 );
516 }
517
518 #[test]
519 fn repos_are_strictly_owner_slash_name() {
520 assert!(is_repo("owner/name"));
521 assert!(is_repo("crate-field/harness.rs"));
522 assert!(!is_repo("owner"));
523 assert!(!is_repo("owner/"));
524 assert!(!is_repo("/name"));
525 assert!(!is_repo("owner/a/b"));
526 assert!(!is_repo("owner/na me"));
527 assert!(!is_repo(""));
528 }
529
530 #[test]
531 fn source_kinds_round_trip_their_config_values() {
532 assert_eq!(
533 SourceKind::parse("github-releases"),
534 Some(SourceKind::GithubReleases)
535 );
536 assert_eq!(
537 SourceKind::parse("changelog-md"),
538 Some(SourceKind::ChangelogMd)
539 );
540 assert_eq!(
541 SourceKind::parse(" changelog-md "),
542 Some(SourceKind::ChangelogMd)
543 );
544 assert_eq!(SourceKind::parse("rss"), None);
545 assert_eq!(SourceKind::GithubReleases.to_string(), "github-releases");
546 }
547
548 #[test]
549 fn a_valid_configuration_is_accepted() {
550 let cfg = config(&[("CHANGELOG_REPO", "owner/name")]);
551 assert!(Changelog::new().validate_config(&cfg).is_ok());
552 }
553
554 #[test]
555 fn a_missing_repo_is_the_one_required_key() {
556 let error = Changelog::new()
557 .validate_config(&config(&[]))
558 .expect_err("no repo is invalid");
559 assert!(error.to_string().contains("CHANGELOG_REPO"), "{error}");
560 }
561
562 #[test]
563 fn every_bad_value_is_reported_at_once() {
564 let cfg = config(&[
565 ("CHANGELOG_REPO", "owner"),
566 ("CHANGELOG_SOURCE", "rss"),
567 ("CHANGELOG_CACHE_TTL_SECONDS", "soon"),
568 ("CHANGELOG_API_BASE", "api.github.com"),
569 ("CHANGELOG_LOCALE", " "),
570 ]);
571 let error = Changelog::new()
572 .validate_config(&cfg)
573 .expect_err("all five values are invalid");
574 let message = error.to_string();
575 for key in [
576 "CHANGELOG_REPO",
577 "CHANGELOG_SOURCE",
578 "CHANGELOG_CACHE_TTL_SECONDS",
579 "CHANGELOG_API_BASE",
580 "CHANGELOG_LOCALE",
581 ] {
582 assert!(message.contains(key), "{key} missing from {message}");
583 }
584 }
585
586 #[test]
587 fn self_check_reports_an_unusable_composition() {
588 let module = Changelog::new().repo("owner");
589 let problems = module.self_check();
590 assert_eq!(problems.len(), 1, "{problems:?}");
591 assert!(problems[0].contains("github-releases"), "{problems:?}");
592
593 assert!(
594 Changelog::new().self_check().is_empty(),
595 "the default composition has nothing to report"
596 );
597 }
598
599 #[test]
600 fn a_pathless_changelog_md_source_is_a_self_check_problem() {
601 let mut module = Changelog::new()
602 .repo("owner/name")
603 .source(SourceKind::ChangelogMd);
604 module.settings.path = String::new();
605 let problems = module.self_check();
606 assert_eq!(problems.len(), 1, "{problems:?}");
607 assert!(problems[0].contains("CHANGELOG_PATH"), "{problems:?}");
608 }
609}