use anyhow::{Context, Result};
use std::collections::HashMap;
use std::fs;
use std::io::IsTerminal;
use std::path::{Path, PathBuf};
pub fn normalize_baseurl(baseurl: &str) -> String {
baseurl.trim_end_matches('/').to_string()
}
pub fn slugify(input: &str) -> String {
let s = slug::slugify(input);
if s.is_empty() {
"untitled".to_string()
} else {
s
}
}
pub fn ensure_dir(path: &Path) -> Result<()> {
fs::create_dir_all(path).with_context(|| format!("creating {}", path.display()))?;
Ok(())
}
pub fn resolve_topic_path(
provided: Option<&Path>,
title: &str,
default_dir: &Path,
) -> Result<PathBuf> {
let filename = format!("{}.md", slugify(title));
match provided {
Some(path) if path.exists() && path.is_dir() => Ok(path.join(filename)),
Some(path) if path.extension().is_some() => Ok(path.to_path_buf()),
Some(path) => Ok(path.join(filename)),
None => Ok(default_dir.join(filename)),
}
}
pub fn read_markdown(path: &Path) -> Result<String> {
let raw = fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
Ok(raw)
}
pub fn write_markdown(path: &Path, content: &str) -> Result<()> {
if let Some(parent) = path.parent() {
ensure_dir(parent)?;
}
fs::write(path, content).with_context(|| format!("writing {}", path.display()))?;
Ok(())
}
pub fn yaml_scalar(value: &str) -> String {
let needs_quoting = value.is_empty()
|| value.contains(':')
|| value.contains('#')
|| value.contains('\n')
|| value.starts_with(['-', '?', '!', '&', '*', '|', '>', '@', '`', '%', '\'', '"', '['])
|| value.starts_with(" ");
if needs_quoting {
let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
format!("\"{}\"", escaped)
} else {
value.to_string()
}
}
pub fn strip_frontmatter(raw: &str) -> (HashMap<String, String>, String) {
let mut map = HashMap::new();
let text = raw.strip_prefix('\u{feff}').unwrap_or(raw);
let mut lines = text.lines();
if lines.next().map(str::trim_end) != Some("---") {
return (map, raw.to_string());
}
let mut body_lines: Vec<&str> = Vec::new();
let mut closed = false;
for line in &mut lines {
if line.trim_end() == "---" {
closed = true;
break;
}
if let Some((key, value)) = line.split_once(':') {
map.insert(key.trim().to_string(), unquote_yaml_scalar(value.trim()));
}
}
if !closed {
return (HashMap::new(), raw.to_string());
}
body_lines.extend(lines);
if body_lines.first() == Some(&"") {
body_lines.remove(0);
}
let mut body = body_lines.join("\n");
if raw.ends_with('\n') && !body.is_empty() {
body.push('\n');
}
(map, body)
}
fn unquote_yaml_scalar(value: &str) -> String {
let bytes = value.as_bytes();
if bytes.len() < 2 || bytes[0] != b'"' || bytes[bytes.len() - 1] != b'"' {
return value.to_string();
}
let inner = &value[1..value.len() - 1];
let mut out = String::with_capacity(inner.len());
let mut chars = inner.chars();
while let Some(c) = chars.next() {
if c == '\\' {
match chars.next() {
Some('"') => out.push('"'),
Some('\\') => out.push('\\'),
Some(other) => {
out.push('\\');
out.push(other);
}
None => out.push('\\'),
}
} else {
out.push(c);
}
}
out
}
pub fn current_utc_iso8601() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let days = (secs / 86_400) as i64;
let secs_of_day = secs % 86_400;
let hh = secs_of_day / 3600;
let mm = (secs_of_day % 3600) / 60;
let ss = secs_of_day % 60;
let (y, m, d) = civil_from_days(days);
format!("{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z", y, m, d, hh, mm, ss)
}
fn civil_from_days(z: i64) -> (i32, u32, u32) {
let z = z + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = (z - era * 146_097) as u64; let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; let y = yoe as i64 + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = doy - (153 * mp + 2) / 5 + 1; let m = if mp < 10 { mp + 3 } else { mp - 9 }; let y = y + if m <= 2 { 1 } else { 0 };
(y as i32, m as u32, d as u32)
}
fn color_mode() -> &'static str {
match std::env::var("DSC_COLOR") {
Ok(value) => match value.trim().to_ascii_lowercase().as_str() {
"always" => "always",
"never" => "never",
_ => "auto",
},
Err(_) => "auto",
}
}
fn color_allowed_for_stdout() -> bool {
if std::env::var_os("NO_COLOR").is_some() {
return false;
}
match color_mode() {
"always" => true,
"never" => false,
_ => std::io::stdout().is_terminal(),
}
}
fn discourse_color_code(key: &str) -> u8 {
const COLORS: [u8; 12] = [31, 32, 33, 34, 35, 36, 91, 92, 93, 94, 95, 96];
let hash = key.bytes().fold(0usize, |acc, b| {
acc.wrapping_mul(31).wrapping_add(b as usize)
});
COLORS[hash % COLORS.len()]
}
pub fn color_discourse_label(label: &str, key: &str) -> String {
if !color_allowed_for_stdout() {
return label.to_string();
}
let code = discourse_color_code(key);
format!("\x1b[1;{}m{}\x1b[0m", code, label)
}
pub fn parse_since_cutoff(input: &str) -> anyhow::Result<chrono::DateTime<chrono::Utc>> {
use anyhow::anyhow;
let trimmed = input.trim();
if trimmed.is_empty() {
return Err(anyhow!("empty --since value"));
}
if let Some(duration) = parse_relative_duration(trimmed) {
return Ok(chrono::Utc::now() - duration);
}
if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(trimmed) {
return Ok(dt.with_timezone(&chrono::Utc));
}
if let Ok(d) = chrono::NaiveDate::parse_from_str(trimmed, "%Y-%m-%d") {
return Ok(
chrono::NaiveDateTime::new(d, chrono::NaiveTime::from_hms_opt(0, 0, 0).unwrap())
.and_utc(),
);
}
Err(anyhow!(
"unrecognised --since value: {:?} (expected e.g. `7d`, `24h`, `30m`, `1w`, or an ISO-8601 timestamp)",
input
))
}
pub fn parse_relative_duration(input: &str) -> Option<chrono::Duration> {
let s = input.trim();
if s.len() < 2 {
return None;
}
let multi_char_units = [("min", 60i64)];
for (suffix, secs_per_unit) in multi_char_units {
if let Some(digits) = s.strip_suffix(suffix) {
let n: i64 = digits.parse().ok()?;
return Some(chrono::Duration::seconds(n * secs_per_unit));
}
}
let (digits, unit) = s.split_at(s.len() - 1);
let n: i64 = digits.parse().ok()?;
match unit {
"s" => Some(chrono::Duration::seconds(n)),
"h" => Some(chrono::Duration::hours(n)),
"d" => Some(chrono::Duration::days(n)),
"w" => Some(chrono::Duration::weeks(n)),
"m" => Some(chrono::Duration::days(n * 30)),
"y" => Some(chrono::Duration::days(n * 365)),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn slugify_simple_ascii() {
assert_eq!(slugify("Hello World"), "hello-world");
}
#[test]
fn slugify_collapses_runs_of_non_alnum() {
assert_eq!(slugify("a b___c!!!d"), "a-b-c-d");
}
#[test]
fn slugify_trims_leading_and_trailing_dashes() {
assert_eq!(slugify(" hello "), "hello");
assert_eq!(slugify("!!!foo!!!"), "foo");
}
#[test]
fn slugify_empty_input_returns_untitled() {
assert_eq!(slugify(""), "untitled");
assert_eq!(slugify(" "), "untitled");
assert_eq!(slugify("!!!"), "untitled");
}
#[test]
fn slugify_preserves_numbers() {
assert_eq!(slugify("Topic 42 - intro"), "topic-42-intro");
}
#[test]
fn slugify_lowercases() {
assert_eq!(slugify("ABCxyz"), "abcxyz");
}
#[test]
fn slugify_transliterates_unicode() {
assert_eq!(slugify("Café Tonight"), "cafe-tonight");
assert_eq!(slugify("Привет мир"), "privet-mir");
assert_eq!(slugify("日本語"), "ri-ben-yu");
}
#[test]
fn slugify_trims_both_ends_of_dashes() {
assert_eq!(slugify("-foo-"), "foo");
assert_eq!(slugify("---foo---bar---"), "foo-bar");
}
#[test]
fn normalize_baseurl_strips_trailing_slashes() {
assert_eq!(normalize_baseurl("https://example.com/"), "https://example.com");
assert_eq!(normalize_baseurl("https://example.com///"), "https://example.com");
assert_eq!(normalize_baseurl("https://example.com"), "https://example.com");
}
#[test]
fn normalize_baseurl_preserves_no_trailing() {
assert_eq!(normalize_baseurl(""), "");
}
#[test]
fn resolve_topic_path_uses_title_when_no_path_given() {
let default_dir = Path::new("/tmp/dsc-test");
let out = resolve_topic_path(None, "Hello World", default_dir).unwrap();
assert_eq!(out, default_dir.join("hello-world.md"));
}
#[test]
fn resolve_topic_path_uses_given_path_with_extension() {
let default_dir = Path::new("/tmp/dsc-test");
let explicit = Path::new("/tmp/custom.md");
let out = resolve_topic_path(Some(explicit), "Ignored", default_dir).unwrap();
assert_eq!(out, explicit);
}
#[test]
fn parse_relative_duration_common_units() {
assert_eq!(
parse_relative_duration("7d"),
Some(chrono::Duration::days(7))
);
assert_eq!(
parse_relative_duration("24h"),
Some(chrono::Duration::hours(24))
);
assert_eq!(
parse_relative_duration("30min"),
Some(chrono::Duration::minutes(30))
);
assert_eq!(
parse_relative_duration("1w"),
Some(chrono::Duration::weeks(1))
);
assert_eq!(
parse_relative_duration("90s"),
Some(chrono::Duration::seconds(90))
);
}
#[test]
fn parse_relative_duration_rejects_nonsense() {
assert!(parse_relative_duration("").is_none());
assert!(parse_relative_duration("d").is_none());
assert!(parse_relative_duration("7x").is_none());
assert!(parse_relative_duration("abc").is_none());
assert!(parse_relative_duration("3M").is_none()); }
#[test]
fn parse_relative_duration_treats_m_as_months() {
assert_eq!(
parse_relative_duration("1m"),
Some(chrono::Duration::days(30))
);
assert_eq!(
parse_relative_duration("3m"),
Some(chrono::Duration::days(90))
);
}
#[test]
fn parse_relative_duration_minutes_via_min_suffix() {
assert_eq!(
parse_relative_duration("5min"),
Some(chrono::Duration::minutes(5))
);
assert_eq!(
parse_relative_duration("90min"),
Some(chrono::Duration::minutes(90))
);
}
#[test]
fn parse_relative_duration_accepts_years_as_365d() {
assert_eq!(
parse_relative_duration("1y"),
Some(chrono::Duration::days(365))
);
assert_eq!(
parse_relative_duration("2y"),
Some(chrono::Duration::days(730))
);
}
#[test]
fn parse_since_cutoff_iso_date() {
let cutoff = parse_since_cutoff("2026-01-01").unwrap();
assert_eq!(cutoff.to_rfc3339(), "2026-01-01T00:00:00+00:00");
}
#[test]
fn parse_since_cutoff_iso_timestamp() {
let cutoff = parse_since_cutoff("2026-04-15T12:30:00Z").unwrap();
assert_eq!(cutoff.to_rfc3339(), "2026-04-15T12:30:00+00:00");
}
#[test]
fn parse_since_cutoff_relative_is_in_the_past() {
let now = chrono::Utc::now();
let cutoff = parse_since_cutoff("7d").unwrap();
let diff = now - cutoff;
assert!(
(diff - chrono::Duration::days(7)).num_seconds().abs() < 2,
"expected ~7 day delta, got {}",
diff
);
}
#[test]
fn parse_since_cutoff_rejects_garbage() {
assert!(parse_since_cutoff("not a date").is_err());
assert!(parse_since_cutoff("").is_err());
}
#[test]
fn yaml_scalar_leaves_simple_values_bare() {
assert_eq!(yaml_scalar("Dependency management"), "Dependency management");
assert_eq!(yaml_scalar("Topic 42"), "Topic 42");
}
#[test]
fn yaml_scalar_quotes_when_needed() {
assert_eq!(yaml_scalar("a: b"), "\"a: b\"");
assert_eq!(yaml_scalar("# hash"), "\"# hash\"");
assert_eq!(yaml_scalar("- leading dash"), "\"- leading dash\"");
assert_eq!(yaml_scalar("she said \"hi\""), "she said \"hi\"");
assert_eq!(yaml_scalar("a: \"b\""), "\"a: \\\"b\\\"\"");
}
#[test]
fn strip_frontmatter_parses_block_and_body() {
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";
let (front, body) = strip_frontmatter(raw);
assert_eq!(front.get("topic_id").map(String::as_str), Some("412"));
assert_eq!(
front.get("title").map(String::as_str),
Some("Dependency management")
);
assert_eq!(
front.get("url").map(String::as_str),
Some("https://forum.rcpch.tech/t/dependency-management/412")
);
assert_eq!(body, "Body line one.\nBody line two.\n");
}
#[test]
fn strip_frontmatter_absent_returns_empty_map_and_full_body() {
let raw = "# Heading\n\nNo front matter here.\n";
let (front, body) = strip_frontmatter(raw);
assert!(front.is_empty());
assert_eq!(body, raw);
}
#[test]
fn strip_frontmatter_unclosed_fence_is_not_front_matter() {
let raw = "---\ntitle: oops\nstill body, no closing fence\n";
let (front, body) = strip_frontmatter(raw);
assert!(front.is_empty());
assert_eq!(body, raw);
}
#[test]
fn strip_frontmatter_preserves_horizontal_rules_in_body() {
let raw = "---\ntopic_id: 7\n---\n\nIntro.\n\n---\n\nAfter the rule.\n";
let (front, body) = strip_frontmatter(raw);
assert_eq!(front.get("topic_id").map(String::as_str), Some("7"));
assert_eq!(body, "Intro.\n\n---\n\nAfter the rule.\n");
}
#[test]
fn strip_frontmatter_unquotes_yaml_scalar_values() {
let title = "Intro: getting started";
let raw = format!("---\ntitle: {}\ntopic_id: 3\n---\n\nbody\n", yaml_scalar(title));
let (front, body) = strip_frontmatter(&raw);
assert_eq!(front.get("title").map(String::as_str), Some(title));
assert_eq!(front.get("topic_id").map(String::as_str), Some("3"));
assert_eq!(body, "body\n");
}
#[test]
fn strip_frontmatter_leaves_url_with_colons_intact() {
let raw = "---\nurl: https://forum.rcpch.tech/t/x/9\n---\n\nbody\n";
let (front, _) = strip_frontmatter(raw);
assert_eq!(
front.get("url").map(String::as_str),
Some("https://forum.rcpch.tech/t/x/9")
);
}
#[test]
fn strip_frontmatter_tolerates_leading_bom() {
let raw = "\u{feff}---\ntopic_id: 99\n---\n\nbody\n";
let (front, body) = strip_frontmatter(raw);
assert_eq!(front.get("topic_id").map(String::as_str), Some("99"));
assert_eq!(body, "body\n");
}
#[test]
fn current_utc_iso8601_has_expected_shape() {
let s = current_utc_iso8601();
assert_eq!(s.len(), 20, "got {s:?}");
assert!(s.ends_with('Z'));
assert_eq!(&s[4..5], "-");
assert_eq!(&s[10..11], "T");
}
#[test]
fn civil_from_days_matches_known_dates() {
assert_eq!(civil_from_days(0), (1970, 1, 1));
assert_eq!(civil_from_days(20614), (2026, 6, 10));
assert_eq!(civil_from_days(19782), (2024, 2, 29));
}
}