//! Link detection for the scrollback render pass. //! //! Provides [`LinkOverlay`] / [`OverlayLink`] (link positions collected during //! rendering) and [`scan_lines_for_url_overlays`] for detecting plain-text URLs //! and absolute file paths across all block types. The collected links are //! handed to the terminal as `LinkSpan`s and emitted as OSC 8 hyperlinks by the //! frame diff (see `kigi_ratatui_inline::Terminal::flush_with_links`). use std::path::{Path, PathBuf}; use std::sync::{Arc, OnceLock}; use linkify::{LinkFinder, LinkKind}; use ratatui::text::Line; use unicode_width::UnicodeWidthStr; /// A single link region on screen. #[derive(Debug, Clone)] pub struct OverlayLink { pub screen_row: u16, pub col_start: u16, pub col_end: u16, pub url: Arc, pub id: Option, } /// Accumulates link positions for post-flush OSC 8 emission. #[derive(Debug, Clone)] pub struct LinkOverlay { links: Vec, } impl Default for LinkOverlay { fn default() -> Self { Self::new() } } impl LinkOverlay { pub fn new() -> Self { Self { links: Vec::new() } } pub fn push(&mut self, link: OverlayLink) { debug_assert!( link.col_start <= link.col_end, "OverlayLink col_start ({}) > col_end ({})", link.col_start, link.col_end ); if link.col_start > link.col_end { return; // Silently skip inverted ranges in release mode. } self.links.push(link); } /// Append all links from `other` (clones each `OverlayLink`). /// /// Each link is routed through [`Self::push`] so the /// `col_start <= col_end` invariant is enforced (inverted ranges are /// silently dropped in release builds, just like the single-link path). pub fn extend_from(&mut self, other: &LinkOverlay) { self.links.reserve(other.links.len()); for link in &other.links { self.push(link.clone()); } } pub fn is_empty(&self) -> bool { self.links.is_empty() } pub fn links(&self) -> &[OverlayLink] { &self.links } /// Returns `true` if an existing link overlaps the given screen region. pub fn overlaps(&self, screen_row: u16, col_start: u16, col_end: u16) -> bool { self.links .iter() .any(|l| l.screen_row == screen_row && l.col_start < col_end && col_start < l.col_end) } } fn link_finder() -> &'static LinkFinder { static FINDER: OnceLock = OnceLock::new(); FINDER.get_or_init(|| { let mut f = LinkFinder::new(); f.kinds(&[LinkKind::Url]); f }) } /// One path segment without spaces (`main.rs`, `.kigi`, `@scope`). Leading `.` /// matches dot-directories and `%` matches percent-encoded segments — kigi /// session media lives under `~/.kigi/sessions/%2F…/images/1.jpg`. const PATH_SEGMENT: &str = r"[a-zA-Z0-9_@.%][a-zA-Z0-9._+@%\-]*"; /// Final path segment may contain *internal* spaces for macOS app bundles and /// similarly named files (`Demo App.app`). Requires a `.ext` suffix /// after the last space so trailing prose (`…/bar here.`) is not consumed. const PATH_SEGMENT_SPACED: &str = r"[a-zA-Z0-9_@.%][a-zA-Z0-9._+@%\-]*(?: [a-zA-Z0-9._+@%\-]+)+\.[a-zA-Z0-9][a-zA-Z0-9._+@%\-]*"; /// Relative file path (`images/1.png`, `.kigi/x.txt`) — one or more `/`-joined /// directory segments plus a filename that has an extension. No leading `/` /// or `~` (those are the absolute forms). The required extension keeps /// slashed prose ("and/or", "TCP/IP") out; the caller still gates on the file /// existing under `cwd`. fn relative_file_path_regex() -> &'static regex::Regex { static RE: OnceLock = OnceLock::new(); RE.get_or_init(|| { let pat = format!( r"(?:{seg}/)+[a-zA-Z0-9_@%+\-]+(?:\.[a-zA-Z0-9_@%+\-]+)+", seg = PATH_SEGMENT, ); regex::Regex::new(&pat).expect("relative file path regex") }) } fn file_path_regex() -> &'static regex::Regex { static RE: OnceLock = OnceLock::new(); RE.get_or_init(|| { // Absolute (`/Users/me/x.md`) or home-relative (`~/Desktop/x.md`) paths. // Leading `~` is expanded to $HOME when building the `file://` URL. // // The *final* segment may include internal spaces when it looks like a // filename with an extension (tutor report: `…/Demo App.app` // only linkified up to the space). Intermediate segments stay // space-free so `…/bar here.` does not eat the word `here`. // Alternation prefers the spaced form first so it wins over the shorter // no-space prefix at the same start position. let pat = format!( r"~?/(?:{seg}/)+(?:{spaced}|{seg})", seg = PATH_SEGMENT, spaced = PATH_SEGMENT_SPACED, ); regex::Regex::new(&pat).expect("file path regex") }) } /// Paths wrapped in single or double quotes, including spaces in any segment. /// Group 1 is the opening quote; group 2 is the path (no surrounding quotes). /// Caller must verify the character immediately after the path is the same quote. fn quoted_file_path_regex() -> &'static regex::Regex { static RE: OnceLock = OnceLock::new(); RE.get_or_init(|| { // Opening quote + path; closing quote checked in code (regex crate has // no backreferences). Path allows spaces in segments; at least two // `/`-separated components required. // `"/Users/me/My Dir/file.app"` or `'~/Desktop/My Notes/todo.md'` let seg = r#"[^/"']+"#; let pat = format!(r#"(["'])(~?/(?:{seg}/)+{seg})"#); regex::Regex::new(&pat).expect("quoted file path regex") }) } /// Turn a display path (`/abs/…` or `~/…`) into a `file://` URL, expanding `~/`. /// Relative paths fail — use [`tool_path_file_url`] to join cwd first. pub fn path_to_file_url(path: &str) -> Option> { tool_path_file_url(path, None) } fn file_path_to_url(path: &Path) -> Option> { url::Url::from_file_path(path) .ok() .map(|u| Arc::from(u.as_str())) } #[cfg(test)] fn tool_path_file_url_with_home( path: &str, cwd: Option<&Path>, home: Option<&Path>, ) -> Option> { let target = crate::render::tool_paths::resolve_tool_path_target_with_home(Path::new(path), cwd, home)?; file_path_to_url(&target) } /// `file://` URL for a Read/Edit target, joining ordinary relative paths to `cwd`. pub fn tool_path_file_url(path: &str, cwd: Option<&Path>) -> Option> { let target = crate::render::tool_paths::resolve_tool_path_target(path, cwd)?; file_path_to_url(&target) } /// Resolve a markdown link destination that names a local file into a `file://` /// URL, so paths the model emits (`[videos/1.mp4](videos/1.mp4)`) open on click. /// /// Web/scheme URLs, `mailto:`/`tel:`, and anchors return `None`. /// /// - **Absolute / `~`** paths resolve directly (must be an existing file). /// - **Relative** paths (`images/1.jpg`) resolve against `media_paths` — the /// absolute paths of media actually generated in this transcript — by /// matching a unique entry whose path ends with those components. This ties /// each short path to the exact file its message produced (correct across /// forks/resumes) and never opens an arbitrary or out-of-session file; an /// ambiguous or absent match is left unlinked. pub fn local_link_to_file_url(dest: &str, media_paths: &[PathBuf]) -> Option> { let dest = dest.trim(); if dest.is_empty() || dest.starts_with('#') || dest.contains("://") { return None; } let lower = dest.to_ascii_lowercase(); if lower.starts_with("mailto:") || lower.starts_with("tel:") { return None; } let path = Path::new(dest); let target = crate::render::tool_paths::resolve_tool_path_target(dest, None)?; let resolved: PathBuf = if target.is_absolute() { target } else { // Relative: match a single generated-media file ending with these // components. Unique match only, so a forked transcript with a duplicate // name resolves to neither rather than the wrong one. let mut hits = media_paths.iter().filter(|p| p.ends_with(path)); let first = hits.next()?.clone(); if hits.next().is_some() { return None; } first }; if !resolved.is_file() { return None; } url::Url::from_file_path(&resolved) .ok() .map(|u| Arc::from(u.as_str())) } /// Convert a display-cell column to a `u16` suitable for overlay coordinates. /// /// Returns `None` when the column (plus content offset) would overflow /// `u16`, in which case the caller should skip the link. fn to_overlay_col(content_x: u16, col: usize) -> Option { let col16 = u16::try_from(col).ok()?; content_x.checked_add(col16) } /// One visual row of a logical (pre-wrap) line: its screen row plus the byte /// range its text occupies within the joined logical string. struct RowSegment { screen_row: u16, start: usize, end: usize, } /// Scan ratatui [`Line`]s for plain-text URLs and file paths, appending /// corresponding [`OverlayLink`] entries to the overlay. /// /// Runs on all blocks. For markdown blocks, existing hyperlinks are /// already in the overlay; detected links that overlap are skipped. /// /// Each item is `(screen_row, line, joiner)` where `joiner` is the soft-wrap /// joiner to the *previous* row (see `BlockLine::joiner`): `None` = hard /// break, `Some("")` = mid-word wrap, `Some(" ")` = word wrap. Consecutive /// rows connected by `Some(..)` joiners are re-joined into one logical line /// before matching, so a long path or URL soft-wrapped across rows (media /// media lives at `~/.kigi/sessions/%2F…/images/1.jpg`, which wraps in /// narrow panes) is detected whole and each row's fragment gets its own /// clickable overlay region. Spans within a row are likewise concatenated so /// styling boundaries never truncate a match. /// /// Detects three kinds of links: /// 1. **URLs** via the `linkify` crate (http, https, mailto). /// 2. **Absolute and `~`-relative file paths** via regex, emitted as /// `file://` URLs (a leading `~/` is expanded to the home directory). /// 3. **Relative file paths** (`images/1.png`) that uniquely match a generated /// media file in `media_paths` — so prose like "and/or" is never linkified. pub fn scan_lines_for_url_overlays<'a>( lines: impl Iterator, Option<&'a str>)>, content_x: u16, media_paths: &[PathBuf], overlay: &mut LinkOverlay, ) { // Joined text + row segments for the logical line currently being // accumulated. Buffers are reused across groups to avoid per-row // allocation on every render frame. let mut group_text = String::new(); let mut group_rows: Vec = Vec::new(); for (screen_row, line, joiner) in lines { // A `None` joiner is a hard break: flush the current group and start // a new logical line. (A `Some` joiner with no accumulated rows — // e.g. a wrap continuation scrolled in at the top of the viewport — // also starts a new group; its fragment is scanned standalone.) match joiner { Some(j) if !group_rows.is_empty() => group_text.push_str(j), _ => { scan_logical_line(&group_text, &group_rows, content_x, media_paths, overlay); group_text.clear(); group_rows.clear(); } } let start = group_text.len(); for span in &line.spans { group_text.push_str(span.content.as_ref()); } group_rows.push(RowSegment { screen_row, start, end: group_text.len(), }); } scan_logical_line(&group_text, &group_rows, content_x, media_paths, overlay); } /// Push one [`OverlayLink`] per visual row that `match_range` (a byte range in /// the joined logical `text`) overlaps. /// /// Returns `true` if at least one overlay region was pushed. Returns `false` /// without pushing anything when any row segment would overlap an existing /// overlay link (e.g. a markdown hyperlink already mapped for that region) or /// when a column exceeds `u16`. fn push_link_segments( text: &str, rows: &[RowSegment], content_x: u16, match_range: std::ops::Range, url: &Arc, overlay: &mut LinkOverlay, ) -> bool { let mut segments: Vec<(u16, u16, u16)> = Vec::new(); for row in rows { // Intersect the match with this row's byte range; joiner bytes // between rows belong to no row and are clamped away. let start = match_range.start.max(row.start); let end = match_range.end.min(row.end); if start >= end { continue; } let col_start = UnicodeWidthStr::width(&text[row.start..start]); let col_end = col_start + UnicodeWidthStr::width(&text[start..end]); let (Some(cs), Some(ce)) = ( to_overlay_col(content_x, col_start), to_overlay_col(content_x, col_end), ) else { return false; }; if overlay.overlaps(row.screen_row, cs, ce) { return false; } segments.push((row.screen_row, cs, ce)); } if segments.is_empty() { return false; } for (screen_row, col_start, col_end) in segments { overlay.push(OverlayLink { screen_row, col_start, col_end, url: Arc::clone(url), id: None, }); } true } /// Run URL / file-path detection over one joined logical line and emit /// per-row overlay regions for every match (see [`push_link_segments`]). fn scan_logical_line( text: &str, rows: &[RowSegment], content_x: u16, media_paths: &[PathBuf], overlay: &mut LinkOverlay, ) { if text.is_empty() || rows.is_empty() { return; } let scheme_filter = crate::terminal::hyperlinks::SchemeFilter::Standard; let finder = link_finder(); let path_re = file_path_regex(); let quoted_path_re = quoted_file_path_regex(); let rel_path_re = relative_file_path_regex(); // Byte ranges consumed by URL links, populated lazily on first safe URL // hit to avoid allocation in the common case of lines with no URLs. let mut url_byte_ranges: Option>> = None; // Byte ranges already turned into file-path overlays (quoted first, then // plain) so later passes do not double-link. let mut path_byte_ranges: Vec> = Vec::new(); for link in finder.links(text) { let url = link.as_str(); if !crate::link_opener::is_safe_to_open(url, scheme_filter) { continue; } url_byte_ranges .get_or_insert_with(Vec::new) .push(link.start()..link.end()); let url: Arc = Arc::from(url); push_link_segments( text, rows, content_x, link.start()..link.end(), &url, overlay, ); } let range_overlaps_urls = |start: usize, end: usize| -> bool { url_byte_ranges .as_ref() .is_some_and(|ranges| ranges.iter().any(|r| start < r.end && r.start < end)) }; // Pass 1: quoted paths — spaces allowed in every segment. for caps in quoted_path_re.captures_iter(text) { let open_q = caps.get(1).expect("open quote"); let path_m = caps.get(2).expect("path group"); // Require a matching closing quote immediately after the path. let close_idx = path_m.end(); if text.as_bytes().get(close_idx) != Some(&open_q.as_str().as_bytes()[0]) { continue; } if range_overlaps_urls(path_m.start(), path_m.end()) { continue; } let Some(file_url) = path_to_file_url(path_m.as_str()) else { continue; }; // Clickable region is the path text only (not the quotes). if push_link_segments( text, rows, content_x, path_m.start()..path_m.end(), &file_url, overlay, ) { path_byte_ranges.push(path_m.start()..path_m.end()); } } // Pass 2: unquoted paths (final segment may include spaces + ext). for m in path_re.find_iter(text) { if range_overlaps_urls(m.start(), m.end()) || path_byte_ranges .iter() .any(|r| m.start() < r.end && r.start < m.end()) { continue; } if m.start() > 0 { let prev = text.as_bytes()[m.start() - 1]; if prev.is_ascii_alphanumeric() || matches!(prev, b'_' | b'.' | b'+' | b'@' | b'-' | b':' | b'/' | b'~') { continue; } } // Drop trailing sentence punctuation so a path ending a sentence // (`…/images/1.jpg.`) links to the file, not `file.jpg.`. let path = m .as_str() .trim_end_matches(['.', ',', ';', ':', '!', '?', ')']); if path.is_empty() { continue; } let path_end = m.start() + path.len(); let Some(file_url) = path_to_file_url(path) else { continue; }; if push_link_segments( text, rows, content_x, m.start()..path_end, &file_url, overlay, ) { path_byte_ranges.push(m.start()..path_end); } } // Pass 3: relative paths that uniquely match a generated media file // (so bare `word/word.ext` prose is not over-linkified). if !media_paths.is_empty() { for m in rel_path_re.find_iter(text) { if range_overlaps_urls(m.start(), m.end()) || path_byte_ranges .iter() .any(|r| m.start() < r.end && r.start < m.end()) { continue; } if m.start() > 0 { let prev = text.as_bytes()[m.start() - 1]; if prev.is_ascii_alphanumeric() || matches!( prev, b'_' | b'.' | b'+' | b'@' | b'-' | b':' | b'/' | b'~' | b'%' ) { continue; } } let path = m .as_str() .trim_end_matches(['.', ',', ';', ':', '!', '?', ')']); let Some(file_url) = local_link_to_file_url(path, media_paths) else { continue; }; let path_end = m.start() + path.len(); if push_link_segments( text, rows, content_x, m.start()..path_end, &file_url, overlay, ) { path_byte_ranges.push(m.start()..path_end); } } } } #[cfg(test)] mod tests { use super::*; use ratatui::style::Color; /// Scan rows as independent logical lines (hard breaks between rows) — /// the common shape for tests that don't exercise soft-wrap joining. fn scan_unjoined<'a>( lines: impl Iterator)>, content_x: u16, media_paths: &[PathBuf], overlay: &mut LinkOverlay, ) { let rows: Vec<(u16, &Line<'static>, Option<&str>)> = lines.map(|(row, line)| (row, line, None)).collect(); scan_lines_for_url_overlays(rows.into_iter(), content_x, media_paths, overlay); } // ── local_link_to_file_url ── #[test] fn local_link_relative_resolves_to_generated_media() { let dir = tempfile::tempdir().unwrap(); std::fs::create_dir(dir.path().join("images")).unwrap(); std::fs::write(dir.path().join("images/1.jpg"), b"x").unwrap(); let media = vec![dir.path().join("images/1.jpg")]; // Short session-relative path matches the generated media by suffix. let url = local_link_to_file_url("images/1.jpg", &media).unwrap(); assert!( url.starts_with("file://") && url.ends_with("/images/1.jpg"), "got {url}" ); } #[test] fn local_link_ignores_web_anchor_and_unknown() { let dir = tempfile::tempdir().unwrap(); std::fs::create_dir(dir.path().join("images")).unwrap(); std::fs::write(dir.path().join("images/1.jpg"), b"x").unwrap(); let media = vec![dir.path().join("images/1.jpg")]; assert!(local_link_to_file_url("https://kimi.com", &media).is_none()); assert!(local_link_to_file_url("mailto:a@b.c", &media).is_none()); assert!(local_link_to_file_url("#section", &media).is_none()); // Relative path that isn't a known generated media file. assert!(local_link_to_file_url("images/2.jpg", &media).is_none()); // No known media at all. assert!(local_link_to_file_url("images/1.jpg", &[]).is_none()); } #[test] fn local_link_relative_rejects_ambiguous_and_traversal() { // Two generated files with the same session-relative name (e.g. a fork): // an ambiguous match resolves to neither, never the wrong one. let dir = tempfile::tempdir().unwrap(); for sub in ["a", "b"] { std::fs::create_dir_all(dir.path().join(sub).join("images")).unwrap(); std::fs::write(dir.path().join(sub).join("images/1.jpg"), b"x").unwrap(); } let media = vec![ dir.path().join("a/images/1.jpg"), dir.path().join("b/images/1.jpg"), ]; assert!(local_link_to_file_url("images/1.jpg", &media).is_none()); // A `..` never matches a clean absolute media path, so it can't escape. assert!(local_link_to_file_url("../images/1.jpg", &media).is_none()); } // ── tool_path_file_url ── #[test] fn tool_path_file_url_resolves_relative_against_cwd() { let cwd = Path::new("/Users/me/project"); let url = tool_path_file_url("src/main.rs", Some(cwd)).expect("url"); assert!(url.starts_with("file://"), "got {url}"); assert!(url.contains("/Users/me/project/src/main.rs"), "got {url}"); } #[test] fn tool_path_file_url_accepts_absolute_without_existing_file() { let url = tool_path_file_url("/tmp/does-not-exist-xyz/foo.rs", None).expect("url"); assert!(url.starts_with("file://"), "got {url}"); assert!(url.contains("foo.rs"), "got {url}"); } #[test] fn tool_path_file_url_preserves_parent_segments_for_os_resolution() { let url = tool_path_file_url("/repo/link/../target.rs", None).expect("url"); assert!(url.contains("/repo/link/../target.rs"), "got {url}"); } #[test] fn unresolved_tilde_never_manufactures_a_cwd_file_url() { assert!( tool_path_file_url_with_home("~/target.rs", Some(Path::new("/repo")), None).is_none() ); } #[cfg(unix)] #[test] fn tool_path_file_url_preserves_non_utf8_cwd_bytes() { use std::ffi::OsString; use std::os::unix::ffi::OsStringExt; let cwd = PathBuf::from(OsString::from_vec(b"/tmp/non-utf8-\x80".to_vec())); let url = tool_path_file_url("main.rs", Some(&cwd)).expect("url"); assert!(url.contains("/tmp/non-utf8-%80/main.rs"), "got {url}"); assert!( !url.contains("%EF%BF%BD"), "lossy replacement leaked: {url}" ); } // ── LinkOverlay ── #[test] fn overlay_empty_by_default() { let overlay = LinkOverlay::new(); assert!(overlay.is_empty()); assert!(overlay.links().is_empty()); } #[test] fn overlay_push_and_access() { let mut overlay = LinkOverlay::new(); overlay.push(OverlayLink { screen_row: 5, col_start: 10, col_end: 20, url: "https://example.com".into(), id: Some(1), }); assert!(!overlay.is_empty()); assert_eq!(overlay.links().len(), 1); assert_eq!(overlay.links()[0].screen_row, 5); } // ── scan_lines_for_url_overlays ── use ratatui::text::{Line as RLine, Span as RSpan}; fn make_line(text: &str) -> Line<'static> { RLine::from(RSpan::raw(text.to_string())) } fn make_styled_line(spans: Vec<(&str, Color)>) -> Line<'static> { RLine::from( spans .into_iter() .map(|(t, c)| RSpan::styled(t.to_string(), ratatui::style::Style::default().fg(c))) .collect::>(), ) } #[test] fn scan_detects_url_in_plain_text() { let line = make_line("See https://example.com for details."); let mut overlay = LinkOverlay::new(); scan_unjoined(std::iter::once((5, &line)), 2, &[], &mut overlay); assert_eq!(overlay.links().len(), 1); let link = &overlay.links()[0]; assert_eq!(&*link.url, "https://example.com"); assert_eq!(link.screen_row, 5); // "See " = 4 display cols, content_x = 2 assert_eq!(link.col_start, 6); assert_eq!(link.col_end, 6 + 19); // "https://example.com" = 19 chars assert_eq!(link.id, None); } #[test] fn scan_detects_multiple_urls_on_one_line() { let line = make_line("https://a.example and https://b.example end"); let mut overlay = LinkOverlay::new(); scan_unjoined(std::iter::once((0, &line)), 0, &[], &mut overlay); assert_eq!(overlay.links().len(), 2); assert_eq!(&*overlay.links()[0].url, "https://a.example"); assert_eq!(&*overlay.links()[1].url, "https://b.example"); assert!(overlay.links()[0].col_end <= overlay.links()[1].col_start); } #[test] fn scan_across_multiple_spans() { let line = make_styled_line(vec![ ("Visit ", Color::Gray), ("https://example.com", Color::Blue), (" now.", Color::Gray), ]); let mut overlay = LinkOverlay::new(); scan_unjoined(std::iter::once((0, &line)), 0, &[], &mut overlay); assert_eq!(overlay.links().len(), 1); assert_eq!(&*overlay.links()[0].url, "https://example.com"); // "Visit " = 6 display cols (in first span) // The URL is in its own span, so col_start = 6 assert_eq!(overlay.links()[0].col_start, 6); } #[test] fn scan_multiple_rows() { let line1 = make_line("See https://first.com here."); let line2 = make_line("And https://second.com there."); let lines: Vec<(u16, &Line<'static>)> = vec![(10, &line1), (11, &line2)]; let mut overlay = LinkOverlay::new(); scan_unjoined(lines.into_iter(), 0, &[], &mut overlay); assert_eq!(overlay.links().len(), 2); assert_eq!(overlay.links()[0].screen_row, 10); assert_eq!(overlay.links()[1].screen_row, 11); } #[test] fn scan_skips_unsafe_schemes() { let line = make_line("Bad: javascript://evil.com/alert(1) ok."); let mut overlay = LinkOverlay::new(); scan_unjoined(std::iter::once((0, &line)), 0, &[], &mut overlay); assert!( overlay.is_empty(), "javascript:// scheme should be filtered" ); } #[test] fn scan_no_urls_produces_empty() { let line = make_line("No links in this ordinary text."); let mut overlay = LinkOverlay::new(); scan_unjoined(std::iter::once((0, &line)), 0, &[], &mut overlay); assert!(overlay.is_empty()); } #[test] fn scan_trailing_punctuation_excluded() { let line = make_line("Visit https://example.com."); let mut overlay = LinkOverlay::new(); scan_unjoined(std::iter::once((0, &line)), 0, &[], &mut overlay); assert_eq!(overlay.links().len(), 1); assert_eq!( &*overlay.links()[0].url, "https://example.com", "trailing dot should be excluded by linkify" ); } #[test] fn scan_url_with_path_and_query() { let line = make_line("Go to https://example.com/path?key=val#sec end."); let mut overlay = LinkOverlay::new(); scan_unjoined(std::iter::once((0, &line)), 0, &[], &mut overlay); assert_eq!(overlay.links().len(), 1); assert_eq!( &*overlay.links()[0].url, "https://example.com/path?key=val#sec" ); } #[test] fn scan_content_x_offset_applied() { let line = make_line("https://kimi.com"); let mut overlay = LinkOverlay::new(); scan_unjoined(std::iter::once((0, &line)), 10, &[], &mut overlay); assert_eq!(overlay.links().len(), 1); assert_eq!(overlay.links()[0].col_start, 10); // 16 == "https://kimi.com".len() assert_eq!(overlay.links()[0].col_end, 10 + 16); } // ── File path detection ── #[test] fn scan_detects_absolute_file_path() { let line = make_line("Error in /Users/foo/src/main.rs at line 10"); let mut overlay = LinkOverlay::new(); scan_unjoined(std::iter::once((0, &line)), 0, &[], &mut overlay); assert_eq!(overlay.links().len(), 1); assert_eq!(&*overlay.links()[0].url, "file:///Users/foo/src/main.rs"); } #[test] fn scan_detects_relative_path_when_generated_media() { // Both media kinds the model prints: `images/N.ext` and `videos/N.ext`. let dir = tempfile::tempdir().unwrap(); std::fs::create_dir(dir.path().join("images")).unwrap(); std::fs::create_dir(dir.path().join("videos")).unwrap(); std::fs::write(dir.path().join("images/1.png"), b"x").unwrap(); std::fs::write(dir.path().join("videos/1.mp4"), b"x").unwrap(); let media = vec![ dir.path().join("images/1.png"), dir.path().join("videos/1.mp4"), ]; for (line_text, suffix) in [ ("Saved to images/1.png in the workspace.", "/images/1.png"), ("Video saved to videos/1.mp4.", "/videos/1.mp4"), ] { let line = make_line(line_text); let mut overlay = LinkOverlay::new(); scan_unjoined(std::iter::once((0, &line)), 0, &media, &mut overlay); assert_eq!(overlay.links().len(), 1, "{line_text}"); let url = &*overlay.links()[0].url; assert!( url.starts_with("file://") && url.ends_with(suffix), "got {url}" ); } } #[test] fn scan_ignores_relative_path_when_not_generated_media() { let dir = tempfile::tempdir().unwrap(); std::fs::create_dir(dir.path().join("images")).unwrap(); std::fs::write(dir.path().join("images/1.png"), b"x").unwrap(); let media = vec![dir.path().join("images/1.png")]; // A path that isn't a known generated media file → not linkified. let line = make_line("edit images/2.png please"); let mut overlay = LinkOverlay::new(); scan_unjoined(std::iter::once((0, &line)), 0, &media, &mut overlay); assert!(overlay.links().is_empty()); // No known media at all → relative paths never resolve. let line = make_line("edit images/1.png please"); let mut overlay = LinkOverlay::new(); scan_unjoined(std::iter::once((0, &line)), 0, &[], &mut overlay); assert!(overlay.links().is_empty()); } #[test] fn scan_detects_kigi_session_media_path() { // Dot-directory (`.kigi`), percent-encoded session segment, and a // trailing sentence period — the shape of media-tool output prose. let line = make_line("Saved to /Users/alice/.kigi/sessions/%2Fabc/00000000/images/1.jpg."); let mut overlay = LinkOverlay::new(); scan_unjoined(std::iter::once((0, &line)), 0, &[], &mut overlay); assert_eq!(overlay.links().len(), 1); assert_eq!( &*overlay.links()[0].url, // `%` is itself percent-encoded (`%25`) when building the file URL. "file:///Users/alice/.kigi/sessions/%252Fabc/00000000/images/1.jpg", ); } #[test] fn scan_detects_media_path_soft_wrapped_across_rows() { // Regression: media-tool output prose wraps the long session path // across visual rows (`joiner: Some("")` mid-word break). Previously // each row was scanned in isolation, so only the `/Users/alice` // fragment on the first row matched and became clickable. let row0 = make_line("Image generated and saved to /Users/alice/.kigi/sessions/%2FUsers%2Fali"); let row1 = make_line("ce%2Fcode%2Fxai/00000000-0000-0000-0000-000000000001/images/1.jpg"); let rows: Vec<(u16, &Line<'static>, Option<&str>)> = vec![(3, &row0, None), (4, &row1, Some(""))]; let mut overlay = LinkOverlay::new(); scan_lines_for_url_overlays(rows.into_iter(), 2, &[], &mut overlay); assert_eq!(overlay.links().len(), 2, "one overlay region per row"); let expected_url = "file:///Users/alice/.kigi/sessions/%252FUsers%252Fali\ ce%252Fcode%252Fxai/00000000-0000-0000-0000-000000000001/images/1.jpg"; for link in overlay.links() { assert_eq!(&*link.url, expected_url); } // Row 0: path starts after the prose and runs to the row's end. let prose = "Image generated and saved to "; let l0 = &overlay.links()[0]; assert_eq!(l0.screen_row, 3); assert_eq!(l0.col_start, 2 + UnicodeWidthStr::width(prose) as u16); assert_eq!( l0.col_end, 2 + UnicodeWidthStr::width( "Image generated and saved to /Users/alice/.kigi/sessions/%2FUsers%2Fali" ) as u16 ); // Row 1: the continuation fragment covers the entire row. let l1 = &overlay.links()[1]; assert_eq!(l1.screen_row, 4); assert_eq!(l1.col_start, 2); assert_eq!( l1.col_end, 2 + UnicodeWidthStr::width( "ce%2Fcode%2Fxai/00000000-0000-0000-0000-000000000001/images/1.jpg" ) as u16 ); } #[test] fn scan_wrapped_path_trailing_sentence_period_excluded() { // Wrapped path ending mid-sentence: trailing `.` on the last row is // trimmed from the clickable region. let row0 = make_line("Saved to /Users/me/.kigi/sessions/%2Fabc/019f3a86/ima"); let row1 = make_line("ges/1.jpg. Enjoy!"); let rows: Vec<(u16, &Line<'static>, Option<&str>)> = vec![(0, &row0, None), (1, &row1, Some(""))]; let mut overlay = LinkOverlay::new(); scan_lines_for_url_overlays(rows.into_iter(), 0, &[], &mut overlay); assert_eq!(overlay.links().len(), 2); for link in overlay.links() { assert_eq!( &*link.url, "file:///Users/me/.kigi/sessions/%252Fabc/019f3a86/images/1.jpg" ); } assert_eq!(overlay.links()[1].col_start, 0); assert_eq!( overlay.links()[1].col_end, UnicodeWidthStr::width("ges/1.jpg") as u16 ); } #[test] fn scan_wrapped_relative_media_path_resolves() { // A relative media path split by a mid-word wrap still resolves // against the generated-media list. let dir = tempfile::tempdir().unwrap(); std::fs::create_dir(dir.path().join("images")).unwrap(); std::fs::write(dir.path().join("images/1.png"), b"x").unwrap(); let media = vec![dir.path().join("images/1.png")]; let row0 = make_line("Saved to images/1.p"); let row1 = make_line("ng in the workspace."); let rows: Vec<(u16, &Line<'static>, Option<&str>)> = vec![(0, &row0, None), (1, &row1, Some(""))]; let mut overlay = LinkOverlay::new(); scan_lines_for_url_overlays(rows.into_iter(), 0, &media, &mut overlay); assert_eq!(overlay.links().len(), 2); for link in overlay.links() { assert!( link.url.starts_with("file://") && link.url.ends_with("/images/1.png"), "got {}", link.url ); } } #[test] fn scan_url_soft_wrapped_across_rows() { let row0 = make_line("See https://example.com/some/lo"); let row1 = make_line("ng/path?key=val for details."); let rows: Vec<(u16, &Line<'static>, Option<&str>)> = vec![(0, &row0, None), (1, &row1, Some(""))]; let mut overlay = LinkOverlay::new(); scan_lines_for_url_overlays(rows.into_iter(), 0, &[], &mut overlay); assert_eq!(overlay.links().len(), 2); for link in overlay.links() { assert_eq!(&*link.url, "https://example.com/some/long/path?key=val"); } } #[test] fn scan_word_break_joiner_restores_source_space() { // A `Some(" ")` joiner re-inserts the collapsed space, so a spaced // final segment (`Demo App.app`) wrapped at the space still // matches as one path. let row0 = make_line("open /tmp/release/Demo"); let row1 = make_line("App.app now"); let rows: Vec<(u16, &Line<'static>, Option<&str>)> = vec![(0, &row0, None), (1, &row1, Some(" "))]; let mut overlay = LinkOverlay::new(); scan_lines_for_url_overlays(rows.into_iter(), 0, &[], &mut overlay); assert_eq!(overlay.links().len(), 2); for link in overlay.links() { assert_eq!(&*link.url, "file:///tmp/release/Demo%20App.app"); } // Row 1's region covers only `App.app` (the joiner space belongs // to no row). assert_eq!(overlay.links()[1].col_start, 0); assert_eq!( overlay.links()[1].col_end, UnicodeWidthStr::width("App.app") as u16 ); } #[test] fn scan_hard_break_rows_not_joined() { // `None` joiner = separate source lines: fragments must not be glued // into a single false path across rows. let row0 = make_line("prefix /Users/alice"); let row1 = make_line("suffix.txt more"); let rows: Vec<(u16, &Line<'static>, Option<&str>)> = vec![(0, &row0, None), (1, &row1, None)]; let mut overlay = LinkOverlay::new(); scan_lines_for_url_overlays(rows.into_iter(), 0, &[], &mut overlay); assert_eq!(overlay.links().len(), 1); assert_eq!(&*overlay.links()[0].url, "file:///Users/alice"); assert_eq!(overlay.links()[0].screen_row, 0); } #[test] fn scan_path_split_across_styled_spans_single_row() { // Markdown styling can split one row into multiple spans; the path // must still be matched across span boundaries. let line = make_styled_line(vec![ ("Saved to ", Color::Gray), ("/Users/foo/", Color::Blue), ("images/1.jpg", Color::Green), ]); let mut overlay = LinkOverlay::new(); scan_unjoined(std::iter::once((0, &line)), 0, &[], &mut overlay); assert_eq!(overlay.links().len(), 1); assert_eq!(&*overlay.links()[0].url, "file:///Users/foo/images/1.jpg"); assert_eq!( overlay.links()[0].col_start, UnicodeWidthStr::width("Saved to ") as u16 ); } #[test] fn scan_file_path_stops_at_colon() { let line = make_line("/Users/foo/bar.rs:45:10: error message"); let mut overlay = LinkOverlay::new(); scan_unjoined(std::iter::once((0, &line)), 0, &[], &mut overlay); assert_eq!(overlay.links().len(), 1); assert_eq!( &*overlay.links()[0].url, "file:///Users/foo/bar.rs", "colon-delimited line number should be excluded" ); } #[test] fn scan_ignores_single_component_path() { // "/home" alone has only one component — not useful as a file link. let line = make_line("See /home for info."); let mut overlay = LinkOverlay::new(); scan_unjoined(std::iter::once((0, &line)), 0, &[], &mut overlay); assert!( overlay.is_empty(), "single-component absolute path should not be linkified" ); } #[test] fn scan_file_path_does_not_overlap_url() { // The path portion of a URL should not be detected as a file path. let line = make_line("Visit https://example.com/foo/bar here."); let mut overlay = LinkOverlay::new(); scan_unjoined(std::iter::once((0, &line)), 0, &[], &mut overlay); assert_eq!(overlay.links().len(), 1); assert_eq!( &*overlay.links()[0].url, "https://example.com/foo/bar", "URL should be detected, not the path portion" ); } #[test] fn scan_url_and_file_path_coexist() { let line = make_line("See https://docs.rs/foo and /Users/me/src/lib.rs end."); let mut overlay = LinkOverlay::new(); scan_unjoined(std::iter::once((0, &line)), 0, &[], &mut overlay); assert_eq!(overlay.links().len(), 2); let urls: Vec<&str> = overlay.links().iter().map(|l| &*l.url).collect(); assert!(urls.contains(&"https://docs.rs/foo")); assert!(urls.contains(&"file:///Users/me/src/lib.rs")); } #[test] fn scan_file_path_with_dots_and_hyphens() { let line = make_line("Reading /tmp/kigi-impl-summary.md now."); let mut overlay = LinkOverlay::new(); scan_unjoined(std::iter::once((0, &line)), 0, &[], &mut overlay); assert_eq!(overlay.links().len(), 1); assert_eq!(&*overlay.links()[0].url, "file:///tmp/kigi-impl-summary.md"); } #[test] fn scan_file_path_with_at_sign() { let line = make_line("In /node_modules/@scope/package/index.js now."); let mut overlay = LinkOverlay::new(); scan_unjoined(std::iter::once((0, &line)), 0, &[], &mut overlay); assert_eq!(overlay.links().len(), 1); assert_eq!( &*overlay.links()[0].url, "file:///node_modules/@scope/package/index.js" ); } #[test] fn scan_file_path_with_space_in_segment_quoted() { // Tutor report: path underline/click target stopped at the space in // `Demo App.app` (macOS app bundle name), inside quotes. let path = "/Users/alice/src/app/release/mac-arm64/Demo App.app"; let line = make_line(&format!("open \"{path}\"")); let mut overlay = LinkOverlay::new(); scan_unjoined(std::iter::once((0, &line)), 0, &[], &mut overlay); assert_eq!( overlay.links().len(), 1, "expected one link for spaced path" ); let link = &overlay.links()[0]; assert_eq!( &*link.url, "file:///Users/alice/src/app/release/mac-arm64/Demo%20App.app", "space must be percent-encoded in the file URL" ); // Clickable region must cover the *entire* displayed path, including // the segment after the space — not stop at `Demo`. let prefix = "open \""; assert_eq!(link.col_start, UnicodeWidthStr::width(prefix) as u16); assert_eq!( link.col_end, (UnicodeWidthStr::width(prefix) + UnicodeWidthStr::width(path)) as u16 ); } #[test] fn scan_file_path_with_space_in_segment_unquoted() { // Same filename without surrounding quotes — final segment has a // space plus extension, so the unquoted regex should still match. let path = "/tmp/release/Demo App.app"; let line = make_line(&format!("open {path} now")); let mut overlay = LinkOverlay::new(); scan_unjoined(std::iter::once((0, &line)), 0, &[], &mut overlay); assert_eq!(overlay.links().len(), 1); assert_eq!( &*overlay.links()[0].url, "file:///tmp/release/Demo%20App.app" ); assert_eq!(overlay.links()[0].col_start, 5); // "open " assert_eq!( overlay.links()[0].col_end, 5 + UnicodeWidthStr::width(path) as u16 ); } #[test] fn scan_file_path_space_does_not_swallow_trailing_sentence() { // A space followed by prose (no `.ext` in the final segment) must not // extend the link past the real path. let line = make_line("See /tmp/foo/bar here."); let mut overlay = LinkOverlay::new(); scan_unjoined(std::iter::once((0, &line)), 0, &[], &mut overlay); assert_eq!(overlay.links().len(), 1); assert_eq!(&*overlay.links()[0].url, "file:///tmp/foo/bar"); // "See " = 4 cols; path is 12 cols (`/tmp/foo/bar`). assert_eq!(overlay.links()[0].col_start, 4); assert_eq!(overlay.links()[0].col_end, 4 + 12); } // ── Home-relative (`~/`) path detection ── #[test] fn scan_detects_tilde_file_path() { // The user's example: a `~/Desktop/…md` path in agent output. let raw = "~/Desktop/kigi-pager-retention-findings-2026-06-06.md"; // Skip when no home directory is resolvable (e.g. minimal sandbox): // the tilde stays unexpanded and cannot form an absolute file URL. let Ok(expected) = url::Url::from_file_path(shellexpand::tilde(raw).as_ref()) else { return; }; let line = make_line(&format!("Findings report {raw} done.")); let mut overlay = LinkOverlay::new(); scan_unjoined(std::iter::once((0, &line)), 0, &[], &mut overlay); assert_eq!(overlay.links().len(), 1); let link = &overlay.links()[0]; // `~` is expanded to the home directory in the file URL. assert_eq!(&*link.url, expected.as_str()); assert!(link.url.starts_with("file:///")); assert!(!link.url.contains('~'), "tilde must be expanded in the URL"); // The clickable region covers the displayed `~/…` text, tilde included. // "Findings report " = 16 display cols. assert_eq!(link.col_start, 16); assert_eq!(link.col_end, 16 + UnicodeWidthStr::width(raw) as u16); } #[test] fn scan_tilde_path_at_line_start() { let raw = "~/notes/todo.md"; let Ok(expected) = url::Url::from_file_path(shellexpand::tilde(raw).as_ref()) else { return; }; let line = make_line(raw); let mut overlay = LinkOverlay::new(); scan_unjoined(std::iter::once((0, &line)), 0, &[], &mut overlay); assert_eq!(overlay.links().len(), 1); assert_eq!(&*overlay.links()[0].url, expected.as_str()); assert_eq!(overlay.links()[0].col_start, 0); } #[test] fn scan_tilde_after_alnum_not_linkified() { // A `~` glued to a preceding word is not a home-relative path. let line = make_line("approx~/foo/bar here"); let mut overlay = LinkOverlay::new(); scan_unjoined(std::iter::once((0, &line)), 0, &[], &mut overlay); assert!( overlay.is_empty(), "tilde preceded by alphanumeric should not linkify" ); } #[test] fn scan_single_component_tilde_path_not_linkified() { // `~/projects` has a single component — mirrors the absolute // single-component rule (`/home`) and is not linkified. let line = make_line("cd ~/projects now"); let mut overlay = LinkOverlay::new(); scan_unjoined(std::iter::once((0, &line)), 0, &[], &mut overlay); assert!( overlay.is_empty(), "single-component tilde path should not be linkified" ); } #[test] fn scan_relative_path_not_partially_linkified() { // A relative path like `crates/codegen/kigi-tui/src/render` should // NOT produce a link for the `/kigi-tui/src/render` substring. let line = make_line("find crates/codegen/kigi-tui/src/render -name '*.rs'"); let mut overlay = LinkOverlay::new(); scan_unjoined(std::iter::once((0, &line)), 0, &[], &mut overlay); assert!( overlay.is_empty(), "relative path substrings should not be partially linkified" ); } #[test] fn overlaps_detects_exact_match() { let mut overlay = LinkOverlay::new(); overlay.push(OverlayLink { screen_row: 5, col_start: 10, col_end: 20, url: Arc::from("https://a.example"), id: None, }); assert!(overlay.overlaps(5, 10, 20)); assert!(!overlay.overlaps(6, 10, 20)); // different row } #[test] fn overlaps_detects_partial() { let mut overlay = LinkOverlay::new(); overlay.push(OverlayLink { screen_row: 0, col_start: 10, col_end: 20, url: Arc::from("https://a.example"), id: None, }); assert!(overlay.overlaps(0, 15, 25)); // right overlap assert!(overlay.overlaps(0, 5, 15)); // left overlap assert!(!overlay.overlaps(0, 20, 30)); // adjacent, no overlap assert!(!overlay.overlaps(0, 0, 10)); // adjacent left } #[test] fn scan_skips_url_overlapping_existing_link() { let line = make_line("See https://example.com for details."); let mut overlay = LinkOverlay::new(); // Pre-populate with a markdown hyperlink covering the same region. // "See " = 4 cols, URL = 19 cols. overlay.push(OverlayLink { screen_row: 0, col_start: 4, col_end: 23, url: Arc::from("https://example.com"), id: None, }); scan_unjoined(std::iter::once((0, &line)), 0, &[], &mut overlay); // Should still have only the original link — scanner skipped the duplicate. assert_eq!(overlay.links().len(), 1); } #[test] fn scan_adds_non_overlapping_url_alongside_existing() { let line = make_line("A https://second.example end."); let mut overlay = LinkOverlay::new(); // Existing link on a different column range. overlay.push(OverlayLink { screen_row: 0, col_start: 50, col_end: 70, url: Arc::from("https://first.example"), id: None, }); scan_unjoined(std::iter::once((0, &line)), 0, &[], &mut overlay); assert_eq!(overlay.links().len(), 2); } #[test] fn scan_skips_file_path_overlapping_existing_link() { let line = make_line("Error in /Users/foo/src/main.rs at line 10"); let mut overlay = LinkOverlay::new(); // Pre-populate a markdown hyperlink covering the file path region. // "Error in " = 9 cols, path = 22 cols. overlay.push(OverlayLink { screen_row: 0, col_start: 9, col_end: 31, url: Arc::from("file:///Users/foo/src/main.rs"), id: None, }); scan_unjoined(std::iter::once((0, &line)), 0, &[], &mut overlay); assert_eq!(overlay.links().len(), 1); } #[test] fn scan_columns_beyond_u16_max_skipped() { // Simulate a line where the URL would start beyond u16::MAX columns. // We can't easily build a 65k-char line in a unit test, so we verify // the helper directly. assert!(to_overlay_col(u16::MAX, 1).is_none()); assert!(to_overlay_col(u16::MAX - 5, 10).is_none()); assert_eq!(to_overlay_col(10, 5), Some(15)); assert_eq!(to_overlay_col(0, 0), Some(0)); } }