yerba 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3 +1,4 @@
1
+ use std::process;
1
2
  use std::sync::LazyLock;
2
3
 
3
4
  use indoc::indoc;
@@ -8,17 +9,17 @@ use super::{output, parse_file, resolve_files};
8
9
  static EXAMPLES: LazyLock<String> = LazyLock::new(|| {
9
10
  colorize_examples(indoc! {r#"
10
11
  yerba sort config.yml "tags"
11
- yerba sort videos.yml --by "title"
12
- yerba sort videos.yml --by "date:desc,title"
13
- yerba sort videos.yml "[].speakers"
14
- yerba sort videos.yml "[].speakers" --by "name"
15
- yerba sort videos.yml --by "kind,date:desc,title" --dry-run
12
+ yerba sort videos.yml --by ".title"
13
+ yerba sort videos.yml --by ".date" --order desc --by ".title"
14
+ yerba sort videos.yml "[].speakers" --by ".name"
15
+ yerba sort videos.yml "[]" --by ".id" --order "talk-3,talk-1,talk-2"
16
+ yerba sort speakers.yml "[]" --by ".name" --order "Charlie,Alice,Bob"
16
17
  "#})
17
18
  });
18
19
 
19
20
  #[derive(clap::Args)]
20
21
  #[command(
21
- about = "Sort items in a sequence by field(s)",
22
+ about = "Sort or reorder items in a sequence",
22
23
  arg_required_else_help = true,
23
24
  after_help = EXAMPLES.as_str()
24
25
  )]
