wdl_modules/
symbolic_path.rs1use std::fmt;
4use std::path::Path;
5use std::path::PathBuf;
6use std::str::FromStr;
7
8use thiserror::Error;
9use wdl_grammar::lexer::v1::is_ident;
10
11use crate::DependencyName;
12
13#[derive(Debug, Error)]
15#[error(
16 "symbolic module path `{0}` does not match `<dep-name>[/<sub-path>]` with non-empty, \
17 non-`.`/`..` segments"
18)]
19pub struct SymbolicPathError(String);
20
21#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
29pub struct SymbolicPath {
30 dep_name: DependencyName,
32 sub_path: Option<PathBuf>,
34}
35
36impl SymbolicPath {
37 pub fn dep_name(&self) -> &DependencyName {
39 &self.dep_name
40 }
41
42 pub fn sub_path(&self) -> Option<&Path> {
44 self.sub_path.as_deref()
45 }
46}
47
48fn validate(s: String) -> Result<SymbolicPath, SymbolicPathError> {
51 let mut iter = s.split('/');
52 let head = iter.next().unwrap();
55
56 let dep_name =
57 DependencyName::try_from(head.to_string()).map_err(|_| SymbolicPathError(s.clone()))?;
58
59 let mut sub_path = PathBuf::new();
60 let mut has_tail = false;
61 for segment in iter {
62 if !is_ident(segment) {
63 return Err(SymbolicPathError(s));
64 }
65 sub_path.push(segment);
66 has_tail = true;
67 }
68
69 Ok(SymbolicPath {
70 dep_name,
71 sub_path: if has_tail { Some(sub_path) } else { None },
72 })
73}
74
75impl TryFrom<String> for SymbolicPath {
76 type Error = SymbolicPathError;
77
78 fn try_from(s: String) -> Result<Self, Self::Error> {
79 validate(s)
80 }
81}
82
83impl FromStr for SymbolicPath {
84 type Err = SymbolicPathError;
85
86 fn from_str(s: &str) -> Result<Self, Self::Err> {
87 Self::try_from(s.to_string())
88 }
89}
90
91impl fmt::Display for SymbolicPath {
92 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
93 f.write_str(self.dep_name.identifier())?;
94 if let Some(sub) = &self.sub_path {
95 for component in sub.iter() {
96 f.write_str("/")?;
97 f.write_str(&component.to_string_lossy())?;
98 }
99 }
100 Ok(())
101 }
102}
103
104#[cfg(test)]
105mod tests {
106 use super::*;
107
108 #[test]
109 fn parses_dep_only() {
110 let p: SymbolicPath = "spellbook".parse().unwrap();
111 assert_eq!(p.dep_name().identifier(), "spellbook");
112 assert!(p.sub_path().is_none());
113 }
114
115 #[test]
116 fn parses_with_sub_path() {
117 let p: SymbolicPath = "spellbook/cauldron".parse().unwrap();
118 assert_eq!(p.dep_name().identifier(), "spellbook");
119 assert_eq!(p.sub_path().unwrap(), Path::new("cauldron"));
120 }
121
122 #[test]
123 fn parses_multi_segment_sub_path() {
124 let p: SymbolicPath = "spellbook/cauldron/runes".parse().unwrap();
125 assert_eq!(p.sub_path().unwrap(), Path::new("cauldron/runes"));
126 }
127
128 #[test]
129 fn rejects_invalid_format() {
130 for bad in [
131 "spellbook/",
132 "spellbook//cauldron",
133 "spellbook/cauldron/",
134 "spellbook/..",
135 "spellbook/.",
136 "1spellbook/cauldron",
137 "spellbook/has-dash", "spellbook/has space", "spellbook/cauldron.runes", ] {
141 assert!(bad.parse::<SymbolicPath>().is_err(), "accepted `{bad}`");
142 }
143 }
144
145 #[test]
146 fn case_sensitive() {
147 let lower: SymbolicPath = "spellbook/cauldron".parse().unwrap();
148 let mixed: SymbolicPath = "spellbook/Cauldron".parse().unwrap();
149 assert_ne!(lower.sub_path(), mixed.sub_path());
150 }
151
152 #[test]
153 fn round_trips_via_display() {
154 for s in [
155 "spellbook",
156 "spellbook/cauldron",
157 "spellbook/cauldron/runes",
158 ] {
159 let p: SymbolicPath = s.parse().unwrap();
160 assert_eq!(p.to_string(), s);
161 }
162 }
163}