use std::time::{SystemTime, UNIX_EPOCH};
pub fn time_ago(t: u64) -> String {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(t);
let d = now.saturating_sub(t);
match d {
0..=59 => "just now".to_string(),
60..=3599 => format!("{}m ago", d / 60),
3600..=86399 => format!("{}h ago", d / 3600),
86400..=2591999 => format!("{}d ago", d / 86400),
2592000..=31535999 => format!("{}mo ago", d / 2_592_000),
_ => format!("{}y ago", d / 31_536_000),
}
}
pub fn domain(url: &str) -> Option<String> {
let after_scheme = url.split("://").nth(1).unwrap_or(url);
let host = after_scheme.split(['/', '?', '#']).next().unwrap_or("");
let host = host.strip_prefix("www.").unwrap_or(host);
(!host.is_empty()).then(|| host.to_string())
}
pub fn clean_html(s: &str) -> String {
if s.is_empty() {
return String::new();
}
let with_breaks = s
.replace("<p>", "\n\n")
.replace("</p>", "")
.replace("<br>", "\n")
.replace("<br/>", "\n")
.replace("<br />", "\n");
let mut stripped = String::with_capacity(with_breaks.len());
let mut in_tag = false;
for ch in with_breaks.chars() {
match ch {
'<' => in_tag = true,
'>' => in_tag = false,
_ if !in_tag => stripped.push(ch),
_ => {}
}
}
decode_entities(&stripped).trim().to_string()
}
pub fn extract_links(html: &str) -> Vec<String> {
let mut links: Vec<String> = Vec::new();
let mut rest = html;
while let Some(at) = rest.find("href=\"") {
rest = &rest[at + 6..];
let Some(end) = rest.find('"') else {
break;
};
let url = decode_entities(&rest[..end]);
rest = &rest[end..];
let is_web = url.starts_with("https://") || url.starts_with("http://");
if is_web && !links.contains(&url) {
links.push(url);
}
}
links
}
fn decode_entities(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut rest = s;
while let Some(amp) = rest.find('&') {
out.push_str(&rest[..amp]);
let tail = &rest[amp..];
if let Some(semi) = tail.find(';') {
let entity = &tail[1..semi];
if entity.len() <= 10
&& let Some(ch) = decode_one(entity)
{
out.push(ch);
rest = &tail[semi + 1..];
continue;
}
}
out.push('&');
rest = &tail[1..];
}
out.push_str(rest);
out
}
fn decode_one(entity: &str) -> Option<char> {
match entity {
"amp" => Some('&'),
"lt" => Some('<'),
"gt" => Some('>'),
"quot" => Some('"'),
"apos" => Some('\''),
"nbsp" => Some(' '),
"hellip" => Some('…'),
"mdash" => Some('—'),
"ndash" => Some('–'),
_ => {
let num = entity.strip_prefix('#')?;
let code = if let Some(hex) = num.strip_prefix(['x', 'X']) {
u32::from_str_radix(hex, 16).ok()?
} else {
num.parse::<u32>().ok()?
};
char::from_u32(code)
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Run {
pub text: String,
pub italic: bool,
pub link: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Block {
Para(Vec<Run>),
Quote(Vec<Run>),
Code(Vec<String>),
}
pub fn blocks(html: &str) -> Vec<Block> {
let mut out = Vec::new();
let mut para: Vec<Run> = Vec::new();
let mut code: Option<String> = None;
let (mut italic, mut link) = (false, false);
let mut rest = html;
while !rest.is_empty() {
let (text, after) = match rest.find('<') {
Some(lt) => (&rest[..lt], &rest[lt..]),
None => (rest, ""),
};
if !text.is_empty() {
let text = decode_entities(text);
match &mut code {
Some(c) => c.push_str(&text),
None => push_run(&mut para, text, italic, link),
}
}
if after.is_empty() {
break;
}
let Some(gt) = after.find('>') else {
break; };
let tag = after[1..gt].trim().to_ascii_lowercase();
rest = &after[gt + 1..];
let name = tag.split_whitespace().next().unwrap_or("");
match name {
"p" if code.is_none() => end_para(&mut out, &mut para),
"pre" => {
end_para(&mut out, &mut para);
code = Some(String::new());
}
"/pre" => {
if let Some(c) = code.take() {
end_code(&mut out, &c);
}
}
"i" | "em" => italic = true,
"/i" | "/em" => italic = false,
"a" => link = true,
"/a" => link = false,
_ => {}
}
}
if let Some(c) = code {
end_code(&mut out, &c);
}
end_para(&mut out, &mut para);
out
}
fn push_run(para: &mut Vec<Run>, text: String, italic: bool, link: bool) {
let text = text.replace('\n', " ");
match para.last_mut() {
Some(last) if last.italic == italic && last.link == link => last.text.push_str(&text),
_ => para.push(Run { text, italic, link }),
}
}
fn end_para(out: &mut Vec<Block>, para: &mut Vec<Run>) {
let mut runs = std::mem::take(para);
if runs.iter().all(|r| r.text.trim().is_empty()) {
return;
}
let quoted = runs
.first()
.is_some_and(|r| r.text.trim_start().starts_with('>'));
if quoted {
let first = &mut runs[0];
first.text = first
.text
.trim_start()
.trim_start_matches('>')
.trim_start()
.to_string();
out.push(Block::Quote(runs));
} else {
out.push(Block::Para(runs));
}
}
fn end_code(out: &mut Vec<Block>, code: &str) {
let lines: Vec<&str> = code.trim_matches('\n').lines().collect();
let indent = lines
.iter()
.filter(|l| !l.trim().is_empty())
.map(|l| l.len() - l.trim_start().len())
.min()
.unwrap_or(0);
let lines: Vec<String> = lines
.iter()
.map(|l| l.get(indent..).unwrap_or("").trim_end().to_string())
.collect();
if !lines.iter().all(|l| l.is_empty()) {
out.push(Block::Code(lines));
}
}
pub fn wrap_runs(runs: &[Run], width: usize) -> Vec<Vec<Run>> {
let width = width.max(8);
let mut words: Vec<Vec<Run>> = Vec::new();
let mut glued = false;
for run in runs {
let starts_with_space = run.text.starts_with(char::is_whitespace);
for (i, piece) in run.text.split_whitespace().enumerate() {
let piece = Run {
text: piece.to_string(),
..run.clone()
};
match words.last_mut() {
Some(word) if i == 0 && glued && !starts_with_space => word.push(piece),
_ => words.push(vec![piece]),
}
}
glued = !run.text.is_empty() && !run.text.ends_with(char::is_whitespace);
}
let mut lines: Vec<Vec<Run>> = Vec::new();
let mut line: Vec<Run> = Vec::new();
let mut line_len = 0;
for word in words {
let wlen: usize = word.iter().map(|p| p.text.chars().count()).sum();
if line_len > 0 && line_len + 1 + wlen > width {
lines.push(std::mem::take(&mut line));
line_len = 0;
}
if line_len > 0 {
push_text(&mut line, " ", word[0].italic, word[0].link);
line_len += 1;
}
for piece in word {
push_text(&mut line, &piece.text, piece.italic, piece.link);
}
line_len += wlen;
}
if !line.is_empty() {
lines.push(line);
}
lines
}
fn push_text(line: &mut Vec<Run>, text: &str, italic: bool, link: bool) {
match line.last_mut() {
Some(last) if last.italic == italic && last.link == link => last.text.push_str(text),
_ => line.push(Run {
text: text.to_string(),
italic,
link,
}),
}
}
pub fn wrap(text: &str, width: usize) -> Vec<String> {
let width = width.max(8);
let mut lines = Vec::new();
for para in text.split('\n') {
if para.trim().is_empty() {
lines.push(String::new());
continue;
}
let mut cur = String::new();
let mut cur_len = 0;
for word in para.split_whitespace() {
let wlen = word.chars().count();
if cur_len == 0 {
cur.push_str(word);
cur_len = wlen;
} else if cur_len + 1 + wlen <= width {
cur.push(' ');
cur.push_str(word);
cur_len += 1 + wlen;
} else {
lines.push(std::mem::take(&mut cur));
cur.push_str(word);
cur_len = wlen;
}
}
if !cur.is_empty() {
lines.push(cur);
}
}
lines
}
pub fn find_ci(haystack: &str, needle: &str) -> Vec<std::ops::Range<usize>> {
if needle.is_empty() {
return Vec::new();
}
let hay = haystack.to_ascii_lowercase();
let needle = needle.to_ascii_lowercase();
hay.match_indices(&needle)
.map(|(i, m)| i..i + m.len())
.collect()
}
pub fn contains_ci(haystack: &str, needle: &str) -> bool {
!needle.is_empty()
&& haystack
.to_ascii_lowercase()
.contains(&needle.to_ascii_lowercase())
}
pub fn open_in_browser(url: &str) -> bool {
let Some(url) = safe_url(url) else {
return false;
};
let from_env = browser_spec().and_then(|b| build_command(&b, &url));
match from_env.or_else(|| browser_command(&url)) {
Some(mut c) => c.spawn().is_ok(),
None => false,
}
}
pub fn safe_url(url: &str) -> Option<String> {
let scheme_ok = ["https://", "http://"].iter().any(|scheme| {
url.get(..scheme.len())
.is_some_and(|s| s.eq_ignore_ascii_case(scheme))
});
if !scheme_ok {
return None;
}
let mut out = String::with_capacity(url.len());
for b in url.bytes() {
let unsafe_char = matches!(
b,
b'"' | b'<' | b'>' | b'\\' | b'^' | b'`' | b'{' | b'|' | b'}'
);
if b.is_ascii_graphic() && !unsafe_char {
out.push(b as char);
} else {
out.push_str(&format!("%{b:02X}"));
}
}
Some(out)
}
fn browser_spec() -> Option<String> {
["HN_TUI_BROWSER", "BROWSER"]
.into_iter()
.filter_map(|key| std::env::var(key).ok())
.find(|value| !value.trim().is_empty())
}
fn build_command(spec: &str, url: &str) -> Option<std::process::Command> {
let mut parts = spec.split_whitespace();
let program = parts.next()?;
let mut c = std::process::Command::new(program);
c.args(parts);
c.arg(url);
Some(c)
}
#[cfg(target_os = "macos")]
fn browser_command(url: &str) -> Option<std::process::Command> {
let mut c = std::process::Command::new("open");
c.arg(url);
Some(c)
}
#[cfg(target_os = "windows")]
fn browser_command(url: &str) -> Option<std::process::Command> {
Some(windows_command(url))
}
#[cfg(any(target_os = "windows", test))]
fn windows_command(url: &str) -> std::process::Command {
let mut c = std::process::Command::new("rundll32.exe");
c.args(["url.dll,FileProtocolHandler", url]);
c
}
#[cfg(all(unix, not(target_os = "macos")))]
fn browser_command(url: &str) -> Option<std::process::Command> {
let mut c = std::process::Command::new("xdg-open");
c.arg(url);
Some(c)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn domains_are_clean() {
assert_eq!(
domain("https://www.example.com/a/b?x=1").as_deref(),
Some("example.com")
);
assert_eq!(
domain("http://news.ycombinator.com").as_deref(),
Some("news.ycombinator.com")
);
assert_eq!(domain("not a url").as_deref(), Some("not a url"));
}
#[test]
fn html_is_decoded_and_stripped() {
let raw = "<p>Tom & Jerry say "hi"</p><p>line/two > one</p>";
assert_eq!(clean_html(raw), "Tom & Jerry say \"hi\"\n\nline/two > one");
}
#[test]
fn html_handles_multibyte_after_entity() {
let raw = "> There’s a certain level of wealth — you can’t earn that.";
assert_eq!(
clean_html(raw),
"> There’s a certain level of wealth — you can’t earn that."
);
}
#[test]
fn html_lone_ampersand_is_preserved() {
assert_eq!(clean_html("a & b &nope; c"), "a & b &nope; c");
assert_eq!(clean_html("Q&A"), "Q&A");
}
#[test]
fn html_keeps_link_text_drops_tags() {
let raw = r#"see <a href="https://x.com" rel="nofollow">https://x.com</a> now"#;
assert_eq!(clean_html(raw), "see https://x.com now");
}
#[test]
fn links_come_from_hrefs_not_the_abbreviated_text() {
let raw = concat!(
r#"see <a href="https://example.com/a/very/long/path" rel="nofollow">"#,
r#"https://example.com/a/very/l...</a>"#,
r#" and <a href="http://x.org">x</a>, again <a href="http://x.org">x</a>"#,
r#" <a href="javascript:alert(1)">no</a>"#,
);
assert_eq!(
extract_links(raw),
["https://example.com/a/very/long/path", "http://x.org"]
);
assert!(extract_links("no links here").is_empty());
}
fn run(text: &str) -> Run {
Run {
text: text.into(),
italic: false,
link: false,
}
}
#[test]
fn blocks_split_paragraphs_quotes_and_code() {
let html = "> quoted <i>bit</i><p>reply with <a href=\"https://x.dev\">x.dev</a>\
<p><pre><code> fn main() {\n x && y\n }\n</code></pre>after";
let b = blocks(html);
assert_eq!(b.len(), 4);
assert_eq!(
b[0],
Block::Quote(vec![
run("quoted "),
Run {
italic: true,
..run("bit")
}
])
);
assert_eq!(
b[1],
Block::Para(vec![
run("reply with "),
Run {
link: true,
..run("x.dev")
}
])
);
assert_eq!(
b[2],
Block::Code(vec!["fn main() {".into(), " x && y".into(), "}".into()])
);
assert_eq!(b[3], Block::Para(vec![run("after")]));
}
#[test]
fn blocks_treat_newlines_outside_code_as_spaces_and_skip_empty_paragraphs() {
assert_eq!(
blocks("one\ntwo<p><p> <p>three"),
[
Block::Para(vec![run("one two")]),
Block::Para(vec![run("three")])
]
);
assert!(blocks("").is_empty());
}
#[test]
fn wrap_runs_keeps_styles_and_glued_words_together() {
let italic = |t: &str| Run {
italic: true,
..run(t)
};
let runs = [run("aaaa bbbb "), italic("cc"), run("dd eeee")];
let lines = wrap_runs(&runs, 10);
assert_eq!(
lines,
[vec![run("aaaa bbbb")], vec![italic("cc"), run("dd eeee")]]
);
}
#[test]
fn wrap_respects_width_and_blank_lines() {
let out = wrap("the quick brown fox\n\njumps", 9);
assert_eq!(out, vec!["the quick", "brown fox", "", "jumps"]);
}
#[test]
fn browser_command_splits_args_and_appends_url() {
let c = build_command("firefox --new-window", "https://example.com").unwrap();
assert_eq!(c.get_program(), "firefox");
let args: Vec<_> = c.get_args().collect();
assert_eq!(args, ["--new-window", "https://example.com"]);
}
#[test]
fn only_web_urls_are_opened() {
assert!(safe_url("https://example.com").is_some());
assert!(safe_url("HTTP://EXAMPLE.COM").is_some());
assert!(safe_url("file:///etc/passwd").is_none());
assert!(safe_url("javascript:alert(1)").is_none());
assert!(safe_url("calc.exe").is_none());
assert!(safe_url("").is_none());
}
#[test]
fn urls_are_normalized_for_the_command_line() {
let q = "https://x.com/a?b=1&c=%20d#e";
assert_eq!(safe_url(q).unwrap(), q);
assert_eq!(
safe_url("https://x.com/\"a b\"|^<>`{}\\é").unwrap(),
"https://x.com/%22a%20b%22%7C%5E%3C%3E%60%7B%7D%5C%C3%A9"
);
assert_eq!(
safe_url("https://x.com/\n&whoami").unwrap(),
"https://x.com/%0A&whoami"
);
}
#[test]
fn windows_opener_does_not_go_through_cmd() {
let url = "https://x.com/?a=1&calc.exe";
let c = windows_command(url);
assert_eq!(c.get_program(), "rundll32.exe");
let args: Vec<_> = c.get_args().collect();
assert_eq!(args, ["url.dll,FileProtocolHandler", url]);
}
#[test]
fn browser_command_blank_spec_is_none() {
assert!(build_command(" ", "https://example.com").is_none());
}
#[test]
fn time_ago_buckets() {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
assert_eq!(time_ago(now), "just now");
assert_eq!(time_ago(now - 120), "2m ago");
assert_eq!(time_ago(now - 7200), "2h ago");
}
#[test]
fn find_ci_returns_case_insensitive_byte_ranges() {
assert_eq!(find_ci("Rust and rust", "RUST"), vec![0..4, 9..13]);
assert_eq!(find_ci("aaaa", "aa"), vec![0..2, 2..4]); assert!(find_ci("anything", "").is_empty());
let s = "café Rust";
let r = find_ci(s, "rust");
assert_eq!(&s[r[0].clone()], "Rust");
}
#[test]
fn contains_ci_ignores_case_and_rejects_empty() {
assert!(contains_ci("Show HN: Thing", "show hn"));
assert!(!contains_ci("Show HN: Thing", "ask"));
assert!(!contains_ci("anything", ""));
}
}