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}
17
18enum DockerState {
19    Ready,
20    MissingCli,
21    DaemonUnavailable(String),
22}
23
24pub fn resolve_searx_root() -> PathBuf {
25    if let Some(explicit) = std::env::var_os(SEARX_ROOT_ENV) {
26        let candidate = PathBuf::from(explicit);
27        if !candidate.as_os_str().is_empty() {
28            return candidate;
29        }
30    }
31
32    let home = std::env::var_os("USERPROFILE")
33        .or_else(|| std::env::var_os("HOME"))
34        .map(PathBuf::from)
35        .unwrap_or_else(|| PathBuf::from("."));
36
37    home.join(".hematite").join("searxng-local")
38}
39
40fn find_setup_script() -> Option<PathBuf> {
41    let mut candidates = Vec::new();
42
43    if let Ok(cwd) = std::env::current_dir() {
44        candidates.push(cwd.join("scripts").join("setup-searxng.ps1"));
45        candidates.push(cwd.join("setup-searxng.ps1"));
46    }
47
48    if let Ok(exe) = std::env::current_exe() {
49        if let Some(exe_dir) = exe.parent() {
50            candidates.push(exe_dir.join("setup-searxng.ps1"));
51            candidates.push(exe_dir.join("scripts").join("setup-searxng.ps1"));
52        }
53    }
54
55    candidates.into_iter().find(|path| Path::new(path).exists())
56}
57
58fn looks_like_local_searx_url(url: &str) -> bool {
59    let lower = url.to_ascii_lowercase();
60    lower.contains("localhost")
61        || lower.contains("127.0.0.1")
62        || lower.contains("[::1]")
63        || !lower.contains("://")
64}
65
66fn docker_state() -> DockerState {
67    match Command::new("docker")
68        .args(["info", "--format", "{{.ServerVersion}}"])
69        .output()
70    {
71        Ok(output) if output.status.success() => DockerState::Ready,
72        Ok(output) => {
73            let detail = String::from_utf8_lossy(&output.stderr).trim().to_string();
74            DockerState::DaemonUnavailable(if detail.is_empty() {
75                "Docker is installed but the daemon is not responding.".to_string()
76            } else {
77                detail
78            })
79        }
80        Err(err) if err.kind() == std::io::ErrorKind::NotFound => DockerState::MissingCli,
81        Err(err) => DockerState::DaemonUnavailable(err.to_string()),
82    }
83}
84
85fn ensure_scaffolded(root: &Path) -> Result<(), String> {
86    let compose_path = root.join("docker-compose.yaml");
87    let start_script = root.join("start_searx.bat");
88    if compose_path.exists() && start_script.exists() {
89        return Ok(());
90    }
91
92    let Some(script_path) = find_setup_script() else {
93        return Err(
94            "Local search bootstrap is unavailable: setup-searxng.ps1 could not be found."
95                .to_string(),
96        );
97    };
98
99    let output = Command::new("powershell")
100        .arg("-ExecutionPolicy")
101        .arg("Bypass")
102        .arg("-File")
103        .arg(script_path)
104        .arg("-TargetRoot")
105        .arg(root)
106        .output()
107        .map_err(|e| format!("Failed to scaffold local search: {}", e))?;
108
109    if output.status.success() {
110        Ok(())
111    } else {
112        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
113        let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
114        let detail = if !stderr.is_empty() { stderr } else { stdout };
115        Err(format!("Failed to scaffold local search: {}", detail))
116    }
117}
118
119fn docker_compose_up(root: &Path) -> Result<(), String> {
120    let output = Command::new("docker")
121        .args(["compose", "up", "-d"])
122        .current_dir(root)
123        .output()
124        .map_err(|e| format!("Failed to start local search: {}", e))?;
125
126    if output.status.success() {
127        Ok(())
128    } else {
129        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
130        let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
131        let detail = if !stderr.is_empty() { stderr } else { stdout };
132        Err(format!("Local search start failed: {}", detail))
133    }
134}
135
136fn docker_compose_down(root: &Path) -> Result<(), String> {
137    let output = Command::new("docker")
138        .args(["compose", "down"])
139        .current_dir(root)
140        .output()
141        .map_err(|e| format!("Failed to stop local search: {}", e))?;
142
143    if output.status.success() {
144        Ok(())
145    } else {
146        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
147        let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
148        let detail = if !stderr.is_empty() { stderr } else { stdout };
149        Err(format!("Local search stop failed: {}", detail))
150    }
151}
152
153async fn wait_for_searx(url: &str) -> bool {
154    for _ in 0..20 {
155        if is_searx_responding(url).await {
156            return true;
157        }
158        tokio::time::sleep(Duration::from_millis(500)).await;
159    }
160    false
161}
162
163/// Checks if SearXNG is responding at the configured URL.
164pub async fn is_searx_responding(url: &str) -> bool {
165    let client = reqwest::Client::builder()
166        .timeout(Duration::from_millis(500))
167        .build()
168        .unwrap_or_default();
169
170    match timeout(Duration::from_millis(600), client.get(url).send()).await {
171        Ok(Ok(resp)) => resp.status().is_success() || resp.status().as_u16() == 403, // 403 is fine, SearXNG might block generic UA but it's alive
172        _ => false,
173    }
174}
175
176/// Automatically boots SearXNG if it's offline and the user has auto-start enabled.
177pub async fn boot_searx_if_needed(config: &HematiteConfig) -> SearxRuntimeSession {
178    let url = config.searx_url.as_deref().unwrap_or(DEFAULT_SEARX_URL);
179    let root = resolve_searx_root();
180    let mut session = SearxRuntimeSession {
181        root: root.clone(),
182        owned_by_session: false,
183        auto_stop_on_exit: config.auto_stop_searx,
184        startup_summary: None,
185    };
186
187    if !config.auto_start_searx {
188        return session;
189    }
190
191    if !looks_like_local_searx_url(url) {
192        return session;
193    }
194
195    // Check if it's already alive.
196    if is_searx_responding(url).await {
197        return session;
198    }
199
200    if let Err(err) = ensure_scaffolded(&root) {
201        session.startup_summary = Some(err);
202        return session;
203    }
204
205    match docker_state() {
206        DockerState::MissingCli => {
207            session.startup_summary = Some(
208                "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(),
209            );
210            return session;
211        }
212        DockerState::DaemonUnavailable(detail) => {
213            session.startup_summary = Some(format!(
214                "Local search is unavailable: Docker is installed but not running. Start Docker Desktop, then relaunch Hematite or start SearXNG manually from `{}`. Detail: {}",
215                root.display(),
216                detail
217            ));
218            return session;
219        }
220        DockerState::Ready => {}
221    }
222
223    if let Err(err) = docker_compose_up(&root) {
224        session.startup_summary = Some(err);
225        return session;
226    }
227
228    if wait_for_searx(url).await {
229        session.owned_by_session = true;
230        session.startup_summary = Some(format!(
231            "Local search auto-started: SearXNG is now live at {} (root: {}). Hematite started this stack in the current session{}.",
232            url,
233            root.display(),
234            if config.auto_stop_searx {
235                " and will stop it on exit"
236            } else {
237                ""
238            }
239        ));
240    } else {
241        session.startup_summary = Some(format!(
242            "Local search was started from `{}`, but {} never became reachable. Check `docker compose logs` in that folder.",
243            root.display(),
244            url
245        ));
246    }
247
248    session
249}
250
251pub async fn shutdown_searx_if_owned(session: &SearxRuntimeSession) -> Option<String> {
252    if !session.owned_by_session || !session.auto_stop_on_exit {
253        return None;
254    }
255
256    match docker_compose_down(&session.root) {
257        Ok(()) => Some(format!(
258            "Stopped session-owned local search stack at {}.",
259            session.root.display()
260        )),
261        Err(err) => Some(err),
262    }
263}