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, service_log_path, service_port_file};
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", target_os = "windows")))]
11mod unknown;
12#[cfg(target_os = "windows")]
13mod windows;
14
15// ── Low-level install/uninstall ─────────────────────────────────────
16
17#[cfg(target_os = "macos")]
18pub use macos::{TEMPLATE, install, is_installed, uninstall};
19
20#[cfg(target_os = "linux")]
21pub use linux::{TEMPLATE, install, is_installed, uninstall};
22
23#[cfg(target_os = "windows")]
24pub use windows::{TEMPLATE, install, is_installed, uninstall};
25
26#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
27pub use unknown::{install, is_installed, uninstall};
28
29// ── Service trait ───────────────────────────────────────────────────
30
31/// Trait for external command binaries that run as system services.
32///
33/// Implementors provide metadata; `start`/`stop`/`logs` come free.
34pub trait Service {
35    /// Service name, e.g. "search". Used for log/port files.
36    fn name(&self) -> &str;
37    /// Human description.
38    fn description(&self) -> &str;
39    /// Reverse-DNS label, e.g. "ai.crabtalk.search".
40    fn label(&self) -> &str;
41
42    /// Install and start the service.
43    ///
44    /// If the service is already installed, prints a message and returns
45    /// unless `force` is set, in which case it re-installs.
46    fn start(&self, force: bool) -> anyhow::Result<()> {
47        if !force && is_installed(self.label()) {
48            println!("{} is already running", self.name());
49            return Ok(());
50        }
51        let binary = std::env::current_exe()?;
52        let rendered = render_service_template(self, &binary);
53        install(&rendered, self.label())
54    }
55
56    /// Stop and uninstall the service.
57    fn stop(&self) -> anyhow::Result<()> {
58        uninstall(self.label())?;
59        let _ = std::fs::remove_file(service_port_file(self.name()));
60        Ok(())
61    }
62
63    /// View service logs.
64    fn logs(&self, tail_args: &[String]) -> anyhow::Result<()> {
65        view_logs(self.name(), tail_args)
66    }
67}
68
69/// Build the `-v`/`-vv`/`-vvv` flag string from a count (empty when 0).
70pub fn verbose_flag(count: u8) -> String {
71    if count == 0 {
72        String::new()
73    } else {
74        format!("-{}", "v".repeat(count as usize))
75    }
76}
77
78/// Render the platform-specific service template for a [`Service`] implementor.
79#[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))]
80pub fn render_service_template(svc: &(impl Service + ?Sized), binary: &Path) -> String {
81    let path_env = std::env::var("PATH").unwrap_or_default();
82    TEMPLATE
83        .replace("{label}", svc.label())
84        .replace("{description}", svc.description())
85        .replace("{log_name}", svc.name())
86        .replace("{binary}", &binary.display().to_string())
87        .replace("{logs_dir}", &LOGS_DIR.display().to_string())
88        .replace("{config_dir}", &CONFIG_DIR.display().to_string())
89        .replace("{path}", &path_env)
90}
91
92#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
93fn render_service_template(_svc: &(impl Service + ?Sized), _binary: &Path) -> String {
94    String::new()
95}
96
97/// View service logs.
98///
99/// `log_name` corresponds to the `{log_name}.log` file under `~/.crabtalk/logs/`.
100/// Extra args (e.g. `-f`, `-n 100`) are passed through to `tail` on Unix.
101/// On Windows (or if `tail` is unavailable), falls back to reading the file natively.
102/// Defaults to showing the last 50 lines if no extra args are given.
103#[cfg(unix)]
104pub fn view_logs(log_name: &str, tail_args: &[String]) -> anyhow::Result<()> {
105    let path = service_log_path(log_name);
106    if !path.exists() {
107        println!("no logs yet: {}", path.display());
108        return Ok(());
109    }
110
111    let args = if tail_args.is_empty() {
112        vec!["-n".to_owned(), "50".to_owned()]
113    } else {
114        tail_args.to_vec()
115    };
116
117    let status = std::process::Command::new("tail")
118        .args(&args)
119        .arg(&path)
120        .status()
121        .map_err(|e| anyhow::anyhow!("failed to run tail: {e}"))?;
122    if !status.success() {
123        anyhow::bail!("tail exited with {status}");
124    }
125    Ok(())
126}
127
128#[cfg(not(unix))]
129pub fn view_logs(log_name: &str, tail_args: &[String]) -> anyhow::Result<()> {
130    use std::io::{BufRead, BufReader};
131
132    let path = service_log_path(log_name);
133    if !path.exists() {
134        println!("no logs yet: {}", path.display());
135        return Ok(());
136    }
137
138    let n: usize = parse_tail_n(tail_args).unwrap_or(50);
139    let file = std::fs::File::open(&path)
140        .map_err(|e| anyhow::anyhow!("failed to open {}: {e}", path.display()))?;
141    let lines: Vec<String> = BufReader::new(file).lines().collect::<Result<_, _>>()?;
142    let start = lines.len().saturating_sub(n);
143    for line in &lines[start..] {
144        println!("{line}");
145    }
146    Ok(())
147}
148
149/// Parse `-n <count>` from tail-style args. Returns None if not found.
150#[cfg(not(unix))]
151fn parse_tail_n(args: &[String]) -> Option<usize> {
152    let mut iter = args.iter();
153    while let Some(arg) = iter.next() {
154        if arg == "-n" {
155            return iter.next().and_then(|v| v.parse().ok());
156        }
157        if let Some(n) = arg.strip_prefix("-n") {
158            return n.parse().ok();
159        }
160    }
161    None
162}
163
164// ── MCP run helper ──────────────────────────────────────────────────
165
166/// Run an MCP (port-bound) service: bind a TCP listener, write the port file,
167/// and serve the router.
168#[cfg(feature = "mcp")]
169pub async fn run_mcp(svc: &(impl McpService + Sync)) -> anyhow::Result<()> {
170    use wcore::paths::RUN_DIR;
171    let router = svc.router();
172    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
173    let addr = listener.local_addr()?;
174    std::fs::create_dir_all(&*RUN_DIR)?;
175    std::fs::write(service_port_file(svc.name()), addr.port().to_string())?;
176    eprintln!("MCP server listening on {addr}");
177    axum::serve(listener, router).await?;
178    Ok(())
179}
180
181// ── McpService ──────────────────────────────────────────────────────
182
183/// MCP (port-bound) service. Implementors provide an axum Router.
184#[cfg(feature = "mcp")]
185pub trait McpService: Service {
186    /// Return the axum Router for the MCP server.
187    fn router(&self) -> axum::Router;
188}