Skip to main content

cratefield_module_changelog/
lib.rs

1//! `cratefield-module-changelog`: a project's releases, mirrored into the
2//! venture's own database and served over an API.
3//!
4//! ```no_run
5//! use cratefield_module_changelog::Changelog;
6//!
7//! let module = Changelog::new()
8//!     .repo("owner/name")
9//!     .locale("en");
10//! ```
11//!
12//! **The read path never leaves the database.** A refresh pulls GitHub (the
13//! Releases API, or a Keep-a-Changelog file) and stores each release —
14//! byte-for-byte from the Releases API; the `CHANGELOG.md` parser normalises
15//! line endings and trims section edges. `GET /` and `GET /{version}` then
16//! serve only what is stored, so GitHub being down, rate-limited or
17//! misconfigured costs a refresh, not the changelog. Before the first
18//! refresh the list is simply empty — a 200 with no releases, never an
19//! error and never an empty error page.
20//!
21//! **A refresh rewrites nothing it does not have to.** Each fetch is
22//! compared against the stored rows field by field: a release upstream has
23//! not touched keeps its `updated_at`, and a fetch that saw the whole list
24//! alone may prune rows upstream deleted — a walk that hit the page cap
25//! applies what it fetched and prunes nothing. There is no LLM in this
26//! module — rewriting and translation arrive later behind a `TextModel`
27//! port that does not exist yet, and until then every response reports
28//! `rendering.style = "original"`.
29//!
30//! See `README.md` for the configuration keys and the response shapes.
31
32#![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
46/// The one migration: `changelog_release` and `changelog_source`, in the
47/// portable SQL subset (ADR 0004).
48const MIGRATION_INIT: SqlMigration = SqlMigration::new(
49    "0001",
50    "init",
51    include_str!("../migrations/sqlite/0001_init.sql"),
52);
53
54/// Where the release notes come from. `github-releases` reads the Releases
55/// API (titles, bodies, prerelease and draft flags, an etag); `changelog-md`
56/// reads one Keep-a-Changelog file out of the repository and splits it into
57/// sections.
58#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
59pub enum SourceKind {
60    /// `/repos/{owner}/{name}/releases` — the default.
61    #[default]
62    GithubReleases,
63    /// `/repos/{owner}/{name}/contents/{path}` read raw, sectioned by `## `
64    /// headings.
65    ChangelogMd,
66}
67
68impl SourceKind {
69    /// The value the `CHANGELOG_SOURCE` configuration key spells it with —
70    /// also what `source.kind` reports in every response.
71    #[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    /// Reads the `CHANGELOG_SOURCE` value. `None` for anything else, so a
80    /// typo is a configuration error rather than a silent fallback.
81    #[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/// The module's settings as composed: the builder's values, overlaid with
98/// the `CHANGELOG_*` configuration keys per request ([`source::resolved`]).
99/// Configuration wins when a key is set, so a venture that only sets
100/// configuration and composes [`Changelog::new()`] is complete.
101#[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    /// The `owner` and `name` of the configured repository, or `None` when
117    /// `CHANGELOG_REPO` is unset or malformed.
118    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    /// The kind upstream, as responses and problems spell it.
124    pub(crate) fn kind(&self) -> &'static str {
125        self.source.as_str()
126    }
127
128    /// The stable identity of the configured source — the key every
129    /// `changelog_release` and `changelog_source` row is filed under.
130    /// Changing the configuration changes the identity, so yesterday's rows
131    /// cannot be silently served as today's source.
132    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
146/// A GitHub repository segment: GitHub allows letters, digits, `.`, `-` and
147/// `_`, up to 100 bytes.
148pub(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
156/// `owner/name`, both segments well-formed.
157pub(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/// A changelog module: mirror a project's releases into the venture's own
165/// database and serve them.
166#[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    /// Defaults: the GitHub Releases API at `api.github.com`, anonymous,
179    /// `CHANGELOG.md` for a `changelog-md` source, original notes in `en`,
180    /// drafts and prereleases excluded, a 60-second read cache. Without
181    /// [`Changelog::repo`] or a configured `CHANGELOG_REPO` the module has
182    /// no source: reads serve an empty list and refresh explains what is
183    /// missing.
184    #[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    /// The repository to mirror, `owner/name`. `CHANGELOG_REPO` overrides
203    /// this when set.
204    #[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    /// Which upstream to read: [`SourceKind::GithubReleases`] (the default)
211    /// or [`SourceKind::ChangelogMd`]. `CHANGELOG_SOURCE` overrides this
212    /// when set.
213    #[must_use]
214    pub fn source(mut self, source: SourceKind) -> Self {
215        self.settings.source = source;
216        self
217    }
218
219    /// The file a `changelog-md` source reads. `CHANGELOG_PATH` overrides
220    /// this when set.
221    #[must_use]
222    pub fn path(mut self, path: impl Into<String>) -> Self {
223        self.settings.path = path.into();
224        self
225    }
226
227    /// The git ref a `changelog-md` source reads from; unset means the
228    /// repository's default branch. `CHANGELOG_REF` overrides this when set.
229    #[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    /// A GitHub token, raising the rate limit above anonymous. Stored in
236    /// settings only; it is read from configuration in production, where it
237    /// belongs to the secret store rather than the binary.
238    /// `CHANGELOG_TOKEN` overrides this when set.
239    #[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    /// Where GitHub's API lives, so tests and GitHub Enterprise point
246    /// elsewhere. `CHANGELOG_API_BASE` overrides this when set.
247    #[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    /// Mirror prereleases too. `CHANGELOG_INCLUDE_PRERELEASES` overrides
254    /// this when set.
255    #[must_use]
256    pub fn include_prereleases(mut self, include: bool) -> Self {
257        self.settings.include_prereleases = include;
258        self
259    }
260
261    /// Mirror drafts too. `CHANGELOG_INCLUDE_DRAFTS` overrides this when
262    /// set.
263    #[must_use]
264    pub fn include_drafts(mut self, include: bool) -> Self {
265        self.settings.include_drafts = include;
266        self
267    }
268
269    /// The locale the original notes are written in, reported in
270    /// `rendering.locale`. `CHANGELOG_LOCALE` overrides this when set.
271    #[must_use]
272    pub fn locale(mut self, locale: impl Into<String>) -> Self {
273        self.settings.locale = locale.into();
274        self
275    }
276
277    /// The `KeyValue` read cache's TTL in seconds; `0` disables the cache
278    /// entirely. `CHANGELOG_CACHE_TTL_SECONDS` overrides this when set.
279    #[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        // HttpClient is for the refresh path only; the reads never need it,
297        // but a changelog that cannot be refreshed is not a mirror, so it is
298        // required rather than optional.
299        &[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    /// A changelog holds nothing about a person — it holds what a project
311    /// published, and a project is not a person.
312    ///
313    /// Declared rather than left silent: an export that skipped these tables
314    /// and an export that had never heard of them look identical from the
315    /// outside, and only one of them is a decision. The reason below is what
316    /// `GET /v1/privacy/manifest` publishes, so it is written for the person
317    /// reading it.
318    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        // The array is the apply order; this refuses a gap, a duplicate
339        // or an entry out of order at build time (issue #27).
340        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    /// Problems the module can find in **itself**: how the venture composed
404    /// it in code, checked against nothing but its own rules. No network, no
405    /// configuration — the deploy config does not exist at `fz doctor` time.
406    /// Each problem names the source it was found on and the knob to turn.
407    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    /// Every wired cron pulls once. A pull is cheap — an etag'd GET that
436    /// answers 304 when nothing moved — and any failure is logged, never
437    /// raised: a failed refresh costs fresh data for one interval, not the
438    /// deployment.
439    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}