use clap::{Parser, ValueEnum};
use once_cell::sync::Lazy;
use regex::Regex;
use serde::Serialize;
use serde_json::Value;
use std::fs;
const EXIT_FILE_ERROR: i32 = 1;
const EXIT_JSON_ERROR: i32 = 2;
const EXIT_FORMAT_ERROR: i32 = 3;
const EXIT_SEARCH_ERROR: i32 = 4;
const EXIT_REGEX_ERROR: i32 = 5;
const SECTION_TABS: &str = "### Open tabs";
const SECTION_ERRORS: &str = "### New console messages";
const SECTION_PAGE_STATE: &str = "### Page state";
const SECTION_TREE_START: &str = "```yaml";
const SECTION_TREE_END: &str = "```";
const SECTION_END: &str = "###";
const FIELD_PAGE_URL: &str = "- Page URL:";
const FIELD_PAGE_TITLE: &str = "- Page Title:";
const FIELD_PAGE_SNAPSHOT: &str = "- Page Snapshot:";
const MARKER_ERROR: &str = "[ERROR]";
const MARKER_WARNING: &str = "[WARNING]";
const ELEM_BUTTON: &str = "button";
const ELEM_LINK: &str = "link";
const ELEM_TEXTBOX: &str = "textbox";
const ELEM_CHECKBOX: &str = "checkbox";
const ELEM_RADIO: &str = "radio";
const ELEM_COMBOBOX: &str = "combobox";
const ELEM_SEARCHBOX: &str = "searchbox";
const ELEM_MENUITEM: &str = "menuitem";
const ELEM_TAB: &str = "tab";
const ELEM_OPTION: &str = "option";
const ELEM_HEADING: &str = "heading";
const ELEM_IMG: &str = "img";
static REF_REGEX: Lazy<Regex> =
Lazy::new(|| Regex::new(r"\[ref=([a-zA-Z0-9-]+)\]").unwrap());
static QUOTE_REGEX: Lazy<Regex> = Lazy::new(|| {
Regex::new(r#"(?:button|link|textbox|heading|img|checkbox|radio|menuitem|tab|option|combobox)\s*"([^"]+)""#).unwrap()
});
static SIMPLE_QUOTE_REGEX: Lazy<Regex> =
Lazy::new(|| Regex::new(r#""([^"]+)""#).unwrap());
#[derive(Clone, ValueEnum)]
enum Section {
Tabs,
Errors,
Tree,
Page,
All,
}
#[derive(Clone, ValueEnum)]
enum ListType {
Buttons,
Links,
Inputs,
Headings,
Images,
Text,
All,
}
#[derive(Clone, ValueEnum)]
enum OutputFormat {
Text,
Table,
Json,
}
#[derive(Parser)]
#[command(name = "meyerhold")]
#[command(about = "Progressive reader for Playwright MCP snapshots")]
#[command(version)]
struct Args {
file: String,
#[arg(short, long)]
section: Option<Section>,
#[arg(short, long)]
depth: Option<usize>,
#[arg(long)]
from: Option<String>,
#[arg(short, long)]
list: Option<ListType>,
#[arg(long)]
search: Option<String>,
#[arg(long)]
regex: bool,
#[arg(short, long, default_value = "text")]
format: OutputFormat,
#[arg(short = 'n', long)]
line_numbers: bool,
#[arg(long)]
limit: Option<usize>,
}
#[derive(Serialize)]
struct Element {
ref_id: String,
element_type: String,
label: String,
depth: usize,
}
#[derive(Serialize)]
struct SnapshotSummary {
page_url: String,
page_title: String,
tab_count: usize,
error_count: usize,
element_count: usize,
}
fn main() {
let args = Args::parse();
let content = match fs::read_to_string(&args.file) {
Ok(c) => c,
Err(e) => {
eprintln!("ERROR: Failed to read file '{}': {}", args.file, e);
std::process::exit(EXIT_FILE_ERROR);
}
};
if content.trim().is_empty() {
eprintln!("ERROR: File '{}' is empty", args.file);
std::process::exit(EXIT_FILE_ERROR);
}
let json: Value = match serde_json::from_str(&content) {
Ok(v) => v,
Err(e) => {
eprintln!("ERROR: Invalid JSON: {}", e);
std::process::exit(EXIT_JSON_ERROR);
}
};
let snapshot_text = match extract_snapshot_text(&json) {
Ok(text) => text,
Err(e) => {
eprintln!("ERROR: {}", e);
std::process::exit(EXIT_FORMAT_ERROR);
}
};
if let Some(search_pattern) = &args.search {
handle_search(&snapshot_text, search_pattern, args.regex, &args);
} else if let Some(list_type) = &args.list {
handle_list(&snapshot_text, list_type, &args);
} else if let Some(depth) = args.depth {
handle_tree(&snapshot_text, depth, args.from.as_deref(), &args);
} else if let Some(section) = &args.section {
handle_section(&snapshot_text, section, &args);
} else {
handle_summary(&snapshot_text, &args);
}
}
fn extract_snapshot_text(json: &Value) -> Result<String, String> {
json.get("content")
.and_then(|c| c.as_array())
.and_then(|arr| arr.first())
.and_then(|item| item.get("text"))
.and_then(|t| t.as_str())
.map(|s| s.to_string())
.ok_or_else(|| "Could not find snapshot text (expected .content[0].text)".to_string())
}
fn print_json<T: Serialize>(value: &T) {
match serde_json::to_string_pretty(value) {
Ok(json) => println!("{}", json),
Err(e) => {
eprintln!("ERROR: Failed to serialize JSON: {}", e);
std::process::exit(EXIT_JSON_ERROR);
}
}
}
fn handle_summary(text: &str, args: &Args) {
let summary = parse_summary(text);
match args.format {
OutputFormat::Json => {
print_json(&summary);
}
_ => {
println!("=== Snapshot Summary ===");
println!();
println!("Page URL: {}", summary.page_url);
println!("Page Title: {}", summary.page_title);
println!();
println!("Tabs: {}", summary.tab_count);
println!("Errors: {}", summary.error_count);
println!("Elements: {}", summary.element_count);
println!();
println!("Use --section <tabs|errors|tree|page> for details");
println!("Use --list <buttons|links|inputs|all> for elements");
println!("Use --depth N for tree navigation");
}
}
}
fn parse_summary(text: &str) -> SnapshotSummary {
let mut page_url = String::new();
let mut page_title = String::new();
let mut error_count = 0;
let element_count = REF_REGEX.find_iter(text).count();
for line in text.lines() {
if line.starts_with(FIELD_PAGE_URL) {
page_url = line.trim_start_matches(FIELD_PAGE_URL).trim().to_string();
} else if line.starts_with(FIELD_PAGE_TITLE) {
page_title = line.trim_start_matches(FIELD_PAGE_TITLE).trim().to_string();
} else if line.contains(MARKER_ERROR) || line.contains(MARKER_WARNING) {
error_count += 1;
}
}
let tab_section = extract_section(text, SECTION_TABS, SECTION_END);
let tab_count = tab_section.lines().filter(|l| l.starts_with("- ")).count();
SnapshotSummary {
page_url,
page_title,
tab_count,
error_count,
element_count,
}
}
fn handle_section(text: &str, section: &Section, args: &Args) {
let output = match section {
Section::Tabs => extract_section(text, SECTION_TABS, SECTION_END),
Section::Errors => extract_section(text, SECTION_ERRORS, SECTION_END),
Section::Page => extract_page_state(text),
Section::Tree => extract_section(text, SECTION_TREE_START, SECTION_TREE_END),
Section::All => text.to_string(),
};
print_output(&output, args);
}
fn extract_section(text: &str, start_marker: &str, end_marker: &str) -> String {
let mut result = Vec::new();
let mut in_section = false;
for line in text.lines() {
if line.contains(start_marker) {
in_section = true;
if !start_marker.starts_with("```") {
result.push(line.to_string());
}
continue;
}
if in_section {
if line.starts_with(end_marker) && line != start_marker {
break;
}
result.push(line.to_string());
}
}
result.join("\n")
}
fn extract_page_state(text: &str) -> String {
let mut result = Vec::new();
let mut in_state = false;
for line in text.lines() {
if line.contains(SECTION_PAGE_STATE) {
in_state = true;
continue;
}
if in_state {
if line.starts_with(FIELD_PAGE_SNAPSHOT) {
result.push(line.to_string());
break;
}
result.push(line.to_string());
}
}
result.join("\n")
}
fn handle_tree(text: &str, max_depth: usize, from_ref: Option<&str>, args: &Args) {
let tree_text = extract_section(text, SECTION_TREE_START, SECTION_TREE_END);
let mut output_lines = Vec::new();
let mut found_from = from_ref.is_none();
let mut base_indent = 0;
for line in tree_text.lines() {
let indent = line.len() - line.trim_start().len();
let depth = indent / 2;
if let Some(ref_id) = from_ref {
if !found_from {
if line.contains(&format!("[ref={}]", ref_id)) {
found_from = true;
base_indent = indent;
} else {
continue;
}
} else {
let is_new_element = line.trim().starts_with('-') || line.contains("[ref=");
if indent <= base_indent && is_new_element && !output_lines.is_empty() {
break;
}
}
}
let effective_depth = if from_ref.is_some() {
(indent.saturating_sub(base_indent)) / 2
} else {
depth
};
if effective_depth < max_depth {
output_lines.push(line.to_string());
}
}
let output = output_lines.join("\n");
print_output(&output, args);
}
fn handle_list(text: &str, list_type: &ListType, args: &Args) {
let elements = extract_elements(text, list_type);
match args.format {
OutputFormat::Json => {
print_json(&elements);
}
OutputFormat::Table => {
println!("{:<10} {:<15} {}", "REF", "TYPE", "LABEL");
println!("{}", "-".repeat(60));
let limit = args.limit.unwrap_or(elements.len());
for elem in elements.iter().take(limit) {
let label = truncate_str(&elem.label, 40);
println!("{:<10} {:<15} {}", elem.ref_id, elem.element_type, label);
}
println!();
println!("Total: {} elements", elements.len());
}
OutputFormat::Text => {
let limit = args.limit.unwrap_or(elements.len());
for (i, elem) in elements.iter().take(limit).enumerate() {
if args.line_numbers {
println!("{:4}: [{}] {} \"{}\"", i + 1, elem.ref_id, elem.element_type, elem.label);
} else {
println!("[{}] {} \"{}\"", elem.ref_id, elem.element_type, elem.label);
}
}
if elements.len() > limit {
println!("... and {} more (use --limit to show more)", elements.len() - limit);
}
}
}
}
fn extract_elements(text: &str, list_type: &ListType) -> Vec<Element> {
let mut elements = Vec::new();
let type_patterns: &[&str] = match list_type {
ListType::Buttons => &[ELEM_BUTTON],
ListType::Links => &[ELEM_LINK],
ListType::Inputs => &[ELEM_TEXTBOX, ELEM_CHECKBOX, ELEM_RADIO, ELEM_COMBOBOX, ELEM_SEARCHBOX],
ListType::Headings => &[ELEM_HEADING],
ListType::Images => &[ELEM_IMG],
ListType::Text => &["__text__"], ListType::All => &[
ELEM_BUTTON, ELEM_LINK, ELEM_TEXTBOX, ELEM_CHECKBOX, ELEM_RADIO,
ELEM_COMBOBOX, ELEM_SEARCHBOX, ELEM_MENUITEM, ELEM_TAB, ELEM_OPTION,
],
};
let extract_text_only = matches!(list_type, ListType::Text);
for line in text.lines() {
let trimmed = line.trim().trim_start_matches("- ");
if extract_text_only {
if trimmed.starts_with("text:") || trimmed.starts_with("- text:") {
let content = trimmed
.trim_start_matches("- ")
.trim_start_matches("text:")
.trim()
.trim_matches('"')
.to_string();
if !content.is_empty() {
let indent = line.len() - line.trim_start().len();
elements.push(Element {
ref_id: String::new(),
element_type: "text".to_string(),
label: content,
depth: indent / 2,
});
}
}
continue;
}
let matched_type = type_patterns
.iter()
.find(|&&p| trimmed.starts_with(p) || trimmed.starts_with(&format!("'{}", p)));
if let Some(&elem_type) = matched_type {
if let Some(caps) = REF_REGEX.captures(line) {
let ref_id = caps.get(1).unwrap().as_str().to_string();
let label = extract_label(trimmed);
let indent = line.len() - line.trim_start().len();
let depth = indent / 2;
elements.push(Element {
ref_id,
element_type: elem_type.to_string(),
label,
depth,
});
}
}
}
elements
}
fn extract_label(line: &str) -> String {
if let Some(caps) = QUOTE_REGEX.captures(line) {
return caps
.get(1)
.map(|m| m.as_str().to_string())
.unwrap_or_default();
}
if let Some(caps) = SIMPLE_QUOTE_REGEX.captures(line) {
return caps
.get(1)
.map(|m| m.as_str().to_string())
.unwrap_or_default();
}
String::new()
}
fn handle_search(text: &str, pattern: &str, use_regex: bool, args: &Args) {
let matcher: Box<dyn Fn(&str) -> bool> = if use_regex {
let re = match Regex::new(pattern) {
Ok(r) => r,
Err(e) => {
eprintln!("ERROR: Invalid regex: {}", e);
std::process::exit(EXIT_REGEX_ERROR);
}
};
Box::new(move |line: &str| re.is_match(line))
} else {
let pattern_lower = pattern.to_lowercase();
Box::new(move |line: &str| line.to_lowercase().contains(&pattern_lower))
};
let mut results = Vec::new();
for (line_num, line) in text.lines().enumerate() {
if matcher(line) {
let ref_id = REF_REGEX
.captures(line)
.and_then(|c| c.get(1))
.map(|m| m.as_str().to_string());
results.push((line_num + 1, line.to_string(), ref_id));
}
}
if results.is_empty() {
eprintln!("No matches found for pattern: {}", pattern);
std::process::exit(EXIT_SEARCH_ERROR);
}
match args.format {
OutputFormat::Json => {
let json_results: Vec<_> = results
.iter()
.map(|(num, line, ref_id)| {
serde_json::json!({
"line": num,
"content": line,
"ref": ref_id,
})
})
.collect();
print_json(&json_results);
}
_ => {
let limit = args.limit.unwrap_or(results.len());
for (line_num, line, ref_id) in results.iter().take(limit) {
let ref_str = ref_id.as_ref().map(|r| format!(" [{}]", r)).unwrap_or_default();
if args.line_numbers {
println!("{:5}:{}{}", line_num, ref_str, line.trim());
} else {
println!("{}{}", ref_str, line.trim());
}
}
println!();
println!("Found {} matches", results.len());
}
}
}
fn truncate_str(s: &str, max_chars: usize) -> String {
let char_count = s.chars().count();
if char_count <= max_chars {
return s.to_string();
}
let truncate_at = max_chars.saturating_sub(3);
let end_pos = s
.char_indices()
.nth(truncate_at)
.map(|(pos, _)| pos)
.unwrap_or(s.len());
format!("{}...", &s[..end_pos])
}
fn print_output(text: &str, args: &Args) {
let limit = args.limit.unwrap_or(usize::MAX);
for (i, line) in text.lines().take(limit).enumerate() {
if args.line_numbers {
println!("{:5}: {}", i + 1, line);
} else {
println!("{}", line);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE_SNAPSHOT: &str = r#"### Open tabs
- 0: (current) [Example Domain] (https://example.com/)
### Page state
- Page URL: https://example.com/
- Page Title: Example Domain
- Page Snapshot:
```yaml
- generic [ref=e2]:
- heading "Example Domain" [level=1] [ref=e3]
- paragraph [ref=e4]: This domain is for use in examples.
- paragraph [ref=e5]:
- link "Learn more" [ref=e6] [cursor=pointer]:
- /url: https://iana.org/domains/example
```
"#;
#[test]
fn test_extract_snapshot_text_valid() {
let json: Value = serde_json::json!({
"content": [{"text": "hello world", "type": "text"}]
});
let result = extract_snapshot_text(&json);
assert_eq!(result, Ok("hello world".to_string()));
}
#[test]
fn test_extract_snapshot_text_missing_content() {
let json: Value = serde_json::json!({});
let result = extract_snapshot_text(&json);
assert!(result.is_err());
}
#[test]
fn test_extract_snapshot_text_empty_array() {
let json: Value = serde_json::json!({"content": []});
let result = extract_snapshot_text(&json);
assert!(result.is_err());
}
#[test]
fn test_parse_summary() {
let summary = parse_summary(SAMPLE_SNAPSHOT);
assert_eq!(summary.page_url, "https://example.com/");
assert_eq!(summary.page_title, "Example Domain");
assert_eq!(summary.tab_count, 1);
assert_eq!(summary.error_count, 0);
assert!(summary.element_count > 0);
}
#[test]
fn test_extract_section_tabs() {
let result = extract_section(SAMPLE_SNAPSHOT, SECTION_TABS, SECTION_END);
assert!(result.contains("Example Domain"));
assert!(result.contains("example.com"));
}
#[test]
fn test_extract_section_tree() {
let result = extract_section(SAMPLE_SNAPSHOT, SECTION_TREE_START, SECTION_TREE_END);
assert!(result.contains("[ref=e2]"));
assert!(result.contains("heading"));
}
#[test]
fn test_extract_elements_links() {
let elements = extract_elements(SAMPLE_SNAPSHOT, &ListType::Links);
assert_eq!(elements.len(), 1);
assert_eq!(elements[0].element_type, "link");
assert_eq!(elements[0].label, "Learn more");
}
#[test]
fn test_extract_elements_headings() {
let elements = extract_elements(SAMPLE_SNAPSHOT, &ListType::Headings);
assert_eq!(elements.len(), 1);
assert_eq!(elements[0].label, "Example Domain");
}
#[test]
fn test_extract_label() {
assert_eq!(extract_label(r#"button "Click me" [ref=e1]"#), "Click me");
assert_eq!(extract_label(r#"link "Home" [ref=e2]"#), "Home");
assert_eq!(extract_label(r#"heading "Title" [level=1]"#), "Title");
}
#[test]
fn test_extract_label_no_quotes() {
assert_eq!(extract_label("button [ref=e1]"), "");
}
#[test]
fn test_truncate_str_short() {
assert_eq!(truncate_str("hello", 10), "hello");
}
#[test]
fn test_truncate_str_long() {
let result = truncate_str("hello world this is long", 10);
assert!(result.ends_with("..."));
assert!(result.len() <= 13); }
#[test]
fn test_truncate_str_unicode() {
let result = truncate_str("こんにちは世界", 5);
assert!(result.ends_with("..."));
}
#[test]
fn test_ref_regex() {
assert!(REF_REGEX.is_match("[ref=e123]"));
assert!(REF_REGEX.is_match("[ref=abc-def]"));
assert!(!REF_REGEX.is_match("[ref=]"));
}
}