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
//! `frame.toml` port-coherence rules.
//!
//! `frame check` runs these immediately after the config loads, so a partial
//! hand-edit — the classic "I bumped `[frame].bind` but forgot
//! `[bus.websocket].allowed_origins`" — fails loudly here rather than as an
//! Origin-refused page in the browser, or as a mid-boot bind error. The rules
//! are a pure function over the four addresses and the allow-list so they are
//! provable in isolation and reusable wherever a loaded [`FrameConfig`] is in
//! hand.
//!
//! There is coherence to check ONLY when the operator stated the topology
//! verbatim ([`FrameConfig::ports_explicit`]): a portless config
//! (2026-07-22 ruling) has its page port chosen by prefer-and-walk, its
//! internal ports OS-assigned, and its `allowed_origins` DERIVED from the real
//! page address at boot — so it is coherent by construction and there is
//! nothing static to check. That path returns no violations, which is a clean
//! pass.

use std::net::SocketAddr;

use frame_host::FrameConfig;

/// Every port-coherence violation in `config`, each naming the exact
/// frame.toml keys involved. An empty vector means the config is coherent.
///
/// Only a fully-stated topology ([`FrameConfig::ports_explicit`]) has
/// coherence to check. A derived/portless config is coherent by construction
/// (the host resolves and reconciles its ports at boot), so this returns an
/// empty vector for it — a clean pass, never a false "origin missing"
/// complaint about an OS-assigned `:0` address or a not-yet-resolved page
/// port.
#[must_use]
pub fn coherence_violations(config: &FrameConfig) -> Vec<String> {
    if !config.ports_explicit {
        return Vec::new();
    }
    let (Some(bind), Some(websocket)) = (config.frame.bind, config.bus.websocket.as_ref()) else {
        // `ports_explicit` already guarantees both are present; this is a
        // defensive clean pass rather than a panic.
        return Vec::new();
    };
    port_coherence_violations(
        bind,
        config.bus.listen_address,
        config.bus.health_listen_address,
        websocket.listen_address,
        &websocket.allowed_origins,
    )
}

/// The pure coherence rules over the four addresses and the allow-list, split
/// out so they are provable without loading a whole `FrameConfig`.
fn port_coherence_violations(
    bind: SocketAddr,
    bus: SocketAddr,
    health: SocketAddr,
    websocket: SocketAddr,
    allowed_origins: &[String],
) -> Vec<String> {
    let mut violations = Vec::new();

    let origin = format!("http://{bind}");
    if !allowed_origins.iter().any(|allowed| allowed == &origin) {
        violations.push(format!(
            "[bus.websocket].allowed_origins {allowed_origins:?} does not contain the page origin \
             \"{origin}\" formed from [frame].bind: the served page would be Origin-refused by the \
             bus. If you change [frame].bind you must also add its origin to \
             [bus.websocket].allowed_origins."
        ));
    }

    let labelled = [
        ("[frame].bind", bind.port()),
        ("[bus].listen_address", bus.port()),
        ("[bus].health_listen_address", health.port()),
        ("[bus.websocket].listen_address", websocket.port()),
    ];
    for (index, (key_a, port_a)) in labelled.iter().enumerate() {
        for (key_b, port_b) in &labelled[index + 1..] {
            if port_a == port_b {
                violations.push(format!(
                    "{key_a} and {key_b} both use port {port_a}: the four frame.toml ports \
                     ([frame].bind, [bus].listen_address, [bus].health_listen_address, \
                     [bus.websocket].listen_address) must be pairwise distinct or the host cannot \
                     bind them all."
                ));
            }
        }
    }

    violations
}

#[cfg(test)]
mod tests {
    use super::port_coherence_violations;
    use std::net::SocketAddr;

    fn addr(port: u16) -> SocketAddr {
        SocketAddr::from(([127, 0, 0, 1], port))
    }

    #[test]
    fn a_coherent_default_family_has_no_violations() {
        let violations = port_coherence_violations(
            addr(4190),
            addr(9460),
            addr(9461),
            addr(9463),
            &["http://127.0.0.1:4190".to_owned()],
        );
        assert!(
            violations.is_empty(),
            "unexpected violations: {violations:?}"
        );
    }

    #[test]
    fn missing_origin_names_the_allowed_origins_key() {
        // The page bind moved to 4191 but allowed_origins still names 4190 —
        // the exact partial hand-edit that yields an Origin-refused page.
        let violations = port_coherence_violations(
            addr(4191),
            addr(9460),
            addr(9461),
            addr(9463),
            &["http://127.0.0.1:4190".to_owned()],
        );
        assert_eq!(
            violations.len(),
            1,
            "one violation expected: {violations:?}"
        );
        let message = &violations[0];
        assert!(message.contains("[bus.websocket].allowed_origins"));
        assert!(message.contains("[frame].bind"));
        assert!(message.contains("http://127.0.0.1:4191"));
    }

    #[test]
    fn a_duplicated_port_names_both_keys() {
        // Health collides with the bus TCP port.
        let violations = port_coherence_violations(
            addr(4190),
            addr(9460),
            addr(9460),
            addr(9463),
            &["http://127.0.0.1:4190".to_owned()],
        );
        assert_eq!(
            violations.len(),
            1,
            "one violation expected: {violations:?}"
        );
        let message = &violations[0];
        assert!(message.contains("[bus].listen_address"));
        assert!(message.contains("[bus].health_listen_address"));
        assert!(message.contains("9460"));
    }

    #[test]
    fn both_kinds_of_violation_surface_together() {
        // Moved bind (origin now stale) AND websocket sharing the bus port.
        let violations = port_coherence_violations(
            addr(4191),
            addr(9460),
            addr(9461),
            addr(9460),
            &["http://127.0.0.1:4190".to_owned()],
        );
        assert_eq!(
            violations.len(),
            2,
            "origin + duplicate expected: {violations:?}"
        );
    }
}