use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::SystemTime;
use frame_host::FrameConfig;
use crate::error::CliError;
#[derive(Debug)]
pub struct App {
pub root: PathBuf,
}
impl App {
pub fn find(start: &Path) -> Result<Self, CliError> {
let mut current = Some(start);
while let Some(directory) = current {
if directory.join("frame.toml").is_file() {
return Ok(Self {
root: directory.to_path_buf(),
});
}
current = directory.parent();
}
Err(CliError::NotAnApplication {
start: start.to_path_buf(),
})
}
pub fn find_from_current_dir() -> Result<Self, CliError> {
let current = std::env::current_dir().map_err(|source| CliError::CurrentDir { source })?;
Self::find(¤t)
}
#[must_use]
pub fn config_path(&self) -> PathBuf {
self.root.join("frame.toml")
}
#[must_use]
pub fn page_dir(&self) -> PathBuf {
self.root.join("page")
}
#[must_use]
pub fn component_dir(&self) -> PathBuf {
self.root.join("component")
}
pub fn load_config(&self) -> Result<FrameConfig, CliError> {
let path = self.config_path();
FrameConfig::load(&path).map_err(|source| CliError::Config {
path,
source: Box::new(source),
})
}
#[must_use]
pub fn page_toolchain_installed(&self) -> bool {
self.page_dir().join("node_modules").is_dir()
}
pub fn stale_page_sources(&self) -> Result<Vec<PathBuf>, CliError> {
let source_root = self.page_dir().join("src");
let dist_root = self.page_dir().join("dist");
let tsconfig_modified = modified_time(&self.page_dir().join("tsconfig.json"))?;
let mut sources = Vec::new();
collect_typescript_sources(&source_root, &mut sources)?;
let mut stale = Vec::new();
for source in sources {
let relative = source
.strip_prefix(&source_root)
.map_err(|_| CliError::Io {
operation: "resolving page source under",
path: source.clone(),
source: std::io::Error::other("source escaped page/src"),
})?;
let compiled = dist_root.join(relative).with_extension("js");
let Some(compiled_modified) = modified_time(&compiled)? else {
stale.push(source);
continue;
};
let Some(source_modified) = modified_time(&source)? else {
stale.push(source);
continue;
};
if source_modified > compiled_modified
|| tsconfig_modified.is_some_and(|tsconfig| tsconfig > compiled_modified)
{
stale.push(source);
}
}
Ok(stale)
}
}
fn modified_time(path: &Path) -> Result<Option<SystemTime>, CliError> {
match std::fs::metadata(path) {
Ok(metadata) => metadata
.modified()
.map(Some)
.map_err(|source| CliError::Io {
operation: "reading modification time of",
path: path.to_path_buf(),
source,
}),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(source) => Err(CliError::Io {
operation: "reading metadata of",
path: path.to_path_buf(),
source,
}),
}
}
fn collect_typescript_sources(
directory: &Path,
sources: &mut Vec<PathBuf>,
) -> Result<(), CliError> {
let entries = std::fs::read_dir(directory).map_err(|source| CliError::Io {
operation: "reading page source directory",
path: directory.to_path_buf(),
source,
})?;
for entry in entries {
let entry = entry.map_err(|source| CliError::Io {
operation: "reading page source directory entry in",
path: directory.to_path_buf(),
source,
})?;
let path = entry.path();
if path.is_dir() {
collect_typescript_sources(&path, sources)?;
} else if path.extension().is_some_and(|extension| extension == "ts") {
sources.push(path);
}
}
Ok(())
}
pub fn run_step(program: &str, args: &[&str], directory: &Path) -> Result<(), CliError> {
let command_line = format!("{program} {}", args.join(" "));
let status = Command::new(program)
.args(args)
.current_dir(directory)
.status()
.map_err(|source| CliError::CommandSpawn {
command: command_line.clone(),
source,
})?;
if status.success() {
Ok(())
} else {
Err(CliError::CommandFailed {
command: command_line,
status: status.to_string(),
})
}
}
pub fn observe_step(program: &str, args: &[&str], directory: &Path) -> Result<bool, CliError> {
match run_step(program, args, directory) {
Ok(()) => Ok(true),
Err(CliError::CommandFailed { .. }) => Ok(false),
Err(other) => Err(other),
}
}
pub fn ensure_page_toolchain(app: &App) -> Result<(), CliError> {
if app.page_toolchain_installed() {
return Ok(());
}
eprintln!(
"page toolchain not installed yet — running `npm install` in page/ (network; first run only)…"
);
run_step("npm", &["install"], &app.page_dir())
}
#[cfg(test)]
mod tests {
use super::App;
use std::fs;
use std::path::PathBuf;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
fn scratch(label: &str) -> Result<PathBuf, Box<dyn std::error::Error>> {
let unique = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos();
let path = std::env::temp_dir().join(format!("frame-project-{label}-{unique}"));
fs::create_dir_all(&path)?;
Ok(path)
}
fn touch(path: &std::path::Path, time: SystemTime) -> Result<(), Box<dyn std::error::Error>> {
fs::File::options()
.write(true)
.open(path)?
.set_modified(time)?;
Ok(())
}
#[test]
fn find_walks_up_to_the_config_and_refuses_outside_one()
-> Result<(), Box<dyn std::error::Error>> {
let root = scratch("find")?;
fs::write(root.join("frame.toml"), "")?;
let nested = root.join("page").join("src");
fs::create_dir_all(&nested)?;
let found = App::find(&nested)?;
assert_eq!(found.root, root);
let outside = scratch("outside")?;
assert!(App::find(&outside).is_err());
fs::remove_dir_all(root)?;
fs::remove_dir_all(outside)?;
Ok(())
}
#[test]
fn staleness_flags_missing_and_outdated_compiled_modules()
-> Result<(), Box<dyn std::error::Error>> {
let root = scratch("stale")?;
fs::write(root.join("frame.toml"), "")?;
let app = App::find(&root)?;
let src = root.join("page/src");
let dist = root.join("page/dist");
fs::create_dir_all(&src)?;
fs::create_dir_all(&dist)?;
fs::write(root.join("page/tsconfig.json"), "{}")?;
fs::write(src.join("main.ts"), "export {};")?;
assert_eq!(app.stale_page_sources()?.len(), 1);
fs::write(dist.join("main.js"), "export {};")?;
let future = SystemTime::now() + Duration::from_secs(5);
touch(&dist.join("main.js"), future)?;
assert!(app.stale_page_sources()?.is_empty());
touch(&src.join("main.ts"), future + Duration::from_secs(5))?;
assert_eq!(app.stale_page_sources()?.len(), 1);
touch(&src.join("main.ts"), SystemTime::now())?;
touch(
&root.join("page/tsconfig.json"),
future + Duration::from_secs(10),
)?;
assert_eq!(app.stale_page_sources()?.len(), 1);
fs::remove_dir_all(root)?;
Ok(())
}
}