use std::io::Read;
use std::path::Path;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
const DEFAULT_TIMEOUT_SECS: u64 = 10;
pub enum Fetch {
Found {
solutions: Vec<String>,
exhausted: bool,
},
NoSolutions,
Failed(String),
Timeout(u64),
Error(String),
}
enum Raw {
Done { stdout: String },
Timeout(u64),
SpawnError(String),
}
fn timeout() -> Duration {
let secs = std::env::var("PLG_REPL_TIMEOUT")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(DEFAULT_TIMEOUT_SECS);
Duration::from_secs(secs)
}
fn query_args(goal: &str, limit: usize, format: &str) -> [String; 6] {
[
"--query".to_string(),
goal.to_string(),
"--limit".to_string(),
limit.to_string(),
"--format".to_string(),
format.to_string(),
]
}
pub fn fetch(binary: &Path, goal: &str, limit: usize) -> Fetch {
let json = match run_raw(binary, &query_args(goal, limit, "json")) {
Raw::Done { stdout, .. } => stdout,
Raw::Timeout(t) => return Fetch::Timeout(t),
Raw::SpawnError(e) => return Fetch::Error(e),
};
if let Some(msg) = json_error(&json) {
return Fetch::Failed(msg);
}
let count = match json_count(&json) {
Some(0) | None => return Fetch::NoSolutions,
Some(n) => n,
};
let exhausted = json_exhausted(&json);
let text = match run_raw(binary, &query_args(goal, limit, "text")) {
Raw::Done { stdout, .. } => stdout,
Raw::Timeout(t) => return Fetch::Timeout(t),
Raw::SpawnError(e) => return Fetch::Error(e),
};
match split_solutions(&text, count) {
Some(solutions) => Fetch::Found {
solutions,
exhausted,
},
None => Fetch::Failed("malformed query output (lines vs. count)".to_string()),
}
}
fn top_field<'a>(json: &'a str, key: &str) -> Option<&'a str> {
let needle = format!("\"{key}\":");
let bytes = json.as_bytes();
let mut from = 0;
while let Some(rel) = json[from..].find(&needle) {
let at = from + rel;
if matches!(at.checked_sub(1).map(|i| bytes[i]), Some(b'{') | Some(b',')) {
return Some(&json[at + needle.len()..]);
}
from = at + needle.len();
}
None
}
fn json_error(json: &str) -> Option<String> {
let rest = top_field(json, "error")?.strip_prefix('"')?;
let end = rest
.find("\"}")
.or_else(|| rest.find('"'))
.unwrap_or(rest.len());
Some(rest[..end].to_string())
}
fn json_count(json: &str) -> Option<usize> {
top_field(json, "count")?
.chars()
.take_while(char::is_ascii_digit)
.collect::<String>()
.parse()
.ok()
}
fn json_exhausted(json: &str) -> bool {
top_field(json, "exhausted").is_some_and(|v| v.starts_with("true"))
}
fn split_solutions(text: &str, count: usize) -> Option<Vec<String>> {
let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
if count == 0 {
return Some(Vec::new());
}
if lines.is_empty() || !lines.len().is_multiple_of(count) {
return None;
}
let per = lines.len() / count;
Some(lines.chunks(per).map(|c| c.join(", ")).collect())
}
fn run_raw(path: &Path, args: &[String]) -> Raw {
let mut child = match Command::new(path)
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
{
Ok(c) => c,
Err(e) => return Raw::SpawnError(format!("failed to start: {e}")),
};
let limit = timeout();
let start = Instant::now();
let poll = Duration::from_millis(50);
loop {
match child.try_wait() {
Ok(Some(_)) => {
let stdout = drain(child.stdout.take());
let _ = drain(child.stderr.take());
return Raw::Done { stdout };
}
Ok(None) => {
if start.elapsed() >= limit {
let _ = child.kill();
let _ = child.wait();
return Raw::Timeout(limit.as_secs());
}
std::thread::sleep(poll);
}
Err(e) => return Raw::SpawnError(format!("wait error: {e}")),
}
}
}
fn drain<R: Read>(pipe: Option<R>) -> String {
pipe.map(|mut r| {
let mut buf = String::new();
let _ = r.read_to_string(&mut buf);
buf
})
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::{json_count, json_error, json_exhausted, split_solutions};
#[test]
fn splits_multi_var_solutions_into_groups() {
let text = "X = tom\nY = bob\nX = bob\nY = ann\nX = ann\nY = sue";
assert_eq!(
split_solutions(text, 3).unwrap(),
["X = tom, Y = bob", "X = bob, Y = ann", "X = ann, Y = sue"]
);
}
#[test]
fn splits_single_var_one_per_line() {
assert_eq!(
split_solutions("X = 1\nX = 2", 2).unwrap(),
["X = 1", "X = 2"]
);
}
#[test]
fn all_anonymous_solutions_are_true() {
assert_eq!(
split_solutions("true.\ntrue.", 2).unwrap(),
["true.", "true."]
);
}
#[test]
fn non_uniform_lines_is_none() {
assert_eq!(split_solutions("X = 1\nX = 2\nY = 3", 2), None);
}
#[test]
fn parses_top_level_count_error_and_exhausted() {
assert_eq!(
json_count(r#"{"count":3,"exhausted":false,"solutions":[]}"#),
Some(3)
);
assert!(json_exhausted(
r#"{"count":1,"exhausted":true,"solutions":[{}]}"#
));
assert!(!json_exhausted(
r#"{"count":3,"exhausted":false,"solutions":[]}"#
));
assert_eq!(json_error(r#"{"count":1}"#), None);
assert_eq!(
json_error(r#"{"error":"Runtime error: boom(a, b)"}"#).as_deref(),
Some("Runtime error: boom(a, b)")
);
assert_eq!(
json_count(r#"{"count":1,"solutions":[{"X":{"functor":"count","args":[9]}}]}"#),
Some(1)
);
}
}