1use crate::error::{Error, ErrorKind};
2use crate::{Meta, Validator};
3use tanzim_value::{Value, ValueType};
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum PathKind {
8 Dir,
9 File,
10 Symlink,
11}
12
13#[derive(Debug, Clone, Default)]
20pub struct Path {
21 meta: Meta,
22 absolute: bool,
23 relative: bool,
24 extensions: Vec<String>,
25 must_exist: bool,
26 kind: Option<PathKind>,
27 readable: bool,
28 writable: bool,
29}
30
31impl Path {
32 pub fn with_meta(mut self, meta: Meta) -> Self {
34 self.meta = meta;
35 self
36 }
37
38 pub fn new() -> Self {
39 Self::default()
40 }
41
42 pub fn absolute(mut self) -> Self {
43 self.absolute = true;
44 self.relative = false;
45 self
46 }
47
48 pub fn relative(mut self) -> Self {
49 self.relative = true;
50 self.absolute = false;
51 self
52 }
53
54 pub fn extension(mut self, extension: impl Into<String>) -> Self {
56 self.extensions.push(extension.into());
57 self
58 }
59
60 pub fn must_exist(mut self) -> Self {
62 self.must_exist = true;
63 self
64 }
65
66 pub fn kind(mut self, kind: PathKind) -> Self {
68 self.kind = Some(kind);
69 self
70 }
71
72 pub fn readable(mut self) -> Self {
74 self.readable = true;
75 self
76 }
77
78 pub fn writable(mut self) -> Self {
80 self.writable = true;
81 self
82 }
83
84 fn touches_filesystem(&self) -> bool {
85 self.must_exist || self.kind.is_some() || self.readable || self.writable
86 }
87}
88
89#[cfg(unix)]
92fn is_readable(metadata: &std::fs::Metadata) -> bool {
93 use std::os::unix::fs::PermissionsExt;
94 metadata.permissions().mode() & 0o444 != 0
95}
96
97#[cfg(not(unix))]
98fn is_readable(_metadata: &std::fs::Metadata) -> bool {
99 true
100}
101
102crate::impl_meta_methods!(Path);
103
104impl Validator for Path {
105 fn meta(&self) -> &Meta {
106 &self.meta
107 }
108
109 fn meta_mut(&mut self) -> &mut Meta {
110 &mut self.meta
111 }
112
113 fn check(&self, value: &mut Value) -> Result<(), Error> {
114 let text = match value {
115 Value::String(text) => text,
116 other => {
117 return Err(Error::new(ErrorKind::Type {
118 expected: ValueType::String,
119 found: other.type_name(),
120 }));
121 }
122 };
123
124 let path = std::path::Path::new(text.as_str());
125
126 if self.absolute && !path.is_absolute() {
127 return Err(Error::new(ErrorKind::Format {
128 expected: "absolute path",
129 }));
130 }
131 if self.relative && path.is_absolute() {
132 return Err(Error::new(ErrorKind::Format {
133 expected: "relative path",
134 }));
135 }
136
137 if !self.extensions.is_empty() {
138 let mut matched = false;
139 if let Some(extension) = path.extension() {
140 for allowed in &self.extensions {
141 if extension.eq_ignore_ascii_case(allowed) {
142 matched = true;
143 break;
144 }
145 }
146 }
147 if !matched {
148 return Err(Error::new(ErrorKind::Format {
149 expected: "allowed file extension",
150 }));
151 }
152 }
153
154 if !self.touches_filesystem() {
155 return Ok(());
156 }
157
158 let metadata = match std::fs::symlink_metadata(path) {
159 Ok(metadata) => metadata,
160 Err(_) => {
161 return Err(Error::new(ErrorKind::Format {
162 expected: "existing path",
163 }));
164 }
165 };
166
167 if let Some(kind) = self.kind {
168 let file_type = metadata.file_type();
169 let ok = match kind {
170 PathKind::Dir => file_type.is_dir(),
171 PathKind::File => file_type.is_file(),
172 PathKind::Symlink => file_type.is_symlink(),
173 };
174 if !ok {
175 let expected = match kind {
176 PathKind::Dir => "directory",
177 PathKind::File => "file",
178 PathKind::Symlink => "symlink",
179 };
180 return Err(Error::new(ErrorKind::Format { expected }));
181 }
182 }
183
184 if self.readable && !is_readable(&metadata) {
185 return Err(Error::new(ErrorKind::Format {
186 expected: "readable path",
187 }));
188 }
189 if self.writable && metadata.permissions().readonly() {
190 return Err(Error::new(ErrorKind::Format {
191 expected: "writable path",
192 }));
193 }
194
195 Ok(())
196 }
197}
198
199#[cfg(test)]
200mod tests {
201 use super::*;
202
203 fn string(text: &str) -> Value {
204 Value::String(text.to_string())
205 }
206
207 #[test]
208 fn absolute_and_relative() {
209 assert!(
210 Path::new()
211 .absolute()
212 .validate(&mut string("/etc/app"))
213 .is_ok()
214 );
215 assert!(Path::new().absolute().validate(&mut string("app")).is_err());
216 assert!(
217 Path::new()
218 .relative()
219 .validate(&mut string("app/conf"))
220 .is_ok()
221 );
222 }
223
224 #[test]
225 fn extension_filter() {
226 assert!(
227 Path::new()
228 .extension("toml")
229 .validate(&mut string("a.toml"))
230 .is_ok()
231 );
232 assert!(
233 Path::new()
234 .extension("toml")
235 .validate(&mut string("a.json"))
236 .is_err()
237 );
238 }
239
240 #[test]
241 fn must_exist_uses_filesystem() {
242 let manifest = env!("CARGO_MANIFEST_DIR");
244 let mut here = string(manifest);
245 assert!(
246 Path::new()
247 .must_exist()
248 .kind(PathKind::Dir)
249 .validate(&mut here)
250 .is_ok()
251 );
252 let mut missing = string("/this/path/should/not/exist/xyzzy");
253 assert!(Path::new().must_exist().validate(&mut missing).is_err());
254 }
255
256 #[test]
257 fn format_only_never_touches_fs() {
258 let mut value = string("/nope/not/here.toml");
260 assert!(
261 Path::new()
262 .absolute()
263 .extension("toml")
264 .validate(&mut value)
265 .is_ok()
266 );
267 }
268}