#![cfg(unix)]
use std::io::{BufRead, BufReader, Write};
use std::os::unix::net::UnixListener;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::Arc;
use std::thread::JoinHandle;
use std::time::{Duration, Instant};
use assert_cmd::Command;
use tempfile::TempDir;
fn v(tokens: &[&str]) -> Vec<String> {
tokens.iter().map(|s| s.to_string()).collect()
}
#[test]
fn test_translate_strips_global_json_flag() {
let (cmd, args) =
cqs::daemon_translate::translate_cli_args_to_batch(&v(&["impact", "foo", "--json"]), true);
assert_eq!(cmd, "impact");
assert!(
!args.iter().any(|a| a == "--json"),
"--json must be stripped; got {args:?}"
);
let (cmd, args) =
cqs::daemon_translate::translate_cli_args_to_batch(&v(&["--json", "hello"]), false);
assert_eq!(cmd, "search");
assert_eq!(args, v(&["hello"]));
}
#[test]
fn test_translate_remaps_n_to_limit() {
let (cmd, args) =
cqs::daemon_translate::translate_cli_args_to_batch(&v(&["impact", "foo", "-n", "5"]), true);
assert_eq!(cmd, "impact");
assert_eq!(args, v(&["foo", "--limit", "5"]));
let (cmd, args) =
cqs::daemon_translate::translate_cli_args_to_batch(&v(&["impact", "foo", "-n=5"]), true);
assert_eq!(cmd, "impact");
assert_eq!(args, v(&["foo", "--limit=5"]));
let (cmd, args) = cqs::daemon_translate::translate_cli_args_to_batch(
&v(&["impact", "foo", "--limit", "7"]),
true,
);
assert_eq!(cmd, "impact");
assert_eq!(args, v(&["foo", "--limit", "7"]));
}
#[test]
fn test_translate_prepends_search_for_bare_query() {
let (cmd, args) =
cqs::daemon_translate::translate_cli_args_to_batch(&v(&["hello world"]), false);
assert_eq!(cmd, "search");
assert_eq!(args, v(&["hello world"]));
let (cmd, args) = cqs::daemon_translate::translate_cli_args_to_batch(
&v(&["alpha", "beta", "--quiet"]),
false,
);
assert_eq!(cmd, "search");
assert_eq!(args, v(&["alpha", "beta"]));
}
#[test]
fn test_translate_preserves_subcommand_flags() {
let (cmd, args) = cqs::daemon_translate::translate_cli_args_to_batch(
&v(&["impact", "foo", "--threshold", "0.5", "--json"]),
true,
);
assert_eq!(cmd, "impact");
assert_eq!(args, v(&["foo", "--threshold", "0.5"]));
let (cmd, args) = cqs::daemon_translate::translate_cli_args_to_batch(
&v(&["similar", "bar", "-n", "3"]),
true,
);
assert_eq!(cmd, "similar");
assert_eq!(args, v(&["bar", "--limit", "3"]));
}
struct MockDaemon {
conn_count: Arc<AtomicUsize>,
stop: Arc<AtomicBool>,
sock_path: PathBuf,
handle: Option<JoinHandle<()>>,
}
impl MockDaemon {
fn new(sock_path: PathBuf, sentinel: &'static str) -> Self {
let listener = UnixListener::bind(&sock_path)
.unwrap_or_else(|e| panic!("bind {} failed: {e}", sock_path.display()));
listener
.set_nonblocking(true)
.expect("set_nonblocking on mock listener");
let conn_count = Arc::new(AtomicUsize::new(0));
let stop = Arc::new(AtomicBool::new(false));
let c2 = Arc::clone(&conn_count);
let s2 = Arc::clone(&stop);
let response = format!(r#"{{"status":"ok","output":"{sentinel}"}}"#);
let handle = std::thread::spawn(move || {
let deadline = Instant::now() + Duration::from_secs(30);
while !s2.load(Ordering::SeqCst) && Instant::now() < deadline {
match listener.accept() {
Ok((mut stream, _addr)) => {
c2.fetch_add(1, Ordering::SeqCst);
let mut buf = String::new();
let _ = BufReader::new(&stream).read_line(&mut buf);
let _ = writeln!(stream, "{response}");
let _ = stream.flush();
}
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
std::thread::sleep(Duration::from_millis(10));
}
Err(_) => break,
}
}
});
Self {
conn_count,
stop,
sock_path,
handle: Some(handle),
}
}
fn conn_count(&self) -> usize {
self.conn_count.load(Ordering::SeqCst)
}
}
impl Drop for MockDaemon {
fn drop(&mut self) {
self.stop.store(true, Ordering::SeqCst);
if let Some(h) = self.handle.take() {
let _ = h.join();
}
let _ = std::fs::remove_file(&self.sock_path);
}
}
fn setup_project() -> (TempDir, PathBuf) {
let dir = TempDir::new().expect("Failed to create temp dir");
let cqs_dir = dir.path().join(".cqs");
std::fs::create_dir_all(&cqs_dir).expect("Failed to create .cqs dir");
let cqs_dir_canonical = dunce::canonicalize(&cqs_dir).expect("canonicalize cqs_dir");
let sock_path = daemon_socket_path_with_runtime_dir(&cqs_dir_canonical, dir.path());
(dir, sock_path)
}
fn daemon_socket_path_with_runtime_dir(cqs_dir: &Path, runtime_dir: &Path) -> PathBuf {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut h = DefaultHasher::new();
cqs_dir.hash(&mut h);
let sock_name = format!("cqs-{:x}.sock", h.finish());
runtime_dir.join(sock_name)
}
fn cqs() -> Command {
#[allow(deprecated)]
Command::cargo_bin("cqs").expect("Failed to find cqs binary")
}
fn clean_cqs_env(cmd: &mut Command) {
cmd.env_remove("CQS_NO_DAEMON");
cmd.env_remove("CQS_TELEMETRY");
cmd.env("RUST_LOG", "warn");
}
#[test]
fn test_try_daemon_query_bypasses_notes_mutations() {
let (dir, sock_path) = setup_project();
let mock = MockDaemon::new(sock_path.clone(), "DAEMON_SHOULD_NOT_RESPOND");
let canonical_dir =
dunce::canonicalize(dir.path()).expect("canonicalize temp dir for CWD override");
let mut cmd = cqs();
clean_cqs_env(&mut cmd);
cmd.env("XDG_RUNTIME_DIR", dir.path())
.current_dir(&canonical_dir)
.args([
"notes",
"add",
"bypass-regression-seed",
"--sentiment",
"0",
"--no-reindex",
]);
let output = cmd.output().expect("cqs notes add spawn");
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
output.status.success(),
"`cqs notes add` failed; stdout=<{stdout}> stderr=<{stderr}>"
);
assert_eq!(
mock.conn_count(),
0,
"notes add reached the daemon socket (bypass regressed); mock saw {} connection(s). stdout=<{stdout}> stderr=<{stderr}>",
mock.conn_count()
);
assert!(
!stdout.contains("DAEMON_SHOULD_NOT_RESPOND"),
"mock response leaked into stdout: {stdout}"
);
}
#[test]
fn test_mock_socket_round_trip_for_daemon_command() {
let (dir, sock_path) = setup_project();
let mock = MockDaemon::new(sock_path.clone(), "DAEMON_MOCK_SENTINEL");
let canonical_dir =
dunce::canonicalize(dir.path()).expect("canonicalize temp dir for CWD override");
let mut cmd = cqs();
clean_cqs_env(&mut cmd);
cmd.env("XDG_RUNTIME_DIR", dir.path())
.current_dir(&canonical_dir)
.args(["notes", "list", "--json"]);
let output = cmd.output().expect("cqs notes list spawn");
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
output.status.success(),
"`cqs notes list --json` failed; stdout=<{stdout}> stderr=<{stderr}>"
);
assert_eq!(
mock.conn_count(),
1,
"expected exactly one daemon connection, got {}; stdout=<{stdout}> stderr=<{stderr}>",
mock.conn_count()
);
assert!(
stdout.contains("DAEMON_MOCK_SENTINEL"),
"daemon sentinel missing from stdout; stdout=<{stdout}> stderr=<{stderr}>"
);
}