pub mod claude_code;
pub mod codex;
pub mod opencode;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::process::Output;
use std::process::{Command, Stdio};
use std::sync::Arc;
use std::sync::mpsc::{self, Receiver};
use std::time::{Duration, Instant};
#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
pub struct BackendSessionIdentity {
pub backend: String,
pub session_id: String,
}
const AVAILABILITY_TIMEOUT: Duration = Duration::from_secs(3);
const MISE_TRUST_TIMEOUT: Duration = Duration::from_secs(3);
const PIPE_DRAIN_TIMEOUT: Duration = Duration::from_millis(100);
const MISE_CONFIGS: &[&str] = &[
"mise.toml",
".mise.toml",
"mise/config.toml",
".tool-versions",
];
fn run_with_timeout(command: &mut Command, timeout: Duration) -> Option<std::process::ExitStatus> {
let mut child = command.spawn().ok()?;
let deadline = Instant::now() + timeout;
loop {
match child.try_wait() {
Ok(Some(status)) => return Some(status),
Ok(None) => {
if Instant::now() >= deadline {
let _ = child.kill();
let _ = child.wait();
return None;
}
std::thread::sleep(Duration::from_millis(25));
}
Err(_) => {
let _ = child.kill();
let _ = child.wait();
return None;
}
}
}
}
fn cli_reports_version(cli_name: &str) -> bool {
let mut command = Command::new(cli_name);
command
.arg("--version")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
matches!(run_with_timeout(&mut command, AVAILABILITY_TIMEOUT), Some(status) if status.success())
}
pub fn pre_trust_mise(dir: &str) {
if cfg!(test) {
return;
}
for path in mise_config_paths(Path::new(dir)) {
let attempt = run_mise_trust(Path::new("mise"), &path, MISE_TRUST_TIMEOUT);
if attempt.success() {
tracing::debug!(config = %path.display(), "mise config pre-trusted");
} else {
log_mise_trust_failure(&path, &attempt);
}
}
}
fn mise_config_paths(dir: &Path) -> Vec<PathBuf> {
MISE_CONFIGS
.iter()
.map(|name| dir.join(name))
.filter(|path| path.exists())
.collect()
}
#[derive(Debug)]
enum MiseTrustAttempt {
Completed(Output),
SpawnFailed(String),
WaitFailed(String),
TimedOut(Option<Output>),
}
impl MiseTrustAttempt {
fn success(&self) -> bool {
matches!(self, Self::Completed(output) if output.status.success())
}
fn status_code(&self) -> Option<i32> {
match self {
Self::Completed(output) | Self::TimedOut(Some(output)) => output.status.code(),
Self::SpawnFailed(_) | Self::WaitFailed(_) | Self::TimedOut(None) => None,
}
}
fn stdout(&self) -> String {
match self {
Self::Completed(output) | Self::TimedOut(Some(output)) => {
String::from_utf8_lossy(&output.stdout).into_owned()
}
Self::SpawnFailed(_) | Self::WaitFailed(_) | Self::TimedOut(None) => String::new(),
}
}
fn stderr(&self) -> String {
match self {
Self::Completed(output) | Self::TimedOut(Some(output)) => {
String::from_utf8_lossy(&output.stderr).into_owned()
}
Self::SpawnFailed(_) | Self::WaitFailed(_) | Self::TimedOut(None) => String::new(),
}
}
}
fn run_mise_trust(mise_bin: &Path, config_path: &Path, timeout: Duration) -> MiseTrustAttempt {
let mut command = Command::new(mise_bin);
command
.arg("trust")
.arg(config_path)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
run_mise_trust_command(&mut command, timeout)
}
fn run_mise_trust_command(command: &mut Command, timeout: Duration) -> MiseTrustAttempt {
let mut child = match command.spawn() {
Ok(child) => child,
Err(error) => return MiseTrustAttempt::SpawnFailed(error.to_string()),
};
let stdout = child.stdout.take().map(read_child_pipe);
let stderr = child.stderr.take().map(read_child_pipe);
let deadline = Instant::now() + timeout;
loop {
match child.try_wait() {
Ok(Some(_status)) => {
return child
.wait()
.map(|status| {
MiseTrustAttempt::Completed(collect_output(status, stdout, stderr))
})
.unwrap_or_else(|error| MiseTrustAttempt::WaitFailed(error.to_string()));
}
Ok(None) => {
if Instant::now() >= deadline {
let _ = child.kill();
return MiseTrustAttempt::TimedOut(
child
.wait()
.ok()
.map(|status| collect_output(status, stdout, stderr)),
);
}
std::thread::sleep(Duration::from_millis(25));
}
Err(error) => {
let _ = child.kill();
let _ = child.wait();
return MiseTrustAttempt::WaitFailed(error.to_string());
}
}
}
}
fn read_child_pipe<T>(mut pipe: T) -> Receiver<Vec<u8>>
where
T: Read + Send + 'static,
{
let (tx, rx) = mpsc::channel();
std::thread::spawn(move || {
let mut buf = [0; 8192];
loop {
match pipe.read(&mut buf) {
Ok(0) => break,
Ok(n) => {
if tx.send(buf[..n].to_vec()).is_err() {
break;
}
}
Err(_) => break,
}
}
});
rx
}
fn collect_output(
status: std::process::ExitStatus,
stdout: Option<Receiver<Vec<u8>>>,
stderr: Option<Receiver<Vec<u8>>>,
) -> Output {
Output {
status,
stdout: join_pipe(stdout),
stderr: join_pipe(stderr),
}
}
fn join_pipe(receiver: Option<Receiver<Vec<u8>>>) -> Vec<u8> {
let Some(receiver) = receiver else {
return Vec::new();
};
let mut output = Vec::new();
while let Ok(chunk) = receiver.recv_timeout(PIPE_DRAIN_TIMEOUT) {
output.extend(chunk);
}
output
}
fn log_mise_trust_failure(path: &Path, attempt: &MiseTrustAttempt) {
let stdout = truncate_for_log(&attempt.stdout());
let stderr = truncate_for_log(&attempt.stderr());
match attempt {
MiseTrustAttempt::Completed(output) => {
tracing::warn!(
config = %path.display(),
status = ?output.status,
stdout = %stdout,
stderr = %stderr,
"mise trust exited unsuccessfully; spawned shells may prompt for trust"
);
}
MiseTrustAttempt::SpawnFailed(error) => {
tracing::warn!(
config = %path.display(),
error = %error,
"mise trust could not be started; spawned shells may prompt for trust"
);
}
MiseTrustAttempt::WaitFailed(error) => {
tracing::warn!(
config = %path.display(),
error = %error,
stdout = %stdout,
stderr = %stderr,
"mise trust wait failed; spawned shells may prompt for trust"
);
}
MiseTrustAttempt::TimedOut(_) => {
tracing::warn!(
config = %path.display(),
timeout_ms = MISE_TRUST_TIMEOUT.as_millis(),
status_code = ?attempt.status_code(),
stdout = %stdout,
stderr = %stderr,
"mise trust timed out; spawned shells may prompt for trust"
);
}
}
}
fn truncate_for_log(value: &str) -> String {
const MAX_CHARS: usize = 1000;
let mut chars = value.trim().chars();
let truncated: String = chars.by_ref().take(MAX_CHARS).collect();
if chars.next().is_some() {
format!("{truncated}...")
} else {
truncated
}
}
#[derive(Debug)]
pub struct BackendRegistry {
backends: Vec<Arc<dyn CodingAssistant>>,
default_name: String,
}
impl BackendRegistry {
pub fn new(backends: Vec<Arc<dyn CodingAssistant>>, default: &str) -> Self {
Self {
backends,
default_name: default.to_string(),
}
}
pub fn default_registry() -> Self {
Self::new(
vec![
Arc::new(claude_code::ClaudeCode) as _,
Arc::new(opencode::OpenCode) as _,
Arc::new(codex::Codex) as _,
],
"opencode",
)
}
pub fn get(&self, name: &str) -> Option<Arc<dyn CodingAssistant>> {
self.backends.iter().find(|b| b.name() == name).cloned()
}
pub fn names(&self) -> Vec<&str> {
self.backends.iter().map(|b| b.name()).collect()
}
pub fn valid_names_csv(&self) -> String {
self.names().join(", ")
}
pub fn unknown_backend_message(&self, name: &str) -> String {
format!(
"unknown backend '{name}'. Valid backends: {}",
self.valid_names_csv()
)
}
pub fn get_required(&self, name: &str) -> Result<Arc<dyn CodingAssistant>, String> {
self.get(name)
.ok_or_else(|| self.unknown_backend_message(name))
}
pub fn default(&self) -> Arc<dyn CodingAssistant> {
self.get(&self.default_name)
.expect("default backend must exist")
}
pub fn available(&self) -> Vec<&str> {
self.backends
.iter()
.filter(|b| b.is_available())
.map(|b| b.name())
.collect()
}
pub fn all_process_names(&self) -> Vec<String> {
self.backends
.iter()
.flat_map(|b| b.process_names().iter().map(|s| s.to_string()))
.collect()
}
pub fn caller_session_identity(&self) -> Option<BackendSessionIdentity> {
let mut identities = self.backends.iter().filter_map(|backend| {
backend
.caller_session_id()
.map(|session_id| BackendSessionIdentity {
backend: backend.name().to_string(),
session_id,
})
});
let identity = identities.next()?;
identities.next().is_none().then_some(identity)
}
pub fn all_backend_process_names(&self) -> Vec<(String, Vec<String>)> {
self.backends
.iter()
.map(|b| {
(
b.name().to_string(),
b.process_names().iter().map(|s| s.to_string()).collect(),
)
})
.collect()
}
pub fn uses_http_delivery(&self, backend_name: &str) -> bool {
self.get(backend_name)
.is_some_and(|b| matches!(b.delivery_mode(), DeliveryMode::HttpApi { .. }))
}
}
#[derive(Debug, Clone)]
pub enum DeliveryMode {
TuiInjection,
HttpApi {
#[allow(dead_code)]
serve_command: String,
#[allow(dead_code)]
attach_command: String,
},
}
#[derive(Debug)]
pub struct StartOpts {
pub project_dir: String,
pub worktree: Option<WorktreeMode>,
pub model: Option<String>,
pub effort: Option<String>,
pub permission_mode: Option<String>,
pub codex_home: Option<String>,
}
#[derive(Debug)]
pub struct ResumeOpts {
pub project_dir: String,
pub session_id: Option<String>,
pub worktree: Option<WorktreeMode>,
pub model: Option<String>,
pub effort: Option<String>,
pub permission_mode: Option<String>,
pub codex_home: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LaunchModelConfig {
pub model: Option<String>,
pub codex_home: Option<String>,
}
pub(crate) fn resolve_launch_model_config(
backend_name: &str,
model: Option<String>,
settings: &crate::persistence::OuijaSettings,
) -> LaunchModelConfig {
if backend_name == "codex-cli" {
if let Some(alias) = model.as_deref().map(str::trim).filter(|m| !m.is_empty()) {
if let Some(route) = settings.codex_model_routes.get(alias) {
return LaunchModelConfig {
model: route.model.clone().or_else(|| Some(alias.to_string())),
codex_home: route
.codex_home
.clone()
.or_else(|| settings.codex_home.clone()),
};
}
}
return LaunchModelConfig {
model,
codex_home: settings.codex_home.clone(),
};
}
LaunchModelConfig {
model,
codex_home: None,
}
}
#[derive(Debug, Clone)]
pub enum WorktreeMode {
Named(String),
Disposable,
}
#[derive(Debug, Clone, Copy)]
pub struct InjectConfig {
pub paste_settle_ms: u64,
pub use_inner_bracketed_paste: bool,
pub startup_inject_delay_secs: u64,
}
#[allow(dead_code)]
pub trait CodingAssistant: Send + Sync + std::fmt::Debug + 'static {
fn name(&self) -> &str;
fn cli_name(&self) -> &str;
fn process_names(&self) -> &[&str];
fn delivery_mode(&self) -> DeliveryMode;
fn build_start_command(&self, opts: &StartOpts) -> String;
fn build_resume_command(&self, opts: &ResumeOpts) -> Option<String>;
fn detect_session_id(&self, project_dir: &str) -> Option<String>;
fn caller_session_id(&self) -> Option<String> {
None
}
fn tui_ready_pattern(&self) -> Option<&str>;
fn inject_config(&self) -> InjectConfig;
fn config_dir_name(&self) -> &str;
fn has_project_history(&self, dir: &Path) -> bool;
fn compact_command(&self) -> Option<&str> {
None
}
fn exit_command(&self) -> Option<&str>;
fn install(&self) -> anyhow::Result<()>;
fn is_available(&self) -> bool {
cli_reports_version(self.cli_name())
}
fn description_file_priority(&self) -> &[&str] {
&["README.md"]
}
}
#[cfg(test)]
pub(crate) fn assert_shared_task_reminder_guidance(skill: &str) {
let normalized_skill = skill.split_whitespace().collect::<Vec<_>>().join(" ");
let active_context_section = skill
.split("## Active-context refresh")
.nth(1)
.and_then(|section| section.split("## Identity and recovery").next())
.expect("shared skill must contain the active-context refresh section");
let active_context = active_context_section
.split_whitespace()
.collect::<Vec<_>>()
.join(" ");
assert!(
normalized_skill.contains("## Progressive disclosure")
&& normalized_skill.contains("Do not preload or reproduce the full command catalog")
&& normalized_skill
.contains("Follow those diagnostics rather than inventing a workaround"),
"skill must delegate routine syntax and recovery to help and actionable errors"
);
assert!(
normalized_skill.contains("`reply=\"true\"` means the sender is blocked")
&& normalized_skill.contains("A progress update does not clear the pending reply")
&& normalized_skill.contains("Do not poll logs, status, or pane output"),
"skill must preserve non-obvious message threading semantics"
);
assert!(
normalized_skill.contains("`--when-done keep-open|ask-parent|close`")
&& normalized_skill
.contains("`--reminder` independently enables recurring recovery nudges")
&& normalized_skill
.contains("Pending replies can wake a session even without a reminder")
&& normalized_skill.contains("never place `ouija clear-reminder` in reminder text"),
"skill must distinguish completion, reminders, and pending replies"
);
let placeholder_command = ["ouija clear-reminder", "N"].join(" ");
assert!(
!skill.contains(&placeholder_command),
"skill must not contain a copyable placeholder clearing command"
);
assert!(
normalized_skill.contains("Repository and task evidence remain authoritative"),
"shared skill must keep continuation records non-authoritative"
);
assert!(
normalized_skill.contains("Native subagents are not Ouija sessions"),
"shared skill must preserve the native-subagent boundary"
);
assert!(
active_context.contains("counts accumulated active work, not wall time")
&& active_context.contains("pauses while parked"),
"shared skill must explain that active-context accounting pauses while parked"
);
assert!(
active_context.contains("triggers only at a safe stopped boundary")
&& active_context.contains("A successful fresh restart resets the counter")
&& active_context.contains("there is no separate rearm command"),
"shared skill must explain the active-context refresh lifecycle"
);
assert!(
active_context
.contains("Make the stored prompt a concise durable role and replay-safe assignment")
&& active_context.contains("Put verified completed/remaining work")
&& active_context.contains("in a one-shot continuation")
&& active_context_section.contains("--one-shot-file /dev/stdin"),
"shared skill must separate durable prompts from mutable continuation state"
);
assert!(
normalized_skill.contains("Every stored prompt must be re-entrant and state-checking")
&& normalized_skill.contains("Perform only work that remains incomplete")
&& normalized_skill
.contains("Do not repeat expensive, destructive, or external actions"),
"shared skill must make replay safety explicit for non-idempotent stored-prompt work"
);
assert!(
active_context.contains("Do not use scheduler tasks or legacy rollover")
&& normalized_skill
.contains("Never guess a sender from a project, branch, role, process")
&& normalized_skill
.contains("never use `opencode` or a backend session ID as `--from`"),
"shared skill must preserve refresh and sender-identity boundaries"
);
assert!(
normalized_skill
.contains("Treat an operator-requested new public name as a literal argument")
&& normalized_skill.contains("do not preflight availability with `ls` or `status`")
&& normalized_skill.contains("Run `ouija rename` once with the exact requested name"),
"shared skill must prevent speculative rename correction and discovery"
);
assert!(
normalized_skill
.contains("`ouija rollover` is a separate explicit continuation-record workflow")
&& normalized_skill.contains("not the active-context refresh path"),
"shared skill must keep legacy manual rollover separate from active-context refresh"
);
assert!(
!skill.contains("--target hub-cx --inject-only")
&& !skill.contains("one recurring exact-target task"),
"shared skill must not recommend the retired scheduled-audit production path"
);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_registry_uses_opencode() {
let registry = BackendRegistry::default_registry();
assert_eq!(registry.default().name(), "opencode");
}
#[test]
fn registry_available_returns_backends_with_binaries() {
let registry = BackendRegistry::default_registry();
let available = registry.available();
assert!(available.iter().all(|name| !name.is_empty()));
}
#[test]
fn codex_without_model_uses_default_home_resolution() {
let settings = crate::persistence::OuijaSettings::default();
let launch = resolve_launch_model_config("codex-cli", None, &settings);
assert_eq!(
launch,
LaunchModelConfig {
model: None,
codex_home: None,
}
);
}
#[test]
fn codex_model_alias_resolves_route() {
let mut settings = crate::persistence::OuijaSettings::default();
settings.codex_model_routes.insert(
"gemini".into(),
crate::persistence::CodexModelRoute {
model: Some("gemini-2.5-pro".into()),
codex_home: Some("~/.cache/codex-gemini".into()),
},
);
let launch = resolve_launch_model_config("codex-cli", Some("gemini".into()), &settings);
assert_eq!(
launch,
LaunchModelConfig {
model: Some("gemini-2.5-pro".into()),
codex_home: Some("~/.cache/codex-gemini".into()),
}
);
}
#[test]
fn run_with_timeout_returns_status_for_fast_success() {
let status = run_with_timeout(&mut Command::new("true"), Duration::from_secs(3));
assert!(status.is_some_and(|s| s.success()));
}
#[test]
fn run_with_timeout_returns_status_for_fast_failure() {
let status = run_with_timeout(&mut Command::new("false"), Duration::from_secs(3));
assert!(status.is_some_and(|s| !s.success()));
}
#[test]
fn run_with_timeout_kills_and_returns_none_when_deadline_exceeded() {
let start = Instant::now();
let status = run_with_timeout(Command::new("sleep").arg("5"), Duration::from_millis(200));
assert!(status.is_none(), "timed-out process must return None");
assert!(
start.elapsed() < Duration::from_secs(2),
"helper must return near the deadline, not wait for the process"
);
}
#[test]
fn run_with_timeout_returns_none_for_missing_binary() {
let status = run_with_timeout(
&mut Command::new("ouija-nonexistent-binary-xyz"),
Duration::from_secs(3),
);
assert!(status.is_none());
}
#[test]
fn cli_reports_version_true_for_command_that_exits_zero() {
assert!(cli_reports_version("true"));
}
#[test]
fn cli_reports_version_false_for_missing_binary() {
assert!(!cli_reports_version("ouija-nonexistent-binary-xyz"));
}
#[test]
fn mise_config_paths_only_include_existing_configs() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(root.join("mise.toml"), "[tools]\n").unwrap();
std::fs::create_dir(root.join("mise")).unwrap();
std::fs::write(root.join("mise/config.toml"), "[env]\n").unwrap();
let paths = mise_config_paths(root);
assert_eq!(
paths,
vec![root.join("mise.toml"), root.join("mise/config.toml")]
);
}
#[test]
fn mise_trust_attempt_captures_nonzero_output() {
let tmp = tempfile::tempdir().unwrap();
let fake_mise = tmp.path().join("mise");
std::fs::write(
&fake_mise,
"#!/bin/sh\nprintf 'out line\\n'\nprintf 'err line\\n' >&2\nexit 42\n",
)
.unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&fake_mise, std::fs::Permissions::from_mode(0o755)).unwrap();
}
let config = tmp.path().join("mise.toml");
std::fs::write(&config, "[tools]\n").unwrap();
let attempt = run_mise_trust(&fake_mise, &config, Duration::from_secs(30));
assert_eq!(attempt.status_code(), Some(42));
assert_eq!(attempt.stdout(), "out line\n");
assert_eq!(attempt.stderr(), "err line\n");
}
#[test]
fn mise_trust_timeout_does_not_wait_for_pipe_holding_descendant() {
let tmp = tempfile::tempdir().unwrap();
let fake_mise = tmp.path().join("mise");
std::fs::write(
&fake_mise,
"#!/bin/sh\n(sleep 30) &\nprintf 'before timeout\\n'\nsleep 30\n",
)
.unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&fake_mise, std::fs::Permissions::from_mode(0o755)).unwrap();
}
let config = tmp.path().join("mise.toml");
std::fs::write(&config, "[tools]\n").unwrap();
let (tx, rx) = std::sync::mpsc::channel();
let fake_mise = fake_mise.clone();
std::thread::spawn(move || {
let attempt = run_mise_trust(&fake_mise, &config, Duration::from_millis(500));
let _ = tx.send(attempt);
});
let attempt = rx
.recv_timeout(Duration::from_secs(2))
.expect("timeout path must not wait for descendant-held stdout pipe");
assert!(matches!(attempt, MiseTrustAttempt::TimedOut(_)));
}
#[test]
fn actual_mise_trust_suppresses_untrusted_config_check_when_available() {
if !cli_reports_version("mise") {
eprintln!("skipping actual mise trust test because mise is not on PATH");
return;
}
let tmp = tempfile::tempdir().unwrap();
let project = tmp.path().join("project");
let mise_data = tmp.path().join("mise-data");
std::fs::create_dir(&project).unwrap();
std::fs::create_dir(&mise_data).unwrap();
let config = project.join("mise.toml");
std::fs::write(
&config,
"[env]\nOUIJA_MISE_TRUST_TEST = \"trusted-after-command\"\n",
)
.unwrap();
let before = std::process::Command::new("mise")
.arg("env")
.arg("-C")
.arg(&project)
.env("MISE_DATA_DIR", &mise_data)
.output()
.unwrap();
assert!(
!before.status.success(),
"untrusted mise config should fail before trust"
);
assert!(
String::from_utf8_lossy(&before.stderr).contains("not trusted"),
"expected untrusted-config error, got stderr: {}",
String::from_utf8_lossy(&before.stderr)
);
let trust = std::process::Command::new("mise")
.arg("trust")
.arg(&config)
.env("MISE_DATA_DIR", &mise_data)
.output()
.unwrap();
assert!(
trust.status.success(),
"mise trust failed: stdout={} stderr={}",
String::from_utf8_lossy(&trust.stdout),
String::from_utf8_lossy(&trust.stderr)
);
let after = std::process::Command::new("mise")
.arg("env")
.arg("-C")
.arg(&project)
.env("MISE_DATA_DIR", &mise_data)
.output()
.unwrap();
assert!(
after.status.success(),
"trusted mise config should load without prompt: stderr={}",
String::from_utf8_lossy(&after.stderr)
);
}
#[test]
fn uses_http_delivery_distinguishes_backends() {
let registry = BackendRegistry::default_registry();
assert!(registry.uses_http_delivery("opencode"));
assert!(!registry.uses_http_delivery("claude-code"));
assert!(!registry.uses_http_delivery("codex-cli"));
assert!(!registry.uses_http_delivery("nonexistent"));
}
#[test]
fn registry_includes_codex_backend() {
let registry = BackendRegistry::default_registry();
let codex = registry
.get("codex-cli")
.expect("codex-cli backend must be registered");
assert_eq!(codex.cli_name(), "codex");
assert!(registry.all_process_names().iter().any(|n| n == "codex"));
}
#[derive(Debug)]
struct UnavailableBackend;
impl CodingAssistant for UnavailableBackend {
fn name(&self) -> &str {
"ghost"
}
fn cli_name(&self) -> &str {
"ouija-nonexistent-binary-xyz"
}
fn process_names(&self) -> &[&str] {
&["ghostproc"]
}
fn delivery_mode(&self) -> DeliveryMode {
DeliveryMode::TuiInjection
}
fn build_start_command(&self, _: &StartOpts) -> String {
String::new()
}
fn build_resume_command(&self, _: &ResumeOpts) -> Option<String> {
None
}
fn detect_session_id(&self, _: &str) -> Option<String> {
None
}
fn tui_ready_pattern(&self) -> Option<&str> {
None
}
fn inject_config(&self) -> InjectConfig {
InjectConfig {
paste_settle_ms: 0,
use_inner_bracketed_paste: false,
startup_inject_delay_secs: 0,
}
}
fn config_dir_name(&self) -> &str {
".ghost"
}
fn has_project_history(&self, _: &Path) -> bool {
false
}
fn exit_command(&self) -> Option<&str> {
None
}
fn install(&self) -> anyhow::Result<()> {
Ok(())
}
}
#[test]
fn all_backend_process_names_ignores_availability() {
let registry = BackendRegistry::new(vec![Arc::new(UnavailableBackend) as _], "ghost");
assert!(registry.available().is_empty());
let names = registry.all_backend_process_names();
assert_eq!(names.len(), 1);
assert_eq!(names[0].0, "ghost");
assert_eq!(names[0].1, vec!["ghostproc".to_string()]);
}
#[test]
fn all_backend_process_names_covers_every_default_backend() {
let registry = BackendRegistry::default_registry();
let names = registry.all_backend_process_names();
for backend in ["claude-code", "opencode", "codex-cli"] {
assert!(
names.iter().any(|(n, _)| n == backend),
"{backend} missing from detection candidate set: {names:?}"
);
}
}
}