mod common; use std::fs; use std::io::{BufRead, BufReader, Read}; use std::path::Path; use std::process::{Child, Command, Stdio}; use std::time::{Duration, Instant}; fn frxd() -> Command { Command::new(env!("CARGO_BIN_EXE_frxd")) } fn frx() -> Command { Command::new(env!("CARGO_BIN_EXE_frx")) } fn init_args( config: &Path, name: &str, listen_port: u16, relay_port: u16, data: &Path, ) -> Vec { vec![ "--config".to_string(), config.display().to_string(), "init".to_string(), "--name".to_string(), name.to_string(), "--listen".to_string(), format!("127.0.0.1:{listen_port}"), "--relay".to_string(), format!("http://127.0.0.1:{relay_port}"), "--data-dir".to_string(), data.display().to_string(), ] } fn add_args(config: &Path, docs: &Path, name: &str, shared: bool) -> Vec { let mut args = vec![ "--config".to_string(), config.display().to_string(), "add".to_string(), docs.display().to_string(), "--name".to_string(), name.to_string(), "--exposure".to_string(), "full".to_string(), ]; if shared { args.push("--shared".to_string()); } args } fn run_ok(args: &[String]) -> String { let output = frxd().args(args).output().unwrap(); assert!( output.status.success(), "command failed: {}\n{}", args.join(" "), String::from_utf8_lossy(&output.stderr) ); String::from_utf8_lossy(&output.stdout).to_string() } struct Service { child: Child, } impl Drop for Service { fn drop(&mut self) { let _ = self.child.kill(); let _ = self.child.wait(); } } fn spawn_service(args: &[String], needle: &str) -> Service { let mut child = frxd() .args(args) .stdout(Stdio::piped()) .stderr(Stdio::null()) .spawn() .unwrap(); let reader = BufReader::new(child.stdout.take().unwrap()); let mut reader = reader; let deadline = Instant::now() + Duration::from_secs(10); loop { let mut line = String::new(); let read = reader.read_line(&mut line).unwrap(); if read == 0 { panic!("service exited before printing '{needle}'"); } if line.contains(needle) { break; } assert!(Instant::now() < deadline, "timeout waiting for '{needle}'"); } std::thread::spawn(move || { let mut sink = Vec::new(); let _ = reader.read_to_end(&mut sink); }); Service { child } } #[test] fn cli_init_add_search_status() { let root = tempfile::tempdir().unwrap(); let config = root.path().join("frxd.toml"); let data = root.path().join("data"); let docs = root.path().join("docs"); fs::create_dir_all(&docs).unwrap(); fs::write(docs.join("note.txt"), "cli smoke rust document").unwrap(); let config_arg = config.display().to_string(); let stdout = run_ok(&init_args(&config, "alice", 0, 1, &data)); assert!(stdout.contains("pubkey")); assert!(config.exists()); let key = data.join("key.hex"); assert!(key.exists()); #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let mode = fs::metadata(&key).unwrap().permissions().mode() & 0o777; assert_eq!(mode, 0o600, "key file must not be world readable"); } let stdout = run_ok(&add_args(&config, &docs, "docs", true)); assert!(stdout.contains("indexed 1 file(s)")); let stdout = run_ok(&[ "--config".to_string(), config_arg.clone(), "search".to_string(), "rust".to_string(), ]); assert!(stdout.contains("cli smoke rust document")); let output = frx() .args(["--config", &config_arg, "search", "rust"]) .output() .unwrap(); assert!(output.status.success()); assert!(String::from_utf8_lossy(&output.stdout).contains("cli smoke rust document")); let stdout = run_ok(&["--config".to_string(), config_arg, "status".to_string()]); assert!(stdout.contains("node not running")); } #[test] fn cli_init_refuses_overwrite_without_force() { let root = tempfile::tempdir().unwrap(); let config = root.path().join("frxd.toml"); let data = root.path().join("data"); let args = init_args(&config, "alice", 0, 1, &data); run_ok(&args); let output = frxd().args(&args).output().unwrap(); assert!(!output.status.success()); assert!(String::from_utf8_lossy(&output.stderr).contains("already exists")); let mut forced = args.clone(); forced.push("--force".to_string()); run_ok(&forced); } #[test] fn cli_full_network_pipeline() { let root = tempfile::tempdir().unwrap(); let relay_port = common::free_port(); let relay_args = vec![ "relay".to_string(), "--listen".to_string(), format!("127.0.0.1:{relay_port}"), ]; let _relay = spawn_service(&relay_args, "relay listening"); let bob_port = common::free_port(); let alice_port = common::free_port(); let bob_config = root.path().join("bob.toml"); let alice_config = root.path().join("alice.toml"); let bob_docs = root.path().join("bob-docs"); let alice_docs = root.path().join("alice-docs"); fs::create_dir_all(&bob_docs).unwrap(); fs::create_dir_all(&alice_docs).unwrap(); fs::write( bob_docs.join("tantivy.md"), "# Tantivy BM25\nTantivy indexes rust documents.", ) .unwrap(); fs::write(alice_docs.join("private.txt"), "alice private rust note").unwrap(); run_ok(&init_args( &bob_config, "bob", bob_port, relay_port, &root.path().join("bob-data"), )); run_ok(&init_args( &alice_config, "alice", alice_port, relay_port, &root.path().join("alice-data"), )); run_ok(&add_args(&bob_config, &bob_docs, "shared", true)); run_ok(&add_args(&alice_config, &alice_docs, "mine", false)); let bob_serve = vec![ "--config".to_string(), bob_config.display().to_string(), "serve".to_string(), ]; let alice_serve = vec![ "--config".to_string(), alice_config.display().to_string(), "serve".to_string(), ]; let _bob = spawn_service(&bob_serve, "frxd listening"); let _alice = spawn_service(&alice_serve, "frxd listening"); std::thread::sleep(Duration::from_millis(400)); let alice_config_arg = alice_config.display().to_string(); let stdout = run_ok(&[ "--config".to_string(), alice_config_arg.clone(), "query".to_string(), "tantivy".to_string(), ]); assert!(stdout.contains("Tantivy BM25"), "{stdout}"); assert!(stdout.contains("remote: 1 response(s)"), "{stdout}"); let stdout = run_ok(&[ "--config".to_string(), alice_config_arg.clone(), "query".to_string(), "rust".to_string(), "--local-only".to_string(), ]); assert!(stdout.contains("alice private rust note"), "{stdout}"); assert!(stdout.contains("remote: 0 response(s)"), "{stdout}"); let output = frx() .args(["--config", &alice_config_arg, "query", "tantivy"]) .output() .unwrap(); assert!(output.status.success()); assert!(String::from_utf8_lossy(&output.stdout).contains("Tantivy BM25")); }