use std::net::{TcpStream, ToSocketAddrs}; use std::path::PathBuf; use std::process::Command; use std::time::Duration; use serde::Serialize; // ── URL parsing helpers ── /// Extract the hostname from a URL like `http://gpu-server:11434`. /// Handles bracketed IPv6 addresses like `http://[::1]:11434`. fn extract_host(base_url: &str) -> &str { let without_scheme = base_url .strip_prefix("http://") .or_else(|| base_url.strip_prefix("https://")) .unwrap_or(base_url); // Handle bracketed IPv6: [::1]:port if without_scheme.starts_with('[') { return without_scheme .find(']') .map_or(without_scheme, |end| &without_scheme[..=end]); } // Take host part (before port colon or path slash) let host = without_scheme.split(':').next().unwrap_or(without_scheme); host.split('/').next().unwrap_or(host) } /// Extract port from a URL like `http://localhost:11434`. /// Handles trailing paths and slashes (e.g. `http://host:8080/api`). fn extract_port(base_url: &str) -> u16 { base_url .rsplit(':') .next() .and_then(|s| { // Strip any path/fragment after the port digits let port_str = s.split('/').next().unwrap_or(s); port_str.parse().ok() }) .unwrap_or(11434) } /// Is this URL pointing at the local machine? fn is_local_url(base_url: &str) -> bool { let host = extract_host(base_url); matches!(host, "localhost" | "127.0.0.1" | "::1" | "[::1]") } // ── Detection (sync, fast) ── /// Find the `ollama` binary. Checks PATH first, then well-known locations /// as fallback for cron/launchd contexts where PATH is minimal. pub fn find_ollama_binary() -> Option { let from_path = Command::new("which") .arg("ollama") .output() .ok() .filter(|o| o.status.success()) .map(|o| PathBuf::from(String::from_utf8_lossy(&o.stdout).trim().to_string())); if from_path.is_some() { return from_path; } const WELL_KNOWN: &[&str] = &[ "/opt/homebrew/bin/ollama", "/usr/local/bin/ollama", "/usr/bin/ollama", "/snap/bin/ollama", ]; WELL_KNOWN .iter() .map(PathBuf::from) .find(|p| p.is_file()) } /// TCP-connect to Ollama's port. Tries all resolved addresses (IPv4/IPv6) /// since `localhost` resolves to `::1` first on macOS but Ollama only /// listens on `127.0.0.1`. pub fn is_ollama_reachable(base_url: &str) -> bool { let port = extract_port(base_url); let host = extract_host(base_url); let addr_str = format!("{host}:{port}"); let Ok(addrs) = addr_str.to_socket_addrs() else { return false; }; addrs .into_iter() .any(|addr| TcpStream::connect_timeout(&addr, Duration::from_secs(2)).is_ok()) } /// Platform-appropriate installation instructions. pub fn install_instructions() -> &'static str { if cfg!(target_os = "macos") { "Install Ollama: brew install ollama (or https://ollama.ai/download)" } else if cfg!(target_os = "linux") { "Install Ollama: curl -fsSL https://ollama.ai/install.sh | sh" } else { "Install Ollama: https://ollama.ai/download" } } // ── Ensure (spawns ollama if needed) ── #[derive(Debug, Serialize)] pub struct OllamaEnsureResult { pub installed: bool, pub was_running: bool, pub started: bool, pub running: bool, #[serde(skip_serializing_if = "Option::is_none")] pub error: Option, #[serde(skip_serializing_if = "Option::is_none")] pub install_hint: Option, } /// Ensure Ollama is available. For local URLs, spawns `ollama serve` if /// not already running. For remote URLs, only checks reachability. /// /// Waits briefly (5s) for hot restarts; cold starts finish during the /// ingestion phase (~90s) before the embed stage needs Ollama. pub fn ensure_ollama(base_url: &str) -> OllamaEnsureResult { let is_local = is_local_url(base_url); let binary_path = if is_local { let path = find_ollama_binary(); if path.is_none() { return OllamaEnsureResult { installed: false, was_running: false, started: false, running: false, error: Some("Ollama is not installed".to_string()), install_hint: Some(install_instructions().to_string()), }; } path } else { None }; if is_ollama_reachable(base_url) { return OllamaEnsureResult { installed: true, was_running: true, started: false, running: true, error: None, install_hint: None, }; } if !is_local { return OllamaEnsureResult { installed: true, was_running: false, started: false, running: false, error: Some(format!( "Ollama at {base_url} is not reachable (remote — cannot auto-start)" )), install_hint: None, }; } // Spawn using the absolute path (cron PATH won't include Homebrew etc.) let ollama_bin = binary_path.expect("binary_path is Some for local URLs after binary check"); let spawn_result = Command::new(&ollama_bin) .arg("serve") .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .spawn(); if let Err(e) = spawn_result { return OllamaEnsureResult { installed: true, was_running: false, started: false, running: false, error: Some(format!("Failed to spawn 'ollama serve': {e}")), install_hint: None, }; } // Brief poll for hot restarts; cold starts finish during ingestion. for _ in 0..10 { std::thread::sleep(Duration::from_millis(500)); if is_ollama_reachable(base_url) { return OllamaEnsureResult { installed: true, was_running: false, started: true, running: true, error: None, install_hint: None, }; } } OllamaEnsureResult { installed: true, was_running: false, started: true, running: false, error: None, install_hint: None, } } // ── Brief status (for cron status display) ── /// Lightweight status snapshot for display in `cron status`. #[derive(Debug, Clone, Serialize)] pub struct OllamaStatusBrief { pub installed: bool, pub running: bool, #[serde(skip_serializing_if = "Option::is_none")] pub binary_path: Option, #[serde(skip_serializing_if = "Option::is_none")] pub install_hint: Option, } /// Quick, non-blocking Ollama status check for display purposes. pub fn ollama_status_brief(base_url: &str) -> OllamaStatusBrief { let is_local = is_local_url(base_url); // For remote URLs, only check reachability (binary check is irrelevant) if !is_local { let running = is_ollama_reachable(base_url); return OllamaStatusBrief { installed: true, // unknown for remote, but not actionable running, binary_path: None, install_hint: None, }; } let binary_path = find_ollama_binary(); let installed = binary_path.is_some(); if !installed { return OllamaStatusBrief { installed: false, running: false, binary_path: None, install_hint: Some(install_instructions().to_string()), }; } let running = is_ollama_reachable(base_url); OllamaStatusBrief { installed: true, running, binary_path: binary_path.map(|p| p.display().to_string()), install_hint: None, } } #[cfg(test)] mod tests { use super::*; // ── URL parsing ── #[test] fn extract_port_default_url() { assert_eq!(extract_port("http://localhost:11434"), 11434); } #[test] fn extract_port_custom() { assert_eq!(extract_port("http://192.168.1.5:9999"), 9999); } #[test] fn extract_port_trailing_slash() { assert_eq!(extract_port("http://localhost:11434/"), 11434); } #[test] fn extract_port_with_path() { assert_eq!(extract_port("http://localhost:8080/api/generate"), 8080); } #[test] fn extract_port_no_port() { assert_eq!(extract_port("http://localhost"), 11434); } #[test] fn extract_port_https() { assert_eq!(extract_port("https://ollama.internal:8080"), 8080); } #[test] fn extract_host_localhost() { assert_eq!(extract_host("http://localhost:11434"), "localhost"); } #[test] fn extract_host_ip() { assert_eq!(extract_host("http://192.168.1.5:9999"), "192.168.1.5"); } #[test] fn extract_host_remote() { assert_eq!(extract_host("http://gpu-server:11434"), "gpu-server"); } #[test] fn extract_host_no_port() { assert_eq!(extract_host("http://localhost"), "localhost"); } #[test] fn extract_host_https() { assert_eq!( extract_host("https://ollama.internal:8080"), "ollama.internal" ); } #[test] fn extract_host_no_scheme() { assert_eq!(extract_host("localhost:11434"), "localhost"); } // ── is_local_url ── #[test] fn is_local_url_localhost() { assert!(is_local_url("http://localhost:11434")); } #[test] fn is_local_url_loopback() { assert!(is_local_url("http://127.0.0.1:11434")); } #[test] fn is_local_url_ipv6_loopback() { assert!(is_local_url("http://[::1]:11434")); } #[test] fn is_local_url_remote() { assert!(!is_local_url("http://gpu-server:11434")); assert!(!is_local_url("http://192.168.1.5:11434")); } #[test] fn is_local_url_fqdn_not_local() { assert!(!is_local_url("http://ollama.example.com:11434")); } // ── install_instructions ── #[test] fn install_instructions_not_empty() { assert!(!install_instructions().is_empty()); assert!(install_instructions().contains("ollama")); } #[test] fn install_instructions_contains_url() { assert!(install_instructions().contains("ollama.ai")); } // ── is_ollama_reachable ── #[test] fn reachable_returns_false_for_closed_port() { // Port 1 is almost never open and requires root to bind assert!(!is_ollama_reachable("http://127.0.0.1:1")); } #[test] fn reachable_returns_false_for_unresolvable_host() { assert!(!is_ollama_reachable( "http://this-host-does-not-exist-xyzzy:11434" )); } // ── OllamaEnsureResult serialization ── #[test] fn ensure_result_serializes_installed_running() { let result = OllamaEnsureResult { installed: true, was_running: true, started: false, running: true, error: None, install_hint: None, }; let json: serde_json::Value = serde_json::to_value(&result).unwrap(); assert_eq!(json["installed"], true); assert_eq!(json["was_running"], true); assert_eq!(json["started"], false); assert_eq!(json["running"], true); // skip_serializing_if: None fields should be absent assert!(json.get("error").is_none()); assert!(json.get("install_hint").is_none()); } #[test] fn ensure_result_serializes_not_installed() { let result = OllamaEnsureResult { installed: false, was_running: false, started: false, running: false, error: Some("Ollama is not installed".to_string()), install_hint: Some("Install Ollama: brew install ollama".to_string()), }; let json: serde_json::Value = serde_json::to_value(&result).unwrap(); assert_eq!(json["installed"], false); assert_eq!(json["running"], false); assert_eq!(json["error"], "Ollama is not installed"); assert!( json["install_hint"] .as_str() .unwrap() .contains("brew install") ); } // ── OllamaStatusBrief serialization ── #[test] fn status_brief_serializes_with_optional_fields() { let brief = OllamaStatusBrief { installed: true, running: true, binary_path: Some("/usr/local/bin/ollama".to_string()), install_hint: None, }; let json: serde_json::Value = serde_json::to_value(&brief).unwrap(); assert_eq!(json["installed"], true); assert_eq!(json["running"], true); assert_eq!(json["binary_path"], "/usr/local/bin/ollama"); assert!(json.get("install_hint").is_none()); } #[test] fn status_brief_serializes_not_installed() { let brief = OllamaStatusBrief { installed: false, running: false, binary_path: None, install_hint: Some("Install Ollama".to_string()), }; let json: serde_json::Value = serde_json::to_value(&brief).unwrap(); assert_eq!(json["installed"], false); assert_eq!(json["running"], false); assert!(json.get("binary_path").is_none()); assert_eq!(json["install_hint"], "Install Ollama"); } #[test] fn status_brief_clone() { let original = OllamaStatusBrief { installed: true, running: false, binary_path: Some("/opt/bin/ollama".to_string()), install_hint: None, }; let cloned = original.clone(); assert_eq!(original.installed, cloned.installed); assert_eq!(original.running, cloned.running); assert_eq!(original.binary_path, cloned.binary_path); assert_eq!(original.install_hint, cloned.install_hint); } // ── ensure_ollama with remote URL ── #[test] fn ensure_remote_unreachable_does_not_set_install_hint() { // A remote URL that nothing listens on — should NOT suggest local install let result = ensure_ollama("http://192.0.2.1:1"); // TEST-NET, will fail fast assert!(!result.started); assert!(!result.running); assert!( result.install_hint.is_none(), "remote URLs should not suggest local install" ); assert!( result.error.as_deref().unwrap_or("").contains("remote"), "error should mention 'remote': {:?}", result.error, ); } // ── ensure_ollama with local URL (binary check) ── #[test] fn ensure_local_closed_port_not_already_running() { // Local URL pointing at a port nothing listens on let result = ensure_ollama("http://127.0.0.1:1"); // Should NOT report was_running since port 1 is closed assert!(!result.was_running); assert!(!result.running); // If ollama binary is not installed, should get install hint if !result.installed { assert!(result.install_hint.is_some()); assert!( result .error .as_deref() .unwrap_or("") .contains("not installed") ); } } }