omh 0.3.0

Launch any coding harness, in a sandbox, with your setup already there.
//! IDE access.
//!
//! The worktree is a plain host directory, so this was never about file access.
//! It is about where the language server runs: dependencies are installed
//! Linux-side while the host is macOS/arm64, so a host LSP means a second
//! dependency tree that silently diverges.
//!
//! The integration point is a **managed SSH config include**, not an IDE plugin.
//! Write one `Host` block and VS Code, Zed, JetBrains Gateway, and plain `ssh`
//! all work without omh knowing they exist — which is the only way a
//! harness-agnostic tool avoids being IDE-locked.

use anyhow::Result;
use std::path::{Path, PathBuf};

pub const INCLUDE_LINE: &str = "Include ~/.ssh/config.d/omh";

/// SSH host alias for a session.
pub fn host_alias(repo: &str, session: &str) -> String {
    format!("omh-{repo}-{session}")
}

/// Loopback port for a session's sshd.
///
/// Derived rather than assigned so the alias keeps working across restarts —
/// a port that moved would silently break every IDE bookmark pointing at it.
pub fn port(repo: &str, session: &str) -> u16 {
    use std::hash::{Hash, Hasher};
    let mut h = std::collections::hash_map::DefaultHasher::new();
    repo.hash(&mut h);
    session.hash(&mut h);
    // Ephemeral range: below 1024 needs root, and staying up here avoids
    // stomping on anything real.
    const LOW: u32 = 49152;
    const SPAN: u32 = 65535 - LOW;
    (LOW + (h.finish() % SPAN as u64) as u32) as u16
}

pub fn config_block(alias: &str, port: u16, key: &Path) -> String {
    // Loopback only. On 0.0.0.0 this would publish a shell inside the sandbox
    // to the local network, inverting the point of the project.
    //
    // Host keys are regenerated whenever the image is rebuilt, so pinning them
    // would produce a mismatch warning the user cannot act on.
    format!(
        "Host {alias}\n  \
         HostName 127.0.0.1\n  \
         Port {port}\n  \
         User agent\n  \
         IdentityFile {}\n  \
         IdentitiesOnly yes\n  \
         StrictHostKeyChecking no\n  \
         UserKnownHostsFile /dev/null\n  \
         LogLevel ERROR\n",
        key.display()
    )
}

/// Rewrite the managed include. Only omh's file is ever touched.
pub fn write_hosts(path: &Path, blocks: &[String]) -> Result<()> {
    std::fs::create_dir_all(path.parent().unwrap())?;
    let mut out =
        String::from("# Generated by omh. Do not edit — rewritten on every `omh code`.\n\n");
    for block in blocks {
        out.push_str(block);
        out.push('\n');
    }
    std::fs::write(path, out)?;
    Ok(())
}

/// Add the `Include` to `~/.ssh/config` if absent, preserving everything else.
pub fn ensure_include(ssh_config: &Path) -> Result<()> {
    let existing = std::fs::read_to_string(ssh_config).unwrap_or_default();
    if existing.lines().any(|l| l.trim() == INCLUDE_LINE) {
        return Ok(());
    }
    std::fs::create_dir_all(ssh_config.parent().unwrap())?;
    // Prepended: ssh applies the first matching block, so an Include placed
    // after someone's `Host *` would never win.
    let mut out = String::from(INCLUDE_LINE);
    out.push_str("\n\n");
    out.push_str(&existing);
    std::fs::write(ssh_config, out)?;
    Ok(())
}

pub fn url(alias: &str) -> String {
    format!("ssh://{alias}/work")
}

/// Per-repo key. Generated once, never leaves the host.
pub fn ensure_key(dir: &Path) -> Result<PathBuf> {
    let key = dir.join("id_ed25519");
    if key.exists() {
        return Ok(key);
    }
    std::fs::create_dir_all(dir)?;
    let out = std::process::Command::new("ssh-keygen")
        .args(["-t", "ed25519", "-N", "", "-C", "omh", "-f"])
        .arg(&key)
        .output()?;
    if !out.status.success() {
        anyhow::bail!(
            "ssh-keygen: {}",
            String::from_utf8_lossy(&out.stderr).trim()
        );
    }
    Ok(key)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn aliases_are_unique_per_repo_and_session() {
        assert_ne!(host_alias("a", "s01"), host_alias("b", "s01"));
        assert_ne!(host_alias("a", "s01"), host_alias("a", "s02"));
    }

    /// An IDE bookmark points at the alias, and the alias resolves through the
    /// port. A port that moved between restarts would break every saved window.
    #[test]
    fn ports_are_stable_across_calls() {
        assert_eq!(port("repo", "s01"), port("repo", "s01"));
    }

    #[test]
    fn ports_differ_between_sessions_and_repos() {
        assert_ne!(port("repo", "s01"), port("repo", "s02"));
        assert_ne!(port("alpha", "s01"), port("beta", "s01"));
    }

    /// Below 1024 needs root; above 65535 does not exist. Staying in the
    /// ephemeral range also avoids stomping on real services.
    #[test]
    fn ports_land_in_the_ephemeral_range() {
        for session in ["s01", "s02", "s99", "doctor"] {
            let p = port("some-repo", session);
            assert!((49152..=65535).contains(&p), "{session} → {p}");
        }
    }

    /// Publishing on 0.0.0.0 would expose a shell inside the sandbox to the
    /// local network, inverting the entire point of the project.
    #[test]
    fn the_host_block_binds_loopback_only() {
        let block = config_block("omh-x-s01", 49200, Path::new("/k/id_ed25519"));
        assert!(block.contains("HostName 127.0.0.1"), "got: {block}");
        assert!(!block.contains("0.0.0.0"));
    }

    #[test]
    fn the_host_block_names_the_alias_port_user_and_key() {
        let block = config_block("omh-x-s01", 49200, Path::new("/k/id_ed25519"));
        assert!(block.contains("Host omh-x-s01"));
        assert!(block.contains("Port 49200"));
        assert!(block.contains("User agent"));
        assert!(block.contains("/k/id_ed25519"));
    }

    /// Container host keys change on every rebuild; without this every
    /// reconnect stops with a scary mismatch the user cannot act on.
    #[test]
    fn ephemeral_host_keys_do_not_trip_known_hosts() {
        let block = config_block("omh-x-s01", 49200, Path::new("/k"));
        assert!(block.contains("StrictHostKeyChecking no"));
        assert!(block.contains("UserKnownHostsFile /dev/null"));
    }

    // ── the managed include ─────────────────────────────────────────────────

    #[test]
    fn the_managed_file_is_replaced_wholesale() {
        let d = tempfile::tempdir().unwrap();
        let f = d.path().join("config.d/omh");
        write_hosts(&f, &["Host a\n".into()]).unwrap();
        write_hosts(&f, &["Host b\n".into()]).unwrap();
        let body = std::fs::read_to_string(&f).unwrap();
        assert!(body.contains("Host b"));
        assert!(!body.contains("Host a"), "stale sessions must not linger");
    }

    #[test]
    fn the_managed_file_says_it_is_generated() {
        let d = tempfile::tempdir().unwrap();
        let f = d.path().join("omh");
        write_hosts(&f, &[]).unwrap();
        let body = std::fs::read_to_string(&f).unwrap();
        assert!(body.to_lowercase().contains("generated"), "got: {body}");
    }

    /// Someone's `~/.ssh/config` is not ours to rewrite. Append the one line and
    /// touch nothing else.
    #[test]
    fn adding_the_include_preserves_the_users_config() {
        let d = tempfile::tempdir().unwrap();
        let cfg = d.path().join("config");
        std::fs::write(&cfg, "Host work\n  User me\n").unwrap();

        ensure_include(&cfg).unwrap();

        let body = std::fs::read_to_string(&cfg).unwrap();
        assert!(body.contains("Host work"), "existing config survived");
        assert!(body.contains("User me"));
        assert!(body.contains(INCLUDE_LINE));
    }

    #[test]
    fn adding_the_include_twice_does_not_duplicate_it() {
        let d = tempfile::tempdir().unwrap();
        let cfg = d.path().join("config");
        ensure_include(&cfg).unwrap();
        ensure_include(&cfg).unwrap();
        let body = std::fs::read_to_string(&cfg).unwrap();
        assert_eq!(body.matches(INCLUDE_LINE).count(), 1);
    }

    /// `Include` must come before any `Host` block, or ssh applies the earlier
    /// block's settings to our alias.
    #[test]
    fn the_include_goes_at_the_top() {
        let d = tempfile::tempdir().unwrap();
        let cfg = d.path().join("config");
        std::fs::write(&cfg, "Host work\n  User me\n").unwrap();
        ensure_include(&cfg).unwrap();
        let body = std::fs::read_to_string(&cfg).unwrap();
        assert!(
            body.find(INCLUDE_LINE) < body.find("Host work"),
            "Include must precede Host blocks:\n{body}"
        );
    }
}