Skip to main content

folio_vitae/
lib.rs

1//! Core library for `folio-vitae`.
2//!
3//! Beginner-friendly map:
4//!
5//! - `src/main.rs` parses CLI commands
6//! - this file wires together the high-level flows
7//! - `app/` contains the website preparation and rendering logic
8//! - `resources.rs` contains embedded starter files and runtime assets
9//!
10//! If you are new to Rust, start by reading:
11//!
12//! 1. `main()`
13//! 2. `run_servers()`
14//! 3. `run_servers_with_watch()`
15//! 4. `prepare_application_state()`
16
17pub 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}