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