1pub mod app;
18pub mod config;
19pub mod error;
20#[cfg(feature = "serve")]
21pub mod http;
22pub mod logging;
23pub mod resources;
24
25use std::{
26 env,
27 io::ErrorKind,
28 net::{IpAddr, SocketAddr},
29 path::Path,
30 time::{Duration, SystemTime},
31};
32
33use app::{
34 cache::{BuildCache, latest_modification},
35 environment::Environment,
36 localization::{detect_languages_with_last_change, load_language_names, load_localizations},
37 setup::{build_app_states, load_templates},
38 state::AppState,
39};
40use app::{compression, images, scripts, styles};
41use config::{TEMPLATES_PATTERN, data_dir};
42use dotenvy::from_path_override;
43#[cfg(feature = "serve")]
44use http::build_router;
45#[cfg(feature = "serve")]
46use tokio::sync::broadcast;
47use tracing::debug;
48use tracing::info;
49
50pub use error::{AppError, AppResult, ResultExt};
51
52pub struct PreparedApplication {
53 pub main_state: AppState,
54 pub cv_state: AppState,
55 pub build_cache: BuildCache,
56}
57
58pub fn initialize_project(force: bool) -> AppResult<()> {
59 resources::init_project_files(force)
60}
61
62pub fn load_env_file(path: &str) -> AppResult<()> {
63 match from_path_override(path) {
64 Ok(_) => Ok(()),
65 Err(dotenvy::Error::Io(error)) if error.kind() == ErrorKind::NotFound => {
66 debug!("No {path} file found; skipping environment loading from disk.");
67 Ok(())
68 },
69 Err(error) => Err(AppError::from(error).with_context(format!("loading {path} file"))),
70 }
71}
72
73pub fn prepare_application_state(environment: Environment) -> AppResult<PreparedApplication> {
74 let mut build_cache = BuildCache::load()?;
75 let data_directory = data_dir();
76
77 let (available_languages, data_last_modified) = detect_languages_with_last_change()?;
78 let assets_last_modified = latest_modification(&["assets/styles", "assets/scripts"])?;
79 let templates_last_modified = latest_modification(&["templates"])?;
80 let images_last_modified = latest_modification(&[data_directory.join("images")])?;
81 let images_outputs_missing = images::generated_outputs_missing()?;
82 let minify_assets = environment.should_minify_assets();
83
84 if images_outputs_missing || build_cache.should_refresh_images(images_last_modified) {
85 images::generate_all_images().with_context(|| "generating images")?;
86 build_cache.record_images(images_last_modified);
87 }
88
89 if build_cache.should_refresh_assets(assets_last_modified, minify_assets) {
90 styles::compile_styles(environment).with_context(|| "compiling SCSS")?;
91 scripts::process_scripts(environment).with_context(|| "processing scripts")?;
92 compression::gzip_assets().with_context(|| "gzipping assets")?;
93 build_cache.record_assets(assets_last_modified, minify_assets);
94 }
95
96 build_cache.record_templates(templates_last_modified);
97 build_cache.record_data(data_last_modified);
98
99 let data = load_localizations(&available_languages)?;
100 let language_names = load_language_names(&available_languages)?;
101 let tera = load_templates(TEMPLATES_PATTERN)?;
102
103 build_app_states(
104 available_languages,
105 data_last_modified,
106 data,
107 tera,
108 environment,
109 language_names,
110 )
111 .map(|states| PreparedApplication {
112 main_state: states.0,
113 cv_state: states.1,
114 build_cache,
115 })
116}
117
118#[cfg(feature = "serve")]
119pub async fn run_servers<T>(
120 environment: Environment,
121 host: IpAddr,
122 main_port: u16,
123 cv_port: u16,
124 shutdown_signal: impl std::future::Future<Output = AppResult<T>>,
125) -> AppResult<T> {
126 let PreparedApplication { main_state, cv_state, build_cache, .. } =
127 prepare_application_state(environment)?;
128
129 build_cache.save()?;
130
131 let main_app = build_router(main_state);
132 let cv_app = build_router(cv_state);
133
134 let main_addr = SocketAddr::new(host, main_port);
135 let cv_addr = SocketAddr::new(host, cv_port);
136
137 debug!("Serving website_main on http://{main_addr}");
138 debug!("Serving website_cv on http://{cv_addr}");
139
140 let main_listener = tokio::net::TcpListener::bind(main_addr)
141 .await
142 .with_context(|| "binding main tcp listener")?;
143
144 let cv_listener =
145 tokio::net::TcpListener::bind(cv_addr).await.with_context(|| "binding cv tcp listener")?;
146
147 let main_server =
148 axum::serve(main_listener, main_app.into_make_service_with_connect_info::<SocketAddr>());
149 let cv_server =
150 axum::serve(cv_listener, cv_app.into_make_service_with_connect_info::<SocketAddr>());
151
152 let (shutdown_tx, _) = broadcast::channel(1);
153 let mut main_shutdown = shutdown_tx.subscribe();
154 let mut cv_shutdown = shutdown_tx.subscribe();
155
156 let main_server_task = async move {
157 main_server
158 .with_graceful_shutdown(async move {
159 let _ = main_shutdown.recv().await;
160 })
161 .await
162 .with_context(|| "running main server")
163 };
164
165 let cv_server_task = async move {
166 cv_server
167 .with_graceful_shutdown(async move {
168 let _ = cv_shutdown.recv().await;
169 })
170 .await
171 .with_context(|| "running cv server")
172 };
173
174 let shutdown_task = async move {
175 let shutdown_result = shutdown_signal.await;
176 info!("Received shutdown signal. Shutting down servers...");
177 let _ = shutdown_tx.send(());
178 shutdown_result
179 };
180
181 let (_, _, shutdown_value) = tokio::try_join!(main_server_task, cv_server_task, shutdown_task)
182 .with_context(|| "running servers")?;
183
184 Ok(shutdown_value)
185}
186
187pub fn read_port_from_env(name: &str, default: u16) -> AppResult<u16> {
188 read_port_from_source(name, default, |var_name| env::var(var_name))
189}
190
191fn read_port_from_source(
192 name: &str,
193 default: u16,
194 get_var: impl for<'a> Fn(&'a str) -> Result<String, env::VarError>,
195) -> AppResult<u16> {
196 match get_var(name) {
197 Ok(value) => value.parse::<u16>().with_context(|| format!("parsing {name} as port number")),
198 Err(env::VarError::NotPresent) => Ok(default),
199 Err(env::VarError::NotUnicode(_)) => {
200 Err(AppError::msg(format!("{name} contains invalid unicode characters")))
201 },
202 }
203}
204
205#[cfg(feature = "serve")]
206#[derive(Clone, Copy, Debug, PartialEq, Eq)]
207pub enum ServerLifecycle {
208 Restart,
209 Stop,
210}
211
212#[cfg(feature = "serve")]
213#[derive(Clone, Debug, PartialEq, Eq)]
214struct WatchSnapshot {
215 assets: Option<SystemTime>,
216 config: Option<SystemTime>,
217 data: Option<SystemTime>,
218 env_file: Option<SystemTime>,
219 i18n: Option<SystemTime>,
220 static_files: Option<SystemTime>,
221 templates: Option<SystemTime>,
222}
223
224#[cfg(feature = "serve")]
225pub async fn run_servers_with_watch(
226 environment: Environment,
227 host: IpAddr,
228 main_port: u16,
229 cv_port: u16,
230 env_file: impl AsRef<Path>,
231) -> AppResult<()> {
232 let env_file = env_file.as_ref().to_path_buf();
233
234 loop {
235 let watch_snapshot = read_watch_snapshot(&env_file)?;
236 let lifecycle = run_servers(environment, host, main_port, cv_port, async {
237 tokio::select! {
238 result = tokio::signal::ctrl_c() => {
239 result.with_context(|| "listening for shutdown signal")?;
240 Ok(ServerLifecycle::Stop)
241 }
242 result = wait_for_project_change(&env_file, watch_snapshot) => {
243 result?;
244 Ok(ServerLifecycle::Restart)
245 }
246 }
247 })
248 .await?;
249
250 match lifecycle {
251 ServerLifecycle::Restart => {
252 info!("Project files changed. Restarting development servers...");
253 load_env_file(&env_file.to_string_lossy())?;
254 },
255 ServerLifecycle::Stop => return Ok(()),
256 }
257 }
258}
259
260#[cfg(feature = "serve")]
261fn read_watch_snapshot(env_file: &Path) -> AppResult<WatchSnapshot> {
262 Ok(WatchSnapshot {
263 assets: latest_modification(&[Path::new("assets")])?,
264 config: latest_modification(&[Path::new("config.yml")])?,
265 data: latest_modification(&[data_dir()])?,
266 env_file: latest_modification(&[env_file])?,
267 i18n: latest_modification(&[Path::new("i18n")])?,
268 static_files: latest_modification(&[Path::new("static")])?,
269 templates: latest_modification(&[Path::new("templates")])?,
270 })
271}
272
273#[cfg(feature = "serve")]
274async fn wait_for_project_change(env_file: &Path, baseline: WatchSnapshot) -> AppResult<()> {
275 loop {
276 tokio::time::sleep(Duration::from_millis(750)).await;
277
278 if read_watch_snapshot(env_file)? != baseline {
279 return Ok(());
280 }
281 }
282}
283
284#[cfg(test)]
285mod tests {
286 use super::*;
287 use std::{fs, time::SystemTime};
288
289 #[test]
290 fn read_port_returns_default_when_missing() {
291 let result =
292 read_port_from_source(config::ENV_MAIN_PORT, 3000, |_| Err(env::VarError::NotPresent))
293 .unwrap();
294
295 assert_eq!(result, 3000);
296 }
297
298 #[test]
299 fn read_port_parses_valid_number() {
300 let parsed =
301 read_port_from_source(config::ENV_MAIN_PORT, 3000, |_| Ok("8080".to_string())).unwrap();
302
303 assert_eq!(parsed, 8080);
304 }
305
306 #[test]
307 fn read_port_rejects_invalid_value() {
308 let error =
309 read_port_from_source(config::ENV_MAIN_PORT, 3000, |_| Ok("not-a-port".to_string()))
310 .unwrap_err();
311
312 assert!(error.to_string().contains("parsing"));
313 }
314
315 #[test]
316 fn load_env_file_tolerates_missing_file() {
317 let unique_stamp = SystemTime::now()
318 .duration_since(std::time::UNIX_EPOCH)
319 .expect("system clock should be after the Unix epoch")
320 .as_nanos();
321 let temp_path = env::temp_dir().join(format!("folio-vitae-missing-{unique_stamp}.env"));
322
323 if temp_path.exists() {
324 fs::remove_file(&temp_path).expect("failed to clear pre-existing test file");
325 }
326
327 assert!(load_env_file(temp_path.to_str().expect("valid temp path")).is_ok());
328 }
329}