Skip to main content

frus_shell/
link.rs

1//! **Links**: an address that comes from outside the application and names a place in it.
2//!
3//! `https://example.com/orders/42?tab=items` (an Android app link), `myapp://orders/42` (a
4//! custom scheme, on Android or on a desktop's command line) — each is one string, and the
5//! application understands *locations*: `/orders/42?tab=items`. This is the whole of the
6//! translation, so that the platforms hand the application the same thing the web's address
7//! bar does, and [`Application::open_location`](crate::Application::open_location) is the one
8//! way in.
9//!
10//! The rule is short and has one choice in it. **A web link's path is the location.** A
11//! **custom scheme has no host in the usual sense** — `myapp://orders/42` means "orders/42" to
12//! whoever wrote it, not a server called `orders` — so there the host is the first segment.
13
14/// The location a link stands for, or `None` if `link` is not one: no scheme, or nothing after
15/// it.
16///
17/// | link | location |
18/// |---|---|
19/// | `https://example.com/orders/42?tab=items` | `/orders/42?tab=items` |
20/// | `https://example.com` | `/` |
21/// | `myapp://orders/42` | `/orders/42` |
22/// | `myapp:orders/42` | `/orders/42` |
23/// | `myapp:///orders/42` | `/orders/42` |
24///
25/// A fragment is dropped: an application's locations have none.
26pub fn location_of_link(link: &str) -> Option<String> {
27    let link = link.trim();
28    let (scheme, rest) = link.split_once(':')?;
29    let scheme_ok = !scheme.is_empty()
30        && scheme.starts_with(|c: char| c.is_ascii_alphabetic())
31        && scheme
32            .chars()
33            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'));
34    if !scheme_ok {
35        return None;
36    }
37    let rest = rest.split('#').next().unwrap_or("");
38    let web = scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https");
39    let location = match rest.strip_prefix("//") {
40        Some(after) if web => match after.find(['/', '?']) {
41            // The authority is the server's, and is dropped.
42            Some(at) => after[at..].to_string(),
43            None => String::new(),
44        },
45        // A custom scheme's "host" is the first segment of the place.
46        Some(after) => after.to_string(),
47        None => rest.to_string(),
48    };
49    Some(normalised(&location))
50}
51
52/// `location` as a location: one leading slash, and `/` for nothing.
53fn normalised(location: &str) -> String {
54    let trimmed = location.trim_start_matches('/');
55    if trimmed.is_empty() {
56        "/".to_string()
57    } else {
58        format!("/{trimmed}")
59    }
60}
61
62/// The link among a program's arguments, if one was passed: the first that has the shape of
63/// one. What the operating system does when a registered scheme is opened is to start the
64/// program with the link as an argument.
65pub fn link_among<I, S>(args: I) -> Option<String>
66where
67    I: IntoIterator<Item = S>,
68    S: AsRef<str>,
69{
70    args.into_iter()
71        .map(|arg| arg.as_ref().to_string())
72        .find(|arg| arg.contains("://") && location_of_link(arg).is_some())
73}
74
75#[cfg(test)]
76mod tests {
77    use super::*;
78
79    #[test]
80    fn a_web_links_path_and_query_are_the_location() {
81        assert_eq!(
82            location_of_link("https://example.com/orders/42?tab=items"),
83            Some("/orders/42?tab=items".to_string())
84        );
85        assert_eq!(
86            location_of_link("http://localhost:8080/a"),
87            Some("/a".to_string()),
88            "a port belongs to the authority"
89        );
90    }
91
92    #[test]
93    fn a_web_link_with_no_path_is_the_root() {
94        assert_eq!(
95            location_of_link("https://example.com"),
96            Some("/".to_string())
97        );
98        assert_eq!(
99            location_of_link("https://example.com/"),
100            Some("/".to_string())
101        );
102        assert_eq!(
103            location_of_link("https://example.com?ref=mail"),
104            Some("/?ref=mail".to_string())
105        );
106    }
107
108    #[test]
109    fn a_custom_schemes_host_is_the_first_segment() {
110        assert_eq!(
111            location_of_link("myapp://orders/42"),
112            Some("/orders/42".to_string())
113        );
114        assert_eq!(
115            location_of_link("myapp://settings"),
116            Some("/settings".to_string())
117        );
118    }
119
120    #[test]
121    fn a_custom_link_without_slashes_or_with_three_names_the_same_place() {
122        assert_eq!(
123            location_of_link("myapp:orders/42"),
124            Some("/orders/42".to_string())
125        );
126        assert_eq!(
127            location_of_link("myapp:///orders/42"),
128            Some("/orders/42".to_string())
129        );
130        assert_eq!(location_of_link("myapp://"), Some("/".to_string()));
131    }
132
133    #[test]
134    fn the_fragment_is_dropped() {
135        assert_eq!(
136            location_of_link("https://example.com/a?x=1#top"),
137            Some("/a?x=1".to_string())
138        );
139    }
140
141    #[test]
142    fn what_is_not_a_link_is_none() {
143        assert_eq!(location_of_link(""), None);
144        assert_eq!(location_of_link("/orders/42"), None, "no scheme");
145        assert_eq!(
146            location_of_link("42:orders"),
147            None,
148            "a scheme starts with a letter"
149        );
150        assert_eq!(location_of_link("not a link"), None);
151    }
152
153    #[test]
154    fn the_link_among_a_programs_arguments_is_the_first_that_looks_like_one() {
155        assert_eq!(
156            link_among(["frus-demo", "--verbose", "myapp://orders/42", "other://x"]),
157            Some("myapp://orders/42".to_string())
158        );
159        assert_eq!(link_among(["frus-demo", "--verbose"]), None);
160        assert_eq!(link_among(["frus-demo", "C:\\Users\\me"]), None);
161    }
162}