Skip to main content

opy_rs/
project.rs

1use std::fmt;
2use std::fs;
3use std::io;
4use std::path::{Path, PathBuf};
5
6/// A filesystem-backed OPY project target and the root used to resolve it.
7#[derive(Debug)]
8pub struct FilesystemProject {
9    source: String,
10    main_path: PathBuf,
11    root: PathBuf,
12}
13
14impl FilesystemProject {
15    /// Load an OPY project from a file entry or a project directory.
16    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                // Permission, loop, or name-resolution failures are not an
22                // absent entry (#484).
23                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    /// The source text read from the selected entry.
50    pub fn source(&self) -> &str {
51        &self.source
52    }
53
54    /// The selected entry path used as the source identity in diagnostics.
55    pub fn main_path(&self) -> &Path {
56        &self.main_path
57    }
58
59    /// The canonical parent directory used for project-relative resolution.
60    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            // Present but not a regular file, or genuinely absent — keep
71            // looking; only a real I/O failure aborts classification (#484).
72            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/// Failure while loading a filesystem-backed OPY project entry.
85#[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    /// Whether the entry could not be resolved to a canonical filesystem path.
106    /// An `EntryNotFound` carrying a non-`NotFound` I/O cause — possible
107    /// through the public fields — reports the real category (#484).
108    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    /// A symlink cycle fails canonicalization with `Filesystem loop`, not
181    /// `NotFound`: the failure must keep its real category instead of
182    /// masquerading as an absent entry (#484).
183    #[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    /// `main.opy` present but unresolvable: the default-entry search must
203    /// report the I/O failure rather than `DefaultEntryNotFound` (#484).
204    #[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    /// A file that resolves but cannot be read stays `EntryUnreadable`.
219    /// Skipped under root, which bypasses permission checks.
220    #[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    /// A path inside a regular file fails canonicalization with `ENOTDIR`
243    /// on every platform — the deterministic non-`NotFound` cause.
244    #[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}