1use std::env;
2use std::path::{Path, PathBuf};
3
4use anyhow::{Context, Result, anyhow};
5use ch_config::environment::{EnvFiles, EnvLoad};
6
7#[derive(Clone, Debug, Eq, PartialEq)]
9pub struct Project {
10 root: PathBuf,
11}
12
13impl Project {
14 pub fn discover() -> Result<Self> {
16 let current_dir =
17 env::current_dir().context("failed to determine the working directory")?;
18 if let Some(project) = Self::find_from(¤t_dir) {
19 return Ok(project);
20 }
21
22 let executable = env::current_exe().context("failed to determine the executable path")?;
23 Self::find_from(&executable).ok_or_else(|| {
24 anyhow!(
25 "failed to discover the application root from {} or {}",
26 current_dir.display(),
27 executable.display()
28 )
29 })
30 }
31
32 pub fn discover_from(start: impl AsRef<Path>) -> Result<Self> {
34 Self::find_from(start.as_ref()).ok_or_else(|| {
35 anyhow!(
36 "failed to discover an application root from {}",
37 start.as_ref().display()
38 )
39 })
40 }
41
42 pub fn root(&self) -> &Path {
44 &self.root
45 }
46
47 pub fn path(&self, path: impl AsRef<Path>) -> PathBuf {
49 self.root.join(path)
50 }
51
52 pub(crate) fn load_environment(&self) -> Result<EnvLoad> {
53 let selected = env::var_os("ENV_FILE")
54 .filter(|path| !path.is_empty())
55 .map(PathBuf::from)
56 .map(|path| {
57 if path.is_absolute() {
58 path
59 } else {
60 self.path(path)
61 }
62 });
63 let files = match selected {
64 Some(path) => EnvFiles {
65 base: Some(path),
66 override_file: None,
67 },
68 None => EnvFiles {
69 base: Some(self.path(".env")),
70 override_file: Some(self.path(".env.local")),
71 },
72 };
73
74 files.load().map_err(Into::into)
75 }
76
77 fn find_from(start: &Path) -> Option<Self> {
78 let start = discovery_root(start);
79 let ancestors = start.ancestors().collect::<Vec<_>>();
80 let root = ancestors
81 .iter()
82 .copied()
83 .find(|candidate| is_application_root(candidate))
84 .or_else(|| {
85 ancestors
86 .iter()
87 .copied()
88 .find(|candidate| candidate.join("Cargo.toml").is_file())
89 })?;
90
91 Some(Self {
92 root: root.to_path_buf(),
93 })
94 }
95}
96
97pub(crate) fn discovery_root(start: &Path) -> &Path {
98 match (start.is_file(), start.parent()) {
99 (true, Some(parent)) => parent,
100 _ => start,
101 }
102}
103
104fn is_application_root(path: &Path) -> bool {
105 path.join("web").is_dir()
106 || path.join("services.env").is_file()
107 || (path.join("Cargo.toml").is_file() && path.join("app").is_dir())
108}
109
110#[cfg(test)]
111mod tests {
112 use std::fs;
113
114 use tempfile::tempdir;
115
116 use super::*;
117
118 #[test]
119 fn discovers_the_project_above_an_app_source_directory() {
120 let temporary = tempdir().expect("temporary directory should be created");
121 let root = temporary.path().join("example");
122 let source = root.join("app/src");
123 fs::create_dir_all(root.join("web")).expect("web directory should be created");
124 fs::create_dir_all(&source).expect("source directory should be created");
125 fs::write(root.join("Cargo.toml"), "[workspace]\n")
126 .expect("workspace manifest should be written");
127 fs::write(root.join("app/Cargo.toml"), "[package]\nname='example'\n")
128 .expect("app manifest should be written");
129
130 let project = Project::discover_from(&source).expect("project should be discovered");
131
132 assert_eq!(project.root(), root);
133 assert_eq!(
134 project.path("web/build/client"),
135 root.join("web/build/client")
136 );
137 }
138
139 #[test]
140 fn discovers_a_packaged_runtime_from_its_web_directory() {
141 let temporary = tempdir().expect("temporary directory should be created");
142 let root = temporary.path().join("runtime");
143 fs::create_dir_all(root.join("web/build/client"))
144 .expect("runtime client directory should be created");
145
146 let project = Project::discover_from(root.join("server"))
147 .expect("runtime project should be discovered");
148
149 assert_eq!(project.root(), root);
150 }
151}