Modernize feresectl and add bounded IPC event streaming

This commit is contained in:
Samuel Onoja committed 2026-10-07 07:39:27 +01:00
1 parent cfdd2a148a
commit 97026a0db7
39 files changed
+1671 -254

No files matched your search

+285
View File
@@ -0,0 +1,285 @@
use std::path::PathBuf;
use clap::{Parser, Subcommand, ValueEnum};
use serde_json::{Value, json};
/// Control a running Ferese session.
#[derive(Debug, Parser)]
#[command(version)]
pub struct Cli {
/// Print query results as JSON.
#[arg(short = 'j', long, global = true)]
pub json: bool,
/// Connect to this IPC socket instead of FERESE_SOCKET or the runtime default.
#[arg(long, global = true)]
pub socket: Option<PathBuf>,
#[command(subcommand)]
pub command: Command,
}
#[derive(Debug, Subcommand)]
pub enum Command {
/// Show the active window.
#[command(alias = "get-focused-window")]
FocusedWindow,
/// List windows.
#[command(alias = "get-windows")]
Windows,
/// Show idle inhibitors.
#[command(alias = "get-idle-inhibition")]
IdleInhibition,
/// List workspaces and their output ownership.
#[command(alias = "get-workspaces")]
Workspaces,
/// List connected outputs and their applied configuration.
#[command(alias = "get-outputs")]
Outputs,
/// List configured keybindings.
#[command(alias = "get-keybindings")]
Keybindings,
/// Show session and inhibition state.
#[command(alias = "get-session-state")]
SessionState,
/// Toggle floating for the focused window.
ToggleFloating,
/// Toggle fullscreen for the focused window.
ToggleFullscreen,
/// Toggle maximization for the focused window.
ToggleMaximized,
/// Switch between scrolling and tree layout.
ToggleLayout,
/// Show or hide the workspace overview.
ToggleOverview,
/// Show or hide the keybinding guide.
ToggleKeybindingGuide,
/// Cycle the focused column width.
CycleColumnWidth,
/// Center the focused column.
CenterColumn,
/// Pull a window into the focused column.
Consume,
/// Move a window out of the focused column.
Expel,
/// Close the focused window.
Close,
/// Cycle the display layout.
ToggleDisplayMode,
/// List available output profiles.
OutputProfiles,
/// Confirm the pending output configuration.
OutputConfirm,
/// Revert the pending output configuration.
OutputRevert,
/// Reload the compositor configuration.
ReloadConfig,
/// Exit the compositor.
Exit,
/// Open the logout confirmation.
RequestLogout,
/// Return to the previous workspace.
WorkspaceBackAndForth,
/// Focus the previously focused window.
FocusLastWindow,
/// Focus the next recently used window.
FocusMruNext,
/// Focus the previous recently used window.
FocusMruPrevious,
/// Focus a window in a direction.
Focus { direction: Direction },
/// Move the focused window in a direction.
Move { direction: Direction },
/// Resize the focused window in a direction.
Resize { direction: Direction },
/// Activate a workspace by its one-based index.
Workspace {
#[arg(value_parser = clap::value_parser!(u32).range(1..))]
index: u32,
},
/// Move the focused window to a workspace.
MoveToWorkspace {
#[arg(value_parser = clap::value_parser!(u32).range(1..))]
index: u32,
},
/// Apply a display layout.
OutputLayout { layout: DisplayLayout },
/// Select an output profile, or auto.
OutputProfile { name: String },
/// Enable or disable the internal display.
OutputInternal { state: Switch },
/// Write a screenshot PNG to stdout.
Screenshot {
#[arg(short = 'g', long)]
geometry: Option<String>,
},
/// Write a window screenshot PNG to stdout.
ScreenshotWindow { window: u64 },
/// Launch desktop autostart entries.
Autostart,
/// Inspect or change the theme.
Theme {
#[command(subcommand)]
action: ThemeAction,
},
/// Inspect or retry the XWayland server.
Xwayland {
#[command(subcommand)]
action: XwaylandAction,
},
/// Inspect media players or control playback.
Media {
#[command(subcommand)]
action: Option<MediaAction>,
},
/// Follow compositor changes as newline-delimited JSON.
EventStream,
}
#[derive(Clone, Copy, Debug, ValueEnum)]
pub enum Direction {
Left,
Right,
Up,
Down,
}
#[derive(Clone, Copy, Debug, ValueEnum)]
pub enum DisplayLayout {
InternalOnly,
ExternalOnly,
Extend,
Mirror,
}
#[derive(Clone, Copy, Debug, ValueEnum)]
pub enum Switch {
On,
Off,
}
#[derive(Clone, Copy, Debug, ValueEnum)]
pub enum ThemeMode {
Light,
Dark,
Auto,
}
#[derive(Debug, Subcommand)]
pub enum ThemeAction {
/// Show the current theme.
#[command(alias = "status")]
Get,
/// Follow theme snapshots as newline-delimited JSON.
Subscribe,
/// Set the appearance mode.
Mode { mode: ThemeMode },
/// Resolve a configuration file without applying it.
Preview { path: PathBuf },
}
#[derive(Debug, Subcommand)]
pub enum XwaylandAction {
/// Show XWayland status.
Status,
/// Retry starting XWayland after a failure.
Retry,
}
#[derive(Debug, Subcommand)]
pub enum MediaAction {
/// Show players and the active player.
Get,
/// Toggle playback.
PlayPause,
/// Skip to the next track.
Next,
/// Return to the previous track.
Previous,
/// Raise the active player's window.
Raise,
/// Select the active player automatically.
Auto,
/// Prefer a particular player.
Pin { player: String },
/// Exclude a player from automatic selection.
Ignore { player: String },
/// Include a previously ignored player.
Unignore { player: String },
}
fn value_name(value: &impl ValueEnum) -> String {
value
.to_possible_value()
.expect("visible enum value")
.get_name()
.to_owned()
}
impl Command {
pub fn ipc(&self) -> Option<(&'static str, Value)> {
let (command, args) = match self {
Self::FocusedWindow => ("get-focused-window", json!({})),
Self::Windows => ("get-windows", json!({})),
Self::IdleInhibition => ("get-idle-inhibition", json!({})),
Self::Workspaces => ("get-workspaces", json!({})),
Self::Outputs => ("outputs", json!({})),
Self::Keybindings => ("get-keybindings", json!({})),
Self::SessionState => ("get-session-state", json!({})),
Self::ToggleFloating => ("toggle-floating", json!({})),
Self::ToggleFullscreen => ("toggle-fullscreen", json!({})),
Self::ToggleMaximized => ("toggle-maximized", json!({})),
Self::ToggleLayout => ("toggle-layout", json!({})),
Self::ToggleOverview => ("toggle-overview", json!({})),
Self::ToggleKeybindingGuide => ("toggle-keybinding-guide", json!({})),
Self::CycleColumnWidth => ("cycle-column-width", json!({})),
Self::CenterColumn => ("center-column", json!({})),
Self::Consume => ("consume", json!({})),
Self::Expel => ("expel", json!({})),
Self::Close => ("close", json!({})),
Self::ToggleDisplayMode => ("toggle-display-mode", json!({})),
Self::OutputProfiles => ("output-profiles", json!({})),
Self::OutputConfirm => ("output-confirm", json!({})),
Self::OutputRevert => ("output-revert", json!({})),
Self::ReloadConfig => ("reload-config", json!({})),
Self::Exit => ("exit", json!({})),
Self::RequestLogout => ("request-logout", json!({})),
Self::WorkspaceBackAndForth => ("workspace-back-and-forth", json!({})),
Self::FocusLastWindow => ("focus-last-window", json!({})),
Self::FocusMruNext => ("focus-mru-next", json!({})),
Self::FocusMruPrevious => ("focus-mru-previous", json!({})),
Self::Focus { direction } => ("focus", json!({"direction": value_name(direction)})),
Self::Move { direction } => ("move", json!({"direction": value_name(direction)})),
Self::Resize { direction } => ("resize", json!({"direction": value_name(direction)})),
Self::Workspace { index } => ("workspace", json!({"index": index})),
Self::MoveToWorkspace { index } => ("move-to-workspace", json!({"index": index})),
Self::OutputLayout { layout } => ("output-layout", json!({"layout": value_name(layout)})),
Self::OutputProfile { name } => ("output-profile", json!({"name": name})),
Self::OutputInternal { state } => ("output-internal", json!({"enabled": matches!(state, Switch::On)})),
Self::Screenshot {
geometry: Some(geometry),
} => ("screenshot", json!({"geometry": geometry})),
Self::Screenshot { geometry: None } => ("screenshot", json!({})),
Self::ScreenshotWindow { window } => ("screenshot-window", json!({"window": window})),
Self::Xwayland { action } => (
match action {
XwaylandAction::Status => ferese_ipc::xwayland::STATUS_COMMAND,
XwaylandAction::Retry => ferese_ipc::xwayland::RETRY_COMMAND,
},
json!({}),
),
Self::Media {
action: None | Some(MediaAction::Get),
} => ("media-get", json!({})),
Self::Media { action: Some(action) } => {
let args = match action {
MediaAction::Pin { player } => json!({"action": "pin", "player": player}),
MediaAction::Ignore { player } => json!({"action": "ignore", "player": player, "ignored": true}),
MediaAction::Unignore { player } => json!({"action": "ignore", "player": player, "ignored": false}),
MediaAction::PlayPause => json!({"action": "play-pause"}),
MediaAction::Next => json!({"action": "next"}),
MediaAction::Previous => json!({"action": "previous"}),
MediaAction::Raise => json!({"action": "raise"}),
MediaAction::Auto => json!({"action": "auto"}),
MediaAction::Get => unreachable!(),
};
("media-action", args)
}
Self::Autostart | Self::Theme { .. } | Self::EventStream => return None,
};
Some((command, args))
}
}
+102 -226
View File
@@ -1,127 +1,130 @@
mod autostart;
mod cli;
mod output;
use std::error::Error;
use std::io::Write;
use std::os::unix::net::UnixStream;
use std::path::PathBuf;
use std::path::Path;
use std::{env, fs, io};
use clap::{Parser, ValueEnum};
use cli::{Cli, Command, ThemeAction};
use ferese_ipc::{Request, Response, VERSION, read_frame, write_frame};
use serde_json::{Value, json};
fn main() -> Result<(), Box<dyn Error>> {
if env::args().nth(1).as_deref() == Some("autostart") {
if env::args().len() != 2 {
return Err("usage: feresectl autostart".into());
}
let cli = Cli::parse();
if matches!(cli.command, Command::Autostart) {
return autostart::run();
}
if env::args().nth(1).as_deref() == Some("theme") {
return theme_command(env::args().skip(2).collect());
}
if env::args().nth(1).as_deref() == Some("xwayland") {
return xwayland_command(env::args().skip(2).collect());
}
let (command, args) = parse_args(env::args().skip(1))?;
let request = Request {
version: VERSION,
id: 1,
kind: "command".to_owned(),
command: command.clone(),
args,
};
let mut stream = UnixStream::connect(socket_path()?)?;
write_frame(&mut stream, &request)?;
let response: Response = read_frame(&mut stream)?;
if let Some(error) = response.error {
return Err(format!("{}: {}", error.code, error.message).into());
}
if matches!(command.as_str(), "screenshot" | "screenshot-window") {
write_png(&response)
} else {
println!(
"{}",
serde_json::to_string_pretty(&response.result.unwrap_or(Value::Null))?
for alias in env::args().skip(1).filter(|arg| {
matches!(
arg.as_str(),
"get-outputs"
| "get-windows"
| "get-workspaces"
| "get-focused-window"
| "get-idle-inhibition"
| "get-session-state"
| "get-keybindings"
)
}) {
eprintln!(
"feresectl: {alias} is deprecated; use {}",
alias.trim_start_matches("get-")
);
Ok(())
}
let socket = ferese_ipc::socket::resolve(cli.socket.as_deref())?;
match &cli.command {
Command::Theme { action } => theme_command(action, &socket, cli.json),
Command::EventStream => event_stream(&socket),
command => {
let (command, args) = command.ipc().expect("IPC command");
let mut stream = UnixStream::connect(socket)?;
let result = request(&mut stream, command, args)?;
if matches!(command, "screenshot" | "screenshot-window") {
write_png(&Response::success(1, result))
} else {
output::print(&result, cli.json)
}
}
}
}
fn theme_command(args: Vec<String>) -> Result<(), Box<dyn Error>> {
let mut connection = ferese_ipc::theme::Connection::connect()?;
match args.as_slice() {
[command] if command == "get" || command == "status" => {
println!(
"{}",
serde_json::to_string_pretty(&connection.get(ferese_config::families::builtins)?)?
);
}
[command] if command == "subscribe" => {
fn request(stream: &mut UnixStream, command: &str, args: Value) -> Result<Value, Box<dyn Error>> {
write_frame(
stream,
&Request {
version: VERSION,
id: 1,
kind: "command".into(),
command: command.into(),
args,
},
)?;
let response: Response = read_frame(stream)?;
if response.version != VERSION || response.id != 1 {
return Err("Unexpected IPC response".into());
}
if let Some(error) = response.error {
return Err(format!("{}: {}", error.code, error.message).into());
}
Ok(response.result.unwrap_or(Value::Null))
}
fn theme_command(action: &ThemeAction, socket: &Path, json_output: bool) -> Result<(), Box<dyn Error>> {
let mut connection = ferese_ipc::theme::Connection::connect_to(socket)?;
let result = match action {
ThemeAction::Get => serde_json::to_value(connection.get(ferese_config::families::builtins)?)?,
ThemeAction::Subscribe => {
let mut snapshot = connection.get(ferese_config::families::builtins)?;
let mut stdout = io::stdout().lock();
loop {
println!("{}", serde_json::to_string(&snapshot)?);
if !output::write_json_line(&mut stdout, &snapshot)? {
return Ok(());
}
snapshot = connection.watch(snapshot.revision, ferese_config::families::builtins)?;
}
}
[command, mode] if command == "mode" && matches!(mode.as_str(), "light" | "dark" | "auto") => {
println!(
"{}",
serde_json::to_string_pretty(&connection.call("theme-set-mode", json!({"mode": mode}))?)?
);
}
[command, path] if command == "preview" => {
let source = fs::read_to_string(path)?;
println!(
"{}",
serde_json::to_string_pretty(&connection.call(
"theme-preview",
json!({"source": source, "directory": std::path::Path::new(path).canonicalize()?.parent()})
)?)?
);
}
_ => {
return Err(
"usage: feresectl theme <get|status|subscribe|mode light|mode dark|mode auto|preview PATH>".into(),
);
}
}
Ok(())
ThemeAction::Mode { mode } => connection.call(
"theme-set-mode",
json!({"mode": mode.to_possible_value().unwrap().get_name()}),
)?,
ThemeAction::Preview { path } => connection.call(
"theme-preview",
json!({
"source": fs::read_to_string(path)?, "directory": path.canonicalize()?.parent(),
}),
)?,
};
output::print(&result, json_output)
}
fn xwayland_command(args: Vec<String>) -> Result<(), Box<dyn Error>> {
use ferese_ipc::xwayland::{RETRY_COMMAND, STATUS_COMMAND, Status};
let command = match args.as_slice() {
[command] if command == "status" => STATUS_COMMAND,
[command] if command == "retry" => RETRY_COMMAND,
_ => return Err("usage: feresectl xwayland <status|retry>".into()),
};
let request = Request {
version: VERSION,
id: 1,
kind: "command".to_owned(),
command: command.to_owned(),
args: json!({}),
};
let mut stream = UnixStream::connect(socket_path()?)?;
write_frame(&mut stream, &request)?;
let response: Response = read_frame(&mut stream)?;
if let Some(error) = response.error {
return Err(format!("{}: {}", error.code, error.message).into());
fn event_stream(socket: &Path) -> Result<(), Box<dyn Error>> {
let mut stream = UnixStream::connect(socket)?;
request(
&mut stream,
"event-stream",
json!({"version": ferese_ipc::events::VERSION}),
)?;
let mut stdout = io::stdout().lock();
loop {
let event: ferese_ipc::events::Event = read_frame(&mut stream)?;
event.validate()?;
if !output::write_json_line(&mut stdout, &event)? {
return Ok(());
}
}
}
let status = Status::from_value(response.result.unwrap_or(Value::Null))?;
if command == RETRY_COMMAND {
println!(
"X11 retry requested; current state: {}",
serde_json::to_string(&status.state)?
);
}
println!("{}", serde_json::to_string_pretty(&status)?);
Ok(())
#[cfg(test)]
fn parse_args(args: impl IntoIterator<Item = String>) -> Result<(String, Value), String> {
let cli = Cli::try_parse_from(std::iter::once("feresectl".to_owned()).chain(args)).map_err(|e| e.to_string())?;
cli.command
.ipc()
.map(|(command, args)| (command.into(), args))
.ok_or_else(|| "not an IPC command".into())
}
// The compositor stages the PNG in a private directory and replies with its
@@ -146,133 +149,6 @@ fn write_png(response: &Response) -> Result<(), Box<dyn Error>> {
Ok(())
}
fn parse_args(args: impl IntoIterator<Item = String>) -> Result<(String, Value), String> {
let mut args = args.into_iter();
let command = args.next().ok_or_else(usage)?;
let positional = args.collect::<Vec<_>>();
let payload = match command.as_str() {
"output-layout" => {
exactly_one(&command, &positional, "internal-only, external-only, extend or mirror")?;
if !["internal-only", "external-only", "extend", "mirror"].contains(&positional[0].as_str()) {
return Err("unknown display layout".into());
}
json!({ "layout": positional[0] })
}
"output-profile" => {
exactly_one(&command, &positional, "profile name or auto")?;
json!({ "name": positional[0] })
}
"output-internal" => {
exactly_one(&command, &positional, "on or off")?;
let enabled = match positional[0].as_str() {
"on" => true,
"off" => false,
_ => return Err("output-internal requires on or off".into()),
};
json!({ "enabled": enabled })
}
"screenshot-window" => {
exactly_one(&command, &positional, "window ID")?;
let window = positional[0]
.parse::<u64>()
.map_err(|_| "Expected an unsigned window ID")?;
json!({"window":window})
}
"focus" | "move" | "resize" => {
exactly_one(&command, &positional, "direction")?;
json!({ "direction": positional[0] })
}
"workspace" | "move-to-workspace" => {
exactly_one(&command, &positional, "index")?;
let index = positional[0]
.parse::<u32>()
.map_err(|_| format!("{} requires a positive workspace index", command))?;
json!({ "index": index })
}
"media" => {
return match positional.as_slice() {
[] => Ok(("media-get".into(), json!({}))),
[mode] if mode == "get" => Ok(("media-get".into(), json!({}))),
[action] if ["play-pause", "next", "previous", "raise", "auto"].contains(&action.as_str()) => {
Ok(("media-action".into(), json!({"action": action})))
}
[action, player] if ["pin", "ignore", "unignore"].contains(&action.as_str()) => {
Ok(("media-action".into(), json!({"action":if action == "pin" { "pin" } else { "ignore" },"player":player,"ignored":action == "ignore"})))
}
_ => Err("usage: feresectl media [get|play-pause|next|previous|raise|auto|pin PLAYER|ignore PLAYER|unignore PLAYER]".into()),
};
}
"screenshot" => match positional.as_slice() {
[] => json!({}),
[flag, geometry] if flag == "--geometry" || flag == "-g" => {
json!({ "geometry": geometry })
}
_ => {
return Err(format!(
"{command} accepts an optional --geometry \"x,y WxH\" ({})",
usage()
));
}
},
"toggle-floating"
| "toggle-fullscreen"
| "toggle-maximized"
| "toggle-layout"
| "toggle-overview"
| "toggle-keybinding-guide"
| "cycle-column-width"
| "center-column"
| "consume"
| "expel"
| "close"
| "get-focused-window"
| "get-windows"
| "get-idle-inhibition"
| "get-workspaces"
| "get-outputs"
| "outputs"
| "toggle-display-mode"
| "output-profiles"
| "output-confirm"
| "output-revert"
| "reload-config"
| "exit"
| "request-logout"
| "workspace-back-and-forth"
| "focus-last-window"
| "focus-mru-next"
| "focus-mru-previous" => {
if !positional.is_empty() {
return Err(format!("{command} does not accept arguments"));
}
json!({})
}
_ => return Err(format!("unknown command {command:?}\n{}", usage())),
};
Ok((command, payload))
}
fn exactly_one(command: &str, args: &[String], name: &str) -> Result<(), String> {
if args.len() == 1 {
Ok(())
} else {
Err(format!("{command} requires exactly one {name}"))
}
}
fn socket_path() -> Result<PathBuf, io::Error> {
env::var_os("XDG_RUNTIME_DIR")
.filter(|value| !value.is_empty())
.map(PathBuf::from)
.map(|directory| directory.join("ferese/control.sock"))
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "XDG_RUNTIME_DIR is not set"))
}
fn usage() -> String {
"usage: feresectl media [get|play-pause|next|previous|raise|auto|pin PLAYER|ignore PLAYER|unignore PLAYER]\n feresectl outputs\n feresectl output-profiles\n feresectl <output-confirm|output-revert>\n feresectl output-layout <internal-only|external-only|extend|mirror>\n feresectl toggle-display-mode\n feresectl output-profile <name|auto>\n feresectl output-internal <on|off>\n feresectl autostart\n feresectl xwayland <status|retry>\n feresectl screenshot [--geometry \"x,y WxH\"]\n feresectl screenshot-window <window-id>\n feresectl <focus|move|resize> <direction>\n feresectl <workspace|move-to-workspace> <index>\n feresectl workspace-back-and-forth\n feresectl <focus-last-window|focus-mru-next|focus-mru-previous>\n feresectl <toggle-floating|toggle-maximized|toggle-fullscreen|toggle-layout|toggle-overview|toggle-keybinding-guide>\n feresectl <cycle-column-width|center-column|consume|expel|close|exit|request-logout>\n feresectl <get-focused-window|get-windows|get-workspaces|get-outputs|get-idle-inhibition|reload-config>".to_owned()
}
#[cfg(test)]
mod tests {
use super::*;
@@ -282,7 +158,7 @@ mod tests {
assert_eq!(ferese_ipc::xwayland::STATUS_COMMAND, "xwayland-status");
assert_eq!(ferese_ipc::xwayland::RETRY_COMMAND, "xwayland-retry");
assert!(usage().contains("feresectl xwayland <status|retry>"));
assert!(Cli::try_parse_from(["feresectl", "xwayland", "status"]).is_ok());
}
#[test]
+97
View File
@@ -0,0 +1,97 @@
use std::error::Error;
use std::io::{self, Write};
use serde_json::Value;
pub fn print(value: &Value, json: bool) -> Result<(), Box<dyn Error>> {
let mut stdout = io::stdout().lock();
if json {
writeln!(stdout, "{}", serde_json::to_string_pretty(value)?)?;
} else {
write_plain(&mut stdout, value, 0)?;
}
stdout.flush()?;
Ok(())
}
fn write_plain(writer: &mut impl Write, value: &Value, indent: usize) -> io::Result<()> {
match value {
Value::Object(fields) => {
for (key, value) in fields {
write!(writer, "{:indent$}{key}:", "")?;
if value.is_object() || value.is_array() {
writeln!(writer)?;
write_plain(writer, value, indent + 2)?;
} else {
writeln!(writer, " {}", scalar(value))?;
}
}
}
Value::Array(items) => {
for (index, item) in items.iter().enumerate() {
if index > 0 && item.is_object() {
writeln!(writer)?;
}
write_plain(writer, item, indent)?;
}
}
_ => writeln!(writer, "{:indent$}{}", "", scalar(value))?,
}
Ok(())
}
fn scalar(value: &Value) -> String {
match value {
Value::Null => "none".into(),
Value::String(text) => text.clone(),
value => value.to_string(),
}
}
/// Return false when a downstream consumer closes stdout.
pub fn write_json_line(writer: &mut impl Write, value: &impl serde::Serialize) -> Result<bool, Box<dyn Error>> {
let line = serde_json::to_vec(value)?;
match writer
.write_all(&line)
.and_then(|()| writer.write_all(b"\n"))
.and_then(|()| writer.flush())
{
Ok(()) => Ok(true),
Err(error) if error.kind() == io::ErrorKind::BrokenPipe => Ok(false),
Err(error) => Err(error.into()),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn plain_output_has_readable_fields_without_json_quotes() {
let mut bytes = Vec::new();
write_plain(
&mut bytes,
&serde_json::json!([{"id": 7, "title": "Terminal", "focused": true}]),
0,
)
.unwrap();
assert_eq!(
String::from_utf8(bytes).unwrap(),
"focused: true\nid: 7\ntitle: Terminal\n"
);
}
#[test]
fn event_output_is_one_json_value_per_line() {
let mut bytes = Vec::new();
for value in [
serde_json::json!({"title": "a\nb"}),
serde_json::json!({"locked": true}),
] {
assert!(write_json_line(&mut bytes, &value).unwrap());
}
let text = String::from_utf8(bytes).unwrap();
assert_eq!(text.lines().count(), 2);
for line in text.lines() {
serde_json::from_str::<Value>(line).unwrap();
}
}
}