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 {
20 let s = slug::slugify(input);
21 if s.is_empty() {
22 "untitled".to_string()
23 } else {
24 s
25 }
26}
27
28pub fn ensure_dir(path: &Path) -> Result<()> {
30 fs::create_dir_all(path).with_context(|| format!("creating {}", path.display()))?;
31 Ok(())
32}
33
34pub 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
49pub 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
55pub 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
102pub 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 if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(trimmed) {
119 return Ok(dt.with_timezone(&chrono::Utc));
120 }
121 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
135pub fn parse_relative_duration(input: &str) -> Option<chrono::Duration> {
152 let s = input.trim();
153 if s.len() < 2 {
154 return None;
155 }
156 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 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 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()); }
293
294 #[test]
295 fn parse_relative_duration_treats_m_as_months() {
296 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 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