frame-cli 0.3.0

CLI for Frame — five intention-verbs over one application: frame new scaffolds it, frame run serves it, frame test proves it (real browser included), frame check verifies it statically, frame doctor walks the prerequisites
Documentation
//! The application a verb operates on: locating it, reading its one stated
//! config, and judging whether the compiled page is stale.

use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::SystemTime;

use frame_host::FrameConfig;

use crate::error::CliError;

/// One located Frame application.
#[derive(Debug)]
pub struct App {
    /// The application root: the directory holding `frame.toml`.
    pub root: PathBuf,
}

impl App {
    /// Locates the application whose tree contains `start`, walking parent
    /// directories until a `frame.toml` is found.
    ///
    /// # Errors
    ///
    /// Returns [`CliError::NotAnApplication`] when no ancestor holds a
    /// `frame.toml`.
    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(),
        })
    }

    /// Locates the application containing the current directory.
    ///
    /// # Errors
    ///
    /// Fails when the current directory is unreadable or no ancestor holds a
    /// `frame.toml`.
    pub fn find_from_current_dir() -> Result<Self, CliError> {
        let current = std::env::current_dir().map_err(|source| CliError::CurrentDir { source })?;
        Self::find(&current)
    }

    /// The application's `frame.toml` path.
    #[must_use]
    pub fn config_path(&self) -> PathBuf {
        self.root.join("frame.toml")
    }

    /// The page directory.
    #[must_use]
    pub fn page_dir(&self) -> PathBuf {
        self.root.join("page")
    }

    /// The component directory.
    #[must_use]
    pub fn component_dir(&self) -> PathBuf {
        self.root.join("component")
    }

    /// Loads and validates the application's stated config through the
    /// host's own loader — the CLI never grows a second parser.
    ///
    /// # Errors
    ///
    /// Surfaces the host's typed load failure, naming the file.
    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),
        })
    }

    /// True when the page's type/proof toolchain is installed
    /// (`page/node_modules` exists).
    #[must_use]
    pub fn page_toolchain_installed(&self) -> bool {
        self.page_dir().join("node_modules").is_dir()
    }

    /// Every page source whose compiled module is missing or older than the
    /// source — empty means the compiled page is current. A tsconfig newer
    /// than a compiled module also marks that module stale, since compiler
    /// options shape the output.
    ///
    /// # Errors
    ///
    /// Fails loudly on an unreadable page tree.
    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 {
                // The source vanished mid-walk; a recompile settles it.
                stale.push(source);
                continue;
            };
            if source_modified > compiled_modified
                || tsconfig_modified.is_some_and(|tsconfig| tsconfig > compiled_modified)
            {
                stale.push(source);
            }
        }
        Ok(stale)
    }
}

/// Modification time of `path`; `None` when the file does not exist.
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(())
}

/// Runs one foreground command with inherited stdio, returning a typed
/// failure carrying the command line when it exits unsuccessfully.
///
/// # Errors
///
/// Fails when the command cannot start or exits non-zero.
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(),
        })
    }
}

/// Like [`run_step`], but reports success as a bool instead of aborting —
/// used by the multi-part verdicts (`frame test`, `frame check`) that run
/// every scope before judging.
///
/// # Errors
///
/// Still fails hard when the command cannot start at all: a missing binary
/// is an environment refusal, not a scope verdict.
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),
    }
}

/// Ensures the page's type/proof toolchain is installed, announcing the one
/// network step loudly before running it — never silently.
///
/// # Errors
///
/// Fails when npm cannot run or the install fails.
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"), "{}")?;

        // Source with no compiled module: stale.
        fs::write(src.join("main.ts"), "export {};")?;
        assert_eq!(app.stale_page_sources()?.len(), 1);

        // Compiled module newer than source and tsconfig: current.
        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());

        // Source touched after the compiled module: stale again.
        touch(&src.join("main.ts"), future + Duration::from_secs(5))?;
        assert_eq!(app.stale_page_sources()?.len(), 1);

        // A tsconfig newer than every compiled module also marks it stale.
        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(())
    }
}