use std::path::PathBuf;
use std::process::{Command, Stdio};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;
pub const CONTAINER: &str = "hyprcorrect-languagetool";
pub const IMAGE: &str = "erikvl87/languagetool";
const IMAGE_PORT: u16 = 8010;
const PROBE_TIMEOUT: Duration = Duration::from_millis(1500);
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LanguageToolStatus {
Reachable { managed_container_running: bool },
Unreachable(DockerState),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DockerState {
NotInstalled,
DockerUnavailable(String),
AbsentContainer,
ContainerStopped,
ContainerRunning,
ForeignContainer { name: String, running: bool },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OpKind {
Install,
Start,
Stop,
Remove,
EnableNgrams,
RemoveNgrams,
}
impl OpKind {
pub fn label(self) -> &'static str {
match self {
Self::Install => "Pulling image and starting container…",
Self::Start => "Starting container…",
Self::Stop => "Stopping container…",
Self::Remove => "Removing container…",
Self::EnableNgrams => "Recreating the container with n-grams…",
Self::RemoveNgrams => "Removing n-grams and deleting the data…",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProbeResult {
pub status: LanguageToolStatus,
pub ngrams: Option<bool>,
pub ngram_mount: Option<String>,
}
pub type OpResult = Result<(), String>;
pub struct OpHandle {
kind: OpKind,
result: Arc<Mutex<Option<OpResult>>>,
}
impl OpHandle {
pub fn kind(&self) -> OpKind {
self.kind
}
pub fn poll(&self) -> Option<OpResult> {
self.result.lock().ok().and_then(|mut g| g.take())
}
}
pub struct StatusHandle {
result: Arc<Mutex<Option<ProbeResult>>>,
}
impl StatusHandle {
pub fn poll(&self) -> Option<ProbeResult> {
self.result.lock().ok().and_then(|mut g| g.take())
}
}
pub fn spawn_status_probe(url: String) -> StatusHandle {
let result = Arc::new(Mutex::new(None));
let result_for_thread = Arc::clone(&result);
thread::Builder::new()
.name("hyprcorrect-lt-probe".into())
.spawn(move || {
let res = probe_status_blocking(&url);
if let Ok(mut g) = result_for_thread.lock() {
*g = Some(res);
}
})
.ok();
StatusHandle { result }
}
fn probe_status_blocking(url: &str) -> ProbeResult {
let status = if probe_url(url) {
let managed_container_running =
matches!(check_docker_state(), DockerState::ContainerRunning);
LanguageToolStatus::Reachable {
managed_container_running,
}
} else {
LanguageToolStatus::Unreachable(check_docker_state())
};
let ngrams = managed_ngrams();
ProbeResult {
status,
ngrams,
ngram_mount: (ngrams == Some(true)).then(managed_ngram_mount).flatten(),
}
}
fn managed_ngrams() -> Option<bool> {
let output = Command::new("docker")
.args(["inspect", "--format", "{{json .Config.Env}}", CONTAINER])
.stdin(Stdio::null())
.output()
.ok()?;
if !output.status.success() {
return None; }
let env = String::from_utf8_lossy(&output.stdout);
Some(env.contains("langtool_languageModel"))
}
fn managed_ngram_mount() -> Option<String> {
let output = Command::new("docker")
.args([
"inspect",
"--format",
r#"{{range .Mounts}}{{if eq .Destination "/ngrams"}}{{.Source}}{{end}}{{end}}"#,
CONTAINER,
])
.stdin(Stdio::null())
.output()
.ok()?;
if !output.status.success() {
return None;
}
let src = String::from_utf8_lossy(&output.stdout).trim().to_string();
(!src.is_empty()).then_some(src)
}
fn probe_url(url: &str) -> bool {
let base = url.trim().trim_end_matches('/');
if base.is_empty() {
return false;
}
let endpoint = format!("{base}/v2/languages");
let agent = ureq::AgentBuilder::new()
.timeout_connect(PROBE_TIMEOUT)
.timeout_read(PROBE_TIMEOUT)
.timeout_write(PROBE_TIMEOUT)
.build();
match agent.get(&endpoint).call() {
Ok(resp) => resp.status() == 200,
Err(_) => false,
}
}
fn check_docker_state() -> DockerState {
let probe = Command::new("docker")
.args(["version", "--format", "{{.Server.Version}}"])
.stdin(Stdio::null())
.output();
let probe = match probe {
Ok(p) => p,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return DockerState::NotInstalled;
}
Err(e) => return DockerState::DockerUnavailable(e.to_string()),
};
if !probe.status.success() {
let stderr = String::from_utf8_lossy(&probe.stderr);
let msg = stderr.lines().next().unwrap_or("").trim().to_string();
let msg = if msg.is_empty() {
"docker daemon not reachable".into()
} else {
msg
};
return DockerState::DockerUnavailable(msg);
}
if let Some(state) = inspect_container_state(&format!("name=^{CONTAINER}$")) {
return match state.as_str() {
"running" => DockerState::ContainerRunning,
_ => DockerState::ContainerStopped,
};
}
if let Some(found) = find_container_by_image(IMAGE) {
return DockerState::ForeignContainer {
name: found.name,
running: found.running,
};
}
DockerState::AbsentContainer
}
fn inspect_container_state(filter: &str) -> Option<String> {
let output = Command::new("docker")
.args(["ps", "-a", "--filter", filter, "--format", "{{.State}}"])
.stdin(Stdio::null())
.output()
.ok()?;
if !output.status.success() {
return None;
}
let text = String::from_utf8_lossy(&output.stdout);
let first = text.lines().next()?.trim().to_string();
if first.is_empty() { None } else { Some(first) }
}
struct ForeignContainer {
name: String,
running: bool,
}
fn find_container_by_image(image: &str) -> Option<ForeignContainer> {
let output = Command::new("docker")
.args([
"ps",
"-a",
"--filter",
&format!("ancestor={image}"),
"--format",
"{{.Names}}\t{{.State}}",
])
.stdin(Stdio::null())
.output()
.ok()?;
if !output.status.success() {
return None;
}
let text = String::from_utf8_lossy(&output.stdout);
let (name, state) = text.lines().next()?.split_once('\t')?;
Some(ForeignContainer {
name: name.trim().to_string(),
running: state.trim() == "running",
})
}
pub fn install(host_port: u16, ngram_dir: Option<&str>) -> OpHandle {
let ngram = ngram_dir.map(str::to_string);
spawn_op(OpKind::Install, move || {
run_install(host_port, ngram.as_deref())
})
}
fn run_install(host_port: u16, ngram_dir: Option<&str>) -> OpResult {
let mut args: Vec<String> = vec![
"run".into(),
"-d".into(),
"--name".into(),
CONTAINER.into(),
"--restart=unless-stopped".into(),
"-p".into(),
format!("{host_port}:{IMAGE_PORT}"),
];
if let Some(dir) = ngram_dir.filter(|d| !d.trim().is_empty()) {
args.push("-v".into());
args.push(format!("{dir}:/ngrams"));
args.push("-e".into());
args.push("langtool_languageModel=/ngrams".into());
}
args.push(IMAGE.into());
let refs: Vec<&str> = args.iter().map(String::as_str).collect();
run_command("docker", &refs)
}
pub fn enable_ngrams(host_port: u16, ngram_dir: &str) -> OpHandle {
let dir = ngram_dir.to_string();
spawn_op(OpKind::EnableNgrams, move || {
let _ = run_command("docker", &["rm", "-f", CONTAINER]); run_install(host_port, Some(&dir))
})
}
pub fn remove_ngrams(host_port: u16, data_dir: PathBuf) -> OpHandle {
spawn_op(OpKind::RemoveNgrams, move || {
let _ = run_command("docker", &["rm", "-f", CONTAINER]); let recreate = run_install(host_port, None);
let deleted = std::fs::remove_dir_all(&data_dir);
recreate?;
deleted.map_err(|e| format!("deleting {}: {e}", data_dir.display()))
})
}
pub fn start() -> OpHandle {
spawn_op(OpKind::Start, || {
run_command("docker", &["start", CONTAINER])
})
}
pub fn stop() -> OpHandle {
spawn_op(OpKind::Stop, || run_command("docker", &["stop", CONTAINER]))
}
pub fn remove() -> OpHandle {
spawn_op(OpKind::Remove, || {
run_command("docker", &["rm", "-f", CONTAINER])
})
}
fn spawn_op<F>(kind: OpKind, op: F) -> OpHandle
where
F: FnOnce() -> OpResult + Send + 'static,
{
let result = Arc::new(Mutex::new(None));
let result_for_thread = Arc::clone(&result);
thread::Builder::new()
.name(format!("hyprcorrect-docker-{kind:?}"))
.spawn(move || {
let out = op();
if let Ok(mut g) = result_for_thread.lock() {
*g = Some(out);
}
})
.ok();
OpHandle { kind, result }
}
fn run_command(program: &str, args: &[&str]) -> OpResult {
let output = Command::new(program)
.args(args)
.stdin(Stdio::null())
.output()
.map_err(|e| format!("failed to launch `{program}`: {e}"))?;
if output.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&output.stderr);
let msg = stderr
.lines()
.last()
.map(str::trim)
.filter(|s| !s.is_empty())
.unwrap_or("docker command failed")
.to_string();
Err(msg)
}
pub fn host_port_from_url(url: &str) -> Option<u16> {
let trimmed = url.trim();
let after_scheme = trimmed
.split_once("://")
.map(|(_, rest)| rest)
.unwrap_or(trimmed);
let authority = after_scheme.split('/').next().unwrap_or(after_scheme);
let port_part = if let Some(rest) = authority.strip_prefix('[') {
rest.split_once("]:").map(|(_, p)| p)?
} else {
authority.rsplit_once(':').map(|(_, p)| p)?
};
port_part.parse().ok()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_port_from_typical_urls() {
assert_eq!(host_port_from_url("http://localhost:8081"), Some(8081));
assert_eq!(
host_port_from_url("http://localhost:8081/v2/check"),
Some(8081)
);
assert_eq!(
host_port_from_url("https://lt.example.com:9000"),
Some(9000)
);
assert_eq!(host_port_from_url("http://[::1]:8081"), Some(8081));
}
#[test]
fn returns_none_without_explicit_port() {
assert!(host_port_from_url("http://localhost").is_none());
assert!(host_port_from_url("").is_none());
assert!(host_port_from_url("not a url").is_none());
}
}