sheetkit_core/
hyperlink.rs

1//! Hyperlink management for worksheet cells.
2//!
3//! Provides types and functions for setting, getting, and deleting hyperlinks
4//! on individual cells. Supports external URLs, internal sheet references,
5//! and email (mailto) links.
6
7use sheetkit_xml::relationships::{rel_types, Relationship, Relationships};
8use sheetkit_xml::worksheet::{Hyperlink, Hyperlinks, WorksheetXml};
9
10use crate::error::Result;
11
12/// Type of hyperlink target.
13#[derive(Debug, Clone, PartialEq)]
14pub enum HyperlinkType {
15    /// External URL (e.g., "https://example.com" or "file:///path").
16    External(String),
17    /// Internal sheet reference (e.g., "Sheet2!A1").
18    Internal(String),
19    /// Email link (e.g., "mailto:[email protected]").
20    Email(String),
21}
22
23/// Hyperlink information returned by get operations.
24#[derive(Debug, Clone, PartialEq)]
25pub struct HyperlinkInfo {
26    /// The hyperlink target.
27    pub link_type: HyperlinkType,
28    /// Optional display text.
29    pub display: Option<String>,
30    /// Optional tooltip text.
31    pub tooltip: Option<String>,
32}
33
34/// Set a hyperlink on a cell.
35///
36/// For external URLs and email links, a relationship entry is created in the
37/// worksheet `.rels` file with `TargetMode="External"`. For internal sheet
38/// references, only the `location` attribute is set on the hyperlink element
39/// (no relationship is needed).
40///
41/// If a hyperlink already exists on the cell, it is replaced.
42pub fn set_cell_hyperlink(
43    ws: &mut WorksheetXml,
44    rels: &mut Relationships,
45    cell: &str,
46    link: &HyperlinkType,
47    display: Option<&str>,
48    tooltip: Option<&str>,
49) -> Result<()> {
50    // Remove any existing hyperlink on this cell first.
51    delete_cell_hyperlink(ws, rels, cell)?;
52
53    let hyperlinks = ws
54        .hyperlinks
55        .get_or_insert_with(|| Hyperlinks { hyperlinks: vec![] });
56
57    match link {
58        HyperlinkType::External(url) | HyperlinkType::Email(url) => {
59            let rid = next_rel_id(rels);
60            rels.relationships.push(Relationship {
61                id: rid.clone(),
62                rel_type: rel_types::HYPERLINK.to_string(),
63                target: url.clone(),
64                target_mode: Some("External".to_string()),
65            });
66            hyperlinks.hyperlinks.push(Hyperlink {
67                reference: cell.to_string(),
68                r_id: Some(rid),
69                location: None,
70                display: display.map(|s| s.to_string()),
71                tooltip: tooltip.map(|s| s.to_string()),
72            });
73        }
74        HyperlinkType::Internal(location) => {
75            hyperlinks.hyperlinks.push(Hyperlink {
76                reference: cell.to_string(),
77                r_id: None,
78                location: Some(location.clone()),
79                display: display.map(|s| s.to_string()),
80                tooltip: tooltip.map(|s| s.to_string()),
81            });
82        }
83    }
84
85    Ok(())
86}
87
88/// Get hyperlink information for a cell.
89///
90/// Returns `None` if the cell has no hyperlink.
91pub fn get_cell_hyperlink(
92    ws: &WorksheetXml,
93    rels: &Relationships,
94    cell: &str,
95) -> Result<Option<HyperlinkInfo>> {
96    let hyperlinks = match &ws.hyperlinks {
97        Some(h) => h,
98        None => return Ok(None),
99    };
100
101    let hl = match hyperlinks.hyperlinks.iter().find(|h| h.reference == cell) {
102        Some(h) => h,
103        None => return Ok(None),
104    };
105
106    let link_type = if let Some(ref rid) = hl.r_id {
107        // Look up the relationship target.
108        let rel = rels.relationships.iter().find(|r| r.id == *rid);
109        match rel {
110            Some(r) => {
111                let target = &r.target;
112                if target.starts_with("mailto:") {
113                    HyperlinkType::Email(target.clone())
114                } else {
115                    HyperlinkType::External(target.clone())
116                }
117            }
118            None => {
119                // Relationship not found; treat as external with empty target.
120                HyperlinkType::External(String::new())
121            }
122        }
123    } else if let Some(ref location) = hl.location {
124        HyperlinkType::Internal(location.clone())
125    } else {
126        // No r:id and no location; should not happen in valid files.
127        return Ok(None);
128    };
129
130    Ok(Some(HyperlinkInfo {
131        link_type,
132        display: hl.display.clone(),
133        tooltip: hl.tooltip.clone(),
134    }))
135}
136
137/// Delete a hyperlink from a cell.
138///
139/// Removes the hyperlink element from the worksheet XML and, if the hyperlink
140/// used a relationship, removes the corresponding relationship entry.
141pub fn delete_cell_hyperlink(
142    ws: &mut WorksheetXml,
143    rels: &mut Relationships,
144    cell: &str,
145) -> Result<()> {
146    let hyperlinks = match &mut ws.hyperlinks {
147        Some(h) => h,
148        None => return Ok(()),
149    };
150
151    // Find and remove the hyperlink for this cell.
152    let removed: Vec<Hyperlink> = hyperlinks
153        .hyperlinks
154        .extract_if(.., |h| h.reference == cell)
155        .collect();
156
157    // Remove associated relationships.
158    for hl in &removed {
159        if let Some(ref rid) = hl.r_id {
160            rels.relationships.retain(|r| r.id != *rid);
161        }
162    }
163
164    // If no hyperlinks remain, remove the container.
165    if hyperlinks.hyperlinks.is_empty() {
166        ws.hyperlinks = None;
167    }
168
169    Ok(())
170}
171
172/// Generate the next relationship ID for a rels collection.
173fn next_rel_id(rels: &Relationships) -> String {
174    let max = rels
175        .relationships
176        .iter()
177        .filter_map(|r| r.id.strip_prefix("rId").and_then(|n| n.parse::<u32>().ok()))
178        .max()
179        .unwrap_or(0);
180    format!("rId{}", max + 1)
181}
182
183#[cfg(test)]
184#[allow(clippy::field_reassign_with_default)]
185mod tests {
186    use super::*;
187    use sheetkit_xml::namespaces;
188
189    fn empty_rels() -> Relationships {
190        Relationships {
191            xmlns: namespaces::PACKAGE_RELATIONSHIPS.to_string(),
192            relationships: vec![],
193        }
194    }
195
196    #[test]
197    fn test_set_external_hyperlink() {
198        let mut ws = WorksheetXml::default();
199        let mut rels = empty_rels();
200
201        set_cell_hyperlink(
202            &mut ws,
203            &mut rels,
204            "A1",
205            &HyperlinkType::External("https://example.com".to_string()),
206            None,
207            None,
208        )
209        .unwrap();
210
211        // Hyperlink element should exist.
212        let hls = ws.hyperlinks.as_ref().unwrap();
213        assert_eq!(hls.hyperlinks.len(), 1);
214        assert_eq!(hls.hyperlinks[0].reference, "A1");
215        assert!(hls.hyperlinks[0].r_id.is_some());
216        assert!(hls.hyperlinks[0].location.is_none());
217
218        // Relationship should exist.
219        assert_eq!(rels.relationships.len(), 1);
220        assert_eq!(rels.relationships[0].target, "https://example.com");
221        assert_eq!(
222            rels.relationships[0].target_mode,
223            Some("External".to_string())
224        );
225        assert_eq!(rels.relationships[0].rel_type, rel_types::HYPERLINK);
226    }
227
228    #[test]
229    fn test_set_internal_hyperlink() {
230        let mut ws = WorksheetXml::default();
231        let mut rels = empty_rels();
232
233        set_cell_hyperlink(
234            &mut ws,
235            &mut rels,
236            "B2",
237            &HyperlinkType::Internal("Sheet2!A1".to_string()),
238            None,
239            None,
240        )
241        .unwrap();
242
243        let hls = ws.hyperlinks.as_ref().unwrap();
244        assert_eq!(hls.hyperlinks.len(), 1);
245        assert_eq!(hls.hyperlinks[0].reference, "B2");
246        assert!(hls.hyperlinks[0].r_id.is_none());
247        assert_eq!(hls.hyperlinks[0].location, Some("Sheet2!A1".to_string()));
248
249        // No relationship should be created for internal links.
250        assert!(rels.relationships.is_empty());
251    }
252
253    #[test]
254    fn test_set_email_hyperlink() {
255        let mut ws = WorksheetXml::default();
256        let mut rels = empty_rels();
257
258        set_cell_hyperlink(
259            &mut ws,
260            &mut rels,
261            "C3",
262            &HyperlinkType::Email("mailto:[email protected]".to_string()),
263            None,
264            None,
265        )
266        .unwrap();
267
268        let hls = ws.hyperlinks.as_ref().unwrap();
269        assert_eq!(hls.hyperlinks.len(), 1);
270        assert!(hls.hyperlinks[0].r_id.is_some());
271
272        assert_eq!(rels.relationships.len(), 1);
273        assert_eq!(rels.relationships[0].target, "mailto:[email protected]");
274        assert_eq!(
275            rels.relationships[0].target_mode,
276            Some("External".to_string())
277        );
278    }
279
280    #[test]
281    fn test_get_hyperlink_external() {
282        let mut ws = WorksheetXml::default();
283        let mut rels = empty_rels();
284
285        set_cell_hyperlink(
286            &mut ws,
287            &mut rels,
288            "A1",
289            &HyperlinkType::External("https://rust-lang.org".to_string()),
290            Some("Rust"),
291            Some("Visit Rust"),
292        )
293        .unwrap();
294
295        let info = get_cell_hyperlink(&ws, &rels, "A1").unwrap().unwrap();
296        assert_eq!(
297            info.link_type,
298            HyperlinkType::External("https://rust-lang.org".to_string())
299        );
300        assert_eq!(info.display, Some("Rust".to_string()));
301        assert_eq!(info.tooltip, Some("Visit Rust".to_string()));
302    }
303
304    #[test]
305    fn test_get_hyperlink_internal() {
306        let mut ws = WorksheetXml::default();
307        let mut rels = empty_rels();
308
309        set_cell_hyperlink(
310            &mut ws,
311            &mut rels,
312            "D4",
313            &HyperlinkType::Internal("Summary!B10".to_string()),
314            Some("Go to Summary"),
315            None,
316        )
317        .unwrap();
318
319        let info = get_cell_hyperlink(&ws, &rels, "D4").unwrap().unwrap();
320        assert_eq!(
321            info.link_type,
322            HyperlinkType::Internal("Summary!B10".to_string())
323        );
324        assert_eq!(info.display, Some("Go to Summary".to_string()));
325        assert!(info.tooltip.is_none());
326    }
327
328    #[test]
329    fn test_get_hyperlink_email() {
330        let mut ws = WorksheetXml::default();
331        let mut rels = empty_rels();
332
333        set_cell_hyperlink(
334            &mut ws,
335            &mut rels,
336            "A1",
337            &HyperlinkType::Email("mailto:[email protected]".to_string()),
338            None,
339            None,
340        )
341        .unwrap();
342
343        let info = get_cell_hyperlink(&ws, &rels, "A1").unwrap().unwrap();
344        assert_eq!(
345            info.link_type,
346            HyperlinkType::Email("mailto:[email protected]".to_string())
347        );
348    }
349
350    #[test]
351    fn test_delete_hyperlink() {
352        let mut ws = WorksheetXml::default();
353        let mut rels = empty_rels();
354
355        set_cell_hyperlink(
356            &mut ws,
357            &mut rels,
358            "A1",
359            &HyperlinkType::External("https://example.com".to_string()),
360            None,
361            None,
362        )
363        .unwrap();
364
365        assert!(ws.hyperlinks.is_some());
366        assert_eq!(rels.relationships.len(), 1);
367
368        delete_cell_hyperlink(&mut ws, &mut rels, "A1").unwrap();
369
370        // Hyperlinks container should be removed when empty.
371        assert!(ws.hyperlinks.is_none());
372        // Relationship should be cleaned up.
373        assert!(rels.relationships.is_empty());
374    }
375
376    #[test]
377    fn test_delete_internal_hyperlink() {
378        let mut ws = WorksheetXml::default();
379        let mut rels = empty_rels();
380
381        set_cell_hyperlink(
382            &mut ws,
383            &mut rels,
384            "A1",
385            &HyperlinkType::Internal("Sheet2!A1".to_string()),
386            None,
387            None,
388        )
389        .unwrap();
390
391        delete_cell_hyperlink(&mut ws, &mut rels, "A1").unwrap();
392
393        assert!(ws.hyperlinks.is_none());
394        assert!(rels.relationships.is_empty());
395    }
396
397    #[test]
398    fn test_hyperlink_with_display_and_tooltip() {
399        let mut ws = WorksheetXml::default();
400        let mut rels = empty_rels();
401
402        set_cell_hyperlink(
403            &mut ws,
404            &mut rels,
405            "A1",
406            &HyperlinkType::External("https://example.com".to_string()),
407            Some("Click here"),
408            Some("Opens example.com"),
409        )
410        .unwrap();
411
412        let hls = ws.hyperlinks.as_ref().unwrap();
413        assert_eq!(hls.hyperlinks[0].display, Some("Click here".to_string()));
414        assert_eq!(
415            hls.hyperlinks[0].tooltip,
416            Some("Opens example.com".to_string())
417        );
418
419        let info = get_cell_hyperlink(&ws, &rels, "A1").unwrap().unwrap();
420        assert_eq!(info.display, Some("Click here".to_string()));
421        assert_eq!(info.tooltip, Some("Opens example.com".to_string()));
422    }
423
424    #[test]
425    fn test_overwrite_hyperlink() {
426        let mut ws = WorksheetXml::default();
427        let mut rels = empty_rels();
428
429        // Set first hyperlink.
430        set_cell_hyperlink(
431            &mut ws,
432            &mut rels,
433            "A1",
434            &HyperlinkType::External("https://old.com".to_string()),
435            None,
436            None,
437        )
438        .unwrap();
439
440        // Overwrite with a new hyperlink.
441        set_cell_hyperlink(
442            &mut ws,
443            &mut rels,
444            "A1",
445            &HyperlinkType::External("https://new.com".to_string()),
446            Some("New Link"),
447            None,
448        )
449        .unwrap();
450
451        // Should have only one hyperlink.
452        let hls = ws.hyperlinks.as_ref().unwrap();
453        assert_eq!(hls.hyperlinks.len(), 1);
454
455        // Should have only one relationship (old one cleaned up).
456        assert_eq!(rels.relationships.len(), 1);
457        assert_eq!(rels.relationships[0].target, "https://new.com");
458
459        let info = get_cell_hyperlink(&ws, &rels, "A1").unwrap().unwrap();
460        assert_eq!(
461            info.link_type,
462            HyperlinkType::External("https://new.com".to_string())
463        );
464        assert_eq!(info.display, Some("New Link".to_string()));
465    }
466
467    #[test]
468    fn test_overwrite_external_with_internal() {
469        let mut ws = WorksheetXml::default();
470        let mut rels = empty_rels();
471
472        set_cell_hyperlink(
473            &mut ws,
474            &mut rels,
475            "A1",
476            &HyperlinkType::External("https://example.com".to_string()),
477            None,
478            None,
479        )
480        .unwrap();
481
482        assert_eq!(rels.relationships.len(), 1);
483
484        // Overwrite with internal link.
485        set_cell_hyperlink(
486            &mut ws,
487            &mut rels,
488            "A1",
489            &HyperlinkType::Internal("Sheet2!A1".to_string()),
490            None,
491            None,
492        )
493        .unwrap();
494
495        // Old relationship should be cleaned up.
496        assert!(rels.relationships.is_empty());
497
498        let hls = ws.hyperlinks.as_ref().unwrap();
499        assert_eq!(hls.hyperlinks.len(), 1);
500        assert!(hls.hyperlinks[0].r_id.is_none());
501        assert_eq!(hls.hyperlinks[0].location, Some("Sheet2!A1".to_string()));
502    }
503
504    #[test]
505    fn test_get_nonexistent_hyperlink() {
506        let ws = WorksheetXml::default();
507        let rels = empty_rels();
508
509        let result = get_cell_hyperlink(&ws, &rels, "Z99").unwrap();
510        assert!(result.is_none());
511    }
512
513    #[test]
514    fn test_get_nonexistent_hyperlink_with_empty_container() {
515        let mut ws = WorksheetXml::default();
516        ws.hyperlinks = Some(Hyperlinks { hyperlinks: vec![] });
517        let rels = empty_rels();
518
519        let result = get_cell_hyperlink(&ws, &rels, "A1").unwrap();
520        assert!(result.is_none());
521    }
522
523    #[test]
524    fn test_delete_nonexistent_hyperlink() {
525        let mut ws = WorksheetXml::default();
526        let mut rels = empty_rels();
527
528        // Deleting a hyperlink that does not exist should succeed silently.
529        delete_cell_hyperlink(&mut ws, &mut rels, "A1").unwrap();
530        assert!(ws.hyperlinks.is_none());
531    }
532
533    #[test]
534    fn test_multiple_hyperlinks() {
535        let mut ws = WorksheetXml::default();
536        let mut rels = empty_rels();
537
538        set_cell_hyperlink(
539            &mut ws,
540            &mut rels,
541            "A1",
542            &HyperlinkType::External("https://example.com".to_string()),
543            Some("Example"),
544            None,
545        )
546        .unwrap();
547
548        set_cell_hyperlink(
549            &mut ws,
550            &mut rels,
551            "B1",
552            &HyperlinkType::Internal("Sheet2!A1".to_string()),
553            Some("Sheet 2"),
554            None,
555        )
556        .unwrap();
557
558        set_cell_hyperlink(
559            &mut ws,
560            &mut rels,
561            "C1",
562            &HyperlinkType::Email("mailto:[email protected]".to_string()),
563            Some("Email Us"),
564            Some("Send email"),
565        )
566        .unwrap();
567
568        let hls = ws.hyperlinks.as_ref().unwrap();
569        assert_eq!(hls.hyperlinks.len(), 3);
570
571        // External and email should have relationships; internal should not.
572        assert_eq!(rels.relationships.len(), 2);
573
574        // Verify each hyperlink.
575        let a1 = get_cell_hyperlink(&ws, &rels, "A1").unwrap().unwrap();
576        assert_eq!(
577            a1.link_type,
578            HyperlinkType::External("https://example.com".to_string())
579        );
580        assert_eq!(a1.display, Some("Example".to_string()));
581
582        let b1 = get_cell_hyperlink(&ws, &rels, "B1").unwrap().unwrap();
583        assert_eq!(
584            b1.link_type,
585            HyperlinkType::Internal("Sheet2!A1".to_string())
586        );
587        assert_eq!(b1.display, Some("Sheet 2".to_string()));
588
589        let c1 = get_cell_hyperlink(&ws, &rels, "C1").unwrap().unwrap();
590        assert_eq!(
591            c1.link_type,
592            HyperlinkType::Email("mailto:[email protected]".to_string())
593        );
594        assert_eq!(c1.display, Some("Email Us".to_string()));
595        assert_eq!(c1.tooltip, Some("Send email".to_string()));
596    }
597
598    #[test]
599    fn test_delete_one_of_multiple() {
600        let mut ws = WorksheetXml::default();
601        let mut rels = empty_rels();
602
603        set_cell_hyperlink(
604            &mut ws,
605            &mut rels,
606            "A1",
607            &HyperlinkType::External("https://a.com".to_string()),
608            None,
609            None,
610        )
611        .unwrap();
612
613        set_cell_hyperlink(
614            &mut ws,
615            &mut rels,
616            "B1",
617            &HyperlinkType::External("https://b.com".to_string()),
618            None,
619            None,
620        )
621        .unwrap();
622
623        assert_eq!(ws.hyperlinks.as_ref().unwrap().hyperlinks.len(), 2);
624        assert_eq!(rels.relationships.len(), 2);
625
626        // Delete only A1.
627        delete_cell_hyperlink(&mut ws, &mut rels, "A1").unwrap();
628
629        let hls = ws.hyperlinks.as_ref().unwrap();
630        assert_eq!(hls.hyperlinks.len(), 1);
631        assert_eq!(hls.hyperlinks[0].reference, "B1");
632
633        // Only B1's relationship should remain.
634        assert_eq!(rels.relationships.len(), 1);
635        assert_eq!(rels.relationships[0].target, "https://b.com");
636    }
637
638    #[test]
639    fn test_next_rel_id_empty() {
640        let rels = empty_rels();
641        assert_eq!(next_rel_id(&rels), "rId1");
642    }
643
644    #[test]
645    fn test_next_rel_id_with_existing() {
646        let rels = Relationships {
647            xmlns: namespaces::PACKAGE_RELATIONSHIPS.to_string(),
648            relationships: vec![
649                Relationship {
650                    id: "rId1".to_string(),
651                    rel_type: rel_types::HYPERLINK.to_string(),
652                    target: "https://a.com".to_string(),
653                    target_mode: Some("External".to_string()),
654                },
655                Relationship {
656                    id: "rId3".to_string(),
657                    rel_type: rel_types::HYPERLINK.to_string(),
658                    target: "https://b.com".to_string(),
659                    target_mode: Some("External".to_string()),
660                },
661            ],
662        };
663        assert_eq!(next_rel_id(&rels), "rId4");
664    }
665}