feat(localnet): add --json flag to localnet logs (#187)

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Sasha
2026-06-04 00:18:25 +02:00
committed by GitHub
co-authored by Claude
parent f7bf0505d3
commit 99fd9c516e
5 changed files with 154 additions and 9 deletions
+7 -1
View File
@@ -365,6 +365,9 @@ struct LocalnetStatusArgs {
struct LocalnetLogsArgs {
#[arg(long, default_value_t = 200)]
tail: usize,
/// Emit the tailed log lines as a JSON object instead of plain text.
#[arg(long)]
json: bool,
}
/// Reset localnet to a clean state: stop the sequencer, delete the sequencer
@@ -620,7 +623,10 @@ pub(crate) fn run(args: Vec<String>) -> 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,
},
LocalnetSubcommand::Reset(args) => LocalnetAction::Reset {
dry_run: args.dry_run,
yes: args.yes,
+2 -1
View File
@@ -68,7 +68,8 @@ pub(crate) const EXAMPLES_LOCALNET_STATUS: &str = r"Examples:
pub(crate) const EXAMPLES_LOCALNET_LOGS: &str = r"Examples:
logos-scaffold localnet logs
logos-scaffold localnet logs --tail 500";
logos-scaffold localnet logs --tail 500
logos-scaffold localnet logs --tail 50 --json";
pub(crate) const EXAMPLES_LOCALNET_RESET: &str = r"Examples:
logos-scaffold localnet reset --dry-run
+44 -7
View File
@@ -11,7 +11,9 @@ use serde_json::Value;
use crate::circuits::ensure_circuits_for_subprocess;
use crate::constants::{SEQUENCER_BIN_REL_PATH, SEQUENCER_CONFIG_REL_PATH};
use crate::error::{LocalnetError, ResetError};
use crate::model::{LocalnetOwnership, LocalnetState, LocalnetStatusReport, Project};
use crate::model::{
LocalnetLogsReport, LocalnetOwnership, LocalnetState, LocalnetStatusReport, Project,
};
use crate::process::{listener_pid, pid_alive, pid_command, pid_running, port_open, spawn_to_log};
use crate::project::{
ensure_dir_exists, find_project_root, load_project, resolve_cache_root, resolve_repo_path,
@@ -34,6 +36,7 @@ pub(crate) enum LocalnetAction {
},
Logs {
tail: usize,
json: bool,
},
Reset {
dry_run: bool,
@@ -109,7 +112,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),
LocalnetAction::Reset {
dry_run,
yes,
@@ -430,29 +433,63 @@ fn ownership_label(ownership: LocalnetOwnership) -> &'static str {
}
}
fn cmd_localnet_logs(log_path: &Path, tail: usize) -> DynResult<()> {
fn cmd_localnet_logs(log_path: &Path, tail: usize, json: bool) -> DynResult<()> {
if !log_path.exists() {
println!("log file does not exist yet: {}", log_path.display());
if json {
print_logs_json(log_path, false, tail, Vec::new())?;
} else {
println!("log file does not exist yet: {}", log_path.display());
}
return Ok(());
}
let content = fs::read_to_string(log_path)
.with_context(|| format!("failed to read log file {}", log_path.display()))?;
// Treat a whitespace-only log as empty in BOTH modes. Without this, a log
// containing only newlines yields `content.lines() == [""]`, so JSON would
// report a non-empty `lines` array — contradicting the LocalnetLogsReport
// contract (empty when the log is empty) and the plain-text branch below.
if content.trim().is_empty() {
if json {
return print_logs_json(log_path, true, tail, Vec::new());
}
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..] {
let all_lines: Vec<&str> = content.lines().collect();
let start = all_lines.len().saturating_sub(tail);
let tail_lines = &all_lines[start..];
if json {
let lines = tail_lines.iter().map(|l| l.to_string()).collect();
return print_logs_json(log_path, true, tail, lines);
}
for line in tail_lines {
println!("{line}");
}
Ok(())
}
fn print_logs_json(
log_path: &Path,
exists: bool,
tail: usize,
lines: Vec<String>,
) -> DynResult<()> {
let report = LocalnetLogsReport {
log_path: log_path.display().to_string(),
exists,
tail,
lines,
};
println!("{}", serde_json::to_string_pretty(&report)?);
Ok(())
}
fn build_status_report(
state_path: &Path,
log_path: &Path,
+12
View File
@@ -205,6 +205,18 @@ pub(crate) struct LocalnetStatusReport {
pub(crate) remediation: Vec<String>,
}
/// Machine-readable shape for `localnet logs --json`. Mirrors the human
/// output: `lines` holds the last `tail` lines of the sequencer log (empty
/// when the log is missing or empty), and `exists` lets consumers tell an
/// absent log file apart from an empty one without parsing prose.
#[derive(Clone, Debug, Serialize)]
pub(crate) struct LocalnetLogsReport {
pub(crate) log_path: String,
pub(crate) exists: bool,
pub(crate) tail: usize,
pub(crate) lines: Vec<String>,
}
#[derive(Clone, Debug, Serialize)]
pub(crate) struct DoctorSummary {
pub(crate) pass: usize,
+89
View File
@@ -829,6 +829,95 @@ fn localnet_status_json_is_parseable() {
assert!(value.get("ready").is_some());
}
#[test]
fn localnet_logs_json_tails_and_is_parseable() {
let temp = tempdir().expect("tempdir");
let lez_path = temp.path().join("lez");
fs::create_dir_all(&lez_path).expect("create lez path");
write_scaffold_toml(temp.path(), &lez_path);
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", "--tail", "2", "--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_eq!(value.get("exists").and_then(|v| v.as_bool()), Some(true));
assert_eq!(value.get("tail").and_then(|v| v.as_u64()), Some(2));
let lines = value
.get("lines")
.and_then(|v| v.as_array())
.expect("lines array");
assert_eq!(
lines.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>(),
vec!["line two", "line three"],
"should return the last two lines"
);
}
#[test]
fn localnet_logs_json_reports_missing_log_without_failing() {
let temp = tempdir().expect("tempdir");
let lez_path = temp.path().join("lez");
fs::create_dir_all(&lez_path).expect("create lez path");
write_scaffold_toml(temp.path(), &lez_path);
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_eq!(value.get("exists").and_then(|v| v.as_bool()), Some(false));
assert_eq!(
value.get("lines").and_then(|v| v.as_array()).map(Vec::len),
Some(0),
"missing log should yield an empty lines array"
);
}
#[test]
fn localnet_logs_json_treats_whitespace_only_log_as_empty() {
// A log file that exists but holds only newlines/whitespace must report
// exists=true with an empty `lines` array — `content.lines()` on "\n\n"
// would otherwise yield ["", ""]. Mirrors the plain-text "empty" branch.
let temp = tempdir().expect("tempdir");
let lez_path = temp.path().join("lez");
fs::create_dir_all(&lez_path).expect("create lez path");
write_scaffold_toml(temp.path(), &lez_path);
fs::create_dir_all(temp.path().join(".scaffold/logs")).expect("create logs dir");
fs::write(temp.path().join(".scaffold/logs/sequencer.log"), "\n\n \n")
.expect("write whitespace-only log");
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_eq!(value.get("exists").and_then(|v| v.as_bool()), Some(true));
assert_eq!(
value.get("lines").and_then(|v| v.as_array()).map(Vec::len),
Some(0),
"whitespace-only log must yield an empty lines array, not [\"\"]"
);
}
#[test]
fn doctor_json_outputs_machine_readable_report() {
let temp = tempdir().expect("tempdir");