1use anyhow::{Context, Result};
2use std::collections::HashMap;
3use std::fs;
4use std::io::IsTerminal;
5use std::path::{Path, PathBuf};
6
7pub fn normalize_baseurl(baseurl: &str) -> String {
9 baseurl.trim_end_matches('/').to_string()
10}
11
12pub 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
29pub fn ensure_dir(path: &Path) -> Result<()> {
31 fs::create_dir_all(path).with_context(|| format!("creating {}", path.display()))?;
32 Ok(())
33}
34
35pub 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
50pub 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
56pub 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
65pub 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
85pub 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 return (HashMap::new(), raw.to_string());
130 }
131
132 body_lines.extend(lines);
133 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
144fn 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
174pub 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 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
194fn 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; let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; let y = yoe as i64 + era * 400;
202 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 };
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
248pub 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 if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(trimmed) {
265 return Ok(dt.with_timezone(&chrono::Utc));
266 }
267 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
282pub fn parse_relative_duration(input: &str) -> Option<chrono::Duration> {
299 let s = input.trim();
300 if s.len() < 2 {
301 return None;
302 }
303 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 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 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()); }
449
450 #[test]
451 fn parse_relative_duration_treats_m_as_months() {
452 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 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 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 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 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 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 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 assert_eq!(civil_from_days(0), (1970, 1, 1));
629 assert_eq!(civil_from_days(20614), (2026, 6, 10));
631 assert_eq!(civil_from_days(19782), (2024, 2, 29));
633 }
634}