mirror of
https://github.com/logos-co/scaffold.git
synced 2026-08-27 12:41:13 +00:00
feat(localnet): add --json flag to localnet logs (#187)
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
+7
-1
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user