use std::process;
use std::sync::LazyLock;
use indoc::indoc;
use super::colorize_examples;
use super::{output, parse_file, resolve_files};
static EXAMPLES: LazyLock<String> = LazyLock::new(|| {
colorize_examples(indoc! {r#"
yerba sort config.yml "tags"
yerba sort videos.yml --by ".title"
yerba sort videos.yml --by ".date" --order desc --by ".title"
yerba sort videos.yml "[].speakers" --by ".name"
yerba sort videos.yml "[]" --by ".id" --order "talk-3,talk-1,talk-2"
yerba sort speakers.yml "[]" --by ".name" --order "Charlie,Alice,Bob"
"#})
});
#[derive(clap::Args)]
#[command(
about = "Sort or reorder items in a sequence",
arg_required_else_help = true,
after_help = EXAMPLES.as_str()
)]
pub struct Args {
file: String,
selector: Option<String>,
#[arg(long, action = clap::ArgAction::Append)]
by: Vec<String>,
#[arg(long, action = clap::ArgAction::Append)]
order: Vec<String>,
#[arg(long, action = clap::ArgAction::Append)]
context: Vec<String>,
#[arg(long)]
case_sensitive: bool,
#[arg(long)]
dry_run: bool,
}
fn is_direction(value: &str) -> bool {
matches!(value, "asc" | "desc" | "ascending" | "descending")
}
fn is_explicit_reorder(orders: &[String]) -> bool {
orders.iter().any(|o| !is_direction(o))
}
impl Args {
pub fn run(self) {
use super::color::*;
if self.by.is_empty() && self.order.is_empty() && self.selector.is_none() {
let document = parse_file(&self.file);
eprintln!("{RED}Error:{RESET} specify a selector, --by, or --order");
eprintln!();
eprintln!(" {BOLD}Examples:{RESET}");
eprintln!(" yerba sort \"{}\" \"tags\"", self.file);
eprintln!(" yerba sort \"{}\" --by \".title\" --order asc", self.file);
eprintln!();
super::show_similar_selectors(&self.file, &document, "[]");
process::exit(1);
}
if !self.by.is_empty() && self.order.is_empty() {
self.show_values();
} else if is_explicit_reorder(&self.order) {
self.run_reorder();
} else {
self.run_sort();
}
}
fn show_values(self) {
use super::color::*;
if self.by.len() != 1 {
eprintln!("{RED}Error:{RESET} --order is required when using --by");
process::exit(1);
}
let by = &self.by[0];
let selector = self.selector.as_deref().unwrap_or("");
let items_selector = if selector.is_empty() {
"[]".to_string()
} else {
format!("{}[]", selector)
};
let document = parse_file(&self.file);
let (labels, context_values, selector_display) = self.resolve_labels(&document, by, selector, &items_selector);
let context_hint = if self.context.is_empty() {
format!("\n\n {DIM}Add --context \".field\" to show additional fields alongside values{RESET}")
} else {
String::new()
};
eprintln!("{RED}Error:{RESET} --order is required when using --by");
eprintln!();
eprintln!(" {BOLD}Current values (by {by}):{RESET}");
for (index, label) in labels.iter().enumerate() {
let context = context_values.get(index).map(|c| c.as_slice()).unwrap_or(&[]);
eprintln!(" {}", self.format_label_line(index, label, context));
}
let csv: String = labels.join(",");
eprintln!("{context_hint}");
eprintln!();
eprintln!(" {BOLD}To sort alphabetically:{RESET}");
eprintln!(
" yerba sort \"{}\"{selector_display} --by \"{by}\" --order asc",
self.file
);
eprintln!(
" yerba sort \"{}\"{selector_display} --by \"{by}\" --order desc",
self.file
);
eprintln!();
eprintln!(" {BOLD}To reorder explicitly:{RESET}");
eprintln!(
" yerba sort \"{}\"{selector_display} --by \"{by}\" --order \"{csv}\"",
self.file
);
eprintln!();
eprintln!(" {BOLD}To move individual items:{RESET}");
eprintln!(
" yerba move \"{}\"{selector_display} <item> --before/--after <target>",
self.file
);
process::exit(1);
}
fn run_sort(self) {
use super::color::*;
let selector = self.selector.as_deref().unwrap_or("");
let sort_fields: Vec<yerba::SortField> = if self.by.is_empty() {
Vec::new()
} else {
self
.by
.iter()
.enumerate()
.map(|(index, field)| {
let direction = self.order.get(index).map(|s| s.as_str());
match direction {
Some("desc" | "descending") => yerba::SortField::desc(field),
Some("asc" | "ascending") | None => yerba::SortField::asc(field),
Some(other) => {
eprintln!("{RED}Error:{RESET} invalid sort direction \"{other}\". Use \"asc\" or \"desc\"");
process::exit(1);
}
}
})
.collect()
};
for resolved_file in resolve_files(&self.file) {
let mut document = parse_file(&resolved_file);
if sort_fields.is_empty() {
let first_item_selector = if selector.is_empty() {
"[0]".to_string()
} else {
format!("{}[0]", selector)
};
match document.get_value(&first_item_selector) {
Some(first) if first.is_mapping() => {
eprintln!("{RED}Error:{RESET} --by is required to sort a sequence of maps");
eprintln!();
let selectors = document.selectors();
let prefix = if selector.is_empty() { "[]." } else { "" };
let fields: Vec<&String> = selectors
.iter()
.filter(|s| {
let check = if prefix.is_empty() {
format!("{}[].", selector)
} else {
prefix.to_string()
};
s.starts_with(&check) && !s[check.len()..].contains('.') && !s[check.len()..].contains('[')
})
.collect();
if !fields.is_empty() {
eprintln!(" {BOLD}Available fields:{RESET}");
for field in &fields {
let short = field.rsplit_once('.').map(|(_, f)| f).unwrap_or(field);
eprintln!(" .{short}");
}
}
process::exit(1);
}
None if selector.is_empty() => {
eprintln!(
"{RED}Error:{RESET} no sequence found at root level in {}",
resolved_file
);
eprintln!();
eprintln!(" {DIM}Specify a selector for the sequence to sort:{RESET}");
eprintln!(" yerba sort \"{}\" \"<selector>\"", self.file);
eprintln!();
super::show_similar_selectors(&resolved_file, &document, "[]");
process::exit(1);
}
_ => {}
}
}
if document.sort_items(selector, &sort_fields, self.case_sensitive).is_ok() {
output(&resolved_file, &document, self.dry_run);
}
}
}
fn run_reorder(self) {
use super::color::*;
if self.by.len() != 1 {
eprintln!("{RED}Error:{RESET} explicit --order requires exactly one --by field");
process::exit(1);
}
if self.order.len() != 1 {
eprintln!("{RED}Error:{RESET} explicit --order must be a single comma-separated list");
process::exit(1);
}
let by = &self.by[0];
let order = &self.order[0];
let selector = self.selector.as_deref().unwrap_or("");
let items_selector = if selector.is_empty() {
"[]".to_string()
} else {
format!("{}[]", selector)
};
let mut document = parse_file(&self.file);
let mut seen = std::collections::HashSet::new();
let (labels, _, _) = self.resolve_labels(&document, by, selector, &items_selector);
let duplicates: Vec<&String> = labels.iter().filter(|label| !seen.insert(label.as_str())).collect();
if !duplicates.is_empty() {
eprintln!("{RED}Error:{RESET} --order requires unique values for {by}, but found duplicates");
eprintln!();
eprintln!(" {BOLD}Duplicate values:{RESET}");
for label in &duplicates {
eprintln!(" {label}");
}
eprintln!();
eprintln!(" {DIM}Use \"yerba sort\" with --by instead to sort by field, or");
eprintln!(" choose a --by field with unique values (e.g. \".id\"){RESET}");
process::exit(1);
}
let values_with_commas: Vec<&String> = labels.iter().filter(|l| l.contains(',')).collect();
if !values_with_commas.is_empty() {
let selector_display = if selector.is_empty() {
String::new()
} else {
format!(" \"{selector}\"")
};
eprintln!("{RED}Error:{RESET} some values for {by} contain commas, which conflicts with --order parsing");
eprintln!();
eprintln!(" {BOLD}Values with commas:{RESET}");
for label in &values_with_commas {
eprintln!(" {label}");
}
eprintln!();
eprintln!(" {BOLD}Use yerba move to reorder individual items instead:{RESET}");
eprintln!(
" yerba move \"{}\"{selector_display} <item> --before/--after <target>",
self.file
);
process::exit(1);
}
let desired_order: Vec<&str> = order.split(',').map(|s| s.trim()).collect();
let mut used = vec![false; labels.len()];
let mut moves: Vec<usize> = Vec::new();
for desired in &desired_order {
let found = labels
.iter()
.enumerate()
.find(|(index, label)| label.as_str() == *desired && !used[*index]);
if let Some((index, _)) = found {
moves.push(index);
used[index] = true;
} else {
eprintln!("{RED}Error:{RESET} no item found with {by} == \"{desired}\"");
eprintln!();
eprintln!(" {BOLD}Available values:{RESET}");
for (index, label) in labels.iter().enumerate() {
eprintln!(" {DIM}{index}:{RESET} {label}");
}
process::exit(1);
}
}
let missing: Vec<&String> = labels
.iter()
.enumerate()
.filter(|(index, _)| !used[*index])
.map(|(_, label)| label)
.collect();
if !missing.is_empty() {
eprintln!(
"{RED}Error:{RESET} --order must specify all {} items, but {} are missing",
labels.len(),
missing.len()
);
eprintln!();
eprintln!(" {BOLD}Missing values:{RESET}");
for label in &missing {
eprintln!(" {label}");
}
eprintln!();
eprintln!(" {BOLD}All values (by {by}):{RESET}");
for label in &labels {
eprintln!(" {label}");
}
eprintln!();
eprintln!(" {BOLD}To move individual items, use:{RESET}");
eprintln!(" yerba move <file> <selector> <item> --before/--after <target>");
process::exit(1);
}
let container = if selector.is_empty() { "" } else { selector };
for target in 0..moves.len() {
let source = moves[target];
if source != target {
let result = document.move_item(container, source, target);
if let Err(error) = result {
eprintln!("{RED}Error:{RESET} {}", error);
process::exit(1);
}
for item in moves.iter_mut().skip(target + 1) {
if *item >= target && *item < source {
*item += 1;
} else if *item == source {
*item = target;
}
}
}
}
output(&self.file, &document, self.dry_run);
}
fn resolve_labels(
&self,
document: &yerba::Document,
by: &str,
selector: &str,
items_selector: &str,
) -> (Vec<String>, Vec<Vec<String>>, String) {
use super::color::*;
let items = document.get_values(items_selector);
if items.is_empty() {
if selector.is_empty() {
eprintln!("{RED}Error:{RESET} no sequence found at root level");
eprintln!();
eprintln!(" {DIM}If the file is a map, specify which sequence to sort:{RESET}");
eprintln!(
" yerba sort \"{}\" \"<selector>\" --by \"{by}\" --order asc",
self.file
);
eprintln!();
super::show_similar_selectors(&self.file, document, "[]");
} else {
eprintln!("{RED}Error:{RESET} no sequence found at selector: {selector}");
super::show_similar_selectors(&self.file, document, selector);
}
process::exit(1);
}
let by_is_scalar = by == ".";
if !by_is_scalar {
let by_selector = if selector.is_empty() {
format!("[].{}", by.strip_prefix('.').unwrap_or(by))
} else {
format!("{}[].{}", selector, by.strip_prefix('.').unwrap_or(by))
};
if !document.exists(&by_selector) {
eprintln!("{RED}Error:{RESET} field \"{by}\" not found in items");
super::show_similar_selectors(&self.file, document, &by_selector);
process::exit(1);
}
}
let labels: Vec<String> = items
.iter()
.map(|item| {
if by_is_scalar {
match item {
serde_yaml::Value::String(string) => string.clone(),
_ => serde_json::to_string(&yerba::json::yaml_to_json(item)).unwrap_or_default(),
}
} else {
let field = by.strip_prefix('.').unwrap_or(by);
yerba::json::resolve_select_field(item, field)
.as_str()
.unwrap_or("")
.to_string()
}
})
.collect();
let context_values: Vec<Vec<String>> = items
.iter()
.map(|item| {
self
.context
.iter()
.map(|context| {
let field = context.strip_prefix('.').unwrap_or(context);
let value = yerba::json::resolve_select_field(item, field);
match &value {
serde_json::Value::String(string) => string.clone(),
serde_json::Value::Null => String::new(),
_ => serde_json::to_string(&value).unwrap_or_default(),
}
})
.collect()
})
.collect();
let selector_display = if selector.is_empty() {
String::new()
} else {
format!(" \"{selector}\"")
};
(labels, context_values, selector_display)
}
fn format_label_line(&self, index: usize, label: &str, context: &[String]) -> String {
use super::color::*;
if context.is_empty() {
format!("{DIM}[{index}]{RESET} {label}")
} else {
let context = context
.iter()
.filter(|c| !c.is_empty())
.cloned()
.collect::<Vec<_>>()
.join(", ");
if context.is_empty() {
format!("{DIM}[{index}]{RESET} {label}")
} else {
format!("{DIM}[{index}]{RESET} {label} {DIM}{context}{RESET}")
}
}
}
}