Skip to main content

par_term/
url_detection.rs

1/// URL and file path detection and handling utilities
2use regex::Regex;
3use std::sync::OnceLock;
4
5/// URL pattern that matches common URL schemes
6static URL_REGEX: OnceLock<Regex> = OnceLock::new();
7
8/// File path pattern that matches Unix-style file paths
9static FILE_PATH_REGEX: OnceLock<Regex> = OnceLock::new();
10
11/// Get the compiled URL regex pattern
12fn url_regex() -> &'static Regex {
13    URL_REGEX.get_or_init(|| {
14        // Matches URLs with common schemes (http, https, ftp, etc.)
15        // Also matches URLs without schemes that start with www.
16        Regex::new(
17            r"(?x)
18            \b(?:
19                # URLs with explicit schemes
20                (?:https?|ftps?|file|git|ssh)://[^\s<>{}|\\^`\[\]]+
21                |
22                # URLs starting with www.
23                www\.[^\s<>{}|\\^`\[\]]+
24            )\b
25            ",
26        )
27        .expect("Failed to compile URL regex")
28    })
29}
30
31/// Get the compiled file path regex pattern
32fn file_path_regex() -> &'static Regex {
33    FILE_PATH_REGEX.get_or_init(|| {
34        // Matches file paths at the START of a logical token:
35        // - Absolute paths starting with / (must follow whitespace or start of line)
36        // - Relative paths starting with ./ or ../
37        // - Home-relative paths starting with ~/
38        //
39        // Absolute paths use a lookbehind to require whitespace or start-of-string
40        // before the leading /, preventing false matches inside relative paths
41        // like ./a/b/c where /b/c would otherwise also match.
42        //
43        // Optionally followed by :line_number or :line_number:col_number
44        // Also supports other line number formats from iTerm2:
45        // - [line, col] or [line,col]
46        // - (line, col) or (line,col)
47        // - (line)
48        Regex::new(
49            r#"(?x)
50            (?:
51                # Home-relative paths (~/...)
52                ~/[^\s:,;'"<>|)\]}\[\(\x00-\x1f]+
53                |
54                # Relative paths starting with ./ or ../
55                \.\.?/[^\s:,;'"<>|)\]}\[\(\x00-\x1f]+
56                |
57                # Absolute paths: must be at start of string or after whitespace
58                # Require at least two path components to reduce false positives
59                (?:^|\s)/[^\s:,;'"<>|)\]}\[\(\x00-\x1f]+/[^\s:,;'"<>|)\]}\[\(\x00-\x1f]+
60            )
61            # Optional line/column number in various formats
62            (?:
63                :\d+(?::\d+)?           # :line or :line:col
64                | \[\d+(?:,\s?\d+)?\]   # [line] or [line, col]
65                | \(\d+(?:,\s?\d+)?\)   # (line) or (line, col)
66            )?
67            "#,
68        )
69        .expect("Failed to compile file path regex")
70    })
71}
72
73/// Type of detected clickable item
74#[derive(Debug, Clone, PartialEq)]
75pub enum DetectedItemType {
76    /// A URL (http, https, etc.)
77    Url,
78    /// A file path (optionally with line number)
79    FilePath {
80        /// Line number if specified (e.g., file.rs:42)
81        line: Option<usize>,
82        /// Column number if specified (e.g., file.rs:42:10)
83        column: Option<usize>,
84    },
85}
86
87/// Detected URL or file path with position information
88#[derive(Debug, Clone, PartialEq)]
89pub struct DetectedUrl {
90    /// The URL or file path text
91    pub url: String,
92    /// Start column position
93    pub start_col: usize,
94    /// End column position (exclusive)
95    pub end_col: usize,
96    /// Row position
97    pub row: usize,
98    /// OSC 8 hyperlink ID (if this is an OSC 8 hyperlink, None for regex-detected items)
99    pub hyperlink_id: Option<u32>,
100    /// Type of detected item (URL or FilePath)
101    pub item_type: DetectedItemType,
102}
103
104/// Detect URLs in a line of text using regex patterns
105pub fn detect_urls_in_line(text: &str, row: usize) -> Vec<DetectedUrl> {
106    let regex = url_regex();
107    let mut urls = Vec::new();
108
109    for mat in regex.find_iter(text) {
110        let url = mat.as_str().to_string();
111        let start_col = mat.start();
112        let end_col = mat.end();
113
114        urls.push(DetectedUrl {
115            url,
116            start_col,
117            end_col,
118            row,
119            hyperlink_id: None, // Regex-detected URLs don't have OSC 8 IDs
120            item_type: DetectedItemType::Url,
121        });
122    }
123
124    urls
125}
126
127/// Detect file paths in a line of text using regex patterns
128/// Detects Unix-style paths like /path/to/file, ./relative, ../parent, ~/home
129/// Also detects line numbers like file.rs:42 and file.rs:42:10
130pub fn detect_file_paths_in_line(text: &str, row: usize) -> Vec<DetectedUrl> {
131    let regex = file_path_regex();
132    let mut paths = Vec::new();
133
134    for mat in regex.find_iter(text) {
135        let full_match = mat.as_str();
136        let mut start_col = mat.start();
137        let end_col = mat.end();
138
139        // The absolute path branch uses (?:^|\s) which may include a leading
140        // whitespace character in the match. Strip it to get the actual path.
141        let trimmed_match = if full_match.starts_with(char::is_whitespace) {
142            let trimmed = full_match.trim_start();
143            start_col += full_match.len() - trimmed.len();
144            trimmed
145        } else {
146            full_match
147        };
148
149        // Parse line and column numbers from the path
150        let (path, line, column) = parse_path_with_line_number(trimmed_match);
151
152        paths.push(DetectedUrl {
153            url: path,
154            start_col,
155            end_col,
156            row,
157            hyperlink_id: None,
158            item_type: DetectedItemType::FilePath { line, column },
159        });
160    }
161
162    paths
163}
164
165/// Parse a file path that may include line/column suffixes in various formats:
166/// - `:line` or `:line:col` (most common)
167/// - `[line]` or `[line, col]` (some editors)
168/// - `(line)` or `(line, col)` (some error formats)
169fn parse_path_with_line_number(path_str: &str) -> (String, Option<usize>, Option<usize>) {
170    // Try bracket format: [line] or [line, col]
171    if let Some(bracket_start) = path_str.rfind('[')
172        && path_str.ends_with(']')
173    {
174        let path = path_str[..bracket_start].to_string();
175        let inner = &path_str[bracket_start + 1..path_str.len() - 1];
176        let (line, col) = parse_line_col_pair(inner);
177        if line.is_some() {
178            return (path, line, col);
179        }
180    }
181
182    // Try paren format: (line) or (line, col)
183    if let Some(paren_start) = path_str.rfind('(')
184        && path_str.ends_with(')')
185    {
186        let path = path_str[..paren_start].to_string();
187        let inner = &path_str[paren_start + 1..path_str.len() - 1];
188        let (line, col) = parse_line_col_pair(inner);
189        if line.is_some() {
190            return (path, line, col);
191        }
192    }
193
194    // Try colon format: :line or :line:col
195    let parts: Vec<&str> = path_str.rsplitn(3, ':').collect();
196
197    match parts.len() {
198        3 => {
199            // file:line:col format
200            let col = parts[0].parse::<usize>().ok();
201            let line = parts[1].parse::<usize>().ok();
202            if line.is_some() {
203                let path = parts[2].to_string();
204                (path, line, col)
205            } else {
206                (path_str.to_string(), None, None)
207            }
208        }
209        2 => {
210            // file:line format (or just path with colon)
211            let line = parts[0].parse::<usize>().ok();
212            if line.is_some() {
213                let path = parts[1].to_string();
214                (path, line, None)
215            } else {
216                (path_str.to_string(), None, None)
217            }
218        }
219        _ => (path_str.to_string(), None, None),
220    }
221}
222
223/// Parse "line" or "line, col" or "line,col" into (Option<line>, Option<col>)
224fn parse_line_col_pair(s: &str) -> (Option<usize>, Option<usize>) {
225    let parts: Vec<&str> = s.split(',').map(|p| p.trim()).collect();
226    match parts.len() {
227        1 => (parts[0].parse().ok(), None),
228        2 => (parts[0].parse().ok(), parts[1].parse().ok()),
229        _ => (None, None),
230    }
231}
232
233/// Detect OSC 8 hyperlinks from terminal cells
234///
235/// # Arguments
236/// * `cells` - Slice of cells from a single row
237/// * `row` - Row number
238/// * `hyperlink_urls` - Mapping from hyperlink_id to URL string
239///
240/// # Returns
241/// Vector of DetectedUrl objects for OSC 8 hyperlinks in this row
242pub fn detect_osc8_hyperlinks(
243    cells: &[crate::cell_renderer::Cell],
244    row: usize,
245    hyperlink_urls: &std::collections::HashMap<u32, String>,
246) -> Vec<DetectedUrl> {
247    let mut urls = Vec::new();
248    let mut current_hyperlink: Option<(u32, usize, String)> = None; // (id, start_col, url)
249
250    for (col, cell) in cells.iter().enumerate() {
251        match (cell.hyperlink_id, &current_hyperlink) {
252            // Cell has a hyperlink ID
253            (Some(id), Some((current_id, _start_col, _url))) if id == *current_id => {
254                // Continue existing hyperlink (same ID as previous cell)
255                continue;
256            }
257            (Some(id), _) => {
258                // Start of a new hyperlink or different hyperlink
259                // First, save the previous hyperlink if there was one
260                if let Some((prev_id, start_col, url)) = current_hyperlink.take() {
261                    urls.push(DetectedUrl {
262                        url,
263                        start_col,
264                        end_col: col, // Previous hyperlink ends at current position
265                        row,
266                        hyperlink_id: Some(prev_id),
267                        item_type: DetectedItemType::Url,
268                    });
269                }
270
271                // Start new hyperlink if we have a URL for this ID
272                if let Some(url) = hyperlink_urls.get(&id) {
273                    current_hyperlink = Some((id, col, url.clone()));
274                }
275            }
276            (None, Some((prev_id, start_col, url))) => {
277                // End of current hyperlink
278                urls.push(DetectedUrl {
279                    url: url.clone(),
280                    start_col: *start_col,
281                    end_col: col, // Hyperlink ends at current position
282                    row,
283                    hyperlink_id: Some(*prev_id),
284                    item_type: DetectedItemType::Url,
285                });
286                current_hyperlink = None;
287            }
288            (None, None) => {
289                // No hyperlink in this cell or previous cells
290                continue;
291            }
292        }
293    }
294
295    // Save last hyperlink if it extends to the end of the row
296    if let Some((id, start_col, url)) = current_hyperlink {
297        urls.push(DetectedUrl {
298            url,
299            start_col,
300            end_col: cells.len(), // Extends to end of row
301            row,
302            hyperlink_id: Some(id),
303            item_type: DetectedItemType::Url,
304        });
305    }
306
307    urls
308}
309
310/// Check if a specific position is within a URL or file path
311pub fn find_url_at_position(urls: &[DetectedUrl], col: usize, row: usize) -> Option<&DetectedUrl> {
312    urls.iter()
313        .find(|url| url.row == row && col >= url.start_col && col < url.end_col)
314}
315
316/// Ensure a URL has a scheme prefix, adding `https://` if missing.
317///
318/// # Examples
319/// - `"www.example.com"` -> `"https://www.example.com"`
320/// - `"https://example.com"` -> `"https://example.com"` (unchanged)
321pub fn ensure_url_scheme(url: &str) -> String {
322    if !url.contains("://") {
323        format!("https://{}", url)
324    } else {
325        url.to_string()
326    }
327}
328
329/// Expand a link handler command template by replacing `{url}` with the given URL.
330///
331/// Returns the command split into program + arguments, ready for spawning.
332/// Returns an error if the expanded command is empty (whitespace-only or blank).
333pub fn expand_link_handler(command: &str, url: &str) -> Result<Vec<String>, String> {
334    let expanded = command.replace("{url}", url);
335    let parts: Vec<String> = expanded.split_whitespace().map(String::from).collect();
336    if parts.is_empty() {
337        return Err("Link handler command is empty after expansion".to_string());
338    }
339    Ok(parts)
340}
341
342/// Open a URL in the configured browser or system default
343pub fn open_url(url: &str, link_handler_command: &str) -> Result<(), String> {
344    let url_with_scheme = ensure_url_scheme(url);
345
346    if link_handler_command.is_empty() {
347        // Use system default
348        open::that(&url_with_scheme).map_err(|e| format!("Failed to open URL: {}", e))
349    } else {
350        // Use custom command with {url} placeholder
351        let parts = expand_link_handler(link_handler_command, &url_with_scheme)?;
352        std::process::Command::new(&parts[0])
353            .args(&parts[1..])
354            .spawn()
355            .map(|_| ())
356            .map_err(|e| format!("Failed to run link handler '{}': {}", parts[0], e))
357    }
358}
359
360/// Open a file path in the configured editor, or a directory in the file manager
361///
362/// # Arguments
363/// * `path` - The file or directory path to open
364/// * `line` - Optional line number to jump to (ignored for directories)
365/// * `column` - Optional column number to jump to (ignored for directories)
366/// * `editor_mode` - How to select the editor (Custom, EnvironmentVariable, or SystemDefault)
367/// * `editor_cmd` - Editor command template with placeholders: `{file}`, `{line}`, `{col}`.
368///   Only used when mode is `Custom`.
369/// * `cwd` - Optional working directory for resolving relative paths
370pub fn open_file_in_editor(
371    path: &str,
372    line: Option<usize>,
373    column: Option<usize>,
374    editor_mode: crate::config::SemanticHistoryEditorMode,
375    editor_cmd: &str,
376    cwd: Option<&str>,
377) -> Result<(), String> {
378    // Expand ~ to home directory
379    let resolved_path = if path.starts_with("~/") {
380        if let Some(home) = dirs::home_dir() {
381            path.replacen("~", &home.to_string_lossy(), 1)
382        } else {
383            path.to_string()
384        }
385    } else {
386        path.to_string()
387    };
388
389    // Resolve relative paths using CWD
390    let resolved_path = if resolved_path.starts_with("./") || resolved_path.starts_with("../") {
391        if let Some(working_dir) = cwd {
392            // Expand ~ in CWD as well
393            let expanded_cwd = if working_dir.starts_with("~/") {
394                if let Some(home) = dirs::home_dir() {
395                    working_dir.replacen("~", &home.to_string_lossy(), 1)
396                } else {
397                    working_dir.to_string()
398                }
399            } else {
400                working_dir.to_string()
401            };
402
403            let cwd_path = std::path::Path::new(&expanded_cwd);
404            let full_path = cwd_path.join(&resolved_path);
405            crate::debug_info!(
406                "SEMANTIC",
407                "Resolved relative path: {:?} + {:?} = {:?}",
408                expanded_cwd,
409                resolved_path,
410                full_path
411            );
412            // Canonicalize to resolve . and .. components
413            full_path
414                .canonicalize()
415                .map(|p| p.to_string_lossy().to_string())
416                .unwrap_or_else(|_| full_path.to_string_lossy().to_string())
417        } else {
418            resolved_path.clone()
419        }
420    } else {
421        resolved_path.clone()
422    };
423
424    // Verify the path exists
425    let path_obj = std::path::Path::new(&resolved_path);
426    if !path_obj.exists() {
427        return Err(format!("Path not found: {}", resolved_path));
428    }
429
430    // If it's a directory, always open in the system file manager
431    if path_obj.is_dir() {
432        crate::debug_info!(
433            "SEMANTIC",
434            "Opening directory in file manager: {}",
435            resolved_path
436        );
437        return open::that(&resolved_path).map_err(|e| format!("Failed to open directory: {}", e));
438    }
439
440    // Determine the editor command based on mode
441    use crate::config::SemanticHistoryEditorMode;
442    let cmd = match editor_mode {
443        SemanticHistoryEditorMode::Custom => {
444            if editor_cmd.is_empty() {
445                // Custom mode but no command configured - fall back to system default
446                crate::debug_info!(
447                    "SEMANTIC",
448                    "Custom mode but no editor configured, using system default for: {}",
449                    resolved_path
450                );
451                return open::that(&resolved_path)
452                    .map_err(|e| format!("Failed to open file: {}", e));
453            }
454            crate::debug_info!("SEMANTIC", "Using custom editor: {:?}", editor_cmd);
455            editor_cmd.to_string()
456        }
457        SemanticHistoryEditorMode::EnvironmentVariable => {
458            // Try $EDITOR, then $VISUAL, then fall back to system default
459            let env_editor = std::env::var("EDITOR")
460                .or_else(|_| std::env::var("VISUAL"))
461                .ok();
462            crate::debug_info!(
463                "SEMANTIC",
464                "Environment variable mode: EDITOR={:?}, VISUAL={:?}",
465                std::env::var("EDITOR").ok(),
466                std::env::var("VISUAL").ok()
467            );
468            if let Some(editor) = env_editor {
469                editor
470            } else {
471                crate::debug_info!(
472                    "SEMANTIC",
473                    "No $EDITOR/$VISUAL set, using system default for: {}",
474                    resolved_path
475                );
476                return open::that(&resolved_path)
477                    .map_err(|e| format!("Failed to open file: {}", e));
478            }
479        }
480        SemanticHistoryEditorMode::SystemDefault => {
481            crate::debug_info!(
482                "SEMANTIC",
483                "System default mode, opening with default app: {}",
484                resolved_path
485            );
486            return open::that(&resolved_path).map_err(|e| format!("Failed to open file: {}", e));
487        }
488    };
489
490    // Replace placeholders in command template
491    let line_str = line
492        .map(|l| l.to_string())
493        .unwrap_or_else(|| "1".to_string());
494    let col_str = column
495        .map(|c| c.to_string())
496        .unwrap_or_else(|| "1".to_string());
497
498    let full_cmd = cmd
499        .replace("{file}", &resolved_path)
500        .replace("{line}", &line_str)
501        .replace("{col}", &col_str);
502
503    // If the template didn't have placeholders, append the file path
504    let full_cmd = if !cmd.contains("{file}") {
505        format!("{} {}", full_cmd, shell_escape(&resolved_path))
506    } else {
507        full_cmd
508    };
509
510    // Execute the command
511    crate::debug_info!(
512        "SEMANTIC",
513        "Executing editor command: {:?} for file: {} (line: {:?}, col: {:?})",
514        full_cmd,
515        resolved_path,
516        line,
517        column
518    );
519
520    #[cfg(target_os = "windows")]
521    {
522        std::process::Command::new("cmd")
523            .args(["/C", &full_cmd])
524            .spawn()
525            .map_err(|e| format!("Failed to launch editor: {}", e))?;
526    }
527
528    #[cfg(not(target_os = "windows"))]
529    {
530        // Use login shell to ensure user's PATH is available
531        // Try user's default shell first, fall back to sh
532        let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string());
533        std::process::Command::new(&shell)
534            .args(["-lc", &full_cmd])
535            .spawn()
536            .map_err(|e| format!("Failed to launch editor with {}: {}", shell, e))?;
537    }
538
539    Ok(())
540}
541
542/// Simple shell escape for file paths (wraps in single quotes)
543fn shell_escape(s: &str) -> String {
544    // Replace single quotes with escaped version and wrap in single quotes
545    format!("'{}'", s.replace('\'', "'\\''"))
546}
547
548#[cfg(test)]
549mod tests {
550    use super::*;
551
552    #[test]
553    fn test_detect_http_url() {
554        let text = "Visit https://example.com for more info";
555        let urls = detect_urls_in_line(text, 0);
556        assert_eq!(urls.len(), 1);
557        assert_eq!(urls[0].url, "https://example.com");
558        assert_eq!(urls[0].start_col, 6);
559        assert_eq!(urls[0].end_col, 25); // Exclusive end position
560    }
561
562    #[test]
563    fn test_detect_www_url() {
564        let text = "Check out www.example.com";
565        let urls = detect_urls_in_line(text, 0);
566        assert_eq!(urls.len(), 1);
567        assert_eq!(urls[0].url, "www.example.com");
568    }
569
570    #[test]
571    fn test_detect_multiple_urls() {
572        let text = "See https://example.com and http://test.org";
573        let urls = detect_urls_in_line(text, 0);
574        assert_eq!(urls.len(), 2);
575        assert_eq!(urls[0].url, "https://example.com");
576        assert_eq!(urls[1].url, "http://test.org");
577    }
578
579    #[test]
580    fn test_find_url_at_position() {
581        let text = "Visit https://example.com for more";
582        let urls = detect_urls_in_line(text, 5);
583
584        // Position within URL
585        assert!(find_url_at_position(&urls, 10, 5).is_some());
586
587        // Position outside URL
588        assert!(find_url_at_position(&urls, 0, 5).is_none());
589        assert!(find_url_at_position(&urls, 30, 5).is_none());
590
591        // Wrong row
592        assert!(find_url_at_position(&urls, 10, 6).is_none());
593    }
594
595    #[test]
596    fn test_no_urls() {
597        let text = "This line has no URLs at all";
598        let urls = detect_urls_in_line(text, 0);
599        assert_eq!(urls.len(), 0);
600    }
601
602    #[test]
603    fn test_url_schemes() {
604        let text = "ftp://files.com ssh://git.com file:///path git://repo.com";
605        let urls = detect_urls_in_line(text, 0);
606        assert_eq!(urls.len(), 4);
607    }
608
609    #[test]
610    fn test_detect_relative_file_path() {
611        let text = "./src/lambda_check_sf_status/.gitignore";
612        let paths = detect_file_paths_in_line(text, 0);
613        assert_eq!(paths.len(), 1, "Should detect exactly one path");
614        assert_eq!(paths[0].url, "./src/lambda_check_sf_status/.gitignore");
615        assert_eq!(paths[0].start_col, 0);
616        assert_eq!(paths[0].end_col, text.len());
617    }
618
619    #[test]
620    fn test_detect_nested_path_no_double_match() {
621        // This test ensures we don't match /src/handler.py inside ./foo/src/handler.py
622        let text = "./src/lambda_sap_po_to_zen/src/handler.py";
623        let paths = detect_file_paths_in_line(text, 0);
624        assert_eq!(
625            paths.len(),
626            1,
627            "Should detect exactly one path, not multiple overlapping ones"
628        );
629        assert_eq!(paths[0].url, text);
630        assert_eq!(paths[0].start_col, 0);
631    }
632
633    #[test]
634    fn test_detect_home_path() {
635        let text = "~/Documents/file.txt";
636        let paths = detect_file_paths_in_line(text, 0);
637        assert_eq!(paths.len(), 1);
638        assert_eq!(paths[0].url, "~/Documents/file.txt");
639    }
640
641    #[test]
642    fn test_detect_path_with_line_number() {
643        let text = "./src/main.rs:42";
644        let paths = detect_file_paths_in_line(text, 0);
645        assert_eq!(paths.len(), 1);
646        assert_eq!(paths[0].url, "./src/main.rs");
647        if let DetectedItemType::FilePath { line, column } = &paths[0].item_type {
648            assert_eq!(*line, Some(42));
649            assert_eq!(*column, None);
650        } else {
651            panic!("Expected FilePath type");
652        }
653    }
654
655    #[test]
656    fn test_detect_path_with_line_and_col() {
657        let text = "./src/main.rs:42:10";
658        let paths = detect_file_paths_in_line(text, 0);
659        assert_eq!(paths.len(), 1);
660        assert_eq!(paths[0].url, "./src/main.rs");
661        if let DetectedItemType::FilePath { line, column } = &paths[0].item_type {
662            assert_eq!(*line, Some(42));
663            assert_eq!(*column, Some(10));
664        } else {
665            panic!("Expected FilePath type");
666        }
667    }
668
669    #[test]
670    fn test_absolute_path_with_multiple_components() {
671        let text = "/Users/probello/.claude";
672        let paths = detect_file_paths_in_line(text, 0);
673        assert_eq!(
674            paths.len(),
675            1,
676            "Should match absolute path at start of string"
677        );
678        assert_eq!(paths[0].url, "/Users/probello/.claude");
679        assert_eq!(paths[0].start_col, 0);
680    }
681
682    #[test]
683    fn test_absolute_path_after_whitespace() {
684        let text = "ls /Users/probello/.claude";
685        let paths = detect_file_paths_in_line(text, 0);
686        assert_eq!(
687            paths.len(),
688            1,
689            "Should match absolute path after whitespace"
690        );
691        assert_eq!(paths[0].url, "/Users/probello/.claude");
692        assert_eq!(paths[0].start_col, 3);
693    }
694
695    #[test]
696    fn test_no_match_single_component_absolute_path() {
697        // Single-component paths like /etc are too likely to be false positives
698        let text = "/etc";
699        let paths = detect_file_paths_in_line(text, 0);
700        assert_eq!(
701            paths.len(),
702            0,
703            "Should not match single-component absolute paths"
704        );
705    }
706
707    #[test]
708    fn test_no_false_absolute_match_inside_relative() {
709        // Absolute path branch should NOT match /bar/baz inside ./foo/bar/baz
710        let text = "./foo/bar/baz";
711        let paths = detect_file_paths_in_line(text, 0);
712        assert_eq!(
713            paths.len(),
714            1,
715            "Should only match the relative path, not internal absolute"
716        );
717        assert_eq!(paths[0].url, "./foo/bar/baz");
718    }
719
720    /// Verify that regex byte offsets can be correctly mapped to column indices
721    /// when multi-byte UTF-8 characters precede the matched text.
722    /// This is the mapping that url_hover.rs applies after detection.
723    #[test]
724    fn test_byte_offset_to_column_mapping_with_multibyte() {
725        // Simulate a terminal line: "★ ~/docs" where ★ is a 3-byte UTF-8 char
726        // Cell layout: [★][ ][~][/][d][o][c][s]
727        // Columns:      0   1  2  3  4  5  6  7
728        let graphemes = ["★", " ", "~", "/", "d", "o", "c", "s"];
729        let cols = graphemes.len();
730
731        // Build line and byte-to-col mapping (same logic as url_hover.rs)
732        let mut line = String::new();
733        let mut byte_to_col: Vec<usize> = Vec::new();
734        for (col_idx, g) in graphemes.iter().enumerate() {
735            for _ in 0..g.len() {
736                byte_to_col.push(col_idx);
737            }
738            line.push_str(g);
739        }
740        byte_to_col.push(cols); // sentinel
741
742        let map = |b: usize| -> usize { byte_to_col.get(b).copied().unwrap_or(cols) };
743
744        // Detect file path in the concatenated string
745        let paths = detect_file_paths_in_line(&line, 0);
746        assert_eq!(paths.len(), 1, "Should detect ~/docs");
747
748        // The regex returns byte offsets: "★" is 3 bytes, " " is 1 byte
749        // so ~/docs starts at byte 4 (not column 2)
750        assert_eq!(paths[0].start_col, 4, "Byte offset should be 4");
751
752        // After mapping, column index should be 2
753        let start_col = map(paths[0].start_col);
754        let end_col = map(paths[0].end_col);
755        assert_eq!(start_col, 2, "Column should be 2 (after ★ and space)");
756        assert_eq!(end_col, cols, "End column should be 8 (end of line)");
757    }
758
759    // --- ensure_url_scheme tests ---
760
761    #[test]
762    fn test_ensure_url_scheme_adds_https_when_no_scheme() {
763        assert_eq!(
764            ensure_url_scheme("www.example.com"),
765            "https://www.example.com"
766        );
767        assert_eq!(
768            ensure_url_scheme("example.com/path"),
769            "https://example.com/path"
770        );
771    }
772
773    #[test]
774    fn test_ensure_url_scheme_preserves_existing_scheme() {
775        assert_eq!(
776            ensure_url_scheme("https://example.com"),
777            "https://example.com"
778        );
779        assert_eq!(
780            ensure_url_scheme("http://example.com"),
781            "http://example.com"
782        );
783        assert_eq!(
784            ensure_url_scheme("ftp://files.example.com"),
785            "ftp://files.example.com"
786        );
787        assert_eq!(
788            ensure_url_scheme("file:///tmp/test.html"),
789            "file:///tmp/test.html"
790        );
791    }
792
793    // --- expand_link_handler tests ---
794
795    #[test]
796    fn test_expand_link_handler_replaces_url_placeholder() {
797        let parts =
798            expand_link_handler("firefox {url}", "https://example.com").expect("should succeed");
799        assert_eq!(parts, vec!["firefox", "https://example.com"]);
800    }
801
802    #[test]
803    fn test_expand_link_handler_multi_word_command() {
804        let parts = expand_link_handler("open -a Firefox {url}", "https://example.com")
805            .expect("should succeed");
806        assert_eq!(parts, vec!["open", "-a", "Firefox", "https://example.com"]);
807    }
808
809    #[test]
810    fn test_expand_link_handler_no_placeholder() {
811        // If command has no {url}, it still works - the URL just doesn't appear
812        let parts =
813            expand_link_handler("my-browser", "https://example.com").expect("should succeed");
814        assert_eq!(parts, vec!["my-browser"]);
815    }
816
817    #[test]
818    fn test_expand_link_handler_errors_on_empty_expansion() {
819        // A command that is only whitespace after expansion should error
820        let result = expand_link_handler("   ", "https://example.com");
821        assert!(result.is_err());
822        assert_eq!(
823            result.unwrap_err(),
824            "Link handler command is empty after expansion"
825        );
826    }
827
828    #[test]
829    fn test_expand_link_handler_empty_command() {
830        let result = expand_link_handler("", "https://example.com");
831        assert!(result.is_err());
832    }
833}