Files
galaxy/app/src/util/link_detection.rs
T

760 lines
29 KiB
Rust

use galaxyui::elements::PartialClickableElement;
use std::collections::HashMap;
use std::ops::Range;
use string_offset::ByteOffset;
use urlocator::{UrlLocation, UrlLocator};
use galaxyui::elements::{MouseStateHandle, PartialClickableElement};
use galaxyui::platform::Cursor;
use galaxyui::text::char_slice;
use galaxyui::Action;
use crate::ai::agent::{AIAgentActionType, AIAgentOutput, AIAgentTextSection, ReadFilesRequest};
use crate::ai::blocklist::block::view_impl::output::LinkActionConstructors;
use crate::ai::blocklist::block::TextLocation;
use crate::terminal::links::should_directly_open_link;
use crate::terminal::model::grid::grid_handler::is_file_link_separator;
use crate::terminal::ShellLaunchData;
cfg_if::cfg_if! {
if #[cfg(feature = "local_fs")] {
use std::collections::HashSet;
use std::path::Path;
use std::path::PathBuf;
use galaxy_util::path::CleanPathResult;
}
}
pub const RICH_CONTENT_LINK_FIRST_CHAR_POSITION_ID: &str =
"ai_block:rich_content_link_first_char_position";
#[derive(Debug, Eq, PartialEq)]
pub(crate) struct LinkLocation {
pub(crate) link_range: Range<usize>,
pub(crate) location: TextLocation,
}
#[derive(Debug, Default)]
pub(crate) struct DetectedLinksState {
pub(crate) detected_links_by_location: HashMap<TextLocation, DetectedLinksInTextLocation>,
// The link that the mouse is currently hovered over.
pub(crate) currently_hovered_link_location: Option<LinkLocation>,
// The link that a tooltip is currently open for.
// This is separate from currently_hovered_link because after clicking
// on a link to open the tooltip, this link should remain highlighted and the tooltip in place
// even if we hover over other links.
pub(crate) link_location_open_tooltip: Option<LinkLocation>,
}
impl DetectedLinksState {
/// Given a text location and char range, returns the detected link there if any.
pub fn link_at(
&self,
location: &TextLocation,
range: &Range<usize>,
) -> Option<&DetectedLinkType> {
Some(
&self
.detected_links_by_location
.get(location)?
.detected_links
.get(range)?
.link,
)
}
pub fn update_hovered_link(
&mut self,
is_hovering: bool,
is_selecting: bool,
link_range: &Range<usize>,
location: &TextLocation,
) {
if is_hovering && !is_selecting {
self.currently_hovered_link_location = Some(LinkLocation {
link_range: link_range.clone(),
location: *location,
});
} else if self.currently_hovered_link_location.as_ref().is_some_and(
|currently_hovered_link| {
currently_hovered_link.link_range == *link_range
&& currently_hovered_link.location == *location
},
) {
self.currently_hovered_link_location = None;
}
}
/// Replaces all detected links with the given background detection results.
pub(crate) fn replace_all_links(
&mut self,
all_links: HashMap<TextLocation, HashMap<Range<usize>, DetectedLinkType>>,
) {
self.detected_links_by_location.clear();
self.currently_hovered_link_location = None;
self.link_location_open_tooltip = None;
for (location, links) in all_links {
let entry = self.detected_links_by_location.entry(location).or_default();
for (range, link) in links {
entry.detected_links.insert(
range,
HoverableDetectedLink {
link,
mouse_state: Default::default(),
},
);
}
}
}
}
#[derive(Clone, Debug)]
pub(crate) enum DetectedLinkType {
Url(String),
#[cfg(feature = "local_fs")]
FilePath {
absolute_path: PathBuf,
line_and_column_num: Option<galaxy_util::path::LineAndColumnArg>,
},
}
#[derive(Debug)]
pub(crate) struct HoverableDetectedLink {
pub(crate) link: DetectedLinkType,
pub(crate) mouse_state: MouseStateHandle,
}
#[derive(Debug, Default)]
pub(crate) struct DetectedLinksInTextLocation {
pub(crate) detected_links: HashMap<Range<usize>, HoverableDetectedLink>,
}
pub(crate) fn add_link_detection_mouse_interactions<T: PartialClickableElement, A: Action>(
mut element: T,
detected_links_state: &DetectedLinksState,
link_action_constructors: LinkActionConstructors<A>,
location: TextLocation,
) -> T {
if let Some(detected_links) = detected_links_state
.detected_links_by_location
.get(&location)
{
for (detected_link_range, hoverable_link) in &detected_links.detected_links {
let detected_link_range_clone = detected_link_range.clone();
element = element.with_clickable_char_range(
detected_link_range_clone.clone(),
move |modifiers, ctx, _app| {
if should_directly_open_link(modifiers) {
let action = (link_action_constructors.construct_open_link_action)(
detected_link_range_clone.clone(),
location,
);
ctx.dispatch_typed_action(action);
} else {
let action = (link_action_constructors.construct_open_link_tooltip_action)(
detected_link_range_clone.clone(),
location,
);
ctx.dispatch_typed_action(action);
}
},
);
let detected_link_range_clone = detected_link_range.clone();
element = element.with_hoverable_char_range(
detected_link_range_clone.clone(),
hoverable_link.mouse_state.clone(),
Some(Cursor::PointingHand),
move |is_hovering, ctx, _app| {
let action = (link_action_constructors.construct_changed_hover_on_link_action)(
detected_link_range_clone.clone(),
location,
is_hovering,
);
ctx.dispatch_typed_action(action);
},
);
}
}
element
}
/// Returns the char ranges of detected URLs in the given text.
fn detect_urls(text: &str) -> Vec<Range<usize>> {
let mut locator = UrlLocator::new();
let mut url_ranges = vec![];
let (mut start, mut end) = (None, None);
for (i, c) in text.chars().enumerate() {
// Reference to https://docs.rs/urlocator/latest/urlocator/#example-url-boundaries
// We know we have fully parsed an url when the locator advances from the `UrlLocation::Url`
// to the `UrlLocation::Reset` stage.
match locator.advance(c) {
UrlLocation::Url(length, end_offset) => {
end = Some(1 + i - end_offset as usize);
start = Some(end.unwrap() - length as usize);
}
UrlLocation::Reset => {
if let Some((start, end)) = start.zip(end) {
url_ranges.push(start..end)
}
start = None;
end = None;
}
_ => (),
}
}
// If the last character completes a valid URL, add it.
if let Some((start, end)) = start.zip(end) {
url_ranges.push(start..end)
}
url_ranges
}
#[cfg_attr(not(feature = "local_fs"), allow(dead_code))]
fn addr_of(s: &str) -> usize {
s.as_ptr() as usize
}
/// Maximum byte length of a token to search for file paths in. Used as a guard against scanning huge non-path tokens.
/// - Linux PATH_MAX: 4096 bytes.
/// - macOS PATH_MAX: 1024 bytes.
/// - Windows long-path cap: 32,767 UTF-16 units = 98,301 bytes.
const MAX_WORD_LEN_FOR_FILE_PATH: usize = 96 * 1024;
/// Maximum [`is_file_link_separator`] characters per token, to bound candidate substrings.
/// 256 keeps per-token allocations under ~1 MiB and is far above any real path.
const MAX_SEPARATORS_PER_WORD: usize = 256;
/// A separator's byte range in the original word.
///
/// File path candidates start after one separator and end before another. Using [`ByteOffset`]
/// keeps the byte-indexing semantics explicit when separators are multi-byte characters like
/// box-drawing glyphs.
#[cfg_attr(not(feature = "local_fs"), allow(dead_code))]
type SeparatorByteRange = Range<ByteOffset>;
/// Returns separator byte ranges in `word`, framed by zero-width virtual separators at
/// the start and end of the word. Returns empty if either safety cap is exceeded.
#[cfg_attr(not(feature = "local_fs"), allow(dead_code))]
fn separator_byte_ranges_for_file_path_search(word: &str) -> Vec<SeparatorByteRange> {
if word.len() > MAX_WORD_LEN_FOR_FILE_PATH {
return Vec::new();
}
// To include any substrings starting at the beginning of the word, we
// pretend there's a zero-width separator before the first character.
let mut separator_byte_ranges = vec![ByteOffset::zero()..ByteOffset::zero()];
// We use char_indices() to get byte indices of each char which are used to index the string,
// rather than chars().enumerate() would give char indices.
for (i, c) in word.char_indices() {
if is_file_link_separator(c) {
if separator_byte_ranges.len() > MAX_SEPARATORS_PER_WORD {
return Vec::new();
}
separator_byte_ranges.push(ByteOffset::from(i)..ByteOffset::from(i + c.len_utf8()));
}
}
// Consider trailing periods to be separators. This is because
// in natural language we might use a file path at the end of a sentence, and want
// to detect them without including the trailing period. But trailing
// periods can also be part of a valid file path.
let word_ends_with_period = word.ends_with('.');
if word_ends_with_period {
separator_byte_ranges.push(ByteOffset::from(word.len() - 1)..ByteOffset::from(word.len()));
}
// To include any substrings ending at the end of the word, we pretend there's
// a zero-width separator after the last character.
separator_byte_ranges.push(ByteOffset::from(word.len())..ByteOffset::from(word.len()));
separator_byte_ranges
}
/// Given a word with no whitespace in it, returns all the possible file paths within the word
/// from longest to shortest. File paths within a word can be split by [`is_file_link_separator`]
/// characters, and those separators may be part of file paths themselves.
/// Possible file paths begin after a separator and end before a separator.
/// For example, given /path/to/file:16:hello, it will return
/// ["/path/to/file:16:hello", "/path/to/file:16", "/path/to/file", "16:hello", "hello"]
///
/// Tokens exceeding [`MAX_WORD_LEN_FOR_FILE_PATH`] or [`MAX_SEPARATORS_PER_WORD`]
/// yield no candidates to bound the substring enumeration.
#[cfg_attr(not(feature = "local_fs"), allow(dead_code))]
fn possible_file_paths_in_word(word: &str) -> impl Iterator<Item = &str> {
let separator_byte_ranges = separator_byte_ranges_for_file_path_search(word);
let mut possible_path_byte_ranges = vec![];
for (i, start_separator) in separator_byte_ranges.iter().cloned().enumerate() {
for end_separator in separator_byte_ranges.iter().skip(i + 1).cloned() {
if start_separator.end < end_separator.start {
possible_path_byte_ranges.push(start_separator.end..end_separator.start);
}
}
}
// Sort by longest to shortest.
possible_path_byte_ranges.sort_by(|a, b| {
(b.end.as_usize() - b.start.as_usize()).cmp(&(a.end.as_usize() - a.start.as_usize()))
});
possible_path_byte_ranges
.into_iter()
.map(|range| &word[range.start.as_usize()..range.end.as_usize()])
}
/// Returns a DetectedLink::FilePath if expanded_path is a valid path that actually exists on the file system.
#[cfg(feature = "local_fs")]
fn compute_valid_file_path(
working_directory: &Path,
expanded_path: &str,
files_and_folders_in_working_directory: &HashSet<PathBuf>,
shell_launch_data: Option<&crate::terminal::ShellLaunchData>,
) -> Option<DetectedLinkType> {
use crate::util::file::{absolute_path_if_valid, ShellPathType};
// Scan for line and column number in the current word (left + right).
let cleaned_path = CleanPathResult::with_line_and_column_number(expanded_path);
// First try to use the files_and_folders_in_working_directory cache.
let path = Path::new(&cleaned_path.path);
if let Some(relative_path) = files_and_folders_in_working_directory.get(path) {
let absolute_path = working_directory.join(relative_path);
return Some(DetectedLinkType::FilePath {
absolute_path,
line_and_column_num: cleaned_path.line_and_column_num,
});
} else if path.components().count() <= 1 {
// If the path does not contain a separator and isn't in files_and_folders_in_working_directory,
// we know it isn't a valid path. Return immediately to save a a file system call.
return None;
}
// This does a file system lookup.
let absolute_path = absolute_path_if_valid(
&cleaned_path,
ShellPathType::PlatformNative(working_directory.to_owned()),
shell_launch_data,
);
absolute_path.map(|absolute_path| DetectedLinkType::FilePath {
absolute_path,
line_and_column_num: cleaned_path.line_and_column_num,
})
}
/// Returns a set of all file and folder names in the given directory (relative, not absolute paths).
#[cfg(feature = "local_fs")]
fn get_files_and_folders_in_directory(directory: &Path) -> HashSet<PathBuf> {
let mut files_and_folders = HashSet::new();
let Ok(entries) = std::fs::read_dir(directory) else {
return files_and_folders;
};
for entry in entries {
let Ok(entry) = entry else {
continue;
};
files_and_folders.insert(PathBuf::from(entry.file_name()));
}
files_and_folders
}
/// Returns the detected valid file paths in some text along with their char ranges.
#[cfg(feature = "local_fs")]
pub(crate) fn detect_file_paths(
working_directory: &str,
text: &str,
shell_launch_data: Option<&ShellLaunchData>,
) -> HashMap<Range<usize>, DetectedLinkType> {
let mut file_paths = HashMap::new();
// List files in this working_directory
let working_directory = shell_launch_data
.and_then(|launch_data| launch_data.maybe_convert_absolute_path(working_directory))
.unwrap_or_else(|| {
// Naively attempt to make a pathbuf from this.
PathBuf::from(working_directory)
});
let files_and_folders_in_working_directory =
get_files_and_folders_in_directory(working_directory.as_path());
for word in text.split_whitespace() {
let possible_paths = possible_file_paths_in_word(word);
// In the word, there can be multiple valid file paths which may or may not overlap.
// Take the longest one to turn into a link.
for possible_path in possible_paths {
// Need to expand the path here as built-in Path lib does not understand tilde.
let expanded_path = shellexpand::tilde(possible_path);
if let Some(path_type) = compute_valid_file_path(
working_directory.as_path(),
&expanded_path,
&files_and_folders_in_working_directory,
shell_launch_data,
) {
let byte_start = addr_of(possible_path) - addr_of(text);
let byte_end = byte_start + possible_path.len();
let char_start = text[..byte_start].chars().count();
let char_end = char_start + possible_path.chars().count();
file_paths.insert(char_start..char_end, path_type.clone());
// Check for line ranges after this file path and add them as separate clickable links
if let Some(line_ranges) = detect_line_ranges_after_file_path(text, byte_end) {
// Extract the base file path from the existing path_type
if let DetectedLinkType::FilePath { absolute_path, .. } = &path_type {
for (line_number, char_range) in line_ranges {
// Create a new DetectedLinkType with the same file path but with the line number
let line_range_link = DetectedLinkType::FilePath {
absolute_path: absolute_path.clone(),
line_and_column_num: Some(galaxy_util::path::LineAndColumnArg {
line_num: line_number as usize,
column_num: None,
}),
};
file_paths.insert(char_range, line_range_link);
}
}
}
break;
}
}
}
file_paths
}
use galaxy_editor::content::buffer::Buffer;
use galaxyui::text::word_boundaries::WordBoundariesPolicy;
use string_offset::CharOffset;
/// Returns the range of the word surrounding the given offset.
pub(crate) fn get_word_range_at_offset(
buffer: &Buffer,
offset: CharOffset,
word_boundary_policy: Option<WordBoundariesPolicy>,
) -> Option<Range<CharOffset>> {
use galaxy_editor::content::buffer::{ToBufferCharOffset, ToBufferPoint};
use galaxyui::text::words::is_default_word_boundary;
use galaxyui::text::TextBuffer;
let word_boundary_policy = word_boundary_policy.unwrap_or(WordBoundariesPolicy::Default);
let mut word_found_at: Option<CharOffset> = None;
let mut cursor_offset = offset;
if let Ok(chars) = buffer.chars_at(offset) {
for c in chars {
if c == '\n' {
// Do not cross line boundaries when searching for the nearest word
break;
}
if !is_default_word_boundary(c) {
word_found_at = Some(cursor_offset);
break;
}
// advance one character
cursor_offset += 1;
}
}
let found_offset = word_found_at?;
let found_point = found_offset.to_buffer_point(buffer);
let word_start_point = buffer
.word_starts_backward_from_offset_inclusive(found_point)
.ok()
.map(|iter| iter.with_policy(&word_boundary_policy))
.and_then(|mut iter| iter.next())
.unwrap_or(found_point);
let word_end_point = buffer
.word_ends_from_offset_exclusive(found_point)
.ok()
.map(|iter| iter.with_policy(&word_boundary_policy))
.and_then(|mut iter| iter.next())
.unwrap_or(found_point);
let word_start = word_start_point.to_buffer_char_offset(buffer);
let word_end = word_end_point.to_buffer_char_offset(buffer);
if word_start < word_end {
Some(word_start..word_end)
} else {
None
}
}
/// Parse line ranges from comma-separated text content and return detected ranges.
#[cfg(feature = "local_fs")]
fn parse_line_range(
potential_range: &str,
text: &str,
) -> Result<(u32, Range<usize>), &'static str> {
let potential_range = potential_range.trim();
// Look for pattern "number-number"
let dash_pos = potential_range.find('-').ok_or("No dash found in range")?;
// Extracting starting line number for potential range
let start_str = potential_range[..dash_pos].trim();
let start_line = start_str
.parse::<u32>()
.map_err(|_| "Failed to parse start line number")?;
let end_str = potential_range[dash_pos + 1..].trim();
end_str
.parse::<u32>()
.map_err(|_| "Failed to parse end line number")?;
let range_start_bytes = addr_of(potential_range) - addr_of(text);
let char_start = text[..range_start_bytes].chars().count();
let range_end_bytes = range_start_bytes + potential_range.len();
let char_end = text[..range_end_bytes].chars().count();
Ok((start_line, char_start..char_end))
}
/// Helper function to detect line ranges that appear after a valid file path.
/// Looks for patterns like "file.rs (1-50, 100-150)" and returns the detected ranges.
/// Returns a vector of (line_number, char_range) tuples.
#[cfg(feature = "local_fs")]
fn detect_line_ranges_after_file_path(
text: &str,
file_path_byte_end: usize,
) -> Option<Vec<(u32, Range<usize>)>> {
let chars_iter = text[file_path_byte_end..]
.char_indices()
.map(|(offs, ch)| (offs + file_path_byte_end, ch));
// Finds an opening parenthesis, allowing some whitespace after file path, or returns None on failure
let mut paren_start_idx = None;
for (char_idx, ch) in chars_iter {
if ch == '(' {
paren_start_idx = Some(char_idx);
break;
} else if !ch.is_whitespace() {
return None;
}
}
let paren_start_idx = paren_start_idx?;
// Find the matching closing parenthesis, or returns None on failure
let paren_end_index = paren_start_idx + text[paren_start_idx..].find(')')?;
// Extract the content between parentheses, and parse valid line ranges
let paren_content = &text[paren_start_idx + 1..paren_end_index];
let mut detected_ranges = Vec::new();
for potential_range in paren_content.split(',') {
match parse_line_range(potential_range, text) {
Ok(range) => detected_ranges.push(range),
Err(_) => return None,
}
}
(!detected_ranges.is_empty()).then_some(detected_ranges)
}
/// Pre-extracted hyperlinks keyed by text location. Each entry contains the char ranges
/// and URL strings for markdown hyperlinks (e.g. `[text](url)`) found in that location.
type HyperlinksByLocation = Vec<(TextLocation, Vec<(Range<usize>, String)>)>;
/// Collects all text/location pairs and markdown hyperlinks from an AI output.
/// Only reads in-memory data (no filesystem I/O), safe to call on the main thread.
/// The returned data is designed to be fed into `detect_all_links` on a background thread.
/// Returns raw text (no MD formatting) with location to run link detection on, and markdown hyperlinks.
pub(crate) fn collect_output_data_for_link_detection(
output: &AIAgentOutput,
current_working_directory: Option<&String>,
shell_launch_data: Option<&ShellLaunchData>,
) -> (Vec<(String, TextLocation)>, HyperlinksByLocation) {
let mut texts = Vec::new();
let mut hyperlinks = Vec::new();
// Collect action texts (ReadFiles requests)
for (action_index, action) in output.actions().enumerate() {
if let AIAgentActionType::ReadFiles(ReadFilesRequest { locations }) = &action.action {
for (line_index, file_location) in locations.iter().enumerate() {
texts.push((
file_location.to_user_message(
shell_launch_data,
current_working_directory,
None,
),
TextLocation::Action {
action_index,
line_index,
},
));
}
}
}
// Collect output text sections and extract hyperlinks from formatted lines
for (section_index, section) in output
.all_text()
.flat_map(|text| text.sections.iter())
.enumerate()
{
match section {
AIAgentTextSection::PlainText { text } => match &text.formatted_lines {
Some(formatted_lines) => {
for (line_index, line) in formatted_lines.lines().iter().enumerate() {
let location = TextLocation::Output {
section_index,
line_index,
};
texts.push((line.raw_text().to_owned(), location));
let url_hyperlinks = line.hyperlinks();
if !url_hyperlinks.is_empty() {
hyperlinks.push((location, url_hyperlinks));
}
}
}
_ => {
texts.push((
text.text().to_owned(),
TextLocation::Output {
section_index,
line_index: 0,
},
));
}
},
AIAgentTextSection::Image { image } => {
texts.push((
image.markdown_source.clone(),
TextLocation::Output {
section_index,
line_index: 0,
},
));
texts.push((
image.source.clone(),
TextLocation::Output {
section_index,
line_index: 1,
},
));
}
AIAgentTextSection::MermaidDiagram { diagram } => {
texts.push((
diagram.markdown_source.clone(),
TextLocation::Output {
section_index,
line_index: 0,
},
));
}
AIAgentTextSection::Code { .. } | AIAgentTextSection::Table { .. } => {}
}
}
(texts, hyperlinks)
}
/// Runs URL and file path detection on the given texts and combines with pre-extracted markdown hyperlinks.
/// Designed to run on a background thread (file path detection does filesystem I/O).
pub(crate) fn detect_all_links(
texts: &[(String, TextLocation)],
md_hyperlinks: HyperlinksByLocation,
#[cfg_attr(not(feature = "local_fs"), allow(unused_variables))]
current_working_directory: Option<&String>,
#[cfg_attr(not(feature = "local_fs"), allow(unused_variables))] shell_launch_data: Option<
&ShellLaunchData,
>,
) -> HashMap<TextLocation, HashMap<Range<usize>, DetectedLinkType>> {
let mut all_links: HashMap<TextLocation, HashMap<Range<usize>, DetectedLinkType>> =
HashMap::new();
for (text, location) in texts {
let url_ranges = detect_urls(text);
let mut links = HashMap::new();
// Detect URLs via regex
for url_range in &url_ranges {
if let Some(link_text) = char_slice(text, url_range.start, url_range.end) {
links.insert(
url_range.clone(),
DetectedLinkType::Url(link_text.to_owned()),
);
}
}
// Detect file path links, skipping any that overlap with URLs
#[cfg(feature = "local_fs")]
if let Some(cwd) = current_working_directory {
let file_paths = detect_file_paths(cwd, text, shell_launch_data);
for (range, link) in file_paths {
if !url_ranges
.iter()
.any(|ur| ur.start < range.end && range.start < ur.end)
{
links.insert(range, link);
}
}
}
if !links.is_empty() {
all_links.insert(*location, links);
}
}
// Add hyperlinks extracted from formatted markdown text
for (location, line_hyperlinks) in md_hyperlinks {
let entry = all_links.entry(location).or_default();
for (range, url) in line_hyperlinks {
entry.insert(range, DetectedLinkType::Url(url));
}
}
all_links
}
/// Given some text and its location
/// the detected_links_state.
pub(crate) fn detect_links(
detected_links_state: &mut DetectedLinksState,
text: &str,
text_location: TextLocation,
#[cfg_attr(not(feature = "local_fs"), allow(unused_variables))]
current_working_directory: Option<&String>,
#[cfg_attr(not(feature = "local_fs"), allow(unused_variables))] shell_launch_data: Option<
&ShellLaunchData,
>,
) {
let url_ranges = detect_urls(text);
for url_range in &url_ranges {
let Some(link) = char_slice(text, url_range.start, url_range.end) else {
continue;
};
detected_links_state
.detected_links_by_location
.entry(text_location)
.or_default()
.detected_links
.insert(
url_range.clone(),
HoverableDetectedLink {
link: DetectedLinkType::Url(link.to_owned()),
mouse_state: Default::default(),
},
);
}
#[cfg(feature = "local_fs")]
if let Some(current_working_directory) = current_working_directory {
let file_paths = detect_file_paths(current_working_directory, text, shell_launch_data);
for (range, link) in file_paths {
// If this file path range overlaps with a URL range, don't add it.
if url_ranges
.iter()
.any(|url_range| url_range.start < range.end && range.start < url_range.end)
{
continue;
}
detected_links_state
.detected_links_by_location
.entry(text_location)
.or_default()
.detected_links
.insert(
range,
HoverableDetectedLink {
link,
mouse_state: Default::default(),
},
);
}
}
}
#[cfg(test)]
#[path = "link_detection_tests.rs"]
mod tests;