claudy 0.2.2

Modern multi-provider launcher for Claude CLI
use crate::adapters::channel::service::{self, ServiceConfig};
use crate::domain::context::Context;

pub(super) fn resolve_listen_addr(ctx: &Context, listen: Option<&str>) -> String {
    let config_addr = ctx.config.channel.listen_addr.clone();
    let default_addr = crate::config::registry::default_listen_addr();
    listen.map(|s| s.to_string()).unwrap_or_else(|| {
        if config_addr.is_empty() {
            default_addr
        } else {
            config_addr
        }
    })
}

fn build_service_config(
    ctx: &Context,
    listen: Option<&str>,
    profile: Option<&str>,
) -> anyhow::Result<ServiceConfig> {
    let listen_addr = resolve_listen_addr(ctx, listen);
    let claudy_bin = std::env::current_exe()?;
    Ok(ServiceConfig {
        listen_addr,
        profile: profile.filter(|p| !p.is_empty()).map(|p| p.to_string()),
        claudy_bin_path: claudy_bin,
        log_dir: ctx.paths.channel_logs_dir.clone().into(),
        pid_file: ctx.paths.channel_pid_file.clone().into(),
    })
}

pub(super) fn run_serve(
    ctx: &mut Context,
    profile: Option<&str>,
    listen: Option<&str>,
) -> anyhow::Result<i32> {
    let listen_addr = resolve_listen_addr(ctx, listen);

    if let Some(p) = profile.filter(|p| !p.is_empty()) {
        ctx.config.channel.default_profile = p.to_string();
    }

    let has_any_profile = !ctx.config.channel.default_profile.is_empty()
        || !ctx.config.channel.platform_profiles.is_empty();
    if !has_any_profile {
        ctx.output.error(
            "No profile configured. Use --profile or set channel.default_profile / channel.platform_profiles in config.",
        );
        return Ok(1);
    }

    ctx.output
        .info(&format!("Serving channel on {}...", listen_addr,));

    let rt = tokio::runtime::Runtime::new()?;
    rt.block_on(async { crate::adapters::channel::server::run(ctx, &listen_addr).await })
}

pub(super) fn run_start(
    ctx: &mut Context,
    profile: Option<&str>,
    listen: Option<&str>,
) -> anyhow::Result<i32> {
    let svc_config = build_service_config(ctx, listen, profile)?;
    let mgr = service::platform_service(svc_config)?;

    if let Ok(true) = mgr.is_running() {
        ctx.output.info("Channel server is already running.");
        return Ok(0);
    }

    ctx.output.info("Starting channel server...");
    mgr.start()?;
    ctx.output.success("Channel server started.");
    Ok(0)
}

pub(super) fn run_stop(ctx: &mut Context) -> anyhow::Result<i32> {
    let svc_config = build_service_config(ctx, None, None)?;
    let mgr = service::platform_service(svc_config)?;

    match mgr.is_running() {
        Ok(true) => {}
        _ => {
            ctx.output.warn("Channel server is not running.");
            return Ok(1);
        }
    }

    ctx.output.info("Stopping channel server...");
    mgr.stop()?;
    ctx.output.success("Channel server stopped.");
    Ok(0)
}

pub(super) fn run_restart(
    ctx: &mut Context,
    profile: Option<&str>,
    listen: Option<&str>,
) -> anyhow::Result<i32> {
    let svc_config = build_service_config(ctx, listen, profile)?;
    let mgr = service::platform_service(svc_config)?;

    let running = mgr.is_running().unwrap_or(false);
    if running {
        ctx.output.info("Stopping channel server...");
        mgr.stop()?;
    }

    ctx.output.info("Starting channel server...");
    mgr.start()?;
    ctx.output.success("Channel server restarted.");
    Ok(0)
}

pub(super) fn run_enable(
    ctx: &mut Context,
    profile: Option<&str>,
    listen: Option<&str>,
) -> anyhow::Result<i32> {
    let svc_config = build_service_config(ctx, listen, profile)?;
    let mgr = service::platform_service(svc_config)?;

    ctx.output
        .info("Enabling channel service (auto-start on login)...");
    mgr.enable()?;
    ctx.output
        .success("Channel service enabled. It will start automatically on login.");
    Ok(0)
}

pub(super) fn run_disable(ctx: &mut Context) -> anyhow::Result<i32> {
    let svc_config = build_service_config(ctx, None, None)?;
    let mgr = service::platform_service(svc_config)?;

    ctx.output.info("Disabling channel service...");
    mgr.disable()?;
    ctx.output
        .success("Channel service disabled. It will no longer start automatically.");
    Ok(0)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::config::layout::AppPaths;
    use crate::config::registry::AppRegistry;
    use crate::config::vault::SecretVault;
    use crate::domain::commands::Options;
    use crate::providers::index as catalog;
    use std::sync::{Arc, Mutex};

    #[derive(Default)]
    struct CaptureOutput {
        messages: Arc<Mutex<Vec<(String, String)>>>,
    }

    impl CaptureOutput {
        fn new() -> Self {
            Self::default()
        }
    }

    impl crate::ports::ui_ports::OutputPort for CaptureOutput {
        fn header(&mut self, title: &str) {
            self.messages
                .lock()
                .unwrap()
                .push(("header".into(), title.into()));
        }
        fn info(&mut self, msg: &str) {
            self.messages
                .lock()
                .unwrap()
                .push(("info".into(), msg.into()));
        }
        fn success(&mut self, msg: &str) {
            self.messages
                .lock()
                .unwrap()
                .push(("success".into(), msg.into()));
        }
        fn warn(&mut self, msg: &str) {
            self.messages
                .lock()
                .unwrap()
                .push(("warn".into(), msg.into()));
        }
        fn error(&mut self, msg: &str) {
            self.messages
                .lock()
                .unwrap()
                .push(("error".into(), msg.into()));
        }
        fn write_line(&mut self, msg: &str) -> std::io::Result<()> {
            self.messages
                .lock()
                .unwrap()
                .push(("line".into(), msg.into()));
            Ok(())
        }
    }

    struct StubPrompt;

    impl crate::ports::ui_ports::PrompterPort for StubPrompt {
        fn prompt(&mut self, _label: &str, _default: &str) -> anyhow::Result<String> {
            Ok(String::new())
        }
        fn prompt_opt(&mut self, _label: &str, _default: &str) -> anyhow::Result<Option<String>> {
            Ok(None)
        }
        fn prompt_secret(&mut self, _label: &str) -> anyhow::Result<String> {
            Ok(String::new())
        }
        fn prompt_secret_opt(&mut self, _label: &str) -> anyhow::Result<Option<String>> {
            Ok(None)
        }
        fn confirm(&mut self, _label: &str, _default_yes: bool) -> anyhow::Result<bool> {
            Ok(_default_yes)
        }
        fn confirm_opt(
            &mut self,
            _label: &str,
            _default_yes: bool,
        ) -> anyhow::Result<Option<bool>> {
            Ok(Some(_default_yes))
        }
        fn select(
            &mut self,
            _label: &str,
            _items: &[String],
            _default: usize,
        ) -> anyhow::Result<usize> {
            Ok(_default)
        }
        fn select_opt(
            &mut self,
            _label: &str,
            _items: &[String],
            _default: usize,
        ) -> anyhow::Result<Option<usize>> {
            Ok(Some(_default))
        }
    }

    fn make_test_context(pid_file_path: &str) -> Context {
        let catalog = catalog::load_index().expect("catalog should load");
        Context {
            paths: AppPaths {
                claudy_home: String::new(),
                config_dir: String::new(),
                data_dir: String::new(),
                cache_dir: String::new(),
                bin_dir: String::new(),
                config_file: String::new(),
                secrets_file: String::new(),
                manifest_file: String::new(),
                session_patch_dir: String::new(),
                update_cache_file: String::new(),
                modes_dir: String::new(),
                channel_dir: String::new(),
                channel_pid_file: pid_file_path.to_string(),
                channel_sessions_file: String::new(),
                channel_audit_file: String::new(),
                channel_logs_dir: String::new(),
                analytics_dir: "/tmp/test-analytics".to_string(),
                analytics_db: "/tmp/test-analytics/analytics.db".to_string(),
            },
            config: AppRegistry::default(),
            secrets: SecretVault::empty(),
            catalog,
            output: Box::new(CaptureOutput::new()),
            prompt: Box::new(StubPrompt),
            options: Options::default(),
        }
    }

    #[test]
    fn test_resolve_listen_addr_uses_default_when_empty() {
        let dir = tempfile::tempdir().expect("tempdir");
        let pid_path = dir.path().join("pid");
        let ctx = make_test_context(&pid_path.to_string_lossy());

        let addr = resolve_listen_addr(&ctx, None);
        assert_eq!(addr, "127.0.0.1:3456");
    }

    #[test]
    fn test_resolve_listen_addr_prefers_cli_over_config() {
        let dir = tempfile::tempdir().expect("tempdir");
        let pid_path = dir.path().join("pid");
        let ctx = make_test_context(&pid_path.to_string_lossy());

        let addr = resolve_listen_addr(&ctx, Some("0.0.0.0:9999"));
        assert_eq!(addr, "0.0.0.0:9999");
    }

    #[test]
    fn test_run_serve_returns_one_when_no_profile_configured() {
        let dir = tempfile::tempdir().expect("tempdir");
        let pid_path = dir.path().join("pid");
        let mut ctx = make_test_context(&pid_path.to_string_lossy());
        ctx.config.channel.default_profile.clear();
        ctx.config.channel.platform_profiles.clear();

        let code = run_serve(&mut ctx, None, Some("127.0.0.1:18080"))
            .expect("run_serve should return code");
        assert_eq!(code, 1);
    }
}