1use anyhow::{Context, Result};
2use std::fs;
3use std::io::IsTerminal;
4use std::path::{Path, PathBuf};
5
6pub fn normalize_baseurl(baseurl: &str) -> String {
8 baseurl.trim_end_matches('/').to_string()
9}
10
11pub 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
37pub fn ensure_dir(path: &Path) -> Result<()> {
39 fs::create_dir_all(path).with_context(|| format!("creating {}", path.display()))?;
40 Ok(())
41}
42
43pub 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
58pub 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
64pub 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
111pub 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 if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(trimmed) {
128 return Ok(dt.with_timezone(&chrono::Utc));
129 }
130 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
144pub fn parse_relative_duration(input: &str) -> Option<chrono::Duration> {
161 let s = input.trim();
162 if s.len() < 2 {
163 return None;
164 }
165 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()); }
283
284 #[test]
285 fn parse_relative_duration_treats_m_as_months() {
286 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 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