vissue-tui 0.3.0

Terminal board over ready, claims, agenda, and search
Documentation
//! Attach story: first paint is core; live serve is optional.

use std::path::Path;

use vissue_core::config::Layout;
use vissue_serve::ServeConfig;

use crate::backend::BoardBackend;
use crate::core_backend::CoreBackend;

/// How the status line labels the current store.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ServeStatus {
    Live,
    Offline,
    Mismatch,
}

pub type ProbeFn = fn(&Path) -> bool;
pub type EnsureFn = fn(&ServeConfig) -> Result<vissue_serve::EnsureResult, String>;
pub type ConnectFn = fn(&Path, &Layout, &str) -> Result<Box<dyn BoardBackend>, AttachFail>;

/// Hooks so `--offline` can be tested with a connector that panics.
pub struct AttachHooks {
    pub probe: ProbeFn,
    pub ensure: EnsureFn,
    pub connect: ConnectFn,
}

#[derive(Debug)]
pub enum AttachFail {
    Mismatch(String),
    Other(String),
}

impl Default for AttachHooks {
    fn default() -> Self {
        Self {
            probe: default_probe,
            ensure: default_ensure,
            connect: default_connect,
        }
    }
}

fn default_probe(path: &Path) -> bool {
    vissue_serve::socket_accepts(path)
}

fn default_ensure(cfg: &ServeConfig) -> Result<vissue_serve::EnsureResult, String> {
    vissue_serve::ensure_serve(cfg).map_err(|e| e.to_string())
}

fn default_connect(
    path: &Path,
    layout: &Layout,
    agent: &str,
) -> Result<Box<dyn BoardBackend>, AttachFail> {
    #[cfg(unix)]
    {
        use crate::control::{ControlAttachError, ControlBackend};
        match ControlBackend::connect(path, layout, agent) {
            Ok(backend) => Ok(Box::new(backend)),
            Err(ControlAttachError::Mismatch {
                want_root,
                want_prefix,
                got_root,
                got_prefix,
            }) => Err(AttachFail::Mismatch(format!(
                "want {want_root}/{want_prefix} got {got_root}/{got_prefix}"
            ))),
            Err(err) => Err(AttachFail::Other(err.to_string())),
        }
    }
    #[cfg(not(unix))]
    {
        let _ = (path, layout, agent);
        Err(AttachFail::Other("vissue tui attach is Unix-only".into()))
    }
}

/// Result of the post-paint attach attempt. First paint always used
/// [`CoreBackend`] already.
pub enum AttachOutcome {
    Stay {
        status: ServeStatus,
        message: String,
    },
    Switch {
        backend: Box<dyn BoardBackend>,
        status: ServeStatus,
    },
}

/// Never probes the socket when `offline`. Otherwise: accept and initialize;
/// on a free socket, `ensure_serve` then attach; on spawn failure stay core.
pub fn try_attach(
    layout: &Layout,
    socket: &Path,
    agent: &str,
    offline: bool,
    hooks: &AttachHooks,
) -> AttachOutcome {
    if offline {
        return AttachOutcome::Stay {
            status: ServeStatus::Offline,
            message: String::new(),
        };
    }

    if (hooks.probe)(socket) {
        return finish_connect(socket, layout, agent, hooks);
    }

    let cfg = ServeConfig {
        layout: layout.clone(),
        socket: socket.to_path_buf(),
        exe: None,
    };
    match (hooks.ensure)(&cfg) {
        Ok(ensured) if ensured.ok && (hooks.probe)(socket) => {
            finish_connect(socket, layout, agent, hooks)
        }
        Ok(ensured) => AttachOutcome::Stay {
            status: ServeStatus::Offline,
            message: ensured.error.unwrap_or_else(|| "serve spawn failed".into()),
        },
        Err(err) => AttachOutcome::Stay {
            status: ServeStatus::Offline,
            message: err,
        },
    }
}

