use std::fs; use std::path::{Path, PathBuf}; use std::process::{Command, Output, Stdio}; use std::sync::atomic::{AtomicU64, Ordering}; const EXIT_USAGE: i32 = 2; const EXIT_INPUT: i32 = 3; const EXIT_OUTPUT: i32 = 5; static TEMP_ID: AtomicU64 = AtomicU64::new(0); struct TestDir(PathBuf); impl TestDir { fn new(name: &str) -> Self { let id = TEMP_ID.fetch_add(1, Ordering::Relaxed); let path = std::env::temp_dir().join(format!( "metacrate-inventory-explorer-{name}-{}-{id}", std::process::id() )); fs::create_dir(&path).expect("create test directory"); Self(path) } fn path(&self, name: &str) -> PathBuf { self.0.join(name) } } impl Drop for TestDir { fn drop(&mut self) { let _ = fs::remove_dir_all(&self.0); } } fn run(args: &[&str]) -> Output { Command::new(env!("CARGO_BIN_EXE_inventory-explorer")) .args(args) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .output() .expect("run inventory-explorer") } fn text(bytes: &[u8]) -> &str { std::str::from_utf8(bytes).expect("UTF-8 output") } fn path_text(path: &Path) -> &str { path.to_str().expect("UTF-8 path") } fn assert_exit(output: &Output, expected: i32) { assert_eq!( output.status.code(), Some(expected), "stdout:\n{}\nstderr:\n{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); } fn fixture(directory: &TestDir) -> PathBuf { let path = directory.path("inventory.json"); fs::write( &path, r#"{ "root_id":"10000000-0000-0000-0000-000000000000", "_embedded":{ "categories":{ "root":{"category_id":"10000000-0000-0000-0000-000000000000","parent_id":"00000000-0000-0000-0000-000000000000","name":"My Inventory","type_default":8,"version":4,"descendents":5}, "objects":{"category_id":"20000000-0000-0000-0000-000000000000","parent_id":"10000000-0000-0000-0000-000000000000","name":"Objects","type_default":6,"version":2,"descendents":2}, "scripts":{"category_id":"30000000-0000-0000-0000-000000000000","parent_id":"10000000-0000-0000-0000-000000000000","name":"Scripts","type_default":10,"version":2,"descendents":1}, "empty":{"category_id":"40000000-0000-0000-0000-000000000000","parent_id":"10000000-0000-0000-0000-000000000000","name":"Empty","type_default":-1,"version":1,"descendents":0} }, "items":{ "sword":{"item_id":"50000000-0000-0000-0000-000000000000","parent_id":"20000000-0000-0000-0000-000000000000","agent_id":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa","asset_id":"51000000-0000-0000-0000-000000000000","name":"Sword","desc":"A test object","type":"object","inv_type":"object","created_at":1}, "texture":{"item_id":"60000000-0000-0000-0000-000000000000","parent_id":"20000000-0000-0000-0000-000000000000","agent_id":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa","asset_id":"61000000-0000-0000-0000-000000000000","name":"Sword Texture","desc":"Texture","type":"texture","inv_type":"texture","created_at":2}, "script":{"item_id":"70000000-0000-0000-0000-000000000000","parent_id":"30000000-0000-0000-0000-000000000000","agent_id":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa","asset_id":"71000000-0000-0000-0000-000000000000","name":"Sword Script","desc":"Script","type":"lsltext","inv_type":"script","created_at":3}, "readme":{"item_id":"80000000-0000-0000-0000-000000000000","parent_id":"10000000-0000-0000-0000-000000000000","agent_id":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa","asset_id":"81000000-0000-0000-0000-000000000000","name":"Read Me","desc":"Notes","type":"notecard","inv_type":"notecard","created_at":4} }, "links":{ "shortcut":{"item_id":"90000000-0000-0000-0000-000000000000","parent_id":"10000000-0000-0000-0000-000000000000","agent_id":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa","linked_id":"50000000-0000-0000-0000-000000000000","name":"Sword Shortcut","desc":"Link","type":"object","inv_type":"texture","created_at":5} } } }"#, ) .expect("write AIS fixture"); path } #[test] fn help_preserves_upstream_options_and_documents_bounded_fake_mode() { let output = run(&["--help"]); assert!(output.status.success()); let help = text(&output.stdout); for marker in [ "[FIRSTNAME]", "[LASTNAME]", "[PASSWORD]", "--stats", "--search ", "--type ", "--export ", "--fake-ais ", "--max-entries", "--max-export-bytes", ] { assert!(help.contains(marker), "help omitted {marker}:\n{help}"); } let output = run(&[]); assert_exit(&output, EXIT_USAGE); assert!(text(&output.stderr).contains("Usage: inventory-explorer")); } #[test] fn fake_ais_top_level_and_statistics_use_real_hierarchical_store() { let directory = TestDir::new("browse-stats"); let fixture = fixture(&directory); let output = run(&["--fake-ais", path_text(&fixture)]); assert!(output.status.success(), "{}", text(&output.stderr)); let output = text(&output.stdout); for expected in [ "CALL AIS fetch root=10000000-0000-0000-0000-000000000000", "Fake AIS inventory loaded: 9 entries", "=== Inventory Tree (Top Level) ===", "Folder: Empty (0 entries)", "Folder: Objects (2 entries)", "Folder: Scripts (1 entries)", "Item: Read Me (Notecard)", "Link: Sword Shortcut (Link -> 50000000-0000-0000-0000-000000000000)", "Fake grid logout complete; active_tasks=0 open_sockets=0", ] { assert!( output.contains(expected), "output omitted {expected}:\n{output}" ); } assert!(output.find("Folder: Empty").unwrap() < output.find("Folder: Objects").unwrap()); let output = run(&["--fake-ais", path_text(&fixture), "--stats"]); assert!(output.status.success(), "{}", text(&output.stderr)); let output = text(&output.stdout); for expected in [ "=== Inventory Statistics ===", "Total Items: 5", "Root Folders: 3", "Link: 1 items", "LSLText: 1 items", "Notecard: 1 items", "Object: 1 items", "Texture: 1 items", ] { assert!( output.contains(expected), "stats omitted {expected}:\n{output}" ); } } #[test] fn search_filter_limit_parent_and_link_details_are_deterministic() { let directory = TestDir::new("search"); let fixture = fixture(&directory); let output = run(&[ "--fake-ais", path_text(&fixture), "--search", "sWoRd", "--type", "TeXtUrE", ]); assert!(output.status.success(), "{}", text(&output.stderr)); let output = text(&output.stdout); assert!(output.contains("Found 1 matching entries")); assert!(output.contains("Sword Texture\nType: Texture\nFolder: Objects")); assert!(!output.contains("Sword Script\n")); assert!(!output.contains("Sword Shortcut\n")); let output = run(&[ "--fake-ais", path_text(&fixture), "--search", "Sword", "--max-results", "2", ]); assert!(output.status.success()); let output = text(&output.stdout); assert!(output.contains("Found 4 matching entries")); assert!(output.contains("... and 2 more results")); assert!(output.contains("Sword\nType: Object")); assert!(output.contains("Sword Script\nType: LSLText")); } #[test] fn export_is_hierarchical_deterministic_and_bounded() { let directory = TestDir::new("export"); let fixture = fixture(&directory); let first = directory.path("first.txt"); let second = directory.path("second.txt"); for destination in [&first, &second] { let output = run(&[ "--fake-ais", path_text(&fixture), "--export", path_text(destination), ]); assert!(output.status.success(), "{}", text(&output.stderr)); assert!(text(&output.stdout).contains("Exported 8 entries")); } let first_bytes = fs::read(&first).expect("read first export"); let second_bytes = fs::read(&second).expect("read second export"); assert_eq!(first_bytes, second_bytes); let export = text(&first_bytes); assert_eq!( export, concat!( "LibreMetaverse Inventory Export\n", "Format: deterministic-v1\n", "Entries: 8\n", "\n", "[Folder] Empty\n", "[Folder] Objects\n", " Sword (Object) - 50000000-0000-0000-0000-000000000000\n", " Sword Texture (Texture) - 60000000-0000-0000-0000-000000000000\n", "[Folder] Scripts\n", " Sword Script (LSLText) - 70000000-0000-0000-0000-000000000000\n", "Read Me (Notecard) - 80000000-0000-0000-0000-000000000000\n", "Sword Shortcut (Link -> 50000000-0000-0000-0000-000000000000) - ", "90000000-0000-0000-0000-000000000000\n", ) ); let limited = directory.path("limited.txt"); let output = run(&[ "--fake-ais", path_text(&fixture), "--export", path_text(&limited), "--max-export-bytes", "10", ]); assert_exit(&output, EXIT_OUTPUT); assert!(!limited.exists()); assert!(text(&output.stderr).contains("export exceeds the byte limit")); } #[test] fn invalid_fixtures_options_filesystem_and_credentials_fail_explicitly() { let directory = TestDir::new("invalid"); let malformed = directory.path("malformed.json"); fs::write(&malformed, "{bad-json}").expect("write malformed fixture"); let output = run(&["--fake-ais", path_text(&malformed)]); assert_exit(&output, EXIT_INPUT); assert!(output.stdout.is_empty()); assert!(text(&output.stderr).contains("could not parse LLSD JSON")); let fixture = fixture(&directory); let output = run(&["--fake-ais", path_text(&fixture), "--type", "Texture"]); assert_exit(&output, EXIT_USAGE); assert!(text(&output.stderr).contains("--type requires --search")); let output = run(&[ "--fake-ais", path_text(&fixture), "--search", "Sword", "--type", "Money", ]); assert_exit(&output, EXIT_USAGE); assert!(text(&output.stderr).contains("unknown asset type")); let password = "do-not-print-this-password"; let output = run(&["First", "Last", password, "--fake-ais", path_text(&fixture)]); assert_exit(&output, EXIT_USAGE); assert!(!text(&output.stdout).contains(password)); assert!(!text(&output.stderr).contains(password)); let output = run(&[ "--fake-ais", path_text(&fixture), "--export", path_text(&directory.0), ]); assert_exit(&output, EXIT_OUTPUT); assert!(text(&output.stderr).contains("writing inventory export")); }