diff --git a/Cargo.lock b/Cargo.lock index 56ba0f14c..d5fc3962c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,17 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + [[package]] name = "ahash" version = "0.8.12" @@ -1041,6 +1052,25 @@ dependencies = [ "either", ] +[[package]] +name = "bzip2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ecfb22d906f800d4fe833b6282cf4dc1c298f5057ca0b5445e5c209735ca47" +dependencies = [ + "bzip2-sys", +] + +[[package]] +name = "bzip2-sys" +version = "0.1.13+1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "cached" version = "0.55.1" @@ -1316,6 +1346,7 @@ dependencies = [ "windows 0.61.3", "winnow 0.6.2", "winreg", + "zip 2.4.2", ] [[package]] @@ -1370,6 +1401,16 @@ dependencies = [ "half", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "clang-sys" version = "1.8.1" @@ -1549,6 +1590,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "constant_time_eq" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" + [[package]] name = "convert_case" version = "0.8.0" @@ -1632,6 +1679,21 @@ dependencies = [ "libc", ] +[[package]] +name = "crc" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9710d3b3739c2e349eb44fe848ad0b7c8cb1e42bd87ee49371df2f7acaf3e675" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + [[package]] name = "crc32fast" version = "1.4.2" @@ -1833,6 +1895,12 @@ dependencies = [ "once_cell", ] +[[package]] +name = "deflate64" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da692b8d1080ea3045efaab14434d40468c3d8657e42abddfffca87b428f4c1b" + [[package]] name = "deranged" version = "0.4.0" @@ -3245,6 +3313,15 @@ version = "2.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd" +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "insta" version = "1.43.1" @@ -3575,6 +3652,27 @@ dependencies = [ "nu-ansi-term 0.50.1", ] +[[package]] +name = "lzma-rs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "297e814c836ae64db86b36cf2a557ba54368d03f6afcd7d947c266692f71115e" +dependencies = [ + "byteorder", + "crc", +] + +[[package]] +name = "lzma-sys" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fda04ab3764e6cde78b9974eec4f779acaba7c4e84b36eca3cf77c581b85d27" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + [[package]] name = "mach2" version = "0.4.3" @@ -4450,6 +4548,16 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + [[package]] name = "percent-encoding" version = "2.3.1" @@ -7688,6 +7796,15 @@ version = "0.13.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "66fee0b777b0f5ac1c69bb06d361268faafa61cd4682ae064a171c16c433e9e4" +[[package]] +name = "xz2" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "388c44dc09d76f1536602ead6d325eb532f5c122f17782bd57fb47baeeb767e2" +dependencies = [ + "lzma-sys", +] + [[package]] name = "yaml-rust" version = "0.4.5" @@ -7797,6 +7914,20 @@ name = "zeroize" version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.104", +] [[package]] name = "zerotrie" @@ -7846,6 +7977,36 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "aes", + "arbitrary", + "bzip2", + "constant_time_eq", + "crc32fast", + "crossbeam-utils", + "deflate64", + "displaydoc", + "flate2", + "getrandom 0.3.3", + "hmac", + "indexmap", + "lzma-rs", + "memchr", + "pbkdf2", + "sha1", + "thiserror 2.0.12", + "time", + "xz2", + "zeroize", + "zopfli", + "zstd", +] + [[package]] name = "zip" version = "4.3.0" @@ -7878,3 +8039,31 @@ dependencies = [ "log", "simd-adler32", ] + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.15+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb81183ddd97d0c74cedf1d50d85c8d08c1b8b68ee863bdee9e706eedba1a237" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/Cargo.toml b/Cargo.toml index a22c78db7..a836a5f15 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -129,6 +129,7 @@ winnow = "=0.6.2" winreg = "0.55.0" schemars = "1.0.4" jsonschema = "0.30.0" +zip = "2.2.0" [workspace.lints.rust] future_incompatible = "warn" diff --git a/crates/chat-cli/Cargo.toml b/crates/chat-cli/Cargo.toml index 1b9b78d86..abcaa3d17 100644 --- a/crates/chat-cli/Cargo.toml +++ b/crates/chat-cli/Cargo.toml @@ -123,6 +123,7 @@ whoami.workspace = true winnow.workspace = true schemars.workspace = true jsonschema.workspace = true +zip.workspace = true [target.'cfg(unix)'.dependencies] nix.workspace = true diff --git a/crates/chat-cli/src/cli/chat/cli/logdump.rs b/crates/chat-cli/src/cli/chat/cli/logdump.rs new file mode 100644 index 000000000..4d7fd15cd --- /dev/null +++ b/crates/chat-cli/src/cli/chat/cli/logdump.rs @@ -0,0 +1,224 @@ +use std::io::Write; +use std::path::{ + Path, + PathBuf, +}; + +use clap::Args; +use crossterm::execute; +use crossterm::style::{ + self, + Color, +}; +use time::OffsetDateTime; +use zip::ZipWriter; +use zip::write::SimpleFileOptions; + +use crate::cli::chat::{ + ChatError, + ChatSession, + ChatState, +}; +use crate::util::directories::logs_dir; + +/// Arguments for the logdump command that collects logs for support investigation +#[derive(Debug, PartialEq, Args)] +pub struct LogdumpArgs; + +impl LogdumpArgs { + pub async fn execute(self, session: &mut ChatSession) -> Result { + execute!( + session.stderr, + style::SetForegroundColor(Color::Cyan), + style::Print("Collecting logs...\n"), + style::ResetColor, + )?; + + let timestamp = OffsetDateTime::now_local() + .unwrap_or_else(|_| OffsetDateTime::now_utc()) + .format(&time::format_description::well_known::Iso8601::DEFAULT) + .unwrap_or_else(|_| "unknown".to_string()) + .replace(':', "-"); // Replace colons for Windows compatibility + + let zip_filename = format!("q-logs-{}.zip", timestamp); + let zip_path = PathBuf::from(&zip_filename); + + match self.create_log_dump(&zip_path).await { + Ok(log_count) => { + execute!( + session.stderr, + style::SetForegroundColor(Color::Green), + style::Print(format!( + "✓ Successfully created {} with {} log files\n", + zip_filename, log_count + )), + style::ResetColor, + )?; + }, + Err(e) => { + execute!( + session.stderr, + style::SetForegroundColor(Color::Red), + style::Print(format!("✗ Failed to create log dump: {}\n\n", e)), + style::ResetColor, + )?; + return Err(ChatError::Custom(format!("Log dump failed: {}", e).into())); + }, + } + + Ok(ChatState::PromptUser { + skip_printing_tools: true, + }) + } + + async fn create_log_dump(&self, zip_path: &Path) -> Result> { + let file = std::fs::File::create(zip_path)?; + let mut zip = ZipWriter::new(file); + let mut log_count = 0; + + log_count += self.collect_chat_logs(&mut zip)?; + + zip.finish()?; + Ok(log_count) + } + + fn collect_chat_logs(&self, zip: &mut ZipWriter) -> Result> { + let mut count = 0; + + // Use the unified logs_dir function to get the correct log directory + // This will recursively collect all log files from $TMPDIR/qlog + if let Ok(log_dir) = logs_dir() { + if log_dir.exists() { + count += self.collect_logs_from_dir(&log_dir, zip, "logs")?; + } + } + + Ok(count) + } + + fn collect_logs_from_dir( + &self, + dir: &Path, + zip: &mut ZipWriter, + prefix: &str, + ) -> Result> { + let mut count = 0; + let entries = std::fs::read_dir(dir)?; + + for entry in entries.flatten() { + let path = entry.path(); + + if path.is_file() && self.is_log_file(&path) { + count += self.add_log_file_to_zip(&path, zip, prefix)?; + } else if path.is_dir() { + count += self.collect_logs_from_subdir(&path, zip, prefix)?; + } + } + + Ok(count) + } + + fn is_log_file(&self, path: &Path) -> bool { + path.extension().map(|ext| ext == "log").unwrap_or(false) + } + + fn add_log_file_to_zip( + &self, + path: &Path, + zip: &mut ZipWriter, + prefix: &str, + ) -> Result> { + let content = std::fs::read(path)?; + let filename = format!( + "{}/{}", + prefix, + path.file_name() + .unwrap_or_else(|| std::ffi::OsStr::new("unknown.log")) + .to_string_lossy() + ); + + zip.start_file(filename, SimpleFileOptions::default())?; + zip.write_all(&content)?; + Ok(1) + } + + fn collect_logs_from_subdir( + &self, + path: &Path, + zip: &mut ZipWriter, + prefix: &str, + ) -> Result> { + let subdir_name = path + .file_name() + .unwrap_or_else(|| std::ffi::OsStr::new("unknown")) + .to_string_lossy(); + let subdir_prefix = format!("{}/{}", prefix, subdir_name); + self.collect_logs_from_dir(path, zip, &subdir_prefix) + } +} + +#[cfg(test)] +mod tests { + use std::fs; + + use tempfile::TempDir; + + use super::*; + + #[tokio::test] + async fn test_logdump_creates_zip_file() { + let temp_dir = TempDir::new().unwrap(); + let zip_path = temp_dir.path().join("test-logs.zip"); + + let logdump = LogdumpArgs; + + // Create the zip file (even if no logs are found, it should create an empty zip) + let result = logdump.create_log_dump(&zip_path).await; + + // The function should succeed and create a zip file + assert!(result.is_ok()); + assert!(zip_path.exists()); + + // Verify it's a valid zip file by trying to read it + let file = fs::File::open(&zip_path).unwrap(); + let archive = zip::ZipArchive::new(file); + assert!(archive.is_ok()); + } + + #[tokio::test] + async fn test_collect_logs_from_dir() { + let temp_dir = TempDir::new().unwrap(); + let log_dir = temp_dir.path().join("logs"); + fs::create_dir_all(&log_dir).unwrap(); + + // Create some test log files + fs::write(log_dir.join("test1.log"), "log content 1").unwrap(); + fs::write(log_dir.join("test2.log"), "log content 2").unwrap(); + fs::write(log_dir.join("not_a_log.json"), "not a log").unwrap(); + + let zip_path = temp_dir.path().join("test.zip"); + let file = fs::File::create(&zip_path).unwrap(); + let mut zip = ZipWriter::new(file); + + let logdump = LogdumpArgs; + let result = logdump.collect_logs_from_dir(&log_dir, &mut zip, "test_logs"); + + assert!(result.is_ok()); + let count = result.unwrap(); + assert_eq!(count, 2); // Should collect .log files, but not .json + + zip.finish().unwrap(); + + // Verify the zip contains the expected files + let file = fs::File::open(&zip_path).unwrap(); + let mut archive = zip::ZipArchive::new(file).unwrap(); + assert_eq!(archive.len(), 2); + + let names: Vec = (0..archive.len()) + .map(|i| archive.by_index(i).unwrap().name().to_string()) + .collect(); + + assert!(names.contains(&"test_logs/test1.log".to_string())); + assert!(names.contains(&"test_logs/test2.log".to_string())); + } +} diff --git a/crates/chat-cli/src/cli/chat/cli/mod.rs b/crates/chat-cli/src/cli/chat/cli/mod.rs index 4805426d0..3714611b5 100644 --- a/crates/chat-cli/src/cli/chat/cli/mod.rs +++ b/crates/chat-cli/src/cli/chat/cli/mod.rs @@ -4,6 +4,7 @@ pub mod context; pub mod editor; pub mod hooks; pub mod knowledge; +pub mod logdump; pub mod mcp; pub mod model; pub mod persist; @@ -20,6 +21,7 @@ use context::ContextSubcommand; use editor::EditorArgs; use hooks::HooksArgs; use knowledge::KnowledgeSubcommand; +use logdump::LogdumpArgs; use mcp::McpArgs; use model::ModelArgs; use persist::PersistSubcommand; @@ -81,6 +83,9 @@ pub enum SlashCommand { Model(ModelArgs), /// Upgrade to a Q Developer Pro subscription for increased query limits Subscribe(SubscribeArgs), + /// Create a zip file with logs for support investigation + Logdump(LogdumpArgs), + /// Make conversations persistent #[command(flatten)] Persist(PersistSubcommand), // #[command(flatten)] @@ -136,6 +141,7 @@ impl SlashCommand { Self::Mcp(args) => args.execute(session).await, Self::Model(args) => args.execute(os, session).await, Self::Subscribe(args) => args.execute(os, session).await, + Self::Logdump(args) => args.execute(session).await, Self::Persist(subcommand) => subcommand.execute(os, session).await, // Self::Root(subcommand) => { // if let Err(err) = subcommand.execute(os, database, telemetry).await { @@ -167,6 +173,7 @@ impl SlashCommand { Self::Mcp(_) => "mcp", Self::Model(_) => "model", Self::Subscribe(_) => "subscribe", + Self::Logdump(_) => "logdump", Self::Persist(sub) => match sub { PersistSubcommand::Save { .. } => "save", PersistSubcommand::Load { .. } => "load",