use regex::Regex;
use std::io::{BufRead, BufReader};
use std::process::{Child, Command, Stdio};
use std::sync::mpsc::{channel, Receiver};
use std::thread;
use std::time::Duration;
#[derive(Debug)]
#[allow(dead_code)]
pub enum NgrokError {
StartError(String),
UrlExtractionError(String),
ProcessTerminated(String),
}
impl std::fmt::Display for NgrokError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
NgrokError::StartError(msg) => write!(f, "Failed to start ngrok: {}", msg),
NgrokError::UrlExtractionError(msg) => {
write!(f, "Failed to extract URL: {}", msg)
}
NgrokError::ProcessTerminated(msg) => write!(f, "Process terminated: {}", msg),
}
}
}
impl std::error::Error for NgrokError {}
#[allow(dead_code)]
pub struct NgrokTunnel {
process: Child,
public_url: String,
}
impl NgrokTunnel {
#[allow(dead_code)]
pub fn start(ngrok_path: &str, port: u16, timeout_secs: u64) -> Result<Self, NgrokError> {
let mut process = Command::new(ngrok_path)
.args(["http", &port.to_string()])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| {
NgrokError::StartError(format!(
"Failed to execute '{}': {}. Make sure ngrok is installed and accessible.",
ngrok_path, e
))
})?;
let stdout = process
.stdout
.take()
.ok_or_else(|| NgrokError::StartError("Failed to capture stdout".to_string()))?;
let stderr = process
.stderr
.take()
.ok_or_else(|| NgrokError::StartError("Failed to capture stderr".to_string()))?;
let (tx, rx): (std::sync::mpsc::Sender<String>, Receiver<String>) = channel();
let tx_clone = tx.clone();
thread::spawn(move || {
let reader = BufReader::new(stdout);
for line in reader.lines().map_while(Result::ok) {
if let Some(url) = extract_public_url(&line) {
let _ = tx_clone.send(url);
break;
}
}
});
thread::spawn(move || {
let reader = BufReader::new(stderr);
for line in reader.lines().map_while(Result::ok) {
if let Some(url) = extract_public_url(&line) {
let _ = tx.send(url);
break;
}
}
});
let public_url = rx
.recv_timeout(Duration::from_secs(timeout_secs))
.map_err(|_| {
NgrokError::UrlExtractionError(format!(
"Timeout waiting for ngrok URL (waited {} seconds). \
Make sure ngrok is working correctly.",
timeout_secs
))
})?;
Ok(Self {
process,
public_url,
})
}
#[allow(dead_code)]
pub fn public_url(&self) -> &str {
&self.public_url
}
#[allow(dead_code)]
pub fn stop(mut self) -> Result<(), NgrokError> {
self.process
.kill()
.map_err(|e| NgrokError::ProcessTerminated(format!("Failed to kill process: {}", e)))?;
let _ = self.process.wait();
Ok(())
}
}
#[allow(dead_code)]
fn extract_public_url(line: &str) -> Option<String> {
let re = Regex::new(r"https://[a-zA-Z0-9-]+\.ngrok-free\.app").ok()?;
re.find(line).map(|m| m.as_str().to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_public_url() {
let line = "Forwarding https://abc-def-123.ngrok-free.app -> http://localhost:8765";
let url = extract_public_url(line);
assert_eq!(url, Some("https://abc-def-123.ngrok-free.app".to_string()));
}
#[test]
fn test_extract_public_url_with_surrounding_text() {
let line = "Your tunnel is ready at https://my-tunnel-xyz.ngrok-free.app for testing";
let url = extract_public_url(line);
assert_eq!(
url,
Some("https://my-tunnel-xyz.ngrok-free.app".to_string())
);
}
#[test]
fn test_extract_public_url_no_match() {
let line = "Some random log line without URL";
let url = extract_public_url(line);
assert_eq!(url, None);
}
#[test]
fn test_extract_public_url_wrong_domain() {
let line = "https://example.com is not an ngrok URL";
let url = extract_public_url(line);
assert_eq!(url, None);
}
}