1mod 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#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct DevOptions {
21 pub config_path: PathBuf,
23 pub host: String,
25 pub port: u16,
27 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
42pub 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 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}