Skip to main content

hematite/agent/
searx_lifecycle.rs

1use crate::agent::config::HematiteConfig;
2use std::path::Path;
3use std::path::PathBuf;
4use std::process::Command;
5use tokio::time::{timeout, Duration};
6
7const SEARX_ROOT_ENV: &str = "HEMATITE_SEARX_ROOT";
8const DEFAULT_SEARX_URL: &str = "http://localhost:8080";
9
10#[derive(Clone, Debug, Default)]
11pub struct SearxRuntimeSession {
12    pub root: PathBuf,
13    pub owned_by_session: bool,
14    pub auto_stop_on_exit: bool,
15    pub startup_summary: Option<String>,
16    /// Docker Desktop was launched this session; background poller should
17    /// watch for daemon readiness and then start SearXNG.
18    pub docker_wake_pending: bool,
19}
20
21pub(crate) enum DockerState {
22    Ready,
23    MissingCli,
24    DaemonUnavailable(String),
25}
26
27pub fn resolve_searx_root() -> PathBuf {
28    if let Some(explicit) = std::env::var_os(SEARX_ROOT_ENV) {
29        let candidate = PathBuf::from(explicit);
30        if !candidate.as_os_str().is_empty() {
31            return candidate;
32        }
33    }
34
35    let home = std::env::var_os("USERPROFILE")
36        .or_else(|| std::env::var_os("HOME"))
37        .map(PathBuf::from)
38        .unwrap_or_else(|| PathBuf::from("."));
39
40    home.join(".hematite").join("searxng-local")
41}
42
43fn find_setup_script() -> Option<PathBuf> {
44    let mut candidates = Vec::new();
45
46    if let Ok(cwd) = std::env::current_dir() {
47        candidates.push(cwd.join("scripts").join("setup-searxng.ps1"));
48        candidates.push(cwd.join("setup-searxng.ps1"));
49    }
50
51    if let Ok(exe) = std::env::current_exe() {
52        if let Some(exe_dir) = exe.parent() {
53            candidates.push(exe_dir.join("setup-searxng.ps1"));
54            candidates.push(exe_dir.join("scripts").join("setup-searxng.ps1"));
55        }
56    }
57
58    candidates.into_iter().find(|path| Path::new(path).exists())
59}
60
61fn looks_like_local_searx_url(url: &str) -> bool {
62    let lower = url.to_ascii_lowercase();
63    lower.contains("localhost")
64        || lower.contains("127.0.0.1")
65        || lower.contains("[::1]")
66        || !lower.contains("://")
67}
68
69/// Try to find Docker Desktop.exe on Windows. Checks the two most common
70/// install locations (system-wide and per-user LOCALAPPDATA).
71#[cfg(target_os = "windows")]
72fn find_docker_desktop_exe() -> Option<PathBuf> {
73    let mut candidates = vec![
74        PathBuf::from(r"C:\Program Files\Docker\Docker\Docker Desktop.exe"),
75        PathBuf::from(r"C:\Program Files (x86)\Docker\Docker\Docker Desktop.exe"),
76    ];
77    if let Some(local) = std::env::var_os("LOCALAPPDATA").map(PathBuf::from) {
78        candidates.push(
79            local
80                .join("Programs")
81                .join("Docker")
82                .join("Docker")
83                .join("Docker Desktop.exe"),
84        );
85    }
86    candidates.into_iter().find(|p| p.exists())
87}
88
89pub(crate) fn docker_state() -> DockerState {
90    match Command::new("docker")
91        .args(["info", "--format", "{{.ServerVersion}}"])
92        .output()
93    {
94        Ok(output) if output.status.success() => DockerState::Ready,
95        Ok(output) => {
96            let detail = String::from_utf8_lossy(&output.stderr).trim().to_string();
97            DockerState::DaemonUnavailable(if detail.is_empty() {
98                "Docker is installed but the daemon is not responding.".to_string()
99            } else {
100                detail
101            })
102        }
103        Err(err) if err.kind() == std::io::ErrorKind::NotFound => DockerState::MissingCli,
104        Err(err) => DockerState::DaemonUnavailable(err.to_string()),
105    }
106}
107
108fn ensure_scaffolded(root: &Path) -> Result<(), String> {
109    let compose_path = root.join("docker-compose.yaml");
110    let start_script = root.join("start_searx.bat");
111    if compose_path.exists() && start_script.exists() {
112        return Ok(());
113    }
114
115    let Some(script_path) = find_setup_script() else {
116        return Err(
117            "Local search bootstrap is unavailable: setup-searxng.ps1 could not be found."
118                .to_string(),
119        );
120    };
121
122    let output = Command::new("powershell")
123        .arg("-ExecutionPolicy")
124        .arg("Bypass")
125        .arg("-File")
126        .arg(script_path)
127        .arg("-TargetRoot")
128        .arg(root)
129        .output()
130        .map_err(|e| format!("Failed to scaffold local search: {}", e))?;
131
132    if output.status.success() {
133        Ok(())
134    } else {
135        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
136        let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
137        let detail = if !stderr.is_empty() { stderr } else { stdout };
138        Err(format!("Failed to scaffold local search: {}", detail))
139    }
140}
141
142pub(crate) fn docker_compose_up(root: &Path) -> Result<(), String> {
143    let output = Command::new("docker")
144        .args(["compose", "up", "-d"])
145        .current_dir(root)
146        .output()
147        .map_err(|e| format!("Failed to start local search: {}", e))?;
148
149    if output.status.success() {
150        Ok(())
151    } else {
152        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
153        let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
154        let detail = if !stderr.is_empty() { stderr } else { stdout };
155        Err(format!("Local search start failed: {}", detail))
156    }
157}
158
159fn docker_compose_down(root: &Path) -> Result<(), String> {
160    let output = Command::new("docker")
161        .args(["compose", "down"])
162        .current_dir(root)
163        .output()
164        .map_err(|e| format!("Failed to stop local search: {}", e))?;
165
166    if output.status.success() {
167        Ok(())
168    } else {
169        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
170        let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
171        let detail = if !stderr.is_empty() { stderr } else { stdout };
172        Err(format!("Local search stop failed: {}", detail))
173    }
174}
175
176pub(crate) async fn wait_for_searx(url: &str) -> bool {
177    for _ in 0..20 {
178        if is_searx_responding(url).await {
179            return true;
180        }
181        tokio::time::sleep(Duration::from_millis(500)).await;
182    }
183    false
184}
185
186/// Checks if SearXNG is responding at the configured URL.
187pub async fn is_searx_responding(url: &str) -> bool {
188    let client = reqwest::Client::builder()
189        .timeout(Duration::from_millis(500))
190        .build()
191        .unwrap_or_default();
192
193    match timeout(Duration::from_millis(600), client.get(url).send()).await {
194        Ok(Ok(resp)) => resp.status().is_success() || resp.status().as_u16() == 403, // 403 is fine, SearXNG might block generic UA but it's alive
195        _ => false,
196    }
197}
198
199/// Automatically boots SearXNG if it's offline and the user has auto-start enabled.
200pub async fn boot_searx_if_needed(config: &HematiteConfig) -> SearxRuntimeSession {
201    let url = config.searx_url.as_deref().unwrap_or(DEFAULT_SEARX_URL);
202    let root = resolve_searx_root();
203    let mut session = SearxRuntimeSession {
204        root: root.clone(),
205        owned_by_session: false,
206        auto_stop_on_exit: config.auto_stop_searx,
207        startup_summary: None,
208        docker_wake_pending: false,
209    };
210
211    if !config.auto_start_searx {
212        return session;
213    }
214
215    if !looks_like_local_searx_url(url) {
216        return session;
217    }
218
219    // Check if it's already alive.
220    if is_searx_responding(url).await {
221        return session;
222    }
223
224    if let Err(err) = ensure_scaffolded(&root) {
225        session.startup_summary = Some(err);
226        return session;
227    }
228
229    match docker_state() {
230        DockerState::MissingCli => {
231            session.startup_summary = Some(
232                "Local search is unavailable: Docker Desktop is not installed. Install it from https://www.docker.com/products/docker-desktop or set `auto_start_searx` to false in `.hematite/settings.json`.".to_string(),
233            );
234            return session;
235        }
236        DockerState::DaemonUnavailable(_detail) => {
237            #[cfg(target_os = "windows")]
238            if let Some(exe) = find_docker_desktop_exe() {
239                let launched = std::process::Command::new(&exe).spawn().is_ok();
240                if launched {
241                    session.docker_wake_pending = true;
242                    session.startup_summary = Some(
243                        "Local search: Docker Desktop wasn't running — launching it now. \
244                        SearXNG will auto-start once Docker is ready (~30–60s). \
245                        Falling back to Jina until then."
246                            .to_string(),
247                    );
248                    return session;
249                }
250            }
251            session.startup_summary = Some(format!(
252                "Local search is unavailable: Docker is installed but not running. \
253                Start Docker Desktop, then relaunch Hematite or run `docker compose up -d` in `{}`.",
254                root.display()
255            ));
256            return session;
257        }
258        DockerState::Ready => {}
259    }
260
261    if let Err(err) = docker_compose_up(&root) {
262        session.startup_summary = Some(err);
263        return session;
264    }
265
266    if wait_for_searx(url).await {
267        session.owned_by_session = true;
268        session.startup_summary = Some(format!(
269            "Local search auto-started: SearXNG is now live at {} (root: {}). Hematite started this stack in the current session{}.",
270            url,
271            root.display(),
272            if config.auto_stop_searx {
273                " and will stop it on exit"
274            } else {
275                ""
276            }
277        ));
278    } else {
279        session.startup_summary = Some(format!(
280            "Local search was started from `{}`, but {} never became reachable. Check `docker compose logs` in that folder.",
281            root.display(),
282            url
283        ));
284    }
285
286    session
287}
288
289pub async fn shutdown_searx_if_owned(session: &SearxRuntimeSession) -> Option<String> {
290    if !session.owned_by_session || !session.auto_stop_on_exit {
291        return None;
292    }
293
294    match docker_compose_down(&session.root) {
295        Ok(()) => Some(format!(
296            "Stopped session-owned local search stack at {}.",
297            session.root.display()
298        )),
299        Err(err) => Some(err),
300    }
301}