use crate::{CliError, CliResult};
use clap::{ArgAction, Args, Subcommand, ValueEnum};
use serde::{Deserialize, Serialize};
use std::io;
use std::path::PathBuf;
use std::process::Command;
#[derive(Debug, Args)]
pub struct ListArgs {
#[arg(long, help = "Herdr workspace id, exact label, or number to filter.")]
pub workspace: Option<String>,
#[arg(long, help = "Herdr tab id, exact label, or number to filter.")]
pub tab: Option<String>,
#[arg(long, help = "Agent name to filter.")]
pub agent: Option<String>,
#[arg(long, help = "Herdr agent_status to filter.")]
pub status: Option<String>,
#[arg(long, default_value = "table", value_enum, help = "Output format.")]
pub format: OutputFormat,
#[arg(long, default_value = "herdr", help = "herdr executable path.")]
pub herdr_bin: String,
}
#[derive(Debug, Args)]
pub struct HerdrArgs {
#[command(subcommand)]
pub command: HerdrCommand,
}
#[derive(Debug, Subcommand)]
pub enum HerdrCommand {
Panes(PanesArgs),
Tab(TabArgs),
}
#[derive(Debug, Args)]
pub struct PanesArgs {
#[command(subcommand)]
pub command: PanesCommand,
}
#[derive(Debug, Subcommand)]
pub enum PanesCommand {
Ensure(PanesEnsureArgs),
}
#[derive(Debug, Args)]
pub struct TabArgs {
#[command(subcommand)]
pub command: TabCommand,
}
#[derive(Debug, Subcommand)]
pub enum TabCommand {
Recreate(TabRecreateArgs),
}
#[derive(Debug, Args)]
pub struct PanesEnsureArgs {
#[arg(long, help = "Target workspace id, exact label, or number.")]
pub workspace: String,
#[arg(long, help = "Target tab id, exact label, number, or new tab label.")]
pub tab: String,
#[arg(long, help = "Final total pane count.")]
pub total: usize,
#[arg(
long,
default_value = "grid",
value_enum,
help = "Pane split layout for newly created panes."
)]
pub layout: Layout,
#[arg(long, help = "Column count for --layout grid.")]
pub cols: Option<usize>,
#[arg(long, help = "cwd for newly created tabs/panes.")]
pub cwd: Option<PathBuf>,
#[arg(long, action = ArgAction::SetTrue, help = "Focus created panes when supported.")]
pub focus: bool,
#[arg(long, help = "Print the plan without mutating Herdr state.")]
pub dry_run: bool,
#[arg(long, default_value = "table", value_enum, help = "Output format.")]
pub format: OutputFormat,
#[arg(long, default_value = "herdr", help = "herdr executable path.")]
pub herdr_bin: String,
}
#[derive(Debug, Args)]
pub struct TabRecreateArgs {
#[arg(long, help = "Target workspace id, exact label, or number.")]
pub workspace: String,
#[arg(long, help = "Target tab id, exact label, number, or new tab label.")]
pub tab: String,
#[arg(long, help = "Final total pane count in the recreated tab.")]
pub panes: usize,
#[arg(long, default_value = "grid", value_enum, help = "Pane split layout.")]
pub layout: Layout,
#[arg(long, help = "Column count for --layout grid.")]
pub cols: Option<usize>,
#[arg(long, help = "cwd for the recreated tab and created panes.")]
pub cwd: Option<PathBuf>,
#[arg(long, action = ArgAction::SetTrue, help = "Do not focus the recreated tab.")]
pub no_focus: bool,
#[arg(long, help = "Required before closing an existing tab.")]
pub yes: bool,
#[arg(
long,
help = "Allow closing working-pane tabs or the only tab in a workspace."
)]
pub force: bool,
#[arg(long, help = "Print the plan without mutating Herdr state.")]
pub dry_run: bool,
#[arg(long, default_value = "table", value_enum, help = "Output format.")]
pub format: OutputFormat,
#[arg(long, default_value = "herdr", help = "herdr executable path.")]
pub herdr_bin: String,
}
#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)]
pub enum OutputFormat {
Table,
Json,
}
#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)]
pub enum Layout {
Grid,
Columns,
Rows,
}
#[derive(Debug, Clone, Serialize)]
pub struct Inventory {
pub workspaces: Vec<WorkspaceView>,
}
#[derive(Debug, Clone, Serialize)]
pub struct WorkspaceView {
pub workspace_id: String,
pub label: Option<String>,
pub number: Option<i64>,
pub focused: bool,
pub tabs: Vec<TabView>,
}
#[derive(Debug, Clone, Serialize)]
pub struct TabView {
pub tab_id: String,
pub workspace_id: String,
pub label: Option<String>,
pub number: Option<i64>,
pub focused: bool,
pub panes: Vec<PaneView>,
}
#[derive(Debug, Clone, Serialize)]
pub struct PaneView {
pub pane_id: String,
pub workspace_id: String,
pub tab_id: String,
pub agent: Option<String>,
pub agent_status: String,
pub label: Option<String>,
pub cwd: Option<String>,
pub focused: bool,
}
#[derive(Debug, Deserialize)]
struct Envelope<T> {
result: T,
}
#[derive(Debug, Deserialize)]
struct WorkspaceListResult {
workspaces: Vec<WorkspaceJson>,
}
#[derive(Debug, Deserialize)]
struct TabListResult {
tabs: Vec<TabJson>,
}
#[derive(Debug, Deserialize)]
struct PaneListResult {
panes: Vec<PaneJson>,
}
#[derive(Debug, Deserialize)]
struct WorkspaceJson {
workspace_id: String,
label: Option<String>,
number: Option<i64>,
focused: Option<bool>,
}
#[derive(Debug, Deserialize)]
struct TabJson {
tab_id: String,
workspace_id: String,
label: Option<String>,
number: Option<i64>,
focused: Option<bool>,
}
#[derive(Debug, Deserialize)]
struct PaneJson {
pane_id: String,
workspace_id: String,
tab_id: String,
agent: Option<String>,
agent_status: Option<String>,
label: Option<String>,
cwd: Option<String>,
foreground_cwd: Option<String>,
focused: Option<bool>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Direction {
Right,
Down,
}
impl Direction {
fn as_herdr_arg(self) -> &'static str {
match self {
Direction::Right => "right",
Direction::Down => "down",
}
}
}
#[derive(Debug)]
struct CreatedTab {
tab_id: String,
root_pane_id: Option<String>,
}
pub fn run_list(args: ListArgs) -> CliResult<()> {
let inventory = filter_inventory(load_inventory(&args.herdr_bin)?, &args)?;
match args.format {
OutputFormat::Table => render_inventory_table(&inventory),
OutputFormat::Json => {
println!(
"{}",
serde_json::to_string_pretty(&inventory).map_err(|error| {
CliError::failure(format!("failed to render inventory JSON: {error}"))
})?
);
}
}
Ok(())
}
pub fn run_herdr(args: HerdrArgs) -> CliResult<()> {
match args.command {
HerdrCommand::Panes(panes) => match panes.command {
PanesCommand::Ensure(args) => run_panes_ensure(args),
},
HerdrCommand::Tab(tab) => match tab.command {
TabCommand::Recreate(args) => run_tab_recreate(args),
},
}
}
fn run_panes_ensure(args: PanesEnsureArgs) -> CliResult<()> {
if args.total == 0 {
return Err(CliError::usage("--total must be at least 1"));
}
validate_layout(args.layout, args.total, args.cols)?;
let inventory = load_inventory(&args.herdr_bin)?;
let workspace = resolve_workspace(&inventory, &args.workspace)?;
let tab = resolve_tab(workspace, &args.tab)?;
let (tab_id, current, anchor_pane_id) = match tab {
Some(tab) => {
let anchor = tab
.panes
.iter()
.find(|pane| pane.focused)
.or_else(|| tab.panes.first())
.ok_or_else(|| CliError::failure("target tab has no pane to split"))?;
(tab.tab_id.clone(), tab.panes.len(), anchor.pane_id.clone())
}
None => {
println!(
"{}create tab {} in workspace {}",
if args.dry_run { "DRY RUN: " } else { "" },
args.tab,
workspace.workspace_id
);
if args.dry_run {
(
format!("<new:{}>", args.tab),
1,
format!("<new-root-pane:{}>", args.tab),
)
} else {
let created = create_tab(
&args.herdr_bin,
&workspace.workspace_id,
&args.tab,
args.cwd.as_ref(),
args.focus,
)?;
let root = root_pane_for_created_tab(
&args.herdr_bin,
&workspace.workspace_id,
&created.tab_id,
created.root_pane_id,
)?;
(created.tab_id, 1, root)
}
}
};
if current >= args.total {
print_or_json_result(
args.format,
&OperationResult {
action: "ensure",
workspace_id: &workspace.workspace_id,
tab_id: &tab_id,
previous: current,
requested: args.total,
created: &[],
dry_run: args.dry_run,
},
)?;
return Ok(());
}
let missing = args.total - current;
let directions = ensure_directions(args.layout, missing, args.cols)?;
if args.dry_run {
print_or_json_result(
args.format,
&OperationResult {
action: "ensure",
workspace_id: &workspace.workspace_id,
tab_id: &tab_id,
previous: current,
requested: args.total,
created: &[],
dry_run: true,
},
)?;
return Ok(());
}
let mut created = Vec::new();
for direction in directions {
created.push(split_pane(
&args.herdr_bin,
&anchor_pane_id,
direction,
args.cwd.as_ref(),
args.focus,
)?);
}
print_or_json_result(
args.format,
&OperationResult {
action: "ensure",
workspace_id: &workspace.workspace_id,
tab_id: &tab_id,
previous: current,
requested: args.total,
created: &created,
dry_run: false,
},
)
}
fn run_tab_recreate(args: TabRecreateArgs) -> CliResult<()> {
if args.panes == 0 {
return Err(CliError::usage("--panes must be at least 1"));
}
validate_layout(args.layout, args.panes, args.cols)?;
let inventory = load_inventory(&args.herdr_bin)?;
let workspace = resolve_workspace(&inventory, &args.workspace)?;
let tab = resolve_tab(workspace, &args.tab)?;
validate_recreate_safety(&args, workspace, tab)?;
let previous = tab.map(|tab| tab.panes.len()).unwrap_or(0);
if args.dry_run {
print_or_json_result(
args.format,
&OperationResult {
action: "recreate",
workspace_id: &workspace.workspace_id,
tab_id: tab.map(|tab| tab.tab_id.as_str()).unwrap_or(&args.tab),
previous,
requested: args.panes,
created: &[],
dry_run: true,
},
)?;
return Ok(());
}
if let Some(tab) = tab {
close_tab(&args.herdr_bin, &tab.tab_id)?;
}
let focus = !args.no_focus;
let created_tab = create_tab(
&args.herdr_bin,
&workspace.workspace_id,
&args.tab,
args.cwd.as_ref(),
focus,
)?;
let root_pane_id = root_pane_for_created_tab(
&args.herdr_bin,
&workspace.workspace_id,
&created_tab.tab_id,
created_tab.root_pane_id,
)?;
let split_plan = recreate_split_plan(args.layout, args.panes, args.cols)?;
let mut panes = vec![root_pane_id];
let mut created = Vec::new();
for (anchor_index, direction) in split_plan {
let anchor = panes
.get(anchor_index)
.ok_or_else(|| CliError::failure("internal split plan referenced missing anchor"))?
.clone();
let new_pane = split_pane(
&args.herdr_bin,
&anchor,
direction,
args.cwd.as_ref(),
false,
)?;
panes.push(new_pane.clone());
created.push(new_pane);
}
print_or_json_result(
args.format,
&OperationResult {
action: "recreate",
workspace_id: &workspace.workspace_id,
tab_id: &created_tab.tab_id,
previous,
requested: args.panes,
created: &created,
dry_run: false,
},
)
}
pub(crate) fn load_inventory(herdr_bin: &str) -> CliResult<Inventory> {
let Envelope {
result: WorkspaceListResult { workspaces },
} = herdr_json(herdr_bin, &["workspace", "list"])?;
let mut views = Vec::new();
for workspace in workspaces {
let workspace_id = workspace.workspace_id.clone();
let Envelope {
result: TabListResult { tabs },
} = herdr_json(herdr_bin, &["tab", "list", "--workspace", &workspace_id])?;
let Envelope {
result: PaneListResult { panes },
} = herdr_json(herdr_bin, &["pane", "list", "--workspace", &workspace_id])?;
let pane_views: Vec<PaneView> = panes
.into_iter()
.map(|pane| PaneView {
pane_id: pane.pane_id,
workspace_id: pane.workspace_id,
tab_id: pane.tab_id,
agent: pane.agent,
agent_status: pane.agent_status.unwrap_or_else(|| "unknown".to_string()),
label: pane.label,
cwd: pane.foreground_cwd.or(pane.cwd),
focused: pane.focused.unwrap_or(false),
})
.collect();
let tabs = tabs
.into_iter()
.map(|tab| {
let panes = pane_views
.iter()
.filter(|pane| pane.tab_id == tab.tab_id)
.cloned()
.collect();
TabView {
tab_id: tab.tab_id,
workspace_id: tab.workspace_id,
label: tab.label,
number: tab.number,
focused: tab.focused.unwrap_or(false),
panes,
}
})
.collect();
views.push(WorkspaceView {
workspace_id,
label: workspace.label,
number: workspace.number,
focused: workspace.focused.unwrap_or(false),
tabs,
});
}
Ok(Inventory { workspaces: views })
}
fn filter_inventory(mut inventory: Inventory, args: &ListArgs) -> CliResult<Inventory> {
if let Some(workspace) = &args.workspace {
inventory.workspaces.retain(|w| {
selector_matches_id_label_number(
&w.workspace_id,
w.label.as_deref(),
w.number,
workspace,
)
});
if inventory.workspaces.is_empty() {
return Err(CliError::usage(format!(
"no Herdr workspace matches {workspace:?}"
)));
}
}
if let Some(tab) = &args.tab {
let matching_tabs = inventory
.workspaces
.iter()
.flat_map(|workspace| workspace.tabs.iter())
.filter(|t| {
selector_matches_id_label_number(&t.tab_id, t.label.as_deref(), t.number, tab)
})
.count();
if args.workspace.is_none() && matching_tabs > 1 {
return Err(CliError::usage(format!(
"ambiguous Herdr tab selector {tab:?}; add --workspace or use tab_id"
)));
}
if matching_tabs == 0 {
return Err(CliError::usage(format!("no Herdr tab matches {tab:?}")));
}
}
for workspace in &mut inventory.workspaces {
if let Some(tab) = &args.tab {
workspace.tabs.retain(|t| {
selector_matches_id_label_number(&t.tab_id, t.label.as_deref(), t.number, tab)
});
}
for tab in &mut workspace.tabs {
tab.panes.retain(|pane| {
let agent_ok = match &args.agent {
Some(agent) => pane.agent.as_deref() == Some(agent.as_str()),
None => true,
};
let status_ok = match &args.status {
Some(status) => &pane.agent_status == status,
None => true,
};
agent_ok && status_ok
});
}
workspace
.tabs
.retain(|tab| !tab.panes.is_empty() || (args.agent.is_none() && args.status.is_none()));
}
inventory.workspaces.retain(|w| !w.tabs.is_empty());
if inventory.workspaces.is_empty() {
return Err(CliError::usage(
"no Herdr panes match the requested filters",
));
}
Ok(inventory)
}
fn render_inventory_table(inventory: &Inventory) {
println!(
"{:<18} {:<14} {:<18} {:<12} {:<9} {:<24} CWD",
"WORKSPACE", "TAB", "PANE", "AGENT", "STATUS", "LABEL"
);
for workspace in &inventory.workspaces {
let workspace_label = workspace
.label
.as_deref()
.unwrap_or(&workspace.workspace_id);
for tab in &workspace.tabs {
let tab_label = tab.label.as_deref().unwrap_or(&tab.tab_id);
for pane in &tab.panes {
println!(
"{:<18} {:<14} {:<18} {:<12} {:<9} {:<24} {}",
workspace_label,
tab_label,
pane.pane_id,
pane.agent.as_deref().unwrap_or("-"),
pane.agent_status,
pane.label.as_deref().unwrap_or("-"),
pane.cwd.as_deref().unwrap_or("-"),
);
}
}
}
}
fn selector_matches_id_label_number(
id: &str,
label: Option<&str>,
number: Option<i64>,
selector: &str,
) -> bool {
id == selector || label == Some(selector) || number.is_some_and(|n| n.to_string() == selector)
}
fn resolve_workspace<'a>(inventory: &'a Inventory, selector: &str) -> CliResult<&'a WorkspaceView> {
let matches: Vec<&WorkspaceView> = inventory
.workspaces
.iter()
.filter(|w| {
selector_matches_id_label_number(
&w.workspace_id,
w.label.as_deref(),
w.number,
selector,
)
})
.collect();
match matches.as_slice() {
[one] => Ok(*one),
[] => Err(CliError::usage(format!(
"no Herdr workspace matches {selector:?}"
))),
_ => Err(CliError::usage(format!(
"ambiguous Herdr workspace selector {selector:?}; use workspace_id"
))),
}
}
fn resolve_tab<'a>(workspace: &'a WorkspaceView, selector: &str) -> CliResult<Option<&'a TabView>> {
let matches: Vec<&TabView> = workspace
.tabs
.iter()
.filter(|t| {
selector_matches_id_label_number(&t.tab_id, t.label.as_deref(), t.number, selector)
})
.collect();
match matches.as_slice() {
[] => Ok(None),
[one] => Ok(Some(*one)),
_ => Err(CliError::usage(format!(
"ambiguous Herdr tab selector {selector:?}; use tab_id"
))),
}
}
fn validate_layout(layout: Layout, total: usize, cols: Option<usize>) -> CliResult<()> {
if cols.is_some() && layout != Layout::Grid {
return Err(CliError::usage("--cols is only valid with --layout grid"));
}
let _ = grid_columns(total, cols)?;
Ok(())
}
fn validate_recreate_safety(
args: &TabRecreateArgs,
workspace: &WorkspaceView,
tab: Option<&TabView>,
) -> CliResult<()> {
let Some(tab) = tab else {
return Ok(());
};
if !args.yes {
return Err(CliError::usage(
"recreating an existing Herdr tab requires --yes",
));
}
if workspace.tabs.len() == 1 && !args.force {
return Err(CliError::usage(
"recreating the only tab in a workspace requires --force",
));
}
if tab.panes.iter().any(|pane| pane.agent_status == "working") && !args.force {
return Err(CliError::usage(
"recreating a tab with agent_status=working panes requires --force",
));
}
Ok(())
}
fn grid_columns(total: usize, cols: Option<usize>) -> CliResult<usize> {
if total == 0 {
return Err(CliError::usage("pane total must be at least 1"));
}
if let Some(cols) = cols {
if cols == 0 || cols > total {
return Err(CliError::usage(
"--cols must be between 1 and the requested pane total",
));
}
return Ok(cols);
}
Ok((total as f64).sqrt().ceil() as usize)
}
fn ensure_directions(
layout: Layout,
missing: usize,
cols: Option<usize>,
) -> CliResult<Vec<Direction>> {
match layout {
Layout::Columns => Ok(vec![Direction::Right; missing]),
Layout::Rows => Ok(vec![Direction::Down; missing]),
Layout::Grid => {
let cols = cols.unwrap_or_else(|| ((missing + 1) as f64).sqrt().ceil() as usize);
if cols == 0 {
return Err(CliError::usage("--cols must be at least 1"));
}
let mut directions = Vec::new();
for idx in 0..missing {
if idx > 0 && idx % cols == 0 {
directions.push(Direction::Down);
} else {
directions.push(Direction::Right);
}
}
Ok(directions)
}
}
}
fn recreate_split_plan(
layout: Layout,
total: usize,
cols: Option<usize>,
) -> CliResult<Vec<(usize, Direction)>> {
if total == 0 {
return Err(CliError::usage("pane total must be at least 1"));
}
match layout {
Layout::Columns => Ok((0..total.saturating_sub(1))
.map(|_| (0, Direction::Right))
.collect()),
Layout::Rows => Ok((0..total.saturating_sub(1))
.map(|_| (0, Direction::Down))
.collect()),
Layout::Grid => {
let cols = grid_columns(total, cols)?;
let rows = total.div_ceil(cols);
let mut plan = Vec::new();
let mut row_anchors = vec![0usize];
for next_index in 1..rows {
plan.push((0, Direction::Down));
row_anchors.push(next_index);
}
let mut remaining = total.saturating_sub(rows);
for anchor in row_anchors {
let row_cells = remaining.min(cols.saturating_sub(1));
for _ in 0..row_cells {
plan.push((anchor, Direction::Right));
}
remaining = remaining.saturating_sub(row_cells);
}
Ok(plan)
}
}
}
pub(crate) fn herdr_stdout(herdr_bin: &str, args: &[&str]) -> CliResult<String> {
let output = Command::new(herdr_bin)
.args(args)
.output()
.map_err(|error| {
if error.kind() == io::ErrorKind::NotFound {
CliError::with_code(127, format!("herdr CLI not found at {herdr_bin}"))
} else {
CliError::failure(format!("failed to run herdr: {error}"))
}
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(CliError::with_code(
output.status.code().unwrap_or(1),
format!("herdr {} failed: {}", args.join(" "), stderr.trim()),
));
}
String::from_utf8(output.stdout)
.map_err(|error| CliError::failure(format!("herdr output was not utf-8: {error}")))
}
pub(crate) fn herdr_json<T: for<'de> Deserialize<'de>>(
herdr_bin: &str,
args: &[&str],
) -> CliResult<T> {
let stdout = herdr_stdout(herdr_bin, args)?;
serde_json::from_str(&stdout).map_err(|error| {
CliError::failure(format!(
"failed to parse herdr {} JSON: {error}",
args.join(" ")
))
})
}
fn run_herdr_mutation(herdr_bin: &str, args: &[String]) -> CliResult<String> {
let borrowed = args.iter().map(String::as_str).collect::<Vec<_>>();
herdr_stdout(herdr_bin, &borrowed)
}
fn split_pane(
herdr_bin: &str,
pane_id: &str,
direction: Direction,
cwd: Option<&PathBuf>,
focus: bool,
) -> CliResult<String> {
let mut args = vec![
"pane".to_string(),
"split".to_string(),
pane_id.to_string(),
"--direction".to_string(),
direction.as_herdr_arg().to_string(),
];
if let Some(cwd) = cwd {
args.push("--cwd".to_string());
args.push(cwd.display().to_string());
}
args.push(if focus { "--focus" } else { "--no-focus" }.to_string());
let stdout = run_herdr_mutation(herdr_bin, &args)?;
let value: serde_json::Value = serde_json::from_str(&stdout).map_err(|error| {
CliError::failure(format!("failed to parse herdr pane split JSON: {error}"))
})?;
value["result"]["pane"]["pane_id"]
.as_str()
.map(str::to_string)
.ok_or_else(|| CliError::failure("herdr pane split JSON missing result.pane.pane_id"))
}
fn close_tab(herdr_bin: &str, tab_id: &str) -> CliResult<()> {
run_herdr_mutation(
herdr_bin,
&["tab".to_string(), "close".to_string(), tab_id.to_string()],
)?;
Ok(())
}
fn create_tab(
herdr_bin: &str,
workspace_id: &str,
label: &str,
cwd: Option<&PathBuf>,
focus: bool,
) -> CliResult<CreatedTab> {
let mut args = vec![
"tab".to_string(),
"create".to_string(),
"--workspace".to_string(),
workspace_id.to_string(),
"--label".to_string(),
label.to_string(),
];
if let Some(cwd) = cwd {
args.push("--cwd".to_string());
args.push(cwd.display().to_string());
}
args.push(if focus { "--focus" } else { "--no-focus" }.to_string());
let stdout = run_herdr_mutation(herdr_bin, &args)?;
let value: serde_json::Value = serde_json::from_str(&stdout).map_err(|error| {
CliError::failure(format!("failed to parse herdr tab create JSON: {error}"))
})?;
let tab_id = value["result"]["tab"]["tab_id"]
.as_str()
.map(str::to_string)
.ok_or_else(|| CliError::failure("herdr tab create JSON missing result.tab.tab_id"))?;
let root_pane_id = value["result"]["pane"]["pane_id"]
.as_str()
.or_else(|| value["result"]["tab"]["pane_id"].as_str())
.map(str::to_string);
Ok(CreatedTab {
tab_id,
root_pane_id,
})
}
fn root_pane_for_created_tab(
herdr_bin: &str,
workspace_id: &str,
tab_id: &str,
create_response_pane_id: Option<String>,
) -> CliResult<String> {
if let Some(pane_id) = create_response_pane_id {
return Ok(pane_id);
}
let Envelope {
result: PaneListResult { panes },
} = herdr_json(herdr_bin, &["pane", "list", "--workspace", workspace_id])?;
panes
.into_iter()
.find(|pane| pane.tab_id == tab_id)
.map(|pane| pane.pane_id)
.ok_or_else(|| {
CliError::failure(format!(
"could not find a root pane for newly created tab {tab_id}"
))
})
}
struct OperationResult<'a> {
action: &'a str,
workspace_id: &'a str,
tab_id: &'a str,
previous: usize,
requested: usize,
created: &'a [String],
dry_run: bool,
}
fn print_or_json_result(format: OutputFormat, result: &OperationResult<'_>) -> CliResult<()> {
#[derive(Serialize)]
struct ResultView<'a> {
action: &'a str,
workspace_id: &'a str,
tab_id: &'a str,
previous_panes: usize,
requested_panes: usize,
created_panes: &'a [String],
dry_run: bool,
}
let view = ResultView {
action: result.action,
workspace_id: result.workspace_id,
tab_id: result.tab_id,
previous_panes: result.previous,
requested_panes: result.requested,
created_panes: result.created,
dry_run: result.dry_run,
};
match format {
OutputFormat::Json => {
println!(
"{}",
serde_json::to_string_pretty(&view).map_err(|error| CliError::failure(format!(
"failed to render JSON: {error}"
)))?
);
}
OutputFormat::Table => {
let prefix = if result.dry_run { "DRY RUN: " } else { "" };
println!(
"{prefix}{} workspace {} tab {}: {} -> {} pane(s)",
result.action,
result.workspace_id,
result.tab_id,
result.previous,
result.requested,
);
for pane in result.created {
println!("created pane {pane}");
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_grid_columns_are_balanced() {
assert_eq!(grid_columns(1, None).unwrap(), 1);
assert_eq!(grid_columns(4, None).unwrap(), 2);
assert_eq!(grid_columns(6, None).unwrap(), 3);
assert_eq!(grid_columns(9, None).unwrap(), 3);
}
#[test]
fn explicit_grid_columns_validate_bounds() {
assert_eq!(grid_columns(6, Some(3)).unwrap(), 3);
assert!(grid_columns(6, Some(0)).is_err());
assert!(grid_columns(6, Some(7)).is_err());
}
#[test]
fn recreate_split_plan_for_columns_and_rows_is_predictable() {
assert_eq!(
recreate_split_plan(Layout::Columns, 4, None).unwrap(),
vec![
(0, Direction::Right),
(0, Direction::Right),
(0, Direction::Right)
]
);
assert_eq!(
recreate_split_plan(Layout::Rows, 4, None).unwrap(),
vec![
(0, Direction::Down),
(0, Direction::Down),
(0, Direction::Down)
]
);
}
#[test]
fn recreate_split_plan_for_grid_uses_row_major_shape() {
assert_eq!(
recreate_split_plan(Layout::Grid, 6, Some(3)).unwrap(),
vec![
(0, Direction::Down),
(0, Direction::Right),
(0, Direction::Right),
(1, Direction::Right),
(1, Direction::Right),
]
);
}
}