fn finish_connect(
    socket: &Path,
    layout: &Layout,
    agent: &str,
    hooks: &AttachHooks,
) -> AttachOutcome {
    match (hooks.connect)(socket, layout, agent) {
        Ok(backend) => AttachOutcome::Switch {
            backend,
            status: ServeStatus::Live,
        },
        Err(AttachFail::Mismatch(message)) => AttachOutcome::Stay {
            status: ServeStatus::Mismatch,
            message,
        },
        Err(AttachFail::Other(message)) => AttachOutcome::Stay {
            status: ServeStatus::Offline,
            message,
        },
    }
}

/// First paint: core catalog, revision 0, no socket.
pub fn open_core(layout: Layout, agent: String) -> Result<CoreBackend, vissue_core::error::Error> {
    CoreBackend::open(layout, agent)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::backend::BoardBackend;
    use std::path::{Path, PathBuf};
    use std::sync::atomic::{AtomicBool, Ordering};

    static TOUCHED: AtomicBool = AtomicBool::new(false);

    fn panic_probe(_: &Path) -> bool {
        TOUCHED.store(true, Ordering::SeqCst);
        panic!("--offline must not probe the socket");
    }

    fn panic_ensure(_: &ServeConfig) -> Result<vissue_serve::EnsureResult, String> {
        TOUCHED.store(true, Ordering::SeqCst);
        panic!("--offline must not spawn serve");
    }

    fn panic_connect(_: &Path, _: &Layout, _: &str) -> Result<Box<dyn BoardBackend>, AttachFail> {
        TOUCHED.store(true, Ordering::SeqCst);
        panic!("--offline must not connect");
    }

    fn no_probe(_: &Path) -> bool {
        false
    }

    fn ensure_fails(_: &ServeConfig) -> Result<vissue_serve::EnsureResult, String> {
        Err("spawn failed".into())
    }

    fn connect_mismatch(
        _: &Path,
        _: &Layout,
        _: &str,
    ) -> Result<Box<dyn BoardBackend>, AttachFail> {
        Err(AttachFail::Mismatch("other root".into()))
    }

    fn yes_probe(_: &Path) -> bool {
        true
    }

    #[test]
    fn spawn_failure_stays_core() {
        let layout = Layout::new("/tmp/vissue-spawn", "Software");
        let hooks = AttachHooks {
            probe: no_probe,
            ensure: ensure_fails,
            connect: panic_connect,
        };
        match try_attach(
            &layout,
            &PathBuf::from("/tmp/vissue-spawn.sock"),
            "agent",
            false,
            &hooks,
        ) {
            AttachOutcome::Stay {
                status: ServeStatus::Offline,
                message,
            } => assert!(message.contains("spawn failed"), "{message}"),
            _ => panic!("expected offline stay"),
        }
    }

    #[test]
    fn mismatch_stays_core() {
        let layout = Layout::new("/tmp/vissue-mis", "Software");
        let hooks = AttachHooks {
            probe: yes_probe,
            ensure: panic_ensure,
            connect: connect_mismatch,
        };
        match try_attach(
            &layout,
            &PathBuf::from("/tmp/vissue-mis.sock"),
            "agent",
            false,
            &hooks,
        ) {
            AttachOutcome::Stay {
                status: ServeStatus::Mismatch,
                message,
            } => assert!(message.contains("other root"), "{message}"),
            _ => panic!("expected mismatch stay"),
        }
    }

    #[test]
    fn offline_never_connects() {
        TOUCHED.store(false, Ordering::SeqCst);
        let layout = Layout::new("/tmp/vissue-offline", "Software");
        let hooks = AttachHooks {
            probe: panic_probe,
            ensure: panic_ensure,
            connect: panic_connect,
        };
        let outcome = try_attach(
            &layout,
            &PathBuf::from("/tmp/vissue-offline.sock"),
            "agent",
            true,
            &hooks,
        );
        match outcome {
            AttachOutcome::Stay {
                status: ServeStatus::Offline,
                ..
            } => {}
            _ => panic!("offline must stay on CoreBackend"),
        }
        assert!(!TOUCHED.load(Ordering::SeqCst));
    }
}