1use std::fmt;
2use std::fs;
3use std::io;
4use std::path::{Path, PathBuf};
5
6#[derive(Debug)]
8pub struct FilesystemProject {
9 source: String,
10 main_path: PathBuf,
11 root: PathBuf,
12}
13
14impl FilesystemProject {
15 pub fn load(path: &Path) -> Result<Self, FilesystemProjectError> {
17 let canonical_target = path.canonicalize().map_err(|error| {
18 if is_absent(error.kind()) {
19 FilesystemProjectError::entry_not_found(path.to_path_buf(), error)
20 } else {
21 FilesystemProjectError::entry_unreadable(path.to_path_buf(), error)
24 }
25 })?;
26 let selected_path = if canonical_target.is_dir() {
27 default_entry(&canonical_target)?
28 } else {
29 canonical_target.clone()
30 };
31 let source = fs::read_to_string(&selected_path).map_err(|error| {
32 FilesystemProjectError::entry_unreadable(selected_path.clone(), error)
33 })?;
34 let root = selected_path
35 .parent()
36 .map(Path::to_path_buf)
37 .unwrap_or_else(|| PathBuf::from("."));
38 Ok(Self {
39 source,
40 main_path: if canonical_target.is_dir() {
41 selected_path
42 } else {
43 path.to_path_buf()
44 },
45 root,
46 })
47 }
48
49 pub fn source(&self) -> &str {
51 &self.source
52 }
53
54 pub fn main_path(&self) -> &Path {
56 &self.main_path
57 }
58
59 pub fn root(&self) -> &Path {
61 &self.root
62 }
63}
64
65fn is_absent(kind: io::ErrorKind) -> bool {
71 matches!(kind, io::ErrorKind::NotFound | io::ErrorKind::NotADirectory)
72}
73
74fn default_entry(directory: &Path) -> Result<PathBuf, FilesystemProjectError> {
75 let candidates = [directory.join("main.opy"), directory.join("src/main.opy")];
76 for candidate in candidates {
77 match fs::metadata(&candidate) {
78 Ok(metadata) if metadata.is_file() => return Ok(candidate),
79 Ok(_) => {}
84 Err(error) if is_absent(error.kind()) => {}
85 Err(error) => {
86 return Err(FilesystemProjectError::entry_unreadable(candidate, error));
87 }
88 }
89 }
90 Err(FilesystemProjectError::default_entry_not_found(
91 directory.to_path_buf(),
92 ))
93}
94
95#[derive(Debug)]
97pub enum FilesystemProjectError {
98 EntryNotFound { path: PathBuf, source: io::Error },
99 EntryUnreadable { path: PathBuf, source: io::Error },
100 DefaultEntryNotFound { path: PathBuf },
101}
102
103impl FilesystemProjectError {
104 fn entry_not_found(path: PathBuf, source: io::Error) -> Self {
105 Self::EntryNotFound { path, source }
106 }
107
108 fn entry_unreadable(path: PathBuf, source: io::Error) -> Self {
109 Self::EntryUnreadable { path, source }
110 }
111
112 fn default_entry_not_found(path: PathBuf) -> Self {
113 Self::DefaultEntryNotFound { path }
114 }
115
116 pub fn is_entry_not_found(&self) -> bool {
121 match self {
122 Self::EntryNotFound { source, .. } => is_absent(source.kind()),
123 Self::DefaultEntryNotFound { .. } => true,
124 Self::EntryUnreadable { .. } => false,
125 }
126 }
127}
128
129impl fmt::Display for FilesystemProjectError {
130 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
131 match self {
132 Self::EntryNotFound { source, .. } => write!(formatter, "cannot find entry: {source}"),
133 Self::EntryUnreadable { source, .. } => {
134 write!(formatter, "cannot read entry: {source}")
135 }
136 Self::DefaultEntryNotFound { path } => {
137 write!(
138 formatter,
139 "cannot find a default OPY entry in '{}'; expected main.opy or src/main.opy",
140 path.display()
141 )
142 }
143 }
144 }
145}
146
147impl std::error::Error for FilesystemProjectError {
148 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
149 match self {
150 Self::EntryNotFound { source, .. } | Self::EntryUnreadable { source, .. } => {
151 Some(source)
152 }
153 Self::DefaultEntryNotFound { .. } => None,
154 }
155 }
156}
157
158#[cfg(test)]
159mod tests {
160 use super::{FilesystemProject, FilesystemProjectError};
161 use std::io;
162 use std::path::{Path, PathBuf};
163
164 #[test]
165 fn directory_targets_use_the_owner_default_entry() {
166 let directory = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
167 .join("tests/fixtures/project-preprocessing");
168 let project = FilesystemProject::load(&directory).expect("directory project loads");
169 assert!(project.main_path().ends_with("main.opy"));
170 assert_eq!(project.root(), directory.canonicalize().unwrap());
171 assert!(project.source().contains("rule \"main\""));
172 }
173
174 fn scratch(tag: &str) -> PathBuf {
175 let dir = std::env::temp_dir().join(format!("opy-project-io-{tag}-{}", std::process::id()));
176 let _ = std::fs::remove_dir_all(&dir);
177 std::fs::create_dir_all(&dir).expect("scratch dir");
178 dir
179 }
180
181 #[test]
182 fn absent_file_entry_is_entry_not_found() {
183 let missing = scratch("missing").join("no-such.opy");
184 let error = FilesystemProject::load(&missing).expect_err("absent entry fails");
185 assert!(error.is_entry_not_found());
186 assert!(matches!(
187 error,
188 FilesystemProjectError::EntryNotFound { .. }
189 ));
190 }
191
192 #[cfg(unix)]
196 #[test]
197 fn unresolvable_entry_is_not_classified_as_missing() {
198 let dir = scratch("loop");
199 let entry = dir.join("main.opy");
200 std::os::unix::fs::symlink(Path::new("loop.opy"), dir.join("loop.opy")).unwrap();
201 std::os::unix::fs::symlink(Path::new("main.opy"), &entry).unwrap();
202 let error = FilesystemProject::load(&entry).expect_err("symlink cycle fails");
203 assert!(!error.is_entry_not_found());
204 assert!(matches!(
205 error,
206 FilesystemProjectError::EntryUnreadable { .. }
207 ));
208 let source = std::error::Error::source(&error).expect("io cause preserved");
209 let io_error = source.downcast_ref::<io::Error>().expect("typed io::Error");
210 assert_ne!(io_error.kind(), io::ErrorKind::NotFound);
211 std::fs::remove_dir_all(&dir).unwrap();
212 }
213
214 #[cfg(unix)]
217 #[test]
218 fn unresolvable_default_entry_is_not_classified_as_missing() {
219 let dir = scratch("default-loop");
220 std::os::unix::fs::symlink(Path::new("main.opy"), dir.join("main.opy")).unwrap();
221 let error = FilesystemProject::load(&dir).expect_err("default entry cycle fails");
222 assert!(!error.is_entry_not_found());
223 assert!(matches!(
224 error,
225 FilesystemProjectError::EntryUnreadable { .. }
226 ));
227 std::fs::remove_dir_all(&dir).unwrap();
228 }
229
230 #[cfg(unix)]
233 #[test]
234 fn unreadable_entry_reports_unreadable() {
235 use std::os::unix::fs::PermissionsExt;
236 if unsafe { libc::geteuid() } == 0 {
237 return;
238 }
239 let dir = scratch("denied");
240 let entry = dir.join("main.opy");
241 std::fs::write(&entry, "rule \"x\": @Event global\n").unwrap();
242 std::fs::set_permissions(&entry, std::fs::Permissions::from_mode(0o000)).unwrap();
243 let result = FilesystemProject::load(&entry);
244 std::fs::set_permissions(&entry, std::fs::Permissions::from_mode(0o644)).unwrap();
245 std::fs::remove_dir_all(&dir).unwrap();
246 let error = result.expect_err("unreadable entry fails");
247 assert!(!error.is_entry_not_found());
248 assert!(matches!(
249 error,
250 FilesystemProjectError::EntryUnreadable { .. }
251 ));
252 }
253
254 #[test]
261 fn file_named_src_is_default_entry_not_found() {
262 let dir = scratch("file-src");
263 std::fs::write(dir.join("src"), "not a directory\n").unwrap();
264 let error = FilesystemProject::load(&dir).expect_err("no default entry");
265 assert!(error.is_entry_not_found());
266 assert!(matches!(
267 error,
268 FilesystemProjectError::DefaultEntryNotFound { .. }
269 ));
270 std::fs::remove_dir_all(&dir).unwrap();
271 }
272
273 #[test]
276 fn empty_directory_is_default_entry_not_found() {
277 let dir = scratch("empty");
278 let error = FilesystemProject::load(&dir).expect_err("no default entry");
279 assert!(error.is_entry_not_found());
280 assert!(matches!(
281 error,
282 FilesystemProjectError::DefaultEntryNotFound { .. }
283 ));
284 std::fs::remove_dir_all(&dir).unwrap();
285 }
286
287 #[test]
292 fn entry_inside_a_file_is_entry_not_found() {
293 let dir = scratch("notdir");
294 let file = dir.join("main.opy");
295 std::fs::write(&file, "rule \"x\": @Event global\n").unwrap();
296 let entry = file.join("child.opy");
297 let error = FilesystemProject::load(&entry).expect_err("ENOTDIR fails");
298 assert!(error.is_entry_not_found());
299 assert!(matches!(
300 error,
301 FilesystemProjectError::EntryNotFound { .. }
302 ));
303 std::fs::remove_dir_all(&dir).unwrap();
304 }
305}