1use std::collections::{BTreeMap, BTreeSet};
8use std::fs;
9use std::ops::Range;
10use std::path::Path;
11
12use anyhow::{Context, Result, bail};
13use globset::{Glob, GlobSet, GlobSetBuilder};
14use serde::Serialize;
15
16use crate::model::{CommandBody, Entry, Layer, Library, ParamSpec};
17
18const SCHEMA_VERSION: u32 = 1;
20
21const DISABLED: &str = "disabled:";
23
24const COMMANDS: &str = "commands:";
26
27const DEFAULT_INDENT: &str = " ";
29
30macro_rules! builtin {
31 ($name:literal) => {
32 (
33 concat!("builtin:", $name, ".yaml"),
34 include_str!(concat!("../../assets/builtins/", $name, ".yaml")),
35 )
36 };
37}
38
39const BUILTINS: &[(&str, &str)] = &[
46 builtin!("archive"),
47 builtin!("docker"),
48 builtin!("git"),
49 builtin!("kubernetes"),
50 builtin!("network"),
51 builtin!("node"),
52 builtin!("python"),
53 builtin!("rust"),
54 builtin!("ssh"),
55 builtin!("system"),
56 builtin!("text"),
57];
58
59pub fn load(user_library: Option<&Path>) -> Result<Vec<Entry>> {
61 let mut layers = Vec::new();
62
63 for (origin, source) in BUILTINS {
64 layers.push(read(source, origin, Layer::Builtin)?);
65 }
66
67 if let Some(path) = user_library
68 && path.exists()
69 {
70 let source = fs::read_to_string(path)
71 .with_context(|| format!("failed to read {}", path.display()))?;
72 layers.push(read(&source, &path.display().to_string(), Layer::User)?);
73 }
74
75 merge(layers)
76}
77
78#[derive(Debug, Serialize)]
84pub struct NewEntry {
85 pub id: String,
86 pub cmd: CommandBody,
87 pub desc: String,
88
89 #[serde(skip_serializing_if = "Vec::is_empty")]
90 pub tags: Vec<String>,
91
92 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
93 pub params: BTreeMap<String, ParamSpec>,
94
95 #[serde(skip_serializing_if = "std::ops::Not::not")]
96 pub danger: bool,
97}
98
99impl From<&Entry> for NewEntry {
100 fn from(entry: &Entry) -> Self {
101 Self {
102 id: entry.id.clone(),
103 cmd: entry.cmd.clone(),
104 desc: entry.desc.clone(),
105 tags: entry.tags.clone(),
106 params: entry.params.clone(),
107 danger: entry.danger,
108 }
109 }
110}
111
112pub fn parse_user(text: &str, origin: &str) -> Result<Library> {
117 if text.trim().is_empty() {
118 return Ok(Library {
119 version: SCHEMA_VERSION,
120 commands: Vec::new(),
121 disabled: Vec::new(),
122 });
123 }
124 read(text, origin, Layer::User)
125}
126
127pub fn parse_tags(text: &str) -> Vec<String> {
129 text.split(',')
130 .map(|tag| tag.trim().to_string())
131 .filter(|tag| !tag.is_empty())
132 .collect()
133}
134
135pub fn split_purpose(text: &str) -> (String, Vec<String>) {
141 let mut words = Vec::new();
142 let mut tags = Vec::new();
143
144 for word in text.split_whitespace() {
145 match word.strip_prefix('#') {
146 Some(tag) if !tag.is_empty() => {
147 let tag = tag.to_lowercase();
148 if !tags.contains(&tag) {
149 tags.push(tag);
150 }
151 }
152 _ => words.push(word),
153 }
154 }
155
156 (words.join(" "), tags)
157}
158
159pub fn derive_tags(cmd: &str) -> Vec<String> {
166 const DEPTH: usize = 3;
168
169 let mut words = cmd
170 .split_whitespace()
171 .skip_while(|word| matches!(*word, "sudo" | "doas") || word.contains('='));
172
173 let mut tags: Vec<String> = Vec::new();
174
175 if let Some(program) = words.next() {
177 let name = program.rsplit(['/', '\\']).next().unwrap_or(program);
178 let name = name.strip_suffix(".exe").unwrap_or(name).to_lowercase();
179 if plain(&name) {
180 tags.push(name);
181 } else {
182 return tags;
183 }
184 }
185
186 for word in words {
187 if tags.len() == DEPTH || !plain(word) {
188 break;
189 }
190 if !tags.iter().any(|tag| tag == word) {
191 tags.push(word.to_string());
192 }
193 }
194
195 tags
196}
197
198fn plain(word: &str) -> bool {
201 word.len() >= 2
202 && word.starts_with(|c: char| c.is_ascii_lowercase())
203 && word
204 .chars()
205 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
206}
207
208pub fn merge_tags(given: Vec<String>, cmd: &str) -> Vec<String> {
210 let mut tags = given;
211 for tag in derive_tags(cmd) {
212 if !tags.contains(&tag) {
213 tags.push(tag);
214 }
215 }
216 tags
217}
218
219#[derive(Debug, PartialEq, Eq)]
221pub enum Written {
222 Replaced,
223 Appended,
224}
225
226pub fn append(path: &Path, entry: &NewEntry) -> Result<()> {
233 let text = read_or_empty(path)?;
234 write(path, &appended(&text, entry)?)
235}
236
237pub fn appended(text: &str, entry: &NewEntry) -> Result<String> {
244 if text.trim().is_empty() {
245 return Ok(format!(
246 "version: {SCHEMA_VERSION}\n{COMMANDS}\n{}",
247 as_list_item(entry, DEFAULT_INDENT)?
248 ));
249 }
250
251 let mut lines: Vec<String> = text.lines().map(String::from).collect();
252 let Some(key) = open_list(&mut lines, COMMANDS)? else {
253 lines.push(COMMANDS.to_string());
254 let mut out = joined(&lines);
255 out.push_str(&as_list_item(entry, DEFAULT_INDENT)?);
256 return Ok(out);
257 };
258
259 let end = end_of_section(&lines, key);
260 let indent = item_indent(&lines[key + 1..end]).unwrap_or(DEFAULT_INDENT.to_string());
261 let item = as_list_item(entry, &indent)?;
262
263 let mut at = end;
266 while at > key + 1 && belongs_to_next(&lines[at - 1]) {
267 at -= 1;
268 }
269 lines.splice(at..at, item.lines().map(String::from));
270
271 Ok(joined(&lines))
272}
273
274fn as_list_item(entry: &NewEntry, indent: &str) -> Result<String> {
279 let body = serde_yaml_ng::to_string(entry).context("failed to serialise the entry")?;
280
281 let mut out = String::new();
282 for (index, line) in body.lines().enumerate() {
283 out.push_str(indent);
284 out.push_str(if index == 0 { "- " } else { " " });
285 out.push_str(line);
286 out.push('\n');
287 }
288
289 Ok(out)
290}
291
292pub fn remove(path: &Path, id: &str) -> Result<bool> {
298 let text =
299 fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?;
300
301 match removed(&text, id) {
302 Some(text) => {
303 write(path, &text)?;
304 Ok(true)
305 }
306 None => Ok(false),
307 }
308}
309
310pub fn removed(text: &str, id: &str) -> Option<String> {
312 item_of(text, id).map(|block| splice(text, block, ""))
313}
314
315pub fn upsert(path: &Path, entry: &NewEntry) -> Result<Written> {
326 let text = read_or_empty(path)?;
327 let (text, written) = upserted(&text, entry)?;
328 write(path, &text)?;
329 Ok(written)
330}
331
332pub fn upserted(text: &str, entry: &NewEntry) -> Result<(String, Written)> {
335 match item_of(text, &entry.id) {
336 Some(block) => {
337 let lines: Vec<&str> = text.lines().collect();
338 let indent = lines[block.start][..indent_of(lines[block.start])].to_string();
339 let item = as_list_item(entry, &indent)?;
340 Ok((splice(text, block, &item), Written::Replaced))
341 }
342 None => Ok((appended(text, entry)?, Written::Appended)),
343 }
344}
345
346fn splice(text: &str, block: Range<usize>, replacement: &str) -> String {
348 let mut out = String::with_capacity(text.len() + replacement.len());
349
350 for (number, line) in text.lines().enumerate() {
351 if number == block.start {
352 out.push_str(replacement);
353 }
354 if !block.contains(&number) {
355 out.push_str(line);
356 out.push('\n');
357 }
358 }
359
360 out
361}
362
363fn read_or_empty(path: &Path) -> Result<String> {
364 match fs::read_to_string(path) {
365 Ok(text) => Ok(text),
366 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(String::new()),
367 Err(error) => Err(error).with_context(|| format!("failed to read {}", path.display())),
368 }
369}
370
371fn write(path: &Path, text: &str) -> Result<()> {
372 if let Some(parent) = path.parent() {
373 fs::create_dir_all(parent)
374 .with_context(|| format!("failed to create {}", parent.display()))?;
375 }
376 fs::write(path, text).with_context(|| format!("failed to write {}", path.display()))
377}
378
379pub fn disable(path: &Path, id: &str) -> Result<()> {
381 let text = read_or_empty(path)?;
382 write(path, &with_disabled(&text, id)?)
383}
384
385pub fn with_disabled(text: &str, pattern: &str) -> Result<String> {
387 let mut lines: Vec<String> = text.lines().map(String::from).collect();
388
389 match open_list(&mut lines, DISABLED)? {
390 Some(key) => {
391 let end = end_of_section(&lines, key);
392 let indent = item_indent(&lines[key + 1..end]).unwrap_or(DEFAULT_INDENT.to_string());
393 lines.insert(key + 1, format!("{indent}- {pattern}"));
394 }
395 None => {
396 if text.trim().is_empty() {
397 lines.push(format!("version: {SCHEMA_VERSION}"));
398 }
399 lines.push(DISABLED.to_string());
400 lines.push(format!("{DEFAULT_INDENT}- {pattern}"));
401 }
402 }
403
404 Ok(joined(&lines))
405}
406
407pub fn with_enabled(text: &str, pattern: &str) -> String {
409 let lines: Vec<&str> = text.lines().collect();
410 let Some(key) = lines.iter().position(|line| is_key(line, DISABLED)) else {
411 return text.to_string();
412 };
413 let end = end_of_section_of(&lines, key);
414
415 let kept: Vec<String> = lines
416 .iter()
417 .enumerate()
418 .filter(|(number, line)| {
419 !(*number > key
420 && *number < end
421 && line
422 .trim()
423 .strip_prefix("- ")
424 .is_some_and(|value| unquote(value.trim()) == pattern))
425 })
426 .map(|(_, line)| line.to_string())
427 .collect();
428
429 joined(&kept)
430}
431
432fn open_list(lines: &mut [String], key: &str) -> Result<Option<usize>> {
435 let Some(at) = lines.iter().position(|line| is_key(line, key)) else {
436 return Ok(None);
437 };
438
439 let rest = lines[at][key.len()..].trim();
440 let rest = if rest.starts_with('#') { "" } else { rest };
442 match rest {
443 "" => {}
444 "[]" => lines[at] = key.to_string(),
445 _ => bail!(
446 "`{}` is written on one line, add to it by hand",
447 lines[at].trim()
448 ),
449 }
450 Ok(Some(at))
451}
452
453fn is_key(line: &str, key: &str) -> bool {
455 line.starts_with(key)
456}
457
458fn end_of_section(lines: &[String], key: usize) -> usize {
460 let lines: Vec<&str> = lines.iter().map(String::as_str).collect();
461 end_of_section_of(&lines, key)
462}
463
464fn end_of_section_of(lines: &[&str], key: usize) -> usize {
465 lines
466 .iter()
467 .enumerate()
468 .skip(key + 1)
469 .find(|(_, line)| starts_top_level(line))
470 .map_or(lines.len(), |(at, _)| at)
471}
472
473fn starts_top_level(line: &str) -> bool {
478 !line.is_empty() && indent_of(line) == 0 && !line.starts_with('-') && !line.starts_with('#')
479}
480
481fn belongs_to_next(line: &str) -> bool {
484 line.trim().is_empty() || line.starts_with('#')
485}
486
487fn item_indent<S: AsRef<str>>(lines: &[S]) -> Option<String> {
489 lines
490 .iter()
491 .map(AsRef::as_ref)
492 .find(|line| line.trim_start().starts_with("- "))
493 .map(|line| line[..indent_of(line)].to_string())
494}
495
496fn joined<S: AsRef<str>>(lines: &[S]) -> String {
497 let mut out = String::new();
498 for line in lines {
499 out.push_str(line.as_ref());
500 out.push('\n');
501 }
502 out
503}
504
505fn item_of(text: &str, id: &str) -> Option<Range<usize>> {
507 let lines: Vec<&str> = text.lines().collect();
508
509 for (number, line) in lines.iter().enumerate() {
510 if !line.trim_start().starts_with("- ") {
511 continue;
512 }
513
514 let indent = indent_of(line);
515 let end = lines
518 .iter()
519 .enumerate()
520 .skip(number + 1)
521 .find(|(_, line)| !line.trim().is_empty() && indent_of(line) <= indent)
522 .map_or(lines.len(), |(at, _)| at);
523
524 if declares(&lines[number..end], id) {
525 return Some(number..end);
526 }
527 }
528
529 None
530}
531
532fn declares(block: &[&str], id: &str) -> bool {
533 block.iter().any(|line| {
534 line.trim_start()
535 .trim_start_matches("- ")
536 .strip_prefix("id:")
537 .is_some_and(|value| unquote(value.trim()) == id)
538 })
539}
540
541fn unquote(value: &str) -> &str {
542 value
543 .strip_prefix('"')
544 .and_then(|value| value.strip_suffix('"'))
545 .or_else(|| {
546 value
547 .strip_prefix('\'')
548 .and_then(|value| value.strip_suffix('\''))
549 })
550 .unwrap_or(value)
551}
552
553fn indent_of(line: &str) -> usize {
554 line.len() - line.trim_start().len()
555}
556
557pub fn suggest_id(cmd: &str, taken: &BTreeSet<String>) -> String {
559 let slug: Vec<String> = cmd
560 .split_whitespace()
561 .filter(|word| !word.starts_with('-'))
562 .take(2)
563 .map(|word| {
564 word.chars()
568 .filter(|c| c.is_ascii_alphanumeric() || *c == '-')
569 .collect::<String>()
570 .trim_matches('-')
571 .to_lowercase()
572 })
573 .filter(|word| !word.is_empty())
574 .collect();
575
576 let stem = if slug.is_empty() {
577 "user.command".to_string()
578 } else {
579 format!("user.{}", slug.join("-"))
580 };
581
582 if !taken.contains(&stem) {
583 return stem;
584 }
585 (2..)
586 .map(|n| format!("{stem}-{n}"))
587 .find(|candidate| !taken.contains(candidate))
588 .expect("the sequence is unbounded")
589}
590
591fn read(source: &str, origin: &str, layer: Layer) -> Result<Library> {
593 let mut library: Library = serde_yaml_ng::from_str(source)
594 .with_context(|| format!("{origin} is not a valid command library"))?;
595
596 if library.version != SCHEMA_VERSION {
597 bail!(
598 "{origin} declares schema version {}, this build understands {SCHEMA_VERSION}",
599 library.version
600 );
601 }
602
603 let mut seen = BTreeSet::new();
604 for entry in &mut library.commands {
605 if entry.id.trim().is_empty() {
606 bail!("{origin} contains an entry with an empty id");
607 }
608 if entry.desc.trim().is_empty() {
609 bail!("{origin} entry `{}` has an empty description", entry.id);
610 }
611 if !seen.insert(entry.id.clone()) {
612 bail!("{origin} defines `{}` more than once", entry.id);
613 }
614 entry.layer = layer;
615 }
616
617 Ok(library)
618}
619
620fn merge(layers: Vec<Library>) -> Result<Vec<Entry>> {
622 let mut by_id: BTreeMap<String, Entry> = BTreeMap::new();
623 let mut patterns: Vec<String> = Vec::new();
624
625 for layer in layers {
626 patterns.extend(layer.disabled);
627 for entry in layer.commands {
628 by_id.insert(entry.id.clone(), entry);
629 }
630 }
631
632 let disabled = globs(&patterns)?;
633 Ok(by_id
634 .into_values()
635 .filter(|entry| !disabled.is_match(&entry.id))
636 .collect())
637}
638
639fn globs(patterns: &[String]) -> Result<GlobSet> {
640 let mut builder = GlobSetBuilder::new();
641 for pattern in patterns {
642 let glob = Glob::new(pattern)
643 .with_context(|| format!("`{pattern}` is not a valid disable pattern"))?;
644 builder.add(glob);
645 }
646 builder
647 .build()
648 .context("failed to compile disable patterns")
649}
650
651#[cfg(test)]
652mod tests {
653 use super::*;
654 use crate::model::ShellFamily;
655
656 fn read_test(source: &str, layer: Layer) -> Result<Library> {
657 read(source, "test.yaml", layer)
658 }
659
660 fn ids(entries: &[Entry]) -> Vec<&str> {
661 entries.iter().map(|e| e.id.as_str()).collect()
662 }
663
664 #[test]
665 fn builtin_library_is_valid() {
666 let entries = load(None).expect("builtin library must parse");
667 assert!(
668 entries.len() > 100,
669 "only {} entries shipped",
670 entries.len()
671 );
672 assert!(entries.iter().all(|e| e.layer == Layer::Builtin));
673 }
674
675 #[test]
678 fn builtin_ids_are_unique_and_namespaced() {
679 let entries = load(None).unwrap();
680 let mut seen = BTreeSet::new();
681
682 for entry in &entries {
683 assert!(seen.insert(entry.id.clone()), "{} appears twice", entry.id);
684
685 let (namespace, rest) = entry.id.split_once('.').unwrap_or((&entry.id, ""));
686 assert!(
687 !namespace.is_empty() && !rest.is_empty(),
688 "{} is not namespaced",
689 entry.id
690 );
691 assert!(
692 entry
693 .id
694 .chars()
695 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '.' || c == '-'),
696 "{} is not a plain id",
697 entry.id
698 );
699 }
700 }
701
702 #[test]
705 fn every_builtin_placeholder_is_documented() {
706 let entries = load(None).unwrap();
707
708 for entry in &entries {
709 for family in [ShellFamily::Posix, ShellFamily::PowerShell] {
710 let Some(cmd) = entry.cmd_for(family) else {
711 continue;
712 };
713
714 for name in crate::params::names(cmd) {
715 let documented = entry
716 .params
717 .get(&name)
718 .is_some_and(|spec| spec.desc.is_some());
719 assert!(documented, "{} does not document <{name}>", entry.id);
720 }
721 }
722 }
723 }
724
725 #[test]
728 fn every_builtin_is_findable() {
729 let entries = load(None).unwrap();
730
731 for entry in &entries {
732 assert!(
733 !entry.desc.trim().is_empty(),
734 "{} has no description",
735 entry.id
736 );
737 assert!(entry.tags.len() >= 2, "{} carries too few tags", entry.id);
738 }
739 }
740
741 fn echo_entry(id: &str) -> NewEntry {
744 new_entry(id, &format!("echo {id}"))
745 }
746
747 fn ids_in(text: &str) -> Vec<String> {
748 read(text, "test", Layer::User)
749 .expect("the text should still be a valid library")
750 .commands
751 .into_iter()
752 .map(|entry| entry.id)
753 .collect()
754 }
755
756 #[test]
759 fn a_new_entry_goes_into_the_commands_list_even_when_it_is_not_last() {
760 let text = "version: 1\ncommands:\n - id: one\n cmd: echo one\n desc: One\ndisabled:\n - git.status\n";
761 let text = appended(text, &echo_entry("two")).unwrap();
762
763 assert_eq!(ids_in(&text), ["one", "two"]);
764 assert!(
765 text.ends_with("disabled:\n - git.status\n"),
766 "wrote {text}"
767 );
768 }
769
770 #[test]
771 fn a_comment_introducing_the_next_section_stays_with_it() {
772 let text = "version: 1\ncommands:\n - id: one\n cmd: echo one\n desc: One\n\n# builtins I never use\ndisabled:\n - docker.*\n";
773 let text = appended(text, &echo_entry("two")).unwrap();
774
775 assert_eq!(ids_in(&text), ["one", "two"]);
776 assert!(
777 text.contains("- saved\n\n# builtins I never use\ndisabled:"),
778 "wrote {text}"
779 );
780 }
781
782 #[test]
783 fn a_list_written_flush_with_the_margin_stays_that_way() {
784 let text = "version: 1\ncommands:\n- id: one\n cmd: echo one\n desc: One\n";
785 let text = appended(text, &echo_entry("two")).unwrap();
786
787 assert_eq!(ids_in(&text), ["one", "two"]);
788 assert!(text.contains("\n- id: two\n"), "wrote {text}");
789 }
790
791 #[test]
792 fn an_empty_inline_list_is_opened_up() {
793 let text = appended("version: 1\ncommands: []\n", &echo_entry("one")).unwrap();
794 assert_eq!(ids_in(&text), ["one"]);
795 }
796
797 #[test]
798 fn a_file_with_no_commands_key_gains_one() {
799 let text = appended("version: 1\ndisabled:\n - x\n", &echo_entry("one")).unwrap();
800 assert_eq!(ids_in(&text), ["one"]);
801 }
802
803 #[test]
804 fn replacing_keeps_the_indentation_the_file_uses() {
805 let text = "version: 1\ncommands:\n- id: one\n cmd: echo one\n desc: One\n";
806 let mut entry = echo_entry("one");
807 entry.desc = "Changed".to_string();
808
809 let (text, written) = upserted(text, &entry).unwrap();
810 assert_eq!(written, Written::Replaced);
811 assert!(text.contains("\n- id: one\n"), "wrote {text}");
812 assert!(text.contains("desc: Changed"), "wrote {text}");
813 }
814
815 #[test]
816 fn hiding_then_unhiding_leaves_the_file_as_it_was() {
817 let original = "version: 1\ncommands:\n - id: one\n cmd: echo one\n desc: One\ndisabled:\n - docker.*\n";
818 let hidden = with_disabled(original, "git.status").unwrap();
819 assert!(hidden.contains(" - git.status\n"), "wrote {hidden}");
820
821 assert_eq!(with_enabled(&hidden, "git.status"), original);
822 }
823
824 #[test]
825 fn unhiding_something_never_hidden_changes_nothing() {
826 let original =
827 "version: 1\ncommands:\n - id: git.status\n cmd: git status\n desc: S\n";
828 assert_eq!(with_enabled(original, "git.status"), original);
829 }
830
831 #[test]
832 fn an_id_keeps_the_hyphens_of_the_words_it_is_made_from() {
833 let taken = BTreeSet::new();
834 assert_eq!(suggest_id("echo from-a", &taken), "user.echo-from-a");
835 assert_eq!(
836 suggest_id("docker compose logs", &taken),
837 "user.docker-compose"
838 );
839 assert_eq!(suggest_id("kics scan -p .", &taken), "user.kics-scan");
840 assert_eq!(suggest_id("./deploy.sh", &taken), "user.deploysh");
841 }
842
843 #[test]
844 fn hashtags_in_the_purpose_become_tags() {
845 assert_eq!(
846 split_purpose("Follow the api logs #k8s #Debug"),
847 (
848 "Follow the api logs".to_string(),
849 vec!["k8s".to_string(), "debug".to_string()]
850 )
851 );
852 assert_eq!(
853 split_purpose(" Tail the #logs log "),
854 ("Tail the log".to_string(), vec!["logs".to_string()])
855 );
856 }
857
858 #[test]
860 fn a_bare_hash_stays_in_the_description() {
861 assert_eq!(
862 split_purpose("Issue # 42 fix"),
863 ("Issue # 42 fix".to_string(), Vec::new())
864 );
865 }
866
867 #[test]
868 fn tags_come_from_the_program_and_its_subcommands() {
869 assert_eq!(derive_tags("kubectl logs -f <pod>"), ["kubectl", "logs"]);
870 assert_eq!(
871 derive_tags("docker compose logs -f api"),
872 ["docker", "compose", "logs"]
873 );
874 assert_eq!(
875 derive_tags("git log -S\"<text>\" --oneline"),
876 ["git", "log"]
877 );
878 assert_eq!(derive_tags("ls -lah"), ["ls"]);
879 }
880
881 #[test]
882 fn tags_skip_what_is_not_the_program() {
883 assert_eq!(
884 derive_tags("sudo systemctl restart nginx"),
885 ["systemctl", "restart", "nginx"]
886 );
887 assert_eq!(derive_tags("RUST_LOG=debug cargo run"), ["cargo", "run"]);
888 assert_eq!(
889 derive_tags("/usr/local/bin/terraform plan"),
890 ["terraform", "plan"]
891 );
892 assert!(derive_tags("./deploy.sh prod").is_empty());
893 assert!(derive_tags("").is_empty());
894 }
895
896 #[test]
897 fn given_tags_come_first_and_are_not_repeated() {
898 assert_eq!(
899 merge_tags(
900 vec!["k8s".to_string(), "logs".to_string()],
901 "kubectl logs -f x"
902 ),
903 ["k8s", "logs", "kubectl"]
904 );
905 }
906
907 #[test]
908 fn the_builtin_library_is_ascii() {
909 for (origin, source) in BUILTINS {
910 assert!(source.is_ascii(), "{origin} is not ascii");
911 }
912 }
913
914 #[test]
915 fn builtin_library_carries_working_placeholders() {
916 let entries = load(None).unwrap();
917 let logs = entries
918 .iter()
919 .find(|e| e.id == "docker.logs")
920 .expect("docker.logs must exist");
921
922 let cmd = logs.cmd_for(ShellFamily::Posix).unwrap();
923 assert_eq!(crate::params::names(cmd), ["lines", "container"]);
924 }
925
926 #[test]
927 fn builtin_shell_variants_resolve_per_family() {
928 let entries = load(None).unwrap();
929 let ports = entries
930 .iter()
931 .find(|e| e.id == "sys.ports.listening")
932 .unwrap();
933
934 assert!(ports.cmd_for(ShellFamily::Posix).unwrap().contains("lsof "));
935 assert!(
936 ports
937 .cmd_for(ShellFamily::PowerShell)
938 .unwrap()
939 .starts_with("Get-NetTCPConnection")
940 );
941 }
942
943 #[test]
944 fn a_later_layer_shadows_an_earlier_one() {
945 let builtin = read_test(
946 "version: 1
947commands:
948 - id: git.log
949 cmd: git log
950 desc: builtin version
951",
952 Layer::Builtin,
953 )
954 .unwrap();
955
956 let user = read_test(
957 "version: 1
958commands:
959 - id: git.log
960 cmd: git log --oneline
961 desc: user version
962",
963 Layer::User,
964 )
965 .unwrap();
966
967 let merged = merge(vec![builtin, user]).unwrap();
968 assert_eq!(merged.len(), 1);
969 assert_eq!(merged[0].desc, "user version");
970 assert_eq!(merged[0].layer, Layer::User);
971 }
972
973 #[test]
974 fn disable_patterns_hide_entries_from_earlier_layers() {
975 let builtin = read_test(
976 "version: 1
977commands:
978 - id: docker.ps
979 cmd: docker ps
980 desc: list containers
981 - id: docker.logs
982 cmd: docker logs
983 desc: read logs
984 - id: git.log
985 cmd: git log
986 desc: read history
987",
988 Layer::Builtin,
989 )
990 .unwrap();
991
992 let user = read_test(
993 "version: 1
994disabled:
995 - docker.*
996",
997 Layer::User,
998 )
999 .unwrap();
1000
1001 let merged = merge(vec![builtin, user]).unwrap();
1002 assert_eq!(ids(&merged), ["git.log"]);
1003 }
1004
1005 #[test]
1006 fn an_unsupported_schema_version_is_rejected() {
1007 let error = read_test("version: 99\ncommands: []\n", Layer::User).unwrap_err();
1008 assert!(error.to_string().contains("schema version 99"));
1009 }
1010
1011 #[test]
1012 fn a_duplicate_id_within_one_file_is_rejected() {
1013 let error = read_test(
1014 "version: 1
1015commands:
1016 - id: dup
1017 cmd: a
1018 desc: first
1019 - id: dup
1020 cmd: b
1021 desc: second
1022",
1023 Layer::User,
1024 )
1025 .unwrap_err();
1026 assert!(error.to_string().contains("more than once"));
1027 }
1028
1029 #[test]
1030 fn an_empty_description_is_rejected() {
1031 let error = read_test(
1032 "version: 1
1033commands:
1034 - id: bare
1035 cmd: ls
1036 desc: ' '
1037",
1038 Layer::User,
1039 )
1040 .unwrap_err();
1041 assert!(error.to_string().contains("empty description"));
1042 }
1043
1044 fn scratch(name: &str) -> std::path::PathBuf {
1045 let path = std::env::temp_dir().join(format!("lore-{}-{name}.yaml", std::process::id()));
1046 let _ = fs::remove_file(&path);
1047 path
1048 }
1049
1050 fn new_entry(id: &str, cmd: &str) -> NewEntry {
1051 NewEntry {
1052 id: id.to_string(),
1053 cmd: CommandBody::Shared(cmd.to_string()),
1054 desc: "saved from the shell".to_string(),
1055 tags: vec!["saved".to_string()],
1056 params: BTreeMap::new(),
1057 danger: false,
1058 }
1059 }
1060
1061 #[test]
1065 fn rewriting_an_entry_leaves_the_rest_of_the_file_alone() {
1066 let path = scratch("rewrite");
1067 fs::write(
1068 &path,
1069 concat!(
1070 "version: 1\n",
1071 "# my own commands\n",
1072 "commands:\n",
1073 "\n",
1074 " # the one I always forget\n",
1075 " - id: user.kics\n",
1076 " cmd: kics scan -p .\n",
1077 " desc: old\n",
1078 "\n",
1079 " - id: user.trivy\n",
1080 " cmd: trivy image alpine\n",
1081 " desc: keep me\n",
1082 ),
1083 )
1084 .unwrap();
1085
1086 let mut entry = new_entry("user.kics", "kics scan -p . --report-formats json");
1087 entry.desc = "new".to_string();
1088 assert_eq!(upsert(&path, &entry).unwrap(), Written::Replaced);
1089
1090 let text = fs::read_to_string(&path).unwrap();
1091 assert!(text.contains("# my own commands"), "wrote {text:?}");
1092 assert!(text.contains("# the one I always forget"), "wrote {text:?}");
1093 assert!(text.contains("desc: keep me"), "wrote {text:?}");
1094 assert!(text.contains("--report-formats json"), "wrote {text:?}");
1095 assert!(!text.contains("desc: old"), "wrote {text:?}");
1096 assert_eq!(text.matches("id: user.kics").count(), 1, "wrote {text:?}");
1097 }
1098
1099 #[test]
1102 fn rewriting_keeps_the_fields_no_form_ever_shows() {
1103 let path = scratch("rewrite-fields");
1104 let entry = NewEntry {
1105 id: "sys.ports".to_string(),
1106 cmd: CommandBody::PerShell(BTreeMap::from([
1107 (ShellFamily::Posix, "ss -tulpn".to_string()),
1108 (ShellFamily::PowerShell, "Get-NetTCPConnection".to_string()),
1109 ])),
1110 desc: "List listening ports".to_string(),
1111 tags: vec!["net".to_string()],
1112 params: BTreeMap::from([(
1113 "port".to_string(),
1114 ParamSpec {
1115 desc: Some("Port to look for".to_string()),
1116 from: None,
1117 },
1118 )]),
1119 danger: true,
1120 };
1121
1122 assert_eq!(upsert(&path, &entry).unwrap(), Written::Appended);
1123 let reloaded = load(Some(&path)).unwrap();
1124 let reloaded = reloaded
1125 .iter()
1126 .find(|e| e.id == "sys.ports")
1127 .expect("the entry should load back");
1128
1129 assert_eq!(reloaded.cmd_for(ShellFamily::Posix), Some("ss -tulpn"));
1130 assert_eq!(
1131 reloaded.cmd_for(ShellFamily::PowerShell),
1132 Some("Get-NetTCPConnection")
1133 );
1134 assert!(reloaded.danger);
1135 assert_eq!(
1136 reloaded.params["port"].desc.as_deref(),
1137 Some("Port to look for")
1138 );
1139 }
1140
1141 #[test]
1144 fn upserting_an_id_the_file_does_not_hold_appends_it() {
1145 let path = scratch("upsert-new");
1146 fs::write(
1147 &path,
1148 concat!(
1149 "version: 1\n",
1150 "commands:\n",
1151 " - id: user.trivy\n",
1152 " cmd: trivy image alpine\n",
1153 " desc: keep me\n",
1154 ),
1155 )
1156 .unwrap();
1157
1158 assert_eq!(
1159 upsert(&path, &new_entry("git.log.graph", "git log --graph")).unwrap(),
1160 Written::Appended
1161 );
1162
1163 let text = fs::read_to_string(&path).unwrap();
1164 assert!(text.contains("desc: keep me"), "wrote {text:?}");
1165 assert!(text.contains("id: git.log.graph"), "wrote {text:?}");
1166 }
1167
1168 #[test]
1169 fn appending_to_a_missing_file_writes_a_whole_document() {
1170 let path = scratch("missing");
1171 append(&path, &new_entry("user.kics", "kics scan -p .")).unwrap();
1172
1173 let entries = load(Some(&path)).unwrap();
1174 let saved = entries.iter().find(|e| e.id == "user.kics").unwrap();
1175 assert_eq!(saved.cmd_for(ShellFamily::Posix), Some("kics scan -p ."));
1176 assert_eq!(saved.layer, Layer::User);
1177
1178 let _ = fs::remove_file(&path);
1179 }
1180
1181 #[test]
1182 fn appending_preserves_hand_written_comments() {
1183 let path = scratch("comments");
1184 fs::write(
1185 &path,
1186 "# my own notes, do not lose these\nversion: 1\ncommands:\n - id: mine\n cmd: ls\n desc: list\n",
1187 )
1188 .unwrap();
1189
1190 append(&path, &new_entry("user.added", "docker ps")).unwrap();
1191
1192 let text = fs::read_to_string(&path).unwrap();
1193 assert!(text.contains("# my own notes, do not lose these"));
1194
1195 let entries = load(Some(&path)).unwrap();
1196 let ids = ids(&entries);
1197 assert!(ids.contains(&"mine") && ids.contains(&"user.added"));
1198
1199 let _ = fs::remove_file(&path);
1200 }
1201
1202 #[test]
1203 fn appended_commands_survive_yaml_quoting() {
1204 let path = scratch("quoting");
1205 let tricky = r#"docker ps --format "table {{.Names}}: #1" | grep -v '^x'"#;
1206 append(&path, &new_entry("user.tricky", tricky)).unwrap();
1207
1208 let entries = load(Some(&path)).unwrap();
1209 let saved = entries.iter().find(|e| e.id == "user.tricky").unwrap();
1210 assert_eq!(saved.cmd_for(ShellFamily::Posix), Some(tricky));
1211
1212 let _ = fs::remove_file(&path);
1213 }
1214
1215 #[test]
1216 fn removing_an_entry_leaves_the_rest_of_the_file_alone() {
1217 let path = scratch("remove");
1218 fs::write(
1219 &path,
1220 "# notes I wrote myself\nversion: 1\ncommands:\n - id: keep.me\n cmd: ls\n desc: list\n\n - id: drop.me\n cmd: rm -rf /\n desc: do not\n - id: keep.me.too\n cmd: pwd\n desc: where\n",
1221 )
1222 .unwrap();
1223
1224 assert!(remove(&path, "drop.me").unwrap());
1225
1226 let text = fs::read_to_string(&path).unwrap();
1227 assert!(text.contains("# notes I wrote myself"));
1228 assert!(!text.contains("drop.me"));
1229 assert!(!text.contains("rm -rf"));
1230
1231 let entries = load(Some(&path)).unwrap();
1232 let ids = ids(&entries);
1233 assert!(ids.contains(&"keep.me") && ids.contains(&"keep.me.too"));
1234
1235 let _ = fs::remove_file(&path);
1236 }
1237
1238 #[test]
1239 fn removing_the_last_entry_does_not_swallow_what_follows() {
1240 let path = scratch("remove-last");
1241 fs::write(
1242 &path,
1243 "version: 1\ncommands:\n - id: drop.me\n cmd: ls\n desc: list\ndisabled:\n - docker.*\n",
1244 )
1245 .unwrap();
1246
1247 assert!(remove(&path, "drop.me").unwrap());
1248
1249 let text = fs::read_to_string(&path).unwrap();
1250 assert!(
1251 text.contains("disabled:"),
1252 "lost the disabled list: {text:?}"
1253 );
1254 assert!(text.contains("docker.*"));
1255
1256 let _ = fs::remove_file(&path);
1257 }
1258
1259 #[test]
1260 fn removing_something_that_is_not_there_reports_it() {
1261 let path = scratch("remove-missing");
1262 fs::write(&path, "version: 1\ncommands: []\n").unwrap();
1263
1264 assert!(!remove(&path, "nope").unwrap());
1265
1266 let _ = fs::remove_file(&path);
1267 }
1268
1269 #[test]
1270 fn disabling_hides_a_builtin() {
1271 let path = scratch("disable");
1272 let before = load(None).unwrap().len();
1273 disable(&path, "docker.prune.everything").unwrap();
1274
1275 let entries = load(Some(&path)).unwrap();
1276 assert!(!ids(&entries).contains(&"docker.prune.everything"));
1277 assert_eq!(entries.len(), before - 1);
1278
1279 let _ = fs::remove_file(&path);
1280 }
1281
1282 #[test]
1283 fn disabling_twice_extends_the_existing_list() {
1284 let path = scratch("disable-twice");
1285 let before = load(None).unwrap().len();
1286 disable(&path, "docker.prune.everything").unwrap();
1287 disable(&path, "git.log.graph").unwrap();
1288
1289 let text = fs::read_to_string(&path).unwrap();
1290 assert_eq!(text.matches("disabled:").count(), 1, "wrote {text:?}");
1291
1292 let entries = load(Some(&path)).unwrap();
1293 assert_eq!(entries.len(), before - 2);
1294
1295 let _ = fs::remove_file(&path);
1296 }
1297
1298 #[test]
1299 fn disabling_leaves_saved_commands_in_place() {
1300 let path = scratch("disable-keeps");
1301 append(&path, &new_entry("user.mine", "docker ps")).unwrap();
1302 disable(&path, "docker.prune.everything").unwrap();
1303
1304 let entries = load(Some(&path)).unwrap();
1305 let ids = ids(&entries);
1306 assert!(ids.contains(&"user.mine"));
1307 assert!(!ids.contains(&"docker.prune.all"));
1308
1309 let _ = fs::remove_file(&path);
1310 }
1311
1312 #[test]
1313 fn suggested_ids_read_like_the_command_and_avoid_collisions() {
1314 let mut taken = BTreeSet::new();
1315 assert_eq!(suggest_id("docker ps -a", &taken), "user.docker-ps");
1316
1317 taken.insert("user.docker-ps".to_string());
1318 assert_eq!(suggest_id("docker ps -a", &taken), "user.docker-ps-2");
1319
1320 taken.insert("user.docker-ps-2".to_string());
1321 assert_eq!(suggest_id("docker ps -a", &taken), "user.docker-ps-3");
1322 }
1323
1324 #[test]
1325 fn a_suggested_id_ignores_leading_flags_and_odd_input() {
1326 let taken = BTreeSet::new();
1327 assert_eq!(
1328 suggest_id("kubectl -n prod get", &taken),
1329 "user.kubectl-prod"
1330 );
1331 assert_eq!(suggest_id("--- ???", &taken), "user.command");
1332 assert_eq!(suggest_id("", &taken), "user.command");
1333 }
1334
1335 #[test]
1336 fn an_unknown_field_is_rejected() {
1337 let error = read_test(
1338 "version: 1
1339commands:
1340 - id: typo
1341 cmd: ls
1342 desc: list
1343 tag: [files]
1344",
1345 Layer::User,
1346 )
1347 .unwrap_err();
1348 assert!(error.to_string().contains("not a valid command library"));
1349 }
1350}