Skip to main content

dsc/
utils.rs

1use anyhow::{Context, Result};
2use std::collections::HashMap;
3use std::fs;
4use std::io::IsTerminal;
5use std::path::{Path, PathBuf};
6
7/// Trim trailing slashes from a base URL.
8pub fn normalize_baseurl(baseurl: &str) -> String {
9    baseurl.trim_end_matches('/').to_string()
10}
11
12/// Create a URL-safe slug from arbitrary input.
13///
14/// Wraps the [`slug`] crate, which transliterates Unicode (so `"Café"`
15/// becomes `"cafe"`, Cyrillic and CJK get sensible romanisations) and
16/// emits the standard kebab-case shape used across most slug-generating
17/// tooling. Returns `"untitled"` when the slug would otherwise be empty
18/// (the `slug` crate itself returns an empty string for input that has
19/// no transliterable characters).
20pub fn slugify(input: &str) -> String {
21    let s = slug::slugify(input);
22    if s.is_empty() {
23        "untitled".to_string()
24    } else {
25        s
26    }
27}
28
29/// Ensure a directory exists.
30pub fn ensure_dir(path: &Path) -> Result<()> {
31    fs::create_dir_all(path).with_context(|| format!("creating {}", path.display()))?;
32    Ok(())
33}
34
35/// Resolve a topic path from a user-provided path and a topic title.
36pub fn resolve_topic_path(
37    provided: Option<&Path>,
38    title: &str,
39    default_dir: &Path,
40) -> Result<PathBuf> {
41    let filename = format!("{}.md", slugify(title));
42    match provided {
43        Some(path) if path.exists() && path.is_dir() => Ok(path.join(filename)),
44        Some(path) if path.extension().is_some() => Ok(path.to_path_buf()),
45        Some(path) => Ok(path.join(filename)),
46        None => Ok(default_dir.join(filename)),
47    }
48}
49
50/// Read a Markdown file.
51pub fn read_markdown(path: &Path) -> Result<String> {
52    let raw = fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
53    Ok(raw)
54}
55
56/// Write a Markdown file, creating parent directories if needed.
57pub fn write_markdown(path: &Path, content: &str) -> Result<()> {
58    if let Some(parent) = path.parent() {
59        ensure_dir(parent)?;
60    }
61    fs::write(path, content).with_context(|| format!("writing {}", path.display()))?;
62    Ok(())
63}
64
65/// Quote a YAML scalar if it contains characters that would confuse the
66/// parser. Keeps simple values unquoted. Shared by every command that
67/// writes YAML front matter (`topic pull --full`, `category pull`).
68pub fn yaml_scalar(value: &str) -> String {
69    let needs_quoting = value.is_empty()
70        || value.contains(':')
71        || value.contains('#')
72        || value.contains('\n')
73        || value.starts_with([
74            '-', '?', '!', '&', '*', '|', '>', '@', '`', '%', '\'', '"', '[',
75        ])
76        || value.starts_with("  ");
77    if needs_quoting {
78        let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
79        format!("\"{}\"", escaped)
80    } else {
81        value.to_string()
82    }
83}
84
85/// Split a Markdown document into its leading YAML front matter (if any) and
86/// the body that follows.
87///
88/// Front matter is recognised only when the file's very first line is exactly
89/// `---` (an optional leading BOM is tolerated), terminated by a later line
90/// that is exactly `---`. The fenced block is parsed shallowly into a flat
91/// `key → value` map (one `key: value` pair per line; lines without a colon
92/// are ignored) — `dsc` front matter is intentionally shallow (`title`,
93/// `topic_id`, `url`, `pulled_at`), so a full YAML parse is unnecessary and a
94/// flat scan keeps the body intact.
95///
96/// Returns `(map, body)`. When there is no recognisable front matter the map
97/// is empty and the body is the original content unchanged, so callers can
98/// treat "no front matter" and "empty front matter" identically. One blank
99/// line separating the closing fence from the body is consumed (it mirrors
100/// what the `pull` side writes), giving a stable pull → push round-trip.
101///
102/// Note the inherent ambiguity shared with Jekyll/Hugo: a file with no front
103/// matter whose body genuinely opens with a `---` thematic break followed by
104/// another `---` will be misread as front matter. This is accepted; real
105/// snapshots written by `dsc` always carry proper front matter.
106pub fn strip_frontmatter(raw: &str) -> (HashMap<String, String>, String) {
107    let mut map = HashMap::new();
108    let text = raw.strip_prefix('\u{feff}').unwrap_or(raw);
109
110    let mut lines = text.lines();
111    if lines.next().map(str::trim_end) != Some("---") {
112        return (map, raw.to_string());
113    }
114
115    let mut body_lines: Vec<&str> = Vec::new();
116    let mut closed = false;
117    for line in &mut lines {
118        if line.trim_end() == "---" {
119            closed = true;
120            break;
121        }
122        if let Some((key, value)) = line.split_once(':') {
123            map.insert(key.trim().to_string(), unquote_yaml_scalar(value.trim()));
124        }
125    }
126
127    if !closed {
128        // Opening fence with no matching close: not front matter after all.
129        return (HashMap::new(), raw.to_string());
130    }
131
132    body_lines.extend(lines);
133    // Consume a single conventional blank line between fence and body.
134    if body_lines.first() == Some(&"") {
135        body_lines.remove(0);
136    }
137    let mut body = body_lines.join("\n");
138    if raw.ends_with('\n') && !body.is_empty() {
139        body.push('\n');
140    }
141    (map, body)
142}
143
144/// Inverse of [`yaml_scalar`]'s quoting: if `value` is wrapped in double
145/// quotes, strip them and unescape `\"` and `\\`. Bare values pass through
146/// unchanged, so a value Discourse never sees as quoted (an integer, a URL)
147/// is untouched.
148fn unquote_yaml_scalar(value: &str) -> String {
149    let bytes = value.as_bytes();
150    if bytes.len() < 2 || bytes[0] != b'"' || bytes[bytes.len() - 1] != b'"' {
151        return value.to_string();
152    }
153    let inner = &value[1..value.len() - 1];
154    let mut out = String::with_capacity(inner.len());
155    let mut chars = inner.chars();
156    while let Some(c) = chars.next() {
157        if c == '\\' {
158            match chars.next() {
159                Some('"') => out.push('"'),
160                Some('\\') => out.push('\\'),
161                Some(other) => {
162                    out.push('\\');
163                    out.push(other);
164                }
165                None => out.push('\\'),
166            }
167        } else {
168            out.push(c);
169        }
170    }
171    out
172}
173
174/// Current time in `YYYY-MM-DDTHH:MM:SSZ` form, derived directly from
175/// `SystemTime` to avoid a chrono dependency where one is not otherwise
176/// needed. Used for the `pulled_at` front-matter stamp.
177pub fn current_utc_iso8601() -> String {
178    use std::time::{SystemTime, UNIX_EPOCH};
179    let secs = SystemTime::now()
180        .duration_since(UNIX_EPOCH)
181        .map(|d| d.as_secs())
182        .unwrap_or(0);
183    // Days-from-epoch arithmetic (proleptic Gregorian via the standard
184    // 1970-01-01 epoch). Good for any year `dsc` will plausibly run in.
185    let days = (secs / 86_400) as i64;
186    let secs_of_day = secs % 86_400;
187    let hh = secs_of_day / 3600;
188    let mm = (secs_of_day % 3600) / 60;
189    let ss = secs_of_day % 60;
190    let (y, m, d) = civil_from_days(days);
191    format!("{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z", y, m, d, hh, mm, ss)
192}
193
194/// Convert days-from-1970-01-01 to (year, month, day).
195/// Reference: Howard Hinnant, "chrono-Compatible Low-Level Date Algorithms".
196fn civil_from_days(z: i64) -> (i32, u32, u32) {
197    let z = z + 719_468;
198    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
199    let doe = (z - era * 146_097) as u64; // [0, 146096]
200    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
201    let y = yoe as i64 + era * 400;
202    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
203    let mp = (5 * doy + 2) / 153; // [0, 11]
204    let d = doy - (153 * mp + 2) / 5 + 1; // [1, 31]
205    let m = if mp < 10 { mp + 3 } else { mp - 9 }; // [1, 12]
206    let y = y + if m <= 2 { 1 } else { 0 };
207    (y as i32, m as u32, d as u32)
208}
209
210fn color_mode() -> &'static str {
211    match std::env::var("DSC_COLOR") {
212        Ok(value) => match value.trim().to_ascii_lowercase().as_str() {
213            "always" => "always",
214            "never" => "never",
215            _ => "auto",
216        },
217        Err(_) => "auto",
218    }
219}
220
221fn color_allowed_for_stdout() -> bool {
222    if std::env::var_os("NO_COLOR").is_some() {
223        return false;
224    }
225    match color_mode() {
226        "always" => true,
227        "never" => false,
228        _ => std::io::stdout().is_terminal(),
229    }
230}
231
232fn discourse_color_code(key: &str) -> u8 {
233    const COLORS: [u8; 12] = [31, 32, 33, 34, 35, 36, 91, 92, 93, 94, 95, 96];
234    let hash = key.bytes().fold(0usize, |acc, b| {
235        acc.wrapping_mul(31).wrapping_add(b as usize)
236    });
237    COLORS[hash % COLORS.len()]
238}
239
240pub fn color_discourse_label(label: &str, key: &str) -> String {
241    if !color_allowed_for_stdout() {
242        return label.to_string();
243    }
244    let code = discourse_color_code(key);
245    format!("\x1b[1;{}m{}\x1b[0m", code, label)
246}
247
248/// Parse a `--since`-style value. Accepts either a relative duration
249/// (`7d`, `24h`, `30m`, `1w`, `90s`) or an ISO-8601 absolute timestamp
250/// (`2026-04-01`, `2026-04-01T12:00:00Z`). Returns the resulting cutoff
251/// instant (now - duration, or the ISO value itself).
252pub fn parse_since_cutoff(input: &str) -> anyhow::Result<chrono::DateTime<chrono::Utc>> {
253    use anyhow::anyhow;
254    let trimmed = input.trim();
255    if trimmed.is_empty() {
256        return Err(anyhow!("empty --since value"));
257    }
258
259    if let Some(duration) = parse_relative_duration(trimmed) {
260        return Ok(chrono::Utc::now() - duration);
261    }
262
263    // Try RFC3339 (full timestamp).
264    if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(trimmed) {
265        return Ok(dt.with_timezone(&chrono::Utc));
266    }
267    // Try date-only — treat as midnight UTC.
268    if let Ok(d) = chrono::NaiveDate::parse_from_str(trimmed, "%Y-%m-%d") {
269        return Ok(chrono::NaiveDateTime::new(
270            d,
271            chrono::NaiveTime::from_hms_opt(0, 0, 0).unwrap(),
272        )
273        .and_utc());
274    }
275
276    Err(anyhow!(
277        "unrecognised --since value: {:?} (expected e.g. `7d`, `24h`, `30m`, `1w`, or an ISO-8601 timestamp)",
278        input
279    ))
280}
281
282/// Parse a relative duration like `7d`, `24h`, `1w`, `1m`, `90s`, `1y`.
283///
284/// Calendar units (`m`, `y`) are imprecise; for windows we use these
285/// conventions:
286///
287/// - `s` — seconds
288/// - `min` — minutes (use this rather than `m` to avoid the months-vs-minutes
289///   ambiguity)
290/// - `h` — hours
291/// - `d` — days
292/// - `w` — weeks (= 7 days)
293/// - `m` — **months** (= 30 days; matches what most users mean by "1m" in
294///   analytics windows)
295/// - `y` — years (= 365 days)
296///
297/// For exact calendar math, pass an ISO-8601 timestamp instead.
298pub fn parse_relative_duration(input: &str) -> Option<chrono::Duration> {
299    let s = input.trim();
300    if s.len() < 2 {
301        return None;
302    }
303    // Order matters: `min` must be tried before `m` so we don't read
304    // "10min" as "10mi" + "n".
305    let multi_char_units = [("min", 60i64)];
306    for (suffix, secs_per_unit) in multi_char_units {
307        if let Some(digits) = s.strip_suffix(suffix) {
308            let n: i64 = digits.parse().ok()?;
309            return Some(chrono::Duration::seconds(n * secs_per_unit));
310        }
311    }
312    let (digits, unit) = s.split_at(s.len() - 1);
313    let n: i64 = digits.parse().ok()?;
314    match unit {
315        "s" => Some(chrono::Duration::seconds(n)),
316        "h" => Some(chrono::Duration::hours(n)),
317        "d" => Some(chrono::Duration::days(n)),
318        "w" => Some(chrono::Duration::weeks(n)),
319        "m" => Some(chrono::Duration::days(n * 30)),
320        "y" => Some(chrono::Duration::days(n * 365)),
321        _ => None,
322    }
323}
324
325#[cfg(test)]
326mod tests {
327    use super::*;
328
329    #[test]
330    fn slugify_simple_ascii() {
331        assert_eq!(slugify("Hello World"), "hello-world");
332    }
333
334    #[test]
335    fn slugify_collapses_runs_of_non_alnum() {
336        assert_eq!(slugify("a   b___c!!!d"), "a-b-c-d");
337    }
338
339    #[test]
340    fn slugify_trims_leading_and_trailing_dashes() {
341        assert_eq!(slugify("   hello   "), "hello");
342        assert_eq!(slugify("!!!foo!!!"), "foo");
343    }
344
345    #[test]
346    fn slugify_empty_input_returns_untitled() {
347        assert_eq!(slugify(""), "untitled");
348        assert_eq!(slugify("   "), "untitled");
349        assert_eq!(slugify("!!!"), "untitled");
350    }
351
352    #[test]
353    fn slugify_preserves_numbers() {
354        assert_eq!(slugify("Topic 42 - intro"), "topic-42-intro");
355    }
356
357    #[test]
358    fn slugify_lowercases() {
359        assert_eq!(slugify("ABCxyz"), "abcxyz");
360    }
361
362    #[test]
363    fn slugify_transliterates_unicode() {
364        // The whole reason for adopting the `slug` crate: pre-existing
365        // ASCII behaviour preserved, plus accented Latin, Cyrillic, and
366        // CJK now produce meaningful slugs instead of "untitled".
367        assert_eq!(slugify("Café Tonight"), "cafe-tonight");
368        assert_eq!(slugify("Привет мир"), "privet-mir");
369        assert_eq!(slugify("日本語"), "ri-ben-yu");
370    }
371
372    #[test]
373    fn slugify_trims_both_ends_of_dashes() {
374        // Regression guard: catches a latent bug from a contributor PR
375        // that only trimmed trailing dashes. The `slug` crate handles
376        // both ends correctly.
377        assert_eq!(slugify("-foo-"), "foo");
378        assert_eq!(slugify("---foo---bar---"), "foo-bar");
379    }
380
381    #[test]
382    fn normalize_baseurl_strips_trailing_slashes() {
383        assert_eq!(
384            normalize_baseurl("https://example.com/"),
385            "https://example.com"
386        );
387        assert_eq!(
388            normalize_baseurl("https://example.com///"),
389            "https://example.com"
390        );
391        assert_eq!(
392            normalize_baseurl("https://example.com"),
393            "https://example.com"
394        );
395    }
396
397    #[test]
398    fn normalize_baseurl_preserves_no_trailing() {
399        assert_eq!(normalize_baseurl(""), "");
400    }
401
402    #[test]
403    fn resolve_topic_path_uses_title_when_no_path_given() {
404        let default_dir = Path::new("/tmp/dsc-test");
405        let out = resolve_topic_path(None, "Hello World", default_dir).unwrap();
406        assert_eq!(out, default_dir.join("hello-world.md"));
407    }
408
409    #[test]
410    fn resolve_topic_path_uses_given_path_with_extension() {
411        let default_dir = Path::new("/tmp/dsc-test");
412        let explicit = Path::new("/tmp/custom.md");
413        let out = resolve_topic_path(Some(explicit), "Ignored", default_dir).unwrap();
414        assert_eq!(out, explicit);
415    }
416
417    #[test]
418    fn parse_relative_duration_common_units() {
419        assert_eq!(
420            parse_relative_duration("7d"),
421            Some(chrono::Duration::days(7))
422        );
423        assert_eq!(
424            parse_relative_duration("24h"),
425            Some(chrono::Duration::hours(24))
426        );
427        assert_eq!(
428            parse_relative_duration("30min"),
429            Some(chrono::Duration::minutes(30))
430        );
431        assert_eq!(
432            parse_relative_duration("1w"),
433            Some(chrono::Duration::weeks(1))
434        );
435        assert_eq!(
436            parse_relative_duration("90s"),
437            Some(chrono::Duration::seconds(90))
438        );
439    }
440
441    #[test]
442    fn parse_relative_duration_rejects_nonsense() {
443        assert!(parse_relative_duration("").is_none());
444        assert!(parse_relative_duration("d").is_none());
445        assert!(parse_relative_duration("7x").is_none());
446        assert!(parse_relative_duration("abc").is_none());
447        assert!(parse_relative_duration("3M").is_none()); // case-sensitive
448    }
449
450    #[test]
451    fn parse_relative_duration_treats_m_as_months() {
452        // `m` = months (= 30 days). Users naturally write `1m` for "one
453        // month" in analytics windows; we match that. Use `min` for the
454        // rare minutes case.
455        assert_eq!(
456            parse_relative_duration("1m"),
457            Some(chrono::Duration::days(30))
458        );
459        assert_eq!(
460            parse_relative_duration("3m"),
461            Some(chrono::Duration::days(90))
462        );
463    }
464
465    #[test]
466    fn parse_relative_duration_minutes_via_min_suffix() {
467        assert_eq!(
468            parse_relative_duration("5min"),
469            Some(chrono::Duration::minutes(5))
470        );
471        assert_eq!(
472            parse_relative_duration("90min"),
473            Some(chrono::Duration::minutes(90))
474        );
475    }
476
477    #[test]
478    fn parse_relative_duration_accepts_years_as_365d() {
479        assert_eq!(
480            parse_relative_duration("1y"),
481            Some(chrono::Duration::days(365))
482        );
483        assert_eq!(
484            parse_relative_duration("2y"),
485            Some(chrono::Duration::days(730))
486        );
487    }
488
489    #[test]
490    fn parse_since_cutoff_iso_date() {
491        let cutoff = parse_since_cutoff("2026-01-01").unwrap();
492        assert_eq!(cutoff.to_rfc3339(), "2026-01-01T00:00:00+00:00");
493    }
494
495    #[test]
496    fn parse_since_cutoff_iso_timestamp() {
497        let cutoff = parse_since_cutoff("2026-04-15T12:30:00Z").unwrap();
498        assert_eq!(cutoff.to_rfc3339(), "2026-04-15T12:30:00+00:00");
499    }
500
501    #[test]
502    fn parse_since_cutoff_relative_is_in_the_past() {
503        let now = chrono::Utc::now();
504        let cutoff = parse_since_cutoff("7d").unwrap();
505        let diff = now - cutoff;
506        // Should be very close to 7 days (within a second).
507        assert!(
508            (diff - chrono::Duration::days(7)).num_seconds().abs() < 2,
509            "expected ~7 day delta, got {}",
510            diff
511        );
512    }
513
514    #[test]
515    fn parse_since_cutoff_rejects_garbage() {
516        assert!(parse_since_cutoff("not a date").is_err());
517        assert!(parse_since_cutoff("").is_err());
518    }
519
520    #[test]
521    fn yaml_scalar_leaves_simple_values_bare() {
522        assert_eq!(
523            yaml_scalar("Dependency management"),
524            "Dependency management"
525        );
526        assert_eq!(yaml_scalar("Topic 42"), "Topic 42");
527    }
528
529    #[test]
530    fn yaml_scalar_quotes_when_needed() {
531        assert_eq!(yaml_scalar("a: b"), "\"a: b\"");
532        assert_eq!(yaml_scalar("# hash"), "\"# hash\"");
533        assert_eq!(yaml_scalar("- leading dash"), "\"- leading dash\"");
534        // Interior quotes alone do not trigger quoting; a quote that coincides
535        // with another trigger (here the colon) is escaped inside the wrap.
536        assert_eq!(yaml_scalar("she said \"hi\""), "she said \"hi\"");
537        assert_eq!(yaml_scalar("a: \"b\""), "\"a: \\\"b\\\"\"");
538    }
539
540    #[test]
541    fn strip_frontmatter_parses_block_and_body() {
542        let raw = "---\ntitle: Dependency management\ntopic_id: 412\nurl: https://forum.rcpch.tech/t/dependency-management/412\npulled_at: 2026-06-22T09:19:00Z\n---\n\nBody line one.\nBody line two.\n";
543        let (front, body) = strip_frontmatter(raw);
544        assert_eq!(front.get("topic_id").map(String::as_str), Some("412"));
545        assert_eq!(
546            front.get("title").map(String::as_str),
547            Some("Dependency management")
548        );
549        assert_eq!(
550            front.get("url").map(String::as_str),
551            Some("https://forum.rcpch.tech/t/dependency-management/412")
552        );
553        assert_eq!(body, "Body line one.\nBody line two.\n");
554    }
555
556    #[test]
557    fn strip_frontmatter_absent_returns_empty_map_and_full_body() {
558        let raw = "# Heading\n\nNo front matter here.\n";
559        let (front, body) = strip_frontmatter(raw);
560        assert!(front.is_empty());
561        assert_eq!(body, raw);
562    }
563
564    #[test]
565    fn strip_frontmatter_unclosed_fence_is_not_front_matter() {
566        // Opening `---` but never closed: treat the whole thing as body.
567        let raw = "---\ntitle: oops\nstill body, no closing fence\n";
568        let (front, body) = strip_frontmatter(raw);
569        assert!(front.is_empty());
570        assert_eq!(body, raw);
571    }
572
573    #[test]
574    fn strip_frontmatter_preserves_horizontal_rules_in_body() {
575        // A `---` inside the body (after the real close) must survive intact.
576        let raw = "---\ntopic_id: 7\n---\n\nIntro.\n\n---\n\nAfter the rule.\n";
577        let (front, body) = strip_frontmatter(raw);
578        assert_eq!(front.get("topic_id").map(String::as_str), Some("7"));
579        assert_eq!(body, "Intro.\n\n---\n\nAfter the rule.\n");
580    }
581
582    #[test]
583    fn strip_frontmatter_unquotes_yaml_scalar_values() {
584        // yaml_scalar quotes a title containing a colon; strip must invert it.
585        let title = "Intro: getting started";
586        let raw = format!(
587            "---\ntitle: {}\ntopic_id: 3\n---\n\nbody\n",
588            yaml_scalar(title)
589        );
590        let (front, body) = strip_frontmatter(&raw);
591        assert_eq!(front.get("title").map(String::as_str), Some(title));
592        assert_eq!(front.get("topic_id").map(String::as_str), Some("3"));
593        assert_eq!(body, "body\n");
594    }
595
596    #[test]
597    fn strip_frontmatter_leaves_url_with_colons_intact() {
598        // URLs are written bare (not via yaml_scalar) and only the first colon
599        // separates key from value, so the scheme colon must survive.
600        let raw = "---\nurl: https://forum.rcpch.tech/t/x/9\n---\n\nbody\n";
601        let (front, _) = strip_frontmatter(raw);
602        assert_eq!(
603            front.get("url").map(String::as_str),
604            Some("https://forum.rcpch.tech/t/x/9")
605        );
606    }
607
608    #[test]
609    fn strip_frontmatter_tolerates_leading_bom() {
610        let raw = "\u{feff}---\ntopic_id: 99\n---\n\nbody\n";
611        let (front, body) = strip_frontmatter(raw);
612        assert_eq!(front.get("topic_id").map(String::as_str), Some("99"));
613        assert_eq!(body, "body\n");
614    }
615
616    #[test]
617    fn current_utc_iso8601_has_expected_shape() {
618        let s = current_utc_iso8601();
619        assert_eq!(s.len(), 20, "got {s:?}");
620        assert!(s.ends_with('Z'));
621        assert_eq!(&s[4..5], "-");
622        assert_eq!(&s[10..11], "T");
623    }
624
625    #[test]
626    fn civil_from_days_matches_known_dates() {
627        // 1970-01-01 is day 0.
628        assert_eq!(civil_from_days(0), (1970, 1, 1));
629        // 2026-06-10 = 20614 days from epoch (well-known via cal / date).
630        assert_eq!(civil_from_days(20614), (2026, 6, 10));
631        // Leap-day check: 2024-02-29.
632        assert_eq!(civil_from_days(19782), (2024, 2, 29));
633    }
634}