Skip to main content

dsc/
utils.rs

1use anyhow::{Context, Result};
2use std::fs;
3use std::io::IsTerminal;
4use std::path::{Path, PathBuf};
5
6/// Trim trailing slashes from a base URL.
7pub fn normalize_baseurl(baseurl: &str) -> String {
8    baseurl.trim_end_matches('/').to_string()
9}
10
11/// Create a URL-safe slug from arbitrary input.
12///
13/// Wraps the [`slug`] crate, which transliterates Unicode (so `"Café"`
14/// becomes `"cafe"`, Cyrillic and CJK get sensible romanisations) and
15/// emits the standard kebab-case shape used across most slug-generating
16/// tooling. Returns `"untitled"` when the slug would otherwise be empty
17/// (the `slug` crate itself returns an empty string for input that has
18/// no transliterable characters).
19pub fn slugify(input: &str) -> String {
20    let s = slug::slugify(input);
21    if s.is_empty() {
22        "untitled".to_string()
23    } else {
24        s
25    }
26}
27
28/// Ensure a directory exists.
29pub fn ensure_dir(path: &Path) -> Result<()> {
30    fs::create_dir_all(path).with_context(|| format!("creating {}", path.display()))?;
31    Ok(())
32}
33
34/// Resolve a topic path from a user-provided path and a topic title.
35pub fn resolve_topic_path(
36    provided: Option<&Path>,
37    title: &str,
38    default_dir: &Path,
39) -> Result<PathBuf> {
40    let filename = format!("{}.md", slugify(title));
41    match provided {
42        Some(path) if path.exists() && path.is_dir() => Ok(path.join(filename)),
43        Some(path) if path.extension().is_some() => Ok(path.to_path_buf()),
44        Some(path) => Ok(path.join(filename)),
45        None => Ok(default_dir.join(filename)),
46    }
47}
48
49/// Read a Markdown file.
50pub fn read_markdown(path: &Path) -> Result<String> {
51    let raw = fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
52    Ok(raw)
53}
54
55/// Write a Markdown file, creating parent directories if needed.
56pub fn write_markdown(path: &Path, content: &str) -> Result<()> {
57    if let Some(parent) = path.parent() {
58        ensure_dir(parent)?;
59    }
60    fs::write(path, content).with_context(|| format!("writing {}", path.display()))?;
61    Ok(())
62}
63
64fn color_mode() -> &'static str {
65    match std::env::var("DSC_COLOR") {
66        Ok(value) => match value.trim().to_ascii_lowercase().as_str() {
67            "always" => "always",
68            "never" => "never",
69            _ => "auto",
70        },
71        Err(_) => "auto",
72    }
73}
74
75fn color_allowed_for_stdout() -> bool {
76    if std::env::var_os("NO_COLOR").is_some() {
77        return false;
78    }
79    match color_mode() {
80        "always" => true,
81        "never" => false,
82        _ => std::io::stdout().is_terminal(),
83    }
84}
85
86fn discourse_color_code(key: &str) -> u8 {
87    const COLORS: [u8; 12] = [31, 32, 33, 34, 35, 36, 91, 92, 93, 94, 95, 96];
88    let hash = key.bytes().fold(0usize, |acc, b| {
89        acc.wrapping_mul(31).wrapping_add(b as usize)
90    });
91    COLORS[hash % COLORS.len()]
92}
93
94pub fn color_discourse_label(label: &str, key: &str) -> String {
95    if !color_allowed_for_stdout() {
96        return label.to_string();
97    }
98    let code = discourse_color_code(key);
99    format!("\x1b[1;{}m{}\x1b[0m", code, label)
100}
101
102/// Parse a `--since`-style value. Accepts either a relative duration
103/// (`7d`, `24h`, `30m`, `1w`, `90s`) or an ISO-8601 absolute timestamp
104/// (`2026-04-01`, `2026-04-01T12:00:00Z`). Returns the resulting cutoff
105/// instant (now - duration, or the ISO value itself).
106pub fn parse_since_cutoff(input: &str) -> anyhow::Result<chrono::DateTime<chrono::Utc>> {
107    use anyhow::anyhow;
108    let trimmed = input.trim();
109    if trimmed.is_empty() {
110        return Err(anyhow!("empty --since value"));
111    }
112
113    if let Some(duration) = parse_relative_duration(trimmed) {
114        return Ok(chrono::Utc::now() - duration);
115    }
116
117    // Try RFC3339 (full timestamp).
118    if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(trimmed) {
119        return Ok(dt.with_timezone(&chrono::Utc));
120    }
121    // Try date-only — treat as midnight UTC.
122    if let Ok(d) = chrono::NaiveDate::parse_from_str(trimmed, "%Y-%m-%d") {
123        return Ok(
124            chrono::NaiveDateTime::new(d, chrono::NaiveTime::from_hms_opt(0, 0, 0).unwrap())
125                .and_utc(),
126        );
127    }
128
129    Err(anyhow!(
130        "unrecognised --since value: {:?} (expected e.g. `7d`, `24h`, `30m`, `1w`, or an ISO-8601 timestamp)",
131        input
132    ))
133}
134
135/// Parse a relative duration like `7d`, `24h`, `1w`, `1m`, `90s`, `1y`.
136///
137/// Calendar units (`m`, `y`) are imprecise; for windows we use these
138/// conventions:
139///
140/// - `s` — seconds
141/// - `min` — minutes (use this rather than `m` to avoid the months-vs-minutes
142///   ambiguity)
143/// - `h` — hours
144/// - `d` — days
145/// - `w` — weeks (= 7 days)
146/// - `m` — **months** (= 30 days; matches what most users mean by "1m" in
147///   analytics windows)
148/// - `y` — years (= 365 days)
149///
150/// For exact calendar math, pass an ISO-8601 timestamp instead.
151pub fn parse_relative_duration(input: &str) -> Option<chrono::Duration> {
152    let s = input.trim();
153    if s.len() < 2 {
154        return None;
155    }
156    // Order matters: `min` must be tried before `m` so we don't read
157    // "10min" as "10mi" + "n".
158    let multi_char_units = [("min", 60i64)];
159    for (suffix, secs_per_unit) in multi_char_units {
160        if let Some(digits) = s.strip_suffix(suffix) {
161            let n: i64 = digits.parse().ok()?;
162            return Some(chrono::Duration::seconds(n * secs_per_unit));
163        }
164    }
165    let (digits, unit) = s.split_at(s.len() - 1);
166    let n: i64 = digits.parse().ok()?;
167    match unit {
168        "s" => Some(chrono::Duration::seconds(n)),
169        "h" => Some(chrono::Duration::hours(n)),
170        "d" => Some(chrono::Duration::days(n)),
171        "w" => Some(chrono::Duration::weeks(n)),
172        "m" => Some(chrono::Duration::days(n * 30)),
173        "y" => Some(chrono::Duration::days(n * 365)),
174        _ => None,
175    }
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181
182    #[test]
183    fn slugify_simple_ascii() {
184        assert_eq!(slugify("Hello World"), "hello-world");
185    }
186
187    #[test]
188    fn slugify_collapses_runs_of_non_alnum() {
189        assert_eq!(slugify("a   b___c!!!d"), "a-b-c-d");
190    }
191
192    #[test]
193    fn slugify_trims_leading_and_trailing_dashes() {
194        assert_eq!(slugify("   hello   "), "hello");
195        assert_eq!(slugify("!!!foo!!!"), "foo");
196    }
197
198    #[test]
199    fn slugify_empty_input_returns_untitled() {
200        assert_eq!(slugify(""), "untitled");
201        assert_eq!(slugify("   "), "untitled");
202        assert_eq!(slugify("!!!"), "untitled");
203    }
204
205    #[test]
206    fn slugify_preserves_numbers() {
207        assert_eq!(slugify("Topic 42 - intro"), "topic-42-intro");
208    }
209
210    #[test]
211    fn slugify_lowercases() {
212        assert_eq!(slugify("ABCxyz"), "abcxyz");
213    }
214
215    #[test]
216    fn slugify_transliterates_unicode() {
217        // The whole reason for adopting the `slug` crate: pre-existing
218        // ASCII behaviour preserved, plus accented Latin, Cyrillic, and
219        // CJK now produce meaningful slugs instead of "untitled".
220        assert_eq!(slugify("Café Tonight"), "cafe-tonight");
221        assert_eq!(slugify("Привет мир"), "privet-mir");
222        assert_eq!(slugify("日本語"), "ri-ben-yu");
223    }
224
225    #[test]
226    fn slugify_trims_both_ends_of_dashes() {
227        // Regression guard: catches a latent bug from a contributor PR
228        // that only trimmed trailing dashes. The `slug` crate handles
229        // both ends correctly.
230        assert_eq!(slugify("-foo-"), "foo");
231        assert_eq!(slugify("---foo---bar---"), "foo-bar");
232    }
233
234    #[test]
235    fn normalize_baseurl_strips_trailing_slashes() {
236        assert_eq!(normalize_baseurl("https://example.com/"), "https://example.com");
237        assert_eq!(normalize_baseurl("https://example.com///"), "https://example.com");
238        assert_eq!(normalize_baseurl("https://example.com"), "https://example.com");
239    }
240
241    #[test]
242    fn normalize_baseurl_preserves_no_trailing() {
243        assert_eq!(normalize_baseurl(""), "");
244    }
245
246    #[test]
247    fn resolve_topic_path_uses_title_when_no_path_given() {
248        let default_dir = Path::new("/tmp/dsc-test");
249        let out = resolve_topic_path(None, "Hello World", default_dir).unwrap();
250        assert_eq!(out, default_dir.join("hello-world.md"));
251    }
252
253    #[test]
254    fn resolve_topic_path_uses_given_path_with_extension() {
255        let default_dir = Path::new("/tmp/dsc-test");
256        let explicit = Path::new("/tmp/custom.md");
257        let out = resolve_topic_path(Some(explicit), "Ignored", default_dir).unwrap();
258        assert_eq!(out, explicit);
259    }
260
261    #[test]
262    fn parse_relative_duration_common_units() {
263        assert_eq!(
264            parse_relative_duration("7d"),
265            Some(chrono::Duration::days(7))
266        );
267        assert_eq!(
268            parse_relative_duration("24h"),
269            Some(chrono::Duration::hours(24))
270        );
271        assert_eq!(
272            parse_relative_duration("30min"),
273            Some(chrono::Duration::minutes(30))
274        );
275        assert_eq!(
276            parse_relative_duration("1w"),
277            Some(chrono::Duration::weeks(1))
278        );
279        assert_eq!(
280            parse_relative_duration("90s"),
281            Some(chrono::Duration::seconds(90))
282        );
283    }
284
285    #[test]
286    fn parse_relative_duration_rejects_nonsense() {
287        assert!(parse_relative_duration("").is_none());
288        assert!(parse_relative_duration("d").is_none());
289        assert!(parse_relative_duration("7x").is_none());
290        assert!(parse_relative_duration("abc").is_none());
291        assert!(parse_relative_duration("3M").is_none()); // case-sensitive
292    }
293
294    #[test]
295    fn parse_relative_duration_treats_m_as_months() {
296        // `m` = months (= 30 days). Users naturally write `1m` for "one
297        // month" in analytics windows; we match that. Use `min` for the
298        // rare minutes case.
299        assert_eq!(
300            parse_relative_duration("1m"),
301            Some(chrono::Duration::days(30))
302        );
303        assert_eq!(
304            parse_relative_duration("3m"),
305            Some(chrono::Duration::days(90))
306        );
307    }
308
309    #[test]
310    fn parse_relative_duration_minutes_via_min_suffix() {
311        assert_eq!(
312            parse_relative_duration("5min"),
313            Some(chrono::Duration::minutes(5))
314        );
315        assert_eq!(
316            parse_relative_duration("90min"),
317            Some(chrono::Duration::minutes(90))
318        );
319    }
320
321    #[test]
322    fn parse_relative_duration_accepts_years_as_365d() {
323        assert_eq!(
324            parse_relative_duration("1y"),
325            Some(chrono::Duration::days(365))
326        );
327        assert_eq!(
328            parse_relative_duration("2y"),
329            Some(chrono::Duration::days(730))
330        );
331    }
332
333    #[test]
334    fn parse_since_cutoff_iso_date() {
335        let cutoff = parse_since_cutoff("2026-01-01").unwrap();
336        assert_eq!(cutoff.to_rfc3339(), "2026-01-01T00:00:00+00:00");
337    }
338
339    #[test]
340    fn parse_since_cutoff_iso_timestamp() {
341        let cutoff = parse_since_cutoff("2026-04-15T12:30:00Z").unwrap();
342        assert_eq!(cutoff.to_rfc3339(), "2026-04-15T12:30:00+00:00");
343    }
344
345    #[test]
346    fn parse_since_cutoff_relative_is_in_the_past() {
347        let now = chrono::Utc::now();
348        let cutoff = parse_since_cutoff("7d").unwrap();
349        let diff = now - cutoff;
350        // Should be very close to 7 days (within a second).
351        assert!(
352            (diff - chrono::Duration::days(7)).num_seconds().abs() < 2,
353            "expected ~7 day delta, got {}",
354            diff
355        );
356    }
357
358    #[test]
359    fn parse_since_cutoff_rejects_garbage() {
360        assert!(parse_since_cutoff("not a date").is_err());
361        assert!(parse_since_cutoff("").is_err());
362    }
363}
364