@@ -26,9 +27,15 @@ pub struct Args {
26
27
  file: String,
27
28
  /// Selector (optional — omit for root-level sequence)
28
29
  selector: Option<String>,
29
- /// Comma-separated sort fields, optionally with :desc (e.g. "date:desc,title")
30
- #[arg(long)]
31
- by: Option<String>,
30
+ /// Field to sort or match by (can be repeated for tie-breakers)
31
+ #[arg(long, action = clap::ArgAction::Append)]
32
+ by: Vec<String>,
33
+ /// Sort direction (asc/desc) or explicit order (comma-separated values, must list all items)
34
+ #[arg(long, action = clap::ArgAction::Append)]
35
+ order: Vec<String>,
36
+ /// Additional fields to display alongside values (e.g. --context ".title")
37
+ #[arg(long, action = clap::ArgAction::Append)]
38
+ context: Vec<String>,
32
39
  /// Case-sensitive sort (default: case-insensitive)
33
40
  #[arg(long)]
34
41
  case_sensitive: bool,
@@ -36,17 +43,477 @@ pub struct Args {
36
43
  dry_run: bool,
37
44
  }
38
45
 
46
+ fn is_direction(value: &str) -> bool {
47
+ matches!(value, "asc" | "desc" | "ascending" | "descending")
48
+ }
49
+
50
+ fn is_explicit_reorder(orders: &[String]) -> bool {
51
+ orders.iter().any(|o| !is_direction(o))
52
+ }
53
+
39
54
  impl Args {
40
55
  pub fn run(self) {
56
+ use super::color::*;
57
+
58
+ if self.by.is_empty() && self.order.is_empty() && self.selector.is_none() {
59
+ let document = parse_file(&self.file);
60
+
61
+ eprintln!("{RED}Error:{RESET} specify a selector, --by, or --order");
62
+ eprintln!();
63
+ eprintln!(" {BOLD}Examples:{RESET}");
64
+ eprintln!(" yerba sort \"{}\" \"tags\"", self.file);
65
+ eprintln!(" yerba sort \"{}\" --by \".title\" --order asc", self.file);
66
+ eprintln!();
67
+
68
+ super::show_similar_selectors(&self.file, &document, "[]");
69
+
70
+ process::exit(1);
71
+ }
72
+
73
+ if !self.by.is_empty() && self.order.is_empty() {
74
+ self.show_values();
75
+ } else if is_explicit_reorder(&self.order) {
76
+ self.run_reorder();
77
+ } else {
78
+ self.run_sort();
79
+ }
80
+ }
81
+
82
+ fn show_values(self) {
83
+ use super::color::*;
84
+
85
+ if self.by.len() != 1 {
86
+ eprintln!("{RED}Error:{RESET} --order is required when using --by");
87
+ process::exit(1);
88
+ }
89
+
90
+ let by = &self.by[0];
91
+ let selector = self.selector.as_deref().unwrap_or("");
92
+
93
+ let items_selector = if selector.is_empty() {
94
+ "[]".to_string()
95
+ } else {
96
+ format!("{}[]", selector)
97
+ };
98
+
99
+ let document = parse_file(&self.file);
100
+ let (labels, context_values, selector_display) = self.resolve_labels(&document, by, selector, &items_selector);
101
+
102
+ let context_hint = if self.context.is_empty() {
103
+ format!("\n\n {DIM}Add --context \".field\" to show additional fields alongside values{RESET}")
104
+ } else {
105
+ String::new()
106
+ };
107
+
108
+ eprintln!("{RED}Error:{RESET} --order is required when using --by");
109
+ eprintln!();
110
+ eprintln!(" {BOLD}Current values (by {by}):{RESET}");
111
+
112
+ for (index, label) in labels.iter().enumerate() {
113
+ let context = context_values.get(index).map(|c| c.as_slice()).unwrap_or(&[]);
114
+ eprintln!(" {}", self.format_label_line(index, label, context));
115
+ }
116
+
117
+ let csv: String = labels.join(",");
118
+
119
+ eprintln!("{context_hint}");
120
+ eprintln!();
121
+ eprintln!(" {BOLD}To sort alphabetically:{RESET}");
122
+ eprintln!(
123
+ " yerba sort \"{}\"{selector_display} --by \"{by}\" --order asc",
124
+ self.file
125
+ );
126
+ eprintln!(
127
+ " yerba sort \"{}\"{selector_display} --by \"{by}\" --order desc",
128
+ self.file
129
+ );
130
+ eprintln!();
131
+ eprintln!(" {BOLD}To reorder explicitly:{RESET}");
132
+ eprintln!(
133
+ " yerba sort \"{}\"{selector_display} --by \"{by}\" --order \"{csv}\"",
134
+ self.file
135
+ );
136
+ eprintln!();
137
+ eprintln!(" {BOLD}To move individual items:{RESET}");
138
+ eprintln!(
139
+ " yerba move \"{}\"{selector_display} <item> --before/--after <target>",
140
+ self.file
141
+ );
142
+
143
+ process::exit(1);
144
+ }
145
+
146
+ fn run_sort(self) {
147
+ use super::color::*;
148
+
41
149
  let selector = self.selector.as_deref().unwrap_or("");
42
- let sort_fields = self.by.as_deref().map(yerba::SortField::parse_list).unwrap_or_default();
150
+
151
+ let sort_fields: Vec<yerba::SortField> = if self.by.is_empty() {
152
+ Vec::new()
153
+ } else {
154
+ self
155
+ .by
156
+ .iter()
157
+ .enumerate()
158
+ .map(|(index, field)| {
159
+ let direction = self.order.get(index).map(|s| s.as_str());
160
+
161
+ match direction {
162
+ Some("desc" | "descending") => yerba::SortField::desc(field),
163
+ Some("asc" | "ascending") | None => yerba::SortField::asc(field),
164
+ Some(other) => {
165
+ eprintln!("{RED}Error:{RESET} invalid sort direction \"{other}\". Use \"asc\" or \"desc\"");
166
+ process::exit(1);
167
+ }
168
+ }
169
+ })
170
+ .collect()
171
+ };
43
172
 
44
173
  for resolved_file in resolve_files(&self.file) {
45
174
  let mut document = parse_file(&resolved_file);
46
175
 
176
+ if sort_fields.is_empty() {
177
+ let first_item_selector = if selector.is_empty() {
178
+ "[0]".to_string()
179
+ } else {
180
+ format!("{}[0]", selector)
181
+ };
182
+
183
+ match document.get_value(&first_item_selector) {
184
+ Some(first) if first.is_mapping() => {
185
+ eprintln!("{RED}Error:{RESET} --by is required to sort a sequence of maps");
186
+ eprintln!();
187
+
188
+ let selectors = document.selectors();
189
+ let prefix = if selector.is_empty() { "[]." } else { "" };
190
+ let fields: Vec<&String> = selectors
191
+ .iter()
192
+ .filter(|s| {
193
+ let check = if prefix.is_empty() {
194
+ format!("{}[].", selector)
195
+ } else {
196
+ prefix.to_string()
197
+ };
198
+ s.starts_with(&check) && !s[check.len()..].contains('.') && !s[check.len()..].contains('[')
199
+ })
200
+ .collect();
201
+
202
+ if !fields.is_empty() {
203
+ eprintln!(" {BOLD}Available fields:{RESET}");
204
+
205
+ for field in &fields {
206
+ let short = field.rsplit_once('.').map(|(_, f)| f).unwrap_or(field);
207
+ eprintln!(" .{short}");
208
+ }
209
+ }
210
+
211
+ process::exit(1);
212
+ }
213
+
214
+ None if selector.is_empty() => {
215
+ eprintln!(
216
+ "{RED}Error:{RESET} no sequence found at root level in {}",
217
+ resolved_file
218
+ );
219
+ eprintln!();
220
+ eprintln!(" {DIM}Specify a selector for the sequence to sort:{RESET}");
221
+ eprintln!(" yerba sort \"{}\" \"<selector>\"", self.file);
222
+ eprintln!();
223
+
224
+ super::show_similar_selectors(&resolved_file, &document, "[]");
225
+
226
+ process::exit(1);
227
+ }
228
+
229
+ _ => {}
230
+ }
231
+ }
232
+
47
233
  if document.sort_items(selector, &sort_fields, self.case_sensitive).is_ok() {
48
234
  output(&resolved_file, &document, self.dry_run);
49
235
  }
50
236
  }
51
237
  }
238
+
239
+ fn run_reorder(self) {
240
+ use super::color::*;
241
+
242
+ if self.by.len() != 1 {
243
+ eprintln!("{RED}Error:{RESET} explicit --order requires exactly one --by field");
244
+
245
+ process::exit(1);
246
+ }
247
+
248
+ if self.order.len() != 1 {
249
+ eprintln!("{RED}Error:{RESET} explicit --order must be a single comma-separated list");
250
+
251
+ process::exit(1);
252
+ }
253
+
254
+ let by = &self.by[0];
255
+ let order = &self.order[0];
256
+ let selector = self.selector.as_deref().unwrap_or("");
257
+
258
+ let items_selector = if selector.is_empty() {
259
+ "[]".to_string()
260
+ } else {
261
+ format!("{}[]", selector)
262
+ };
263
+
264
+ let mut document = parse_file(&self.file);
265
+ let mut seen = std::collections::HashSet::new();
266
+
267
+ let (labels, _, _) = self.resolve_labels(&document, by, selector, &items_selector);
268
+ let duplicates: Vec<&String> = labels.iter().filter(|label| !seen.insert(label.as_str())).collect();
269
+
270
+ if !duplicates.is_empty() {
271
+ eprintln!("{RED}Error:{RESET} --order requires unique values for {by}, but found duplicates");
272
+ eprintln!();
273
+ eprintln!(" {BOLD}Duplicate values:{RESET}");
274
+
275
+ for label in &duplicates {
276
+ eprintln!(" {label}");
277
+ }
278
+
279
+ eprintln!();
280
+ eprintln!(" {DIM}Use \"yerba sort\" with --by instead to sort by field, or");
281
+ eprintln!(" choose a --by field with unique values (e.g. \".id\"){RESET}");
282
+
283
+ process::exit(1);
284
+ }
285
+
286
+ let values_with_commas: Vec<&String> = labels.iter().filter(|l| l.contains(',')).collect();
287
+
288
+ if !values_with_commas.is_empty() {
289
+ let selector_display = if selector.is_empty() {
290
+ String::new()
291
+ } else {
292
+ format!(" \"{selector}\"")
293
+ };
294
+
295
+ eprintln!("{RED}Error:{RESET} some values for {by} contain commas, which conflicts with --order parsing");
296
+ eprintln!();
297
+ eprintln!(" {BOLD}Values with commas:{RESET}");
298
+
299
+ for label in &values_with_commas {
300
+ eprintln!(" {label}");
301
+ }
302
+
303
+ eprintln!();
304
+ eprintln!(" {BOLD}Use yerba move to reorder individual items instead:{RESET}");
305
+ eprintln!(
306
+ " yerba move \"{}\"{selector_display} <item> --before/--after <target>",
307
+ self.file
308
+ );
309
+
310
+ process::exit(1);
311
+ }
312
+
313
+ let desired_order: Vec<&str> = order.split(',').map(|s| s.trim()).collect();
314
+
315
+ let mut used = vec![false; labels.len()];
316
+ let mut moves: Vec<usize> = Vec::new();
317
+
318
+ for desired in &desired_order {
319
+ let found = labels
320
+ .iter()
321
+ .enumerate()
322
+ .find(|(index, label)| label.as_str() == *desired && !used[*index]);
323
+
324
+ if let Some((index, _)) = found {
325
+ moves.push(index);
326
+ used[index] = true;
327
+ } else {
328
+ eprintln!("{RED}Error:{RESET} no item found with {by} == \"{desired}\"");
329
+ eprintln!();
330
+ eprintln!(" {BOLD}Available values:{RESET}");
331
+
332
+ for (index, label) in labels.iter().enumerate() {
333
+ eprintln!(" {DIM}{index}:{RESET} {label}");
334
+ }
335
+
336
+ process::exit(1);
337
+ }
338
+ }
339
+
340
+ let missing: Vec<&String> = labels
341
+ .iter()
342
+ .enumerate()
343
+ .filter(|(index, _)| !used[*index])
344
+ .map(|(_, label)| label)
345
+ .collect();
346
+
347
+ if !missing.is_empty() {
348
+ eprintln!(
349
+ "{RED}Error:{RESET} --order must specify all {} items, but {} are missing",
350
+ labels.len(),
351
+ missing.len()
352
+ );
353
+ eprintln!();
354
+ eprintln!(" {BOLD}Missing values:{RESET}");
355
+
356
+ for label in &missing {
357
+ eprintln!(" {label}");
358
+ }
359
+
360
+ eprintln!();
361
+ eprintln!(" {BOLD}All values (by {by}):{RESET}");
362
+
363
+ for label in &labels {
364
+ eprintln!(" {label}");
365
+ }
366
+
367
+ eprintln!();
368
+ eprintln!(" {BOLD}To move individual items, use:{RESET}");
369
+ eprintln!(" yerba move <file> <selector> <item> --before/--after <target>");
370
+
371
+ process::exit(1);
372
+ }
373
+
374
+ let container = if selector.is_empty() { "" } else { selector };
375
+
376
+ for target in 0..moves.len() {
377
+ let source = moves[target];
378
+
379
+ if source != target {
380
+ let result = document.move_item(container, source, target);
381
+
382
+ if let Err(error) = result {
383
+ eprintln!("{RED}Error:{RESET} {}", error);
384
+ process::exit(1);
385
+ }
386
+
387
+ for item in moves.iter_mut().skip(target + 1) {
388
+ if *item >= target && *item < source {
389
+ *item += 1;
390
+ } else if *item == source {
391
+ *item = target;
392
+ }
393
+ }
394
+ }
395
+ }
396
+
397
+ output(&self.file, &document, self.dry_run);
398
+ }
399
+
400
+ fn resolve_labels(
401
+ &self,
402
+ document: &yerba::Document,
403
+ by: &str,
404
+ selector: &str,
405
+ items_selector: &str,
406
+ ) -> (Vec<String>, Vec<Vec<String>>, String) {
407
+ use super::color::*;
408
+
409
+ let items = document.get_values(items_selector);
410
+
411
+ if items.is_empty() {
412
+ if selector.is_empty() {
413
+ eprintln!("{RED}Error:{RESET} no sequence found at root level");
414
+ eprintln!();
415
+ eprintln!(" {DIM}If the file is a map, specify which sequence to sort:{RESET}");
416
+ eprintln!(
417
+ " yerba sort \"{}\" \"<selector>\" --by \"{by}\" --order asc",
418
+ self.file
419
+ );
420
+ eprintln!();
421
+
422
+ super::show_similar_selectors(&self.file, document, "[]");
423
+ } else {
424
+ eprintln!("{RED}Error:{RESET} no sequence found at selector: {selector}");
425
+
426
+ super::show_similar_selectors(&self.file, document, selector);
427
+ }
428
+
429
+ process::exit(1);
430
+ }
431
+
432
+ let by_is_scalar = by == ".";
433
+
434
+ if !by_is_scalar {
435
+ let by_selector = if selector.is_empty() {
436
+ format!("[].{}", by.strip_prefix('.').unwrap_or(by))
437
+ } else {
438
+ format!("{}[].{}", selector, by.strip_prefix('.').unwrap_or(by))
439
+ };
440
+
441
+ if !document.exists(&by_selector) {
442
+ eprintln!("{RED}Error:{RESET} field \"{by}\" not found in items");
443
+
444
+ super::show_similar_selectors(&self.file, document, &by_selector);
445
+
446
+ process::exit(1);
447
+ }
448
+ }
449
+
450
+ let labels: Vec<String> = items
451
+ .iter()
452
+ .map(|item| {
453
+ if by_is_scalar {
454
+ match item {
455
+ serde_yaml::Value::String(string) => string.clone(),
456
+ _ => serde_json::to_string(&yerba::json::yaml_to_json(item)).unwrap_or_default(),
457
+ }
458
+ } else {
459
+ let field = by.strip_prefix('.').unwrap_or(by);
460
+
461
+ yerba::json::resolve_select_field(item, field)
462
+ .as_str()
463
+ .unwrap_or("")
464
+ .to_string()
465
+ }
466
+ })
467
+ .collect();
468
+
469
+ let context_values: Vec<Vec<String>> = items
470
+ .iter()
471
+ .map(|item| {
472
+ self
473
+ .context
474
+ .iter()
475
+ .map(|context| {
476
+ let field = context.strip_prefix('.').unwrap_or(context);
477
+
478
+ let value = yerba::json::resolve_select_field(item, field);
479
+
480
+ match &value {
481
+ serde_json::Value::String(string) => string.clone(),
482
+ serde_json::Value::Null => String::new(),
483
+ _ => serde_json::to_string(&value).unwrap_or_default(),
484
+ }
485
+ })
486
+ .collect()
487
+ })
488
+ .collect();
489
+
490
+ let selector_display = if selector.is_empty() {
491
+ String::new()
492
+ } else {
493
+ format!(" \"{selector}\"")
494
+ };
495
+
496
+ (labels, context_values, selector_display)
497
+ }
498
+
499
+ fn format_label_line(&self, index: usize, label: &str, context: &[String]) -> String {
500
+ use super::color::*;
501
+
502
+ if context.is_empty() {
503
+ format!("{DIM}[{index}]{RESET} {label}")
504
+ } else {
505
+ let context = context
506
+ .iter()
507
+ .filter(|c| !c.is_empty())
508
+ .cloned()
509
+ .collect::<Vec<_>>()
510
+ .join(", ");
511
+
512
+ if context.is_empty() {
513
+ format!("{DIM}[{index}]{RESET} {label}")
514
+ } else {
515
+ format!("{DIM}[{index}]{RESET} {label} {DIM}{context}{RESET}")
516
+ }
517
+ }
518
+ }
52
519
  }
@@ -0,0 +1,55 @@
1
+ pub fn didyoumean(input: &str, list: &[String]) -> Option<String> {
2
+ if list.is_empty() {
3
+ return None;
4
+ }
5
+
6
+ let input_lower = input.to_lowercase();
7
+
8
+ let mut scored: Vec<(String, usize)> = list
9
+ .iter()
10
+ .map(|item| (item.clone(), levenshtein(&input_lower, &item.to_lowercase())))
11
+ .collect();
12
+
13
+ scored
14
+ .sort_by(|(a_item, a_distance), (b_item, b_distance)| a_distance.cmp(b_distance).then_with(|| a_item.cmp(b_item)));
15
+
16
+ scored.into_iter().next().map(|(item, _)| item)
17
+ }
18
+
19
+ pub fn didyoumean_ranked(input: &str, list: &[String], threshold: usize) -> Vec<String> {
20
+ if list.is_empty() {
21
+ return Vec::new();
22
+ }
23
+
24
+ let input_lower = input.to_lowercase();
25
+
26
+ let mut scored: Vec<(String, usize)> = list
27
+ .iter()
28
+ .map(|item| (item.clone(), levenshtein(&input_lower, &item.to_lowercase())))
29
+ .filter(|(_, distance)| *distance <= threshold)
30
+ .collect();
31
+
32
+ scored
33
+ .sort_by(|(a_item, a_distance), (b_item, b_distance)| a_distance.cmp(b_distance).then_with(|| a_item.cmp(b_item)));
34
+
35
+ scored.into_iter().map(|(item, _)| item).collect()
36
+ }
37
+
38
+ fn levenshtein(a: &str, b: &str) -> usize {
39
+ let b_length = b.len();
40
+ let mut previous: Vec<usize> = (0..=b_length).collect();
41
+ let mut current = vec![0; b_length + 1];
42
+
43
+ for (i, a_character) in a.chars().enumerate() {
44
+ current[0] = i + 1;
45
+
46
+ for (j, b_character) in b.chars().enumerate() {
47
+ let cost = if a_character == b_character { 0 } else { 1 };
48
+ current[j + 1] = (previous[j] + cost).min(previous[j + 1] + 1).min(current[j] + 1);
49
+ }
50
+
51
+ std::mem::swap(&mut previous, &mut current);
52
+ }
53
+
54
+ previous[b_length]
55
+ }