mirage 0.0.1

LSP proxying between build servers and local machines
use std::time::Duration;

use serde::Deserialize;
use tracing::{debug, info, warn};

use crate::{Namespace, Remote, Workspace};

#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "kebab-case")]
enum Status {
    Watching,
    Disconnected,
    HaltedOnRootEmptied,
    HaltedOnRootDeletion,
    HaltedOnRootTypeChange,
    #[serde(other)]
    Unknown,
}

impl Status {
    fn is_halted(&self) -> bool {
        matches!(
            self,
            Status::HaltedOnRootEmptied
                | Status::HaltedOnRootDeletion
                | Status::HaltedOnRootTypeChange
        )
    }
}

#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
struct SessionEntry {
    status: Status,
}

/// An active mutagen sync session.
///
/// Dropping this value terminates the underlying mutagen session so the daemon
/// does not keep syncing after the program exits.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Session {
    name: String,
}

impl Session {
    /// Create (or re-use) a named mutagen session and wait until it reaches
    /// the `Watching` state before returning.
    pub fn start(
        remote: &Remote,
        workspace: &Workspace,
        namespace: &Namespace,
    ) -> eyre::Result<Self> {
        let workspace = workspace.display().to_string();
        let remote = format!("{remote}:/tmp/mirage/{ns}", ns = namespace.display());
        let name = namespace
            .display()
            .to_string()
            .replace('/', "-")
            .replace('@', "-at-");

        // Mutagen SSH URL format: [user@]host[:port]
        info!(session = %name, target = %remote, "creating mutagen sync session");

        let status = cmd::create(&workspace, &remote, &name)?;

        if !status.success() {
            eyre::bail!("mutagen sync create exited with {status}");
        }

        let session = Self { name };
        session.wait_until_watching()?;

        Ok(session)
    }

    /// Terminate the mutagen session explicitly.
    ///
    /// This is called automatically on `Drop`, but explicit termination lets
    /// callers observe the error.
    pub fn terminate(self) -> eyre::Result<()> {
        info!(session = %self.name, "terminating mutagen sync session");

        let status = cmd::terminate(&self.name)?;

        if !status.success() {
            eyre::bail!("mutagen sync terminate exited with {status}");
        }

        std::mem::forget(self);

        Ok(())
    }

    /// Poll `mutagen sync list` until the session status is `Watching`.
    fn wait_until_watching(&self) -> eyre::Result<()> {
        const POLL_INTERVAL: Duration = Duration::from_millis(500);
        const MAX_ATTEMPTS: u32 = 120; // 60 s

        info!(session = %self.name, "waiting for sync session to reach watching state");

        for attempt in 1..=MAX_ATTEMPTS {
            match self.query_status()? {
                Status::Watching => {
                    info!(session = %self.name, "sync session is watching");
                    return Ok(());
                }
                status if status.is_halted() => {
                    eyre::bail!(
                        "mutagen session '{}' halted with status {:?}",
                        self.name,
                        status,
                    );
                }
                status => {
                    debug!(
                        session = %self.name,
                        attempt,
                        ?status,
                        "waiting…",
                    );
                    std::thread::sleep(POLL_INTERVAL);
                }
            }
        }

        eyre::bail!(
            "mutagen session '{}' did not reach watching state within {}s",
            self.name,
            MAX_ATTEMPTS / 2,
        )
    }

    /// Run `mutagen sync list --template='{{ json . }}'` and return the
    /// current status of this session.
    fn query_status(&self) -> eyre::Result<Status> {
        let output = cmd::list(&self.name)?;

        if !output.status.success() {
            let stderr = String::from_utf8_lossy(&output.stderr);
            eyre::bail!("mutagen sync list failed: {stderr}");
        }

        let entries: Vec<SessionEntry> = serde_json::from_slice(&output.stdout)
            .map_err(|e| eyre::eyre!("failed to parse mutagen output: {e}"))?;

        // `mutagen sync list <name>` returns only the named session
        match entries.into_iter().next() {
            Some(entry) => Ok(entry.status),
            None => {
                warn!(session = %self.name, "session not found in list output");
                Ok(Status::Disconnected)
            }
        }
    }
}

impl Drop for Session {
    fn drop(&mut self) {
        if let Err(e) = cmd::terminate(&self.name) {
            warn!(session = %self.name, "failed to terminate mutagen session: {e}");
        }
    }
}

mod cmd {
    use std::process::{Command, ExitStatus, Output};

    pub(super) fn create(workspace: &str, target: &str, name: &str) -> std::io::Result<ExitStatus> {
        Command::new("mutagen")
            .args(["sync", "create", workspace, target, "--name", name])
            .status()
    }

    pub(super) fn list(name: &str) -> std::io::Result<Output> {
        Command::new("mutagen")
            .args(["sync", "list", "--template", "{{ json . }}", name])
            .output()
    }

    pub(super) fn terminate(name: &str) -> std::io::Result<ExitStatus> {
        Command::new("mutagen")
            .args(["sync", "terminate", name])
            .status()
    }
}