Skip to main content

orbit_md/dev/
mod.rs

1//! Local development server with watch-and-rebuild.
2//!
3//! `orbit dev` serves the configured output directory and rebuilds when source files change.
4
5mod server;
6
7use std::path::{Path, PathBuf};
8use std::sync::mpsc::{self, RecvTimeoutError};
9use std::thread;
10use std::time::{Duration, Instant};
11
12use notify::{Config as NotifyConfig, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
13
14use crate::build;
15use crate::config::Config;
16use crate::error::OrbitError;
17
18/// Options for the development server.
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct DevOptions {
21    /// Path to `orbit.yaml`.
22    pub config_path: PathBuf,
23    /// Host to bind (default `127.0.0.1`).
24    pub host: String,
25    /// Port to bind (default `3000`).
26    pub port: u16,
27    /// Open the site in the default browser on startup.
28    pub open: bool,
29}
30
31impl Default for DevOptions {
32    fn default() -> Self {
33        Self {
34            config_path: PathBuf::from(crate::config::DEFAULT_CONFIG_FILE),
35            host: "127.0.0.1".to_owned(),
36            port: 3000,
37            open: false,
38        }
39    }
40}
41
42/// Runs the dev server: initial build, static HTTP server, and file watching.
43pub fn run(options: &DevOptions) -> Result<(), OrbitError> {
44    let config = Config::load(&options.config_path)?;
45    let url = format!("http://{}:{}/", options.host, options.port);
46
47    println!("Building site...");
48    let report = build(&config)?;
49    println!(
50        "Built {} pages in {} ms",
51        report.pages_written, report.elapsed_ms
52    );
53
54    let output_dir = config.output_dir.clone();
55    let server_host = options.host.clone();
56    let server_port = options.port;
57
58    thread::spawn(move || {
59        if let Err(err) = server::serve(&output_dir, &server_host, server_port) {
60            eprintln!("dev server error: {err}");
61        }
62    });
63
64    // NOTE: brief pause so the server thread binds before we print the URL.
65    thread::sleep(Duration::from_millis(100));
66
67    println!();
68    println!("  Orbit dev server running at {url}");
69    println!("  Watching for changes — press Ctrl+C to stop");
70    println!();
71
72    if options.open {
73        open_browser(&url);
74    }
75
76    watch_and_rebuild(&options.config_path)
77}
78
79fn watch_and_rebuild(config_path: &Path) -> Result<(), OrbitError> {
80    let (tx, rx) = mpsc::channel();
81
82    let mut watcher = RecommendedWatcher::new(
83        move |result| {
84            if let Ok(event) = result {
85                let _ = tx.send(event);
86            }
87        },
88        NotifyConfig::default(),
89    )
90    .map_err(|err| OrbitError::Config(format!("failed to start file watcher: {err}")))?;
91
92    let config = Config::load(config_path)?;
93    watch_project_paths(&mut watcher, config_path, &config)?;
94
95    let debounce = Duration::from_millis(250);
96    let mut pending = false;
97    let mut last_event = Instant::now();
98
99    loop {
100        match rx.recv_timeout(Duration::from_millis(100)) {
101            Ok(event) => match event.kind {
102                EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_) => {
103                    pending = true;
104                    last_event = Instant::now();
105                }
106                _ => {}
107            },
108            Err(RecvTimeoutError::Timeout) => {
109                if pending && last_event.elapsed() >= debounce {
110                    pending = false;
111                    rebuild(config_path);
112                }
113            }
114            Err(RecvTimeoutError::Disconnected) => break,
115        }
116    }
117
118    Ok(())
119}
120
121fn rebuild(config_path: &Path) {
122    match Config::load(config_path).and_then(|config| build(&config).map(|report| (config, report)))
123    {
124        Ok((config, report)) => {
125            println!(
126                "Rebuilt {} pages in {} ms → {}",
127                report.pages_written,
128                report.elapsed_ms,
129                config.output_dir.display()
130            );
131        }
132        Err(err) => {
133            eprintln!("Rebuild failed: {err}");
134        }
135    }
136}
137
138fn watch_project_paths(
139    watcher: &mut RecommendedWatcher,
140    config_path: &Path,
141    config: &Config,
142) -> Result<(), OrbitError> {
143    let paths = [
144        config_path.to_path_buf(),
145        config.source_dir.clone(),
146        config.components_dir.clone(),
147        config.template_dir.clone(),
148    ];
149
150    for path in paths {
151        if !path.exists() {
152            continue;
153        }
154        let mode = if path.is_dir() {
155            RecursiveMode::Recursive
156        } else {
157            RecursiveMode::NonRecursive
158        };
159        watcher.watch(&path, mode).map_err(|err| {
160            OrbitError::Config(format!("failed to watch {}: {err}", path.display()))
161        })?;
162    }
163
164    Ok(())
165}
166
167fn open_browser(url: &str) {
168    #[cfg(target_os = "windows")]
169    let result = std::process::Command::new("cmd")
170        .args(["/C", "start", "", url])
171        .spawn();
172
173    #[cfg(target_os = "macos")]
174    let result = std::process::Command::new("open").arg(url).spawn();
175
176    #[cfg(all(unix, not(target_os = "macos")))]
177    let result = std::process::Command::new("xdg-open").arg(url).spawn();
178
179    if let Err(err) = result {
180        eprintln!("Could not open browser: {err}");
181    }
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187
188    #[test]
189    fn dev_options_default() {
190        let opts = DevOptions::default();
191        assert_eq!(opts.port, 3000);
192        assert_eq!(opts.host, "127.0.0.1");
193    }
194}