diff --git a/src/cli.rs b/src/cli.rs index f389e04..95a7ea0 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -158,6 +158,9 @@ struct LocalnetStatusArgs { struct LocalnetLogsArgs { #[arg(long, default_value_t = 200)] tail: usize, + /// Output logs as JSON (for CI/tooling) + #[arg(long)] + json: bool, } #[derive(Debug, clap::Args)] @@ -259,7 +262,7 @@ pub(crate) fn run(args: Vec) -> DynResult<()> { }, LocalnetSubcommand::Stop => LocalnetAction::Stop, LocalnetSubcommand::Status(args) => LocalnetAction::Status { json: args.json }, - LocalnetSubcommand::Logs(args) => LocalnetAction::Logs { tail: args.tail }, + LocalnetSubcommand::Logs(args) => LocalnetAction::Logs { tail: args.tail, json: args.json }, }; cmd_localnet(action) } diff --git a/src/commands/localnet.rs b/src/commands/localnet.rs index 3e1b45c..a5f0177 100644 --- a/src/commands/localnet.rs +++ b/src/commands/localnet.rs @@ -21,7 +21,7 @@ pub(crate) enum LocalnetAction { Start { timeout_sec: u64 }, Stop, Status { json: bool }, - Logs { tail: usize }, + Logs { tail: usize, json: bool }, } pub(crate) fn cmd_localnet(action: LocalnetAction) -> DynResult<()> { @@ -77,7 +77,7 @@ fn cmd_localnet_in_project(project: &Project, action: LocalnetAction) -> DynResu LocalnetAction::Status { json } => { cmd_localnet_status(&state_path, &log_path, json, &localnet_addr, localnet_port) } - LocalnetAction::Logs { tail } => cmd_localnet_logs(&log_path, tail), + LocalnetAction::Logs { tail, json } => cmd_localnet_logs(&log_path, tail, json), } } @@ -323,8 +323,27 @@ fn ownership_label(ownership: LocalnetOwnership) -> &'static str { } } -fn cmd_localnet_logs(log_path: &Path, tail: usize) -> DynResult<()> { +fn print_log_lines(tail: usize, lines: &[&str], log_path: &Path, json: bool) -> DynResult<()> { + if json { + println!("{}", serde_json::to_string_pretty(&serde_json::json!({ + "tail": tail, + "lines": lines, + }))?); + } else if lines.is_empty() { + println!("log file is empty: {}", log_path.display()); + } else { + for line in lines { + println!("{line}"); + } + } + Ok(()) +} + +fn cmd_localnet_logs(log_path: &Path, tail: usize, json: bool) -> DynResult<()> { if !log_path.exists() { + if json { + return print_log_lines(tail, &[], log_path, true); + } println!("log file does not exist yet: {}", log_path.display()); return Ok(()); } @@ -332,18 +351,10 @@ fn cmd_localnet_logs(log_path: &Path, tail: usize) -> DynResult<()> { let content = fs::read_to_string(log_path) .with_context(|| format!("failed to read log file {}", log_path.display()))?; - if content.trim().is_empty() { - println!("log file is empty: {}", log_path.display()); - return Ok(()); - } - let lines: Vec<&str> = content.lines().collect(); - let start = lines.len().saturating_sub(tail); - for line in &lines[start..] { - println!("{line}"); - } - - Ok(()) + let non_empty: Vec<&str> = lines.iter().filter(|l| !l.trim().is_empty()).copied().collect(); + let start = non_empty.len().saturating_sub(tail); + print_log_lines(tail, &non_empty[start..], log_path, json) } fn build_status_report( diff --git a/tests/cli.rs b/tests/cli.rs index 4a4c765..6263d3b 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -1704,3 +1704,64 @@ fn respond_last_block(stream: &mut TcpStream) { let _ = stream.write_all(response.as_bytes()); let _ = stream.flush(); } + + +#[test] +fn localnet_logs_json_flag_shown_in_help() { + Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold")) + .args(["localnet", "logs", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("--json")); +} + +#[test] +fn localnet_logs_json_outputs_valid_json_when_no_log_file() { + let temp = tempdir().expect("tempdir"); + let lssa_path = temp.path().join("lssa"); + fs::create_dir_all(&lssa_path).expect("create lssa path"); + write_scaffold_toml(temp.path(), &lssa_path, "wallet-not-installed-for-tests"); + + let assert = Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold")) + .current_dir(temp.path()) + .args(["localnet", "logs", "--json"]) + .assert() + .success(); + + let stdout = String::from_utf8(assert.get_output().stdout.clone()).expect("utf8 stdout"); + let value: serde_json::Value = serde_json::from_str(&stdout).expect("valid json"); + + assert!(value.get("tail").is_some()); + assert!(value.get("lines").is_some()); + assert_eq!(value["lines"].as_array().unwrap().len(), 0); +} + +#[test] +fn localnet_logs_json_outputs_lines_array() { + let temp = tempdir().expect("tempdir"); + let lssa_path = temp.path().join("lssa"); + fs::create_dir_all(&lssa_path).expect("create lssa path"); + write_scaffold_toml(temp.path(), &lssa_path, "wallet-not-installed-for-tests"); + + fs::create_dir_all(temp.path().join(".scaffold/logs")).expect("create logs dir"); + fs::write( + temp.path().join(".scaffold/logs/sequencer.log"), + "line-one\nline-two\nline-three\n", + ) + .expect("write sequencer log"); + + let assert = Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold")) + .current_dir(temp.path()) + .args(["localnet", "logs", "--json", "--tail", "2"]) + .assert() + .success(); + + let stdout = String::from_utf8(assert.get_output().stdout.clone()).expect("utf8 stdout"); + let value: serde_json::Value = serde_json::from_str(&stdout).expect("valid json"); + + let lines = value["lines"].as_array().expect("lines array"); + assert_eq!(lines.len(), 2); + assert_eq!(lines[0].as_str().unwrap(), "line-two"); + assert_eq!(lines[1].as_str().unwrap(), "line-three"); + assert_eq!(value["tail"].as_u64().unwrap(), 2); +}