Skip to main content

lore/store/
definitions.rs

1//! Reading command definition files and merging them into one library.
2//!
3//! Layers are merged weakest first, so a later layer shadows an earlier one by
4//! entry id. Any layer may also hide entries by glob pattern without redefining
5//! them.
6
7use 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
18/// Schema version this build understands.
19const SCHEMA_VERSION: u32 = 1;
20
21/// Top level key holding the ids a layer hides.
22const DISABLED: &str = "disabled:";
23
24/// Top level key holding a layer's entries.
25const COMMANDS: &str = "commands:";
26
27/// How far list items are indented in a file that has none yet.
28const 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
39/// Libraries compiled into the binary, so a fresh install opens onto a full
40/// picker without a network round trip.
41///
42/// One file per namespace, listed by hand rather than gathered by a build
43/// script: a list read as easily as it is written is worth more here than one
44/// that maintains itself.
45const 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
59/// Loads the builtin library, overlaying the user's own file when it exists.
60pub 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/// A command on its way into the user's library.
79///
80/// Carries every field an entry can hold, not only the ones a form asks for.
81/// Rewriting an existing entry serialises this whole struct, so anything left
82/// out here would be dropped from the file the moment it was edited.
83#[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
112/// Parses the text of a user library, checked the way loading checks it.
113///
114/// An empty text is an empty library rather than an error: a machine that has
115/// never saved anything has no file at all.
116pub 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
127/// Splits the comma separated tags a user typed into a clean list.
128pub 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
135/// Separates `#tags` from the answer to "what is it for?".
136///
137/// Saving asks one question rather than filling in a form, so tags ride along
138/// in the answer the way they do in a commit message or a post: any word that
139/// starts with `#` becomes a tag and the rest is the description.
140pub 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
159/// Words to know a command by, taken from the command itself.
160///
161/// The program and the subcommands that follow it, stopping at the first flag,
162/// placeholder, path or quote: `kubectl logs -f <pod>` gives `kubectl` and
163/// `logs`. That is what someone types when they half remember the command, and
164/// it saves them inventing tags for every entry.
165pub fn derive_tags(cmd: &str) -> Vec<String> {
166    /// Deep enough for `docker compose logs`, short of the arguments.
167    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    // The program may be named by path, and the name is the part worth keeping.
176    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
198/// A word that reads as a name: lower case letters, digits and hyphens,
199/// starting with a letter, at least two long.
200fn 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
208/// Tags the user gave first, then the ones the command suggests.
209pub 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/// What `upsert` did to the file.
220#[derive(Debug, PartialEq, Eq)]
221pub enum Written {
222    Replaced,
223    Appended,
224}
225
226/// Appends an entry to the user's library, creating the file if needed.
227///
228/// The entry is added as text rather than by re-serialising the whole
229/// document, because users are told to hand edit and version this file. Round
230/// tripping it through a parser would silently delete their comments and
231/// reflow everything they had arranged.
232pub fn append(path: &Path, entry: &NewEntry) -> Result<()> {
233    let text = read_or_empty(path)?;
234    write(path, &appended(&text, entry)?)
235}
236
237/// `text` with `entry` added as the last item of its `commands` list.
238///
239/// The last item of the list, not the last line of the file. A library whose
240/// `disabled` list comes after its commands, which is what hiding a builtin
241/// produces, would otherwise take the new entry into the disabled list and
242/// stop parsing.
243pub 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    // After the last line that belongs to the list, so blank lines and a
264    // comment introducing whatever comes next stay with it.
265    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
274/// Renders one entry as a YAML list item indented by `indent`.
275///
276/// The body is produced by the serialiser so that quoting and escaping are
277/// correct, then indented into place.
278fn 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
292/// Cuts an entry out of a library file, reporting whether it was there.
293///
294/// Only the lines of that one list item are removed. Reserialising the document
295/// would be simpler, but users are told to hand edit and version this file, and
296/// a round trip through the parser silently deletes their comments.
297pub 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
310/// `text` without the entry declared as `id`, or `None` if there is none.
311pub fn removed(text: &str, id: &str) -> Option<String> {
312    item_of(text, id).map(|block| splice(text, block, ""))
313}
314
315/// Writes an entry into the user's library, replacing one already declared
316/// under the same id.
317///
318/// Nothing outside the one list item is touched, so the comments a user wrote
319/// around their entries survive. A comment sitting inside the entry being
320/// rewritten does not, which is the price of not reserialising the document.
321///
322/// A builtin cannot be changed where it lives, inside the binary. Writing it
323/// here under its own id is enough: the loader shadows by id, so the user's
324/// copy wins.
325pub 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
332/// `text` with `entry` in place of the one declared under its id, or added to
333/// the end of the list when there is none.
334pub 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
346/// Swaps the lines of one list item for `replacement`, which may be empty.
347fn 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
379/// Hides an entry the user cannot delete, such as one compiled into the binary.
380pub fn disable(path: &Path, id: &str) -> Result<()> {
381    let text = read_or_empty(path)?;
382    write(path, &with_disabled(&text, id)?)
383}
384
385/// `text` with `pattern` added to its `disabled` list.
386pub 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
407/// `text` with `pattern` taken off its `disabled` list, if it was on it.
408pub 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
432/// Where the list under the top level `key` starts, turning `key: []` into an
433/// open list first so that items can go under it.
434fn 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    // A comment after the key says nothing about the list.
441    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
453/// Whether `line` is the top level `key`, such as `commands:`.
454fn is_key(line: &str, key: &str) -> bool {
455    line.starts_with(key)
456}
457
458/// The line after the last one belonging to the section opened at `key`.
459fn 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
473/// A line that opens a new top level key rather than continuing a list.
474///
475/// A list may be written flush with the margin, so a line starting with `-`
476/// still belongs to it, as does a comment.
477fn starts_top_level(line: &str) -> bool {
478    !line.is_empty() && indent_of(line) == 0 && !line.starts_with('-') && !line.starts_with('#')
479}
480
481/// Blank lines and margin comments at the end of a section introduce what
482/// follows it rather than closing what came before.
483fn belongs_to_next(line: &str) -> bool {
484    line.trim().is_empty() || line.starts_with('#')
485}
486
487/// How far the list's items are indented, from the first one there is.
488fn 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
505/// The lines occupied by the list item that declares `id`.
506fn 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        // The item runs until something at the same level or shallower, which
516        // covers both the next entry and the key that follows the list.
517        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
557/// Turns a command into an id that is stable, readable and not already taken.
558pub 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            // Hyphens are part of the words these commands are made of, as in
565            // `docker compose up` against `force-recreate`, and they are
566            // allowed in an id. Anything else is dropped.
567            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
591/// Parses one library file and stamps its entries with the layer they came from.
592fn 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
620/// Collapses layers into one library, ordered by id for a stable listing.
621fn 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    /// Ids are the only handle a user, an override and the statistics all share,
676    /// so a duplicate across two namespace files would silently shadow an entry.
677    #[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    /// A placeholder with no description is a prompt with nothing above it. The
703    /// user is being asked for a value and told nothing about what it is.
704    #[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    /// A description is the only thing most searches match against, and a tag
726    /// list is what makes an entry findable under a word it does not contain.
727    #[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    /// The interface is ASCII only: a legacy Windows console runs on the system
742    /// code page, where anything else arrives as mojibake.
743    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    /// What every released version up to 0.1.2 did: hiding a builtin puts a
757    /// disabled list at the end of the file, and the next save went into it.
758    #[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    /// A lone `#` is punctuation, not an empty tag.
859    #[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    /// The whole reason an entry is spliced rather than the document
1062    /// reserialised: a user's own file is something they wrote, and a save must
1063    /// not reflow it.
1064    #[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    /// A form only ever shows the command, the description and the tags. Every
1100    /// other field has to survive an edit that never mentioned it.
1101    #[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    /// A builtin cannot be rewritten where it lives. Writing it under its own id
1142    /// is enough because the loader shadows by id.
1143    #[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}