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
18            .canonicalize()
19            .map_err(|error| FilesystemProjectError::entry_not_found(path.to_path_buf(), error))?;
20        let selected_path = if canonical_target.is_dir() {
21            default_entry(&canonical_target)?
22        } else {
23            canonical_target.clone()
24        };
25        let source = fs::read_to_string(&selected_path).map_err(|error| {
26            FilesystemProjectError::entry_unreadable(selected_path.clone(), error)
27        })?;
28        let root = selected_path
29            .parent()
30            .map(Path::to_path_buf)
31            .unwrap_or_else(|| PathBuf::from("."));
32        Ok(Self {
33            source,
34            main_path: if canonical_target.is_dir() {
35                selected_path
36            } else {
37                path.to_path_buf()
38            },
39            root,
40        })
41    }
42
43    /// The source text read from the selected entry.
44    pub fn source(&self) -> &str {
45        &self.source
46    }
47
48    /// The selected entry path used as the source identity in diagnostics.
49    pub fn main_path(&self) -> &Path {
50        &self.main_path
51    }
52
53    /// The canonical parent directory used for project-relative resolution.
54    pub fn root(&self) -> &Path {
55        &self.root
56    }
57}
58
59fn default_entry(directory: &Path) -> Result<PathBuf, FilesystemProjectError> {
60    let candidates = [directory.join("main.opy"), directory.join("src/main.opy")];
61    candidates
62        .into_iter()
63        .find(|candidate| candidate.is_file())
64        .ok_or_else(|| FilesystemProjectError::default_entry_not_found(directory.to_path_buf()))
65}
66
67/// Failure while loading a filesystem-backed OPY project entry.
68#[derive(Debug)]
69pub enum FilesystemProjectError {
70    EntryNotFound { path: PathBuf, source: io::Error },
71    EntryUnreadable { path: PathBuf, source: io::Error },
72    DefaultEntryNotFound { path: PathBuf },
73}
74
75impl FilesystemProjectError {
76    fn entry_not_found(path: PathBuf, source: io::Error) -> Self {
77        Self::EntryNotFound { path, source }
78    }
79
80    fn entry_unreadable(path: PathBuf, source: io::Error) -> Self {
81        Self::EntryUnreadable { path, source }
82    }
83
84    fn default_entry_not_found(path: PathBuf) -> Self {
85        Self::DefaultEntryNotFound { path }
86    }
87
88    /// Whether the entry could not be resolved to a canonical filesystem path.
89    pub fn is_entry_not_found(&self) -> bool {
90        matches!(
91            self,
92            Self::EntryNotFound { .. } | Self::DefaultEntryNotFound { .. }
93        )
94    }
95}
96
97impl fmt::Display for FilesystemProjectError {
98    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
99        match self {
100            Self::EntryNotFound { source, .. } => write!(formatter, "cannot find entry: {source}"),
101            Self::EntryUnreadable { source, .. } => {
102                write!(formatter, "cannot read entry: {source}")
103            }
104            Self::DefaultEntryNotFound { path } => {
105                write!(
106                    formatter,
107                    "cannot find a default OPY entry in '{}'; expected main.opy or src/main.opy",
108                    path.display()
109                )
110            }
111        }
112    }
113}
114
115impl std::error::Error for FilesystemProjectError {
116    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
117        match self {
118            Self::EntryNotFound { source, .. } | Self::EntryUnreadable { source, .. } => {
119                Some(source)
120            }
121            Self::DefaultEntryNotFound { .. } => None,
122        }
123    }
124}
125
126#[cfg(test)]
127mod tests {
128    use super::FilesystemProject;
129
130    #[test]
131    fn directory_targets_use_the_owner_default_entry() {
132        let directory = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
133            .join("tests/fixtures/project-preprocessing");
134        let project = FilesystemProject::load(&directory).expect("directory project loads");
135        assert!(project.main_path().ends_with("main.opy"));
136        assert_eq!(project.root(), directory.canonicalize().unwrap());
137        assert!(project.source().contains("rule \"main\""));
138    }
139}