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#[cfg(test)]
112mod tests {
113    use super::*;
114
115    #[test]
116    fn slugify_simple_ascii() {
117        assert_eq!(slugify("Hello World"), "hello-world");
118    }
119
120    #[test]
121    fn slugify_collapses_runs_of_non_alnum() {
122        assert_eq!(slugify("a   b___c!!!d"), "a-b-c-d");
123    }
124
125    #[test]
126    fn slugify_trims_leading_and_trailing_dashes() {
127        assert_eq!(slugify("   hello   "), "hello");
128        assert_eq!(slugify("!!!foo!!!"), "foo");
129    }
130
131    #[test]
132    fn slugify_empty_input_returns_untitled() {
133        assert_eq!(slugify(""), "untitled");
134        assert_eq!(slugify("   "), "untitled");
135        assert_eq!(slugify("!!!"), "untitled");
136    }
137
138    #[test]
139    fn slugify_preserves_numbers() {
140        assert_eq!(slugify("Topic 42 - intro"), "topic-42-intro");
141    }
142
143    #[test]
144    fn slugify_lowercases() {
145        assert_eq!(slugify("ABCxyz"), "abcxyz");
146    }
147
148    #[test]
149    fn normalize_baseurl_strips_trailing_slashes() {
150        assert_eq!(normalize_baseurl("https://example.com/"), "https://example.com");
151        assert_eq!(normalize_baseurl("https://example.com///"), "https://example.com");
152        assert_eq!(normalize_baseurl("https://example.com"), "https://example.com");
153    }
154
155    #[test]
156    fn normalize_baseurl_preserves_no_trailing() {
157        assert_eq!(normalize_baseurl(""), "");
158    }
159
160    #[test]
161    fn resolve_topic_path_uses_title_when_no_path_given() {
162        let default_dir = Path::new("/tmp/dsc-test");
163        let out = resolve_topic_path(None, "Hello World", default_dir).unwrap();
164        assert_eq!(out, default_dir.join("hello-world.md"));
165    }
166
167    #[test]
168    fn resolve_topic_path_uses_given_path_with_extension() {
169        let default_dir = Path::new("/tmp/dsc-test");
170        let explicit = Path::new("/tmp/custom.md");
171        let out = resolve_topic_path(Some(explicit), "Ignored", default_dir).unwrap();
172        assert_eq!(out, explicit);
173    }
174}