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 error.kind() == io::ErrorKind::NotFound {
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 default_entry(directory: &Path) -> Result<PathBuf, FilesystemProjectError> {
66 let candidates = [directory.join("main.opy"), directory.join("src/main.opy")];
67 for candidate in candidates {
68 match fs::metadata(&candidate) {
69 Ok(metadata) if metadata.is_file() => return Ok(candidate),
70 Ok(_) => {}
73 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
74 Err(error) => {
75 return Err(FilesystemProjectError::entry_unreadable(candidate, error));
76 }
77 }
78 }
79 Err(FilesystemProjectError::default_entry_not_found(
80 directory.to_path_buf(),
81 ))
82}
83
84#[derive(Debug)]
86pub enum FilesystemProjectError {
87 EntryNotFound { path: PathBuf, source: io::Error },
88 EntryUnreadable { path: PathBuf, source: io::Error },
89 DefaultEntryNotFound { path: PathBuf },
90}
91
92impl FilesystemProjectError {
93 fn entry_not_found(path: PathBuf, source: io::Error) -> Self {
94 Self::EntryNotFound { path, source }
95 }
96
97 fn entry_unreadable(path: PathBuf, source: io::Error) -> Self {
98 Self::EntryUnreadable { path, source }
99 }
100
101 fn default_entry_not_found(path: PathBuf) -> Self {
102 Self::DefaultEntryNotFound { path }
103 }
104
105 pub fn is_entry_not_found(&self) -> bool {
109 match self {
110 Self::EntryNotFound { source, .. } => source.kind() == io::ErrorKind::NotFound,
111 Self::DefaultEntryNotFound { .. } => true,
112 Self::EntryUnreadable { .. } => false,
113 }
114 }
115}
116
117impl fmt::Display for FilesystemProjectError {
118 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
119 match self {
120 Self::EntryNotFound { source, .. } => write!(formatter, "cannot find entry: {source}"),
121 Self::EntryUnreadable { source, .. } => {
122 write!(formatter, "cannot read entry: {source}")
123 }
124 Self::DefaultEntryNotFound { path } => {
125 write!(
126 formatter,
127 "cannot find a default OPY entry in '{}'; expected main.opy or src/main.opy",
128 path.display()
129 )
130 }
131 }
132 }
133}
134
135impl std::error::Error for FilesystemProjectError {
136 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
137 match self {
138 Self::EntryNotFound { source, .. } | Self::EntryUnreadable { source, .. } => {
139 Some(source)
140 }
141 Self::DefaultEntryNotFound { .. } => None,
142 }
143 }
144}
145
146#[cfg(test)]
147mod tests {
148 use super::{FilesystemProject, FilesystemProjectError};
149 use std::io;
150 use std::path::{Path, PathBuf};
151
152 #[test]
153 fn directory_targets_use_the_owner_default_entry() {
154 let directory = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
155 .join("tests/fixtures/project-preprocessing");
156 let project = FilesystemProject::load(&directory).expect("directory project loads");
157 assert!(project.main_path().ends_with("main.opy"));
158 assert_eq!(project.root(), directory.canonicalize().unwrap());
159 assert!(project.source().contains("rule \"main\""));
160 }
161
162 fn scratch(tag: &str) -> PathBuf {
163 let dir = std::env::temp_dir().join(format!("opy-project-io-{tag}-{}", std::process::id()));
164 let _ = std::fs::remove_dir_all(&dir);
165 std::fs::create_dir_all(&dir).expect("scratch dir");
166 dir
167 }
168
169 #[test]
170 fn absent_file_entry_is_entry_not_found() {
171 let missing = scratch("missing").join("no-such.opy");
172 let error = FilesystemProject::load(&missing).expect_err("absent entry fails");
173 assert!(error.is_entry_not_found());
174 assert!(matches!(
175 error,
176 FilesystemProjectError::EntryNotFound { .. }
177 ));
178 }
179
180 #[cfg(unix)]
184 #[test]
185 fn unresolvable_entry_is_not_classified_as_missing() {
186 let dir = scratch("loop");
187 let entry = dir.join("main.opy");
188 std::os::unix::fs::symlink(Path::new("loop.opy"), dir.join("loop.opy")).unwrap();
189 std::os::unix::fs::symlink(Path::new("main.opy"), &entry).unwrap();
190 let error = FilesystemProject::load(&entry).expect_err("symlink cycle fails");
191 assert!(!error.is_entry_not_found());
192 assert!(matches!(
193 error,
194 FilesystemProjectError::EntryUnreadable { .. }
195 ));
196 let source = std::error::Error::source(&error).expect("io cause preserved");
197 let io_error = source.downcast_ref::<io::Error>().expect("typed io::Error");
198 assert_ne!(io_error.kind(), io::ErrorKind::NotFound);
199 std::fs::remove_dir_all(&dir).unwrap();
200 }
201
202 #[cfg(unix)]
205 #[test]
206 fn unresolvable_default_entry_is_not_classified_as_missing() {
207 let dir = scratch("default-loop");
208 std::os::unix::fs::symlink(Path::new("main.opy"), dir.join("main.opy")).unwrap();
209 let error = FilesystemProject::load(&dir).expect_err("default entry cycle fails");
210 assert!(!error.is_entry_not_found());
211 assert!(matches!(
212 error,
213 FilesystemProjectError::EntryUnreadable { .. }
214 ));
215 std::fs::remove_dir_all(&dir).unwrap();
216 }
217
218 #[cfg(unix)]
221 #[test]
222 fn unreadable_entry_reports_unreadable() {
223 use std::os::unix::fs::PermissionsExt;
224 if unsafe { libc::geteuid() } == 0 {
225 return;
226 }
227 let dir = scratch("denied");
228 let entry = dir.join("main.opy");
229 std::fs::write(&entry, "rule \"x\": @Event global\n").unwrap();
230 std::fs::set_permissions(&entry, std::fs::Permissions::from_mode(0o000)).unwrap();
231 let result = FilesystemProject::load(&entry);
232 std::fs::set_permissions(&entry, std::fs::Permissions::from_mode(0o644)).unwrap();
233 std::fs::remove_dir_all(&dir).unwrap();
234 let error = result.expect_err("unreadable entry fails");
235 assert!(!error.is_entry_not_found());
236 assert!(matches!(
237 error,
238 FilesystemProjectError::EntryUnreadable { .. }
239 ));
240 }
241
242 #[test]
245 fn entry_inside_a_file_is_not_classified_as_missing() {
246 let dir = scratch("notdir");
247 let file = dir.join("main.opy");
248 std::fs::write(&file, "rule \"x\": @Event global\n").unwrap();
249 let entry = file.join("child.opy");
250 let error = FilesystemProject::load(&entry).expect_err("ENOTDIR fails");
251 assert!(!error.is_entry_not_found());
252 assert!(matches!(
253 error,
254 FilesystemProjectError::EntryUnreadable { .. }
255 ));
256 std::fs::remove_dir_all(&dir).unwrap();
257 }
258}