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", 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 port_file = RUN_DIR.join(format!("{}.port", self.name()));
60        let _ = std::fs::remove_file(&port_file);
61        Ok(())
62    }
63
64    /// View service logs.
65    fn logs(&self, tail_args: &[String]) -> anyhow::Result<()> {
66        view_logs(self.name(), tail_args)
67    }
68}
69
70/// Build the `-v`/`-vv`/`-vvv` flag string from a count (empty when 0).
71pub fn verbose_flag(count: u8) -> String {
72    if count == 0 {
73        String::new()
74    } else {
75        format!("-{}", "v".repeat(count as usize))
76    }
77}
78
79/// Render the platform-specific service template for a [`Service`] implementor.
80#[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))]
81pub fn render_service_template(svc: &(impl Service + ?Sized), binary: &Path) -> String {
82    let path_env = std::env::var("PATH").unwrap_or_default();
83    TEMPLATE
84        .replace("{label}", svc.label())
85        .replace("{description}", svc.description())
86        .replace("{log_name}", svc.name())
87        .replace("{binary}", &binary.display().to_string())
88        .replace("{logs_dir}", &LOGS_DIR.display().to_string())
89        .replace("{config_dir}", &CONFIG_DIR.display().to_string())
90        .replace("{path}", &path_env)
91}
92
93#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
94fn render_service_template(_svc: &(impl Service + ?Sized), _binary: &Path) -> String {
95    String::new()
96}
97
98/// View service logs.
99///
100/// `log_name` corresponds to the `{log_name}.log` file under `~/.crabtalk/logs/`.
101/// Extra args (e.g. `-f`, `-n 100`) are passed through to `tail` on Unix.
102/// On Windows (or if `tail` is unavailable), falls back to reading the file natively.
103/// Defaults to showing the last 50 lines if no extra args are given.
104#[cfg(unix)]
105pub fn view_logs(log_name: &str, tail_args: &[String]) -> anyhow::Result<()> {
106    let path = LOGS_DIR.join(format!("{log_name}.log"));
107    if !path.exists() {
108        println!("no logs yet: {}", path.display());
109        return Ok(());
110    }
111
112    let args = if tail_args.is_empty() {
113        vec!["-n".to_owned(), "50".to_owned()]
114    } else {
115        tail_args.to_vec()
116    };
117
118    let status = std::process::Command::new("tail")
119        .args(&args)
120        .arg(&path)
121        .status()
122        .map_err(|e| anyhow::anyhow!("failed to run tail: {e}"))?;
123    if !status.success() {
124        anyhow::bail!("tail exited with {status}");
125    }
126    Ok(())
127}
128
129#[cfg(not(unix))]
130pub fn view_logs(log_name: &str, tail_args: &[String]) -> anyhow::Result<()> {
131    use std::io::{BufRead, BufReader};
132
133    let path = LOGS_DIR.join(format!("{log_name}.log"));
134    if !path.exists() {
135        println!("no logs yet: {}", path.display());
136        return Ok(());
137    }
138
139    let n: usize = parse_tail_n(tail_args).unwrap_or(50);
140    let file = std::fs::File::open(&path)
141        .map_err(|e| anyhow::anyhow!("failed to open {}: {e}", path.display()))?;
142    let lines: Vec<String> = BufReader::new(file).lines().collect::<Result<_, _>>()?;
143    let start = lines.len().saturating_sub(n);
144    for line in &lines[start..] {
145        println!("{line}");
146    }
147    Ok(())
148}
149
150/// Parse `-n <count>` from tail-style args. Returns None if not found.
151#[cfg(not(unix))]
152fn parse_tail_n(args: &[String]) -> Option<usize> {
153    let mut iter = args.iter();
154    while let Some(arg) = iter.next() {
155        if arg == "-n" {
156            return iter.next().and_then(|v| v.parse().ok());
157        }
158        if let Some(n) = arg.strip_prefix("-n") {
159            return n.parse().ok();
160        }
161    }
162    None
163}
164
165// ── MCP run helper ──────────────────────────────────────────────────
166
167/// Run an MCP (port-bound) service: bind a TCP listener, write the port file,
168/// and serve the router.
169#[cfg(feature = "mcp")]
170pub async fn run_mcp(svc: &(impl McpService + Sync)) -> anyhow::Result<()> {
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(
176        RUN_DIR.join(format!("{}.port", svc.name())),
177        addr.port().to_string(),
178    )?;
179    eprintln!("MCP server listening on {addr}");
180    axum::serve(listener, router).await?;
181    Ok(())
182}
183
184// ── McpService ──────────────────────────────────────────────────────
185
186/// MCP (port-bound) service. Implementors provide an axum Router.
187#[cfg(feature = "mcp")]
188pub trait McpService: Service {
189    /// Return the axum Router for the MCP server.
190    fn router(&self) -> axum::Router;
191}