#![allow(dead_code)]
use std::net::TcpListener;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::time::{Duration, Instant};
use assert_cmd::cargo::CommandCargoExt;
pub struct Out {
pub stdout: String,
pub stderr: String,
pub code: i32,
}
impl Out {
pub fn assert_ok(&self) -> &Self {
assert_eq!(
self.code, 0,
"expected exit 0\n--- stdout ---\n{}\n--- stderr ---\n{}",
self.stdout, self.stderr
);
self
}
pub fn assert_err(&self) -> &Self {
assert_ne!(
self.code, 0,
"expected non-zero exit\n--- stdout ---\n{}\n--- stderr ---\n{}",
self.stdout, self.stderr
);
self
}
pub fn assert_code(&self, code: i32) -> &Self {
assert_eq!(
self.code, code,
"expected exit {code}\n--- stdout ---\n{}\n--- stderr ---\n{}",
self.stdout, self.stderr
);
self
}
}
pub struct Env {
_root: tempfile::TempDir,
pub root: PathBuf,
pub fixtures: PathBuf,
pub config_dir: PathBuf,
pub db: PathBuf,
}
impl Env {
pub fn run(&self, args: &[&str]) -> Out {
self.run_in(&self.fixtures, args)
}
pub fn run_in(&self, cwd: &Path, args: &[&str]) -> Out {
let mut full: Vec<&str> = vec!["--index", "index"];
full.extend_from_slice(args);
spawn(cwd, &self.db, &self.config_dir, &full, &[])
}
pub fn run_in_env(&self, cwd: &Path, args: &[&str], extra: &[(&str, &str)]) -> Out {
spawn(cwd, &self.db, &self.config_dir, args, extra)
}
pub fn run_env(&self, args: &[&str], extra: &[(&str, &str)]) -> Out {
let mut full: Vec<&str> = vec!["--index", "index"];
full.extend_from_slice(args);
spawn(&self.fixtures, &self.db, &self.config_dir, &full, extra)
}
pub fn run_bare(&self, args: &[&str]) -> Out {
spawn(&self.fixtures, &self.db, &self.config_dir, args, &[])
}
pub fn write_config(&self, yaml: &str) {
std::fs::write(self.config_dir.join("index.yml"), yaml).expect("write index.yml");
}
pub fn yaml_path(&self, p: &Path) -> String {
p.to_string_lossy().replace('\\', "/")
}
}
pub fn env() -> Env {
let root = tempfile::tempdir().expect("mkdtemp");
let root_path = root.path().to_path_buf();
let fixtures = root_path.join("fixtures");
let config_dir = root_path.join("config");
let db = root_path.join("index.sqlite");
std::fs::create_dir_all(fixtures.join("notes")).unwrap();
std::fs::create_dir_all(fixtures.join("docs")).unwrap();
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(config_dir.join("index.yml"), "collections: {}\n").unwrap();
write_fixtures(&fixtures);
Env {
_root: root,
root: root_path,
fixtures,
config_dir,
db,
}
}
fn write_fixtures(fixtures: &Path) {
std::fs::write(
fixtures.join("README.md"),
"# Test Project\n\n\
This is a test project for QMD CLI testing.\n\n\
## Features\n\n\
- Full-text search with BM25\n\
- Vector similarity search\n\
- Hybrid search with reranking\n",
)
.unwrap();
std::fs::write(
fixtures.join("notes").join("meeting.md"),
"# Team Meeting Notes\n\n\
Date: 2024-01-15\n\n\
## Attendees\n\
- Alice\n\
- Bob\n\
- Charlie\n\n\
## Discussion Topics\n\
- Project timeline review\n\
- Resource allocation\n\
- Technical debt prioritization\n\n\
## Action Items\n\
1. Alice to update documentation\n\
2. Bob to fix authentication bug\n\
3. Charlie to review pull requests\n",
)
.unwrap();
std::fs::write(
fixtures.join("notes").join("ideas.md"),
"# Product Ideas\n\n\
## Feature Requests\n\
- Dark mode support\n\
- Keyboard shortcuts\n\
- Export to PDF\n\n\
## Technical Improvements\n\
- Improve search performance\n\
- Add caching layer\n\
- Optimize database queries\n",
)
.unwrap();
std::fs::write(
fixtures.join("docs").join("api.md"),
"# API Documentation\n\n\
## Endpoints\n\n\
### GET /search\n\
Search for documents.\n\n\
Parameters:\n\
- q: Search query (required)\n\
- limit: Max results (default: 10)\n\n\
### GET /document/:id\n\
Retrieve a specific document.\n\n\
### POST /index\n\
Index new documents.\n",
)
.unwrap();
std::fs::write(
fixtures.join("test1.md"),
"# Test Document 1\n\n\
This is the first test document.\n\n\
It has multiple lines for testing line numbers.\n\
Line 6 is here.\n\
Line 7 is here.\n",
)
.unwrap();
std::fs::write(
fixtures.join("test2.md"),
"# Test Document 2\n\n\
This is the second test document.\n",
)
.unwrap();
}
fn spawn(cwd: &Path, db: &Path, cfg: &Path, args: &[&str], extra: &[(&str, &str)]) -> Out {
let mut cmd = Command::cargo_bin("rqmd").expect("rqmd binary is built by cargo test");
cmd.current_dir(cwd)
.env_remove("XDG_CACHE_HOME")
.env_remove("XDG_CONFIG_HOME")
.env_remove("RQMD_CACHE_DIR")
.env("NO_COLOR", "1")
.env("CI", "1")
.env("PWD", cwd)
.env("RQMD_INDEX_PATH", db)
.env("RQMD_CONFIG_DIR", cfg)
.args(args);
for (k, v) in extra {
cmd.env(k, v);
}
let out = cmd.output().expect("spawn rqmd");
Out {
stdout: String::from_utf8_lossy(&out.stdout).into_owned(),
stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
code: out.status.code().unwrap_or(-1),
}
}
pub fn spawn_cache(cwd: &Path, cache: &Path, cfg: &Path, args: &[&str]) -> Out {
let mut cmd = Command::cargo_bin("rqmd").expect("rqmd binary is built by cargo test");
cmd.current_dir(cwd)
.env_remove("XDG_CACHE_HOME")
.env_remove("XDG_CONFIG_HOME")
.env_remove("RQMD_INDEX_PATH")
.env("NO_COLOR", "1")
.env("CI", "1")
.env("PWD", cwd)
.env("RQMD_CACHE_DIR", cache)
.env("RQMD_CONFIG_DIR", cfg)
.args(args);
let out = cmd.output().expect("spawn rqmd");
Out {
stdout: String::from_utf8_lossy(&out.stdout).into_owned(),
stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
code: out.status.code().unwrap_or(-1),
}
}
pub fn is_hex6(s: &str) -> bool {
s.len() == 6
&& s.bytes()
.all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())
}
pub fn first_line(s: &str) -> &str {
s.lines().find(|l| !l.trim().is_empty()).unwrap_or("")
}
pub struct ServerChild {
pub child: Child,
pub port: u16,
}
impl Drop for ServerChild {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
pub struct DaemonGuard {
pub cwd: PathBuf,
pub cache: PathBuf,
pub cfg: PathBuf,
}
impl Drop for DaemonGuard {
fn drop(&mut self) {
let _ = spawn_cache(&self.cwd, &self.cache, &self.cfg, &["mcp", "stop"]);
}
}
pub fn free_port() -> u16 {
TcpListener::bind("127.0.0.1:0")
.expect("bind ephemeral port")
.local_addr()
.expect("local_addr")
.port()
}
pub fn spawn_cache_child(cwd: &Path, cache: &Path, cfg: &Path, args: &[&str]) -> Child {
let mut cmd = Command::cargo_bin("rqmd").expect("rqmd binary is built by cargo test");
cmd.current_dir(cwd)
.env_remove("XDG_CACHE_HOME")
.env_remove("XDG_CONFIG_HOME")
.env_remove("RQMD_INDEX_PATH")
.env("NO_COLOR", "1")
.env("CI", "1")
.env("PWD", cwd)
.env("RQMD_CACHE_DIR", cache)
.env("RQMD_CONFIG_DIR", cfg)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.args(args);
cmd.spawn().expect("spawn rqmd server")
}
pub fn wait_for_health(port: u16, timeout: Duration) -> Option<serde_json::Value> {
let url = format!("http://127.0.0.1:{port}/health");
let deadline = Instant::now() + timeout;
while Instant::now() < deadline {
match ureq::get(&url).call() {
Ok(resp) if resp.status() == 200 => {
if let Ok(v) = resp.into_json::<serde_json::Value>() {
return Some(v);
}
}
_ => {}
}
std::thread::sleep(Duration::from_millis(150));
}
None
}
pub fn post_query(port: u16, body: serde_json::Value) -> serde_json::Value {
let url = format!("http://127.0.0.1:{port}/query");
ureq::post(&url)
.send_json(body)
.expect("POST /query")
.into_json::<serde_json::Value>()
.expect("query response json")
}