Skip to main content

frame_cli/
project.rs

1//! The application a verb operates on: locating it, reading its one stated
2//! config, and judging whether the compiled page is stale.
3
4use std::path::{Path, PathBuf};
5use std::process::Command;
6use std::time::SystemTime;
7
8use frame_host::FrameConfig;
9
10use crate::error::CliError;
11
12/// One located Frame application.
13#[derive(Debug)]
14pub struct App {
15    /// The application root: the directory holding `frame.toml`.
16    pub root: PathBuf,
17}
18
19impl App {
20    /// Locates the application whose tree contains `start`, walking parent
21    /// directories until a `frame.toml` is found.
22    ///
23    /// # Errors
24    ///
25    /// Returns [`CliError::NotAnApplication`] when no ancestor holds a
26    /// `frame.toml`.
27    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    /// Locates the application containing the current directory.
43    ///
44    /// # Errors
45    ///
46    /// Fails when the current directory is unreadable or no ancestor holds a
47    /// `frame.toml`.
48    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(&current)
51    }
52
53    /// The application's `frame.toml` path.
54    #[must_use]
55    pub fn config_path(&self) -> PathBuf {
56        self.root.join("frame.toml")
57    }
58
59    /// The page directory.
60    #[must_use]
61    pub fn page_dir(&self) -> PathBuf {
62        self.root.join("page")
63    }
64
65    /// The component directory.
66    #[must_use]
67    pub fn component_dir(&self) -> PathBuf {
68        self.root.join("component")
69    }
70
71    /// Loads and validates the application's stated config through the
72    /// host's own loader — the CLI never grows a second parser.
73    ///
74    /// # Errors
75    ///
76    /// Surfaces the host's typed load failure, naming the file.
77    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    /// True when the page's type/proof toolchain is installed
86    /// (`page/node_modules` exists).
87    #[must_use]
88    pub fn page_toolchain_installed(&self) -> bool {
89        self.page_dir().join("node_modules").is_dir()
90    }
91
92    /// Every page source whose compiled module is missing or older than the
93    /// source — empty means the compiled page is current. A tsconfig newer
94    /// than a compiled module also marks that module stale, since compiler
95    /// options shape the output.
96    ///
97    /// # Errors
98    ///
99    /// Fails loudly on an unreadable page tree.
100    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                // The source vanished mid-walk; a recompile settles it.
122                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
135/// Modification time of `path`; `None` when the file does not exist.
136fn 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
180/// Runs one foreground command with inherited stdio, returning a typed
181/// failure carrying the command line when it exits unsuccessfully.
182///
183/// # Errors
184///
185/// Fails when the command cannot start or exits non-zero.
186pub 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
206/// Like [`run_step`], but reports success as a bool instead of aborting —
207/// used by the multi-part verdicts (`frame test`, `frame check`) that run
208/// every scope before judging.
209///
210/// # Errors
211///
212/// Still fails hard when the command cannot start at all: a missing binary
213/// is an environment refusal, not a scope verdict.
214pub 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
222/// Ensures the page's type/proof toolchain is installed, announcing the one
223/// network step loudly before running it — never silently.
224///
225/// # Errors
226///
227/// Fails when npm cannot run or the install fails.
228pub 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        // Source with no compiled module: stale.
290        fs::write(src.join("main.ts"), "export {};")?;
291        assert_eq!(app.stale_page_sources()?.len(), 1);
292
293        // Compiled module newer than source and tsconfig: current.
294        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        // Source touched after the compiled module: stale again.
300        touch(&src.join("main.ts"), future + Duration::from_secs(5))?;
301        assert_eq!(app.stale_page_sources()?.len(), 1);
302
303        // A tsconfig newer than every compiled module also marks it stale.
304        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}