1use std::path::{Path, PathBuf};
5use std::process::Command;
6use std::time::SystemTime;
7
8use frame_host::FrameConfig;
9
10use crate::error::CliError;
11
12#[derive(Debug)]
14pub struct App {
15 pub root: PathBuf,
17}
18
19impl App {
20 pub fn find(start: &Path) -> Result<Self, CliError> {
28 let mut current = Some(start);
29 while let Some(directory) = current {
30 if directory.join("frame.toml").is_file() {
31 return Ok(Self {
32 root: directory.to_path_buf(),
33 });
34 }
35 current = directory.parent();
36 }
37 Err(CliError::NotAnApplication {
38 start: start.to_path_buf(),
39 })
40 }
41
42 pub fn find_from_current_dir() -> Result<Self, CliError> {
49 let current = std::env::current_dir().map_err(|source| CliError::CurrentDir { source })?;
50 Self::find(¤t)
51 }
52
53 #[must_use]
55 pub fn config_path(&self) -> PathBuf {
56 self.root.join("frame.toml")
57 }
58
59 #[must_use]
61 pub fn page_dir(&self) -> PathBuf {
62 self.root.join("page")
63 }
64
65 #[must_use]
67 pub fn component_dir(&self) -> PathBuf {
68 self.root.join("component")
69 }
70
71 pub fn load_config(&self) -> Result<FrameConfig, CliError> {
78 let path = self.config_path();
79 FrameConfig::load(&path).map_err(|source| CliError::Config {
80 path,
81 source: Box::new(source),
82 })
83 }
84
85 #[must_use]
88 pub fn page_toolchain_installed(&self) -> bool {
89 self.page_dir().join("node_modules").is_dir()
90 }
91
92 pub fn stale_page_sources(&self) -> Result<Vec<PathBuf>, CliError> {
101 let source_root = self.page_dir().join("src");
102 let dist_root = self.page_dir().join("dist");
103 let tsconfig_modified = modified_time(&self.page_dir().join("tsconfig.json"))?;
104 let mut sources = Vec::new();
105 collect_typescript_sources(&source_root, &mut sources)?;
106 let mut stale = Vec::new();
107 for source in sources {
108 let relative = source
109 .strip_prefix(&source_root)
110 .map_err(|_| CliError::Io {
111 operation: "resolving page source under",
112 path: source.clone(),
113 source: std::io::Error::other("source escaped page/src"),
114 })?;
115 let compiled = dist_root.join(relative).with_extension("js");
116 let Some(compiled_modified) = modified_time(&compiled)? else {
117 stale.push(source);
118 continue;
119 };
120 let Some(source_modified) = modified_time(&source)? else {
121 stale.push(source);
123 continue;
124 };
125 if source_modified > compiled_modified
126 || tsconfig_modified.is_some_and(|tsconfig| tsconfig > compiled_modified)
127 {
128 stale.push(source);
129 }
130 }
131 Ok(stale)
132 }
133}
134
135fn modified_time(path: &Path) -> Result<Option<SystemTime>, CliError> {
137 match std::fs::metadata(path) {
138 Ok(metadata) => metadata
139 .modified()
140 .map(Some)
141 .map_err(|source| CliError::Io {
142 operation: "reading modification time of",
143 path: path.to_path_buf(),
144 source,
145 }),
146 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
147 Err(source) => Err(CliError::Io {
148 operation: "reading metadata of",
149 path: path.to_path_buf(),
150 source,
151 }),
152 }
153}
154
155fn collect_typescript_sources(
156 directory: &Path,
157 sources: &mut Vec<PathBuf>,
158) -> Result<(), CliError> {
159 let entries = std::fs::read_dir(directory).map_err(|source| CliError::Io {
160 operation: "reading page source directory",
161 path: directory.to_path_buf(),
162 source,
163 })?;
164 for entry in entries {
165 let entry = entry.map_err(|source| CliError::Io {
166 operation: "reading page source directory entry in",
167 path: directory.to_path_buf(),
168 source,
169 })?;
170 let path = entry.path();
171 if path.is_dir() {
172 collect_typescript_sources(&path, sources)?;
173 } else if path.extension().is_some_and(|extension| extension == "ts") {
174 sources.push(path);
175 }
176 }
177 Ok(())
178}
179
180pub fn run_step(program: &str, args: &[&str], directory: &Path) -> Result<(), CliError> {
187 let command_line = format!("{program} {}", args.join(" "));
188 let status = Command::new(program)
189 .args(args)
190 .current_dir(directory)
191 .status()
192 .map_err(|source| CliError::CommandSpawn {
193 command: command_line.clone(),
194 source,
195 })?;
196 if status.success() {
197 Ok(())
198 } else {
199 Err(CliError::CommandFailed {
200 command: command_line,
201 status: status.to_string(),
202 })
203 }
204}
205
206pub fn observe_step(program: &str, args: &[&str], directory: &Path) -> Result<bool, CliError> {
215 match run_step(program, args, directory) {
216 Ok(()) => Ok(true),
217 Err(CliError::CommandFailed { .. }) => Ok(false),
218 Err(other) => Err(other),
219 }
220}
221
222pub fn ensure_page_toolchain(app: &App) -> Result<(), CliError> {
229 if app.page_toolchain_installed() {
230 return Ok(());
231 }
232 eprintln!(
233 "page toolchain not installed yet — running `npm install` in page/ (network; first run only)…"
234 );
235 run_step("npm", &["install"], &app.page_dir())
236}
237
238#[cfg(test)]
239mod tests {
240 use super::App;
241 use std::fs;
242 use std::path::PathBuf;
243 use std::time::{Duration, SystemTime, UNIX_EPOCH};
244
245 fn scratch(label: &str) -> Result<PathBuf, Box<dyn std::error::Error>> {
246 let unique = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos();
247 let path = std::env::temp_dir().join(format!("frame-project-{label}-{unique}"));
248 fs::create_dir_all(&path)?;
249 Ok(path)
250 }
251
252 fn touch(path: &std::path::Path, time: SystemTime) -> Result<(), Box<dyn std::error::Error>> {
253 fs::File::options()
254 .write(true)
255 .open(path)?
256 .set_modified(time)?;
257 Ok(())
258 }
259
260 #[test]
261 fn find_walks_up_to_the_config_and_refuses_outside_one()
262 -> Result<(), Box<dyn std::error::Error>> {
263 let root = scratch("find")?;
264 fs::write(root.join("frame.toml"), "")?;
265 let nested = root.join("page").join("src");
266 fs::create_dir_all(&nested)?;
267 let found = App::find(&nested)?;
268 assert_eq!(found.root, root);
269
270 let outside = scratch("outside")?;
271 assert!(App::find(&outside).is_err());
272 fs::remove_dir_all(root)?;
273 fs::remove_dir_all(outside)?;
274 Ok(())
275 }
276
277 #[test]
278 fn staleness_flags_missing_and_outdated_compiled_modules()
279 -> Result<(), Box<dyn std::error::Error>> {
280 let root = scratch("stale")?;
281 fs::write(root.join("frame.toml"), "")?;
282 let app = App::find(&root)?;
283 let src = root.join("page/src");
284 let dist = root.join("page/dist");
285 fs::create_dir_all(&src)?;
286 fs::create_dir_all(&dist)?;
287 fs::write(root.join("page/tsconfig.json"), "{}")?;
288
289 fs::write(src.join("main.ts"), "export {};")?;
291 assert_eq!(app.stale_page_sources()?.len(), 1);
292
293 fs::write(dist.join("main.js"), "export {};")?;
295 let future = SystemTime::now() + Duration::from_secs(5);
296 touch(&dist.join("main.js"), future)?;
297 assert!(app.stale_page_sources()?.is_empty());
298
299 touch(&src.join("main.ts"), future + Duration::from_secs(5))?;
301 assert_eq!(app.stale_page_sources()?.len(), 1);
302
303 touch(&src.join("main.ts"), SystemTime::now())?;
305 touch(
306 &root.join("page/tsconfig.json"),
307 future + Duration::from_secs(10),
308 )?;
309 assert_eq!(app.stale_page_sources()?.len(), 1);
310
311 fs::remove_dir_all(root)?;
312 Ok(())
313 }
314}