The PRD's first acceptance gate now holds: grep -RinE '\bx\.ai\b|grok' crates/ --include='*.rs' → 0 matches (exempt: NOTICE and third-party license archives, README provenance, and the required 'Based on Grok Build Open Source' attribution, now sourced from version_attribution.txt). Wire-visible renames (both sides in this repo, changed in lockstep): - Auth method id 'grok.com' → 'kimi-code' (AuthMethodKind::KimiCode). - Every x.ai/* and _x.ai/* ACP ext method and meta key → kigi/* / _kigi/* (~200 names; grokShell → kigiShell). Session-file replay keeps a read-side alias for the legacy '_x.ai/session/update' method so existing updates.jsonl histories load; writes emit only the new name (both directions test-pinned). - Agent types grok-build* → kigi* with a documented legacy-prefix alias at resolution time so persisted sessions keep resolving. - ToolNamespace/BuiltinAgentName GrokBuild* → Kigi* (wire snake_case kigi/kigi_concise/kigi_hashline; schema regenerated); grok_build implementation dirs renamed to kigi*. - x-grok-* headers → x-kigi-*, __GROK_* sentinels → __KIGI_*, themes grokday/groknight → kigiday/kiginight (old persisted values fall back to the default theme), web_fetch allowlist xAI hosts → kimi.com + moonshot platforms, changelog CDN → this repo, grok-build changelog archives deleted. - BYOK default endpoint removed: [endpoints] api_base_url is now truly optional with NO default — consumers fail fast with the flag name when unset (no silent x.ai egress). Mock harnesses inject it explicitly. - System-prompt identity fixed: 'released by xAI' → 'an unofficial community CLI for Kimi' (template + regenerated encrypted form). Also repaired pre-existing grok-era test debt found by the sweep: the stale trace_classify default-model pin, the grok-pager UA label test, pty-harness stale-binary reuse and non-hermetic moonshot routing (a PTY test could previously reach the real api.moonshot.cn), and the outdated oauth fixture scope key. Gates: §9 grep 0; fmt clean; workspace check/clippy 0/0 (-D warnings); FULL cargo test --workspace: 234 suites, 21,961 passed, 0 failed; deny advisories ok.
1361 lines
51 KiB
Rust
1361 lines
51 KiB
Rust
//! 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<str>,
|
|
pub id: Option<u32>,
|
|
}
|
|
|
|
/// Accumulates link positions for post-flush OSC 8 emission.
|
|
#[derive(Debug, Clone)]
|
|
pub struct LinkOverlay {
|
|
links: Vec<OverlayLink>,
|
|
}
|
|
|
|
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<LinkFinder> = 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<regex::Regex> = 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<regex::Regex> = 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<regex::Regex> = 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<Arc<str>> {
|
|
tool_path_file_url(path, None)
|
|
}
|
|
|
|
fn file_path_to_url(path: &Path) -> Option<Arc<str>> {
|
|
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<Arc<str>> {
|
|
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<Arc<str>> {
|
|
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<Arc<str>> {
|
|
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<u16> {
|
|
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<Item = (u16, &'a Line<'static>, 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<RowSegment> = 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<usize>,
|
|
url: &Arc<str>,
|
|
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<Vec<std::ops::Range<usize>>> = 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<std::ops::Range<usize>> = 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<str> = 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<Item = (u16, &'a Line<'static>)>,
|
|
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::<Vec<_>>(),
|
|
)
|
|
}
|
|
|
|
#[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));
|
|
}
|
|
}
|