Skip to main content

crabtalk_command/service/
mod.rs

1//! Shared system service management (launchd/systemd).
2
3use std::path::Path;
4use wcore::paths::{CONFIG_DIR, LOGS_DIR, RUN_DIR};
5
6#[cfg(target_os = "linux")]
7mod linux;
8#[cfg(target_os = "macos")]
9mod macos;
10#[cfg(not(any(target_os = "macos", target_os = "linux")))]
11mod unknown;
12
13// ── Low-level install/uninstall ─────────────────────────────────────
14
15#[cfg(target_os = "macos")]
16pub use macos::{TEMPLATE, install, is_installed, uninstall};
17
18#[cfg(target_os = "linux")]
19pub use linux::{TEMPLATE, install, is_installed, uninstall};
20
21#[cfg(not(any(target_os = "macos", target_os = "linux")))]
22pub use unknown::{install, is_installed, uninstall};
23
24// ── Service trait ───────────────────────────────────────────────────
25
26/// Trait for external command binaries that run as system services.
27///
28/// Implementors provide metadata; `start`/`stop`/`logs` come free.
29pub trait Service {
30    /// Service name, e.g. "search". Used for log/port files.
31    fn name(&self) -> &str;
32    /// Human description.
33    fn description(&self) -> &str;
34    /// Reverse-DNS label, e.g. "ai.crabtalk.search".
35    fn label(&self) -> &str;
36
37    /// Install and start the service.
38    ///
39    /// If the service is already installed, prints a message and returns
40    /// unless `force` is set, in which case it re-installs.
41    fn start(&self, force: bool) -> anyhow::Result<()> {
42        if !force && is_installed(self.label()) {
43            println!("{} is already running", self.name());
44            return Ok(());
45        }
46        let binary = std::env::current_exe()?;
47        let rendered = render_service_template(self, &binary);
48        install(&rendered, self.label())
49    }
50
51    /// Stop and uninstall the service.
52    fn stop(&self) -> anyhow::Result<()> {
53        uninstall(self.label())?;
54        let port_file = RUN_DIR.join(format!("{}.port", self.name()));
55        let _ = std::fs::remove_file(&port_file);
56        Ok(())
57    }
58
59    /// View service logs.
60    fn logs(&self, tail_args: &[String]) -> anyhow::Result<()> {
61        view_logs(self.name(), tail_args)
62    }
63}
64
65/// Build the `-v`/`-vv`/`-vvv` flag string from a count (empty when 0).
66pub fn verbose_flag(count: u8) -> String {
67    if count == 0 {
68        String::new()
69    } else {
70        format!("-{}", "v".repeat(count as usize))
71    }
72}
73
74/// Render the platform-specific service template for a [`Service`] implementor.
75#[cfg(any(target_os = "macos", target_os = "linux"))]
76pub fn render_service_template(svc: &(impl Service + ?Sized), binary: &Path) -> String {
77    let path_env = std::env::var("PATH").unwrap_or_default();
78    TEMPLATE
79        .replace("{label}", svc.label())
80        .replace("{description}", svc.description())
81        .replace("{log_name}", svc.name())
82        .replace("{binary}", &binary.display().to_string())
83        .replace("{logs_dir}", &LOGS_DIR.display().to_string())
84        .replace("{config_dir}", &CONFIG_DIR.display().to_string())
85        .replace("{path}", &path_env)
86}
87
88#[cfg(not(any(target_os = "macos", target_os = "linux")))]
89pub fn render_service_template(_svc: &(impl Service + ?Sized), _binary: &Path) -> String {
90    String::new()
91}
92
93/// View service logs by delegating to `tail`.
94///
95/// `log_name` corresponds to the `{log_name}.log` file under `~/.crabtalk/logs/`.
96/// Extra args (e.g. `-f`, `-n 100`) are passed through to `tail`.
97/// Defaults to `-n 50` if no extra args are given.
98pub fn view_logs(log_name: &str, tail_args: &[String]) -> anyhow::Result<()> {
99    let path = LOGS_DIR.join(format!("{log_name}.log"));
100    if !path.exists() {
101        anyhow::bail!("log file not found: {}", path.display());
102    }
103
104    let args = if tail_args.is_empty() {
105        vec!["-n".to_owned(), "50".to_owned()]
106    } else {
107        tail_args.to_vec()
108    };
109
110    let status = std::process::Command::new("tail")
111        .args(&args)
112        .arg(&path)
113        .status()
114        .map_err(|e| anyhow::anyhow!("failed to run tail: {e}"))?;
115    if !status.success() {
116        anyhow::bail!("tail exited with {status}");
117    }
118    Ok(())
119}
120
121// ── MCP run helper ──────────────────────────────────────────────────
122
123/// Run an MCP (port-bound) service: bind a TCP listener, write the port file,
124/// and serve the router.
125#[cfg(feature = "mcp")]
126pub async fn run_mcp(svc: &(impl McpService + Sync)) -> anyhow::Result<()> {
127    let router = svc.router();
128    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
129    let addr = listener.local_addr()?;
130    std::fs::create_dir_all(&*RUN_DIR)?;
131    std::fs::write(
132        RUN_DIR.join(format!("{}.port", svc.name())),
133        addr.port().to_string(),
134    )?;
135    eprintln!("MCP server listening on {addr}");
136    axum::serve(listener, router).await?;
137    Ok(())
138}
139
140// ── McpService ──────────────────────────────────────────────────────
141
142/// MCP (port-bound) service. Implementors provide an axum Router.
143#[cfg(feature = "mcp")]
144pub trait McpService: Service {
145    /// Return the axum Router for the MCP server.
146    fn router(&self) -> axum::Router;
147}