intelli_shell/
tldr.rs

1use std::{fs, path::Path};
2
3use anyhow::{bail, Context, Error, Result};
4use git2::build::{CheckoutBuilder, RepoBuilder};
5use once_cell::sync::Lazy;
6use regex::Regex;
7
8use crate::{
9    cfg::{cfg_android, cfg_macos, cfg_unix, cfg_windows},
10    model::Command,
11};
12
13/// Regex to parse tldr pages as stated in [contributing guide](https://github.com/tldr-pages/tldr/blob/main/CONTRIBUTING.md#markdown-format)
14static PAGES_REGEX: Lazy<Regex> = Lazy::new(|| Regex::new(r#"\n\s*- (.+?):?\n\n?\s*`([^`]+)`"#).unwrap());
15
16/// Scrape tldr GitHub: https://github.com/tldr-pages/tldr
17pub fn scrape_tldr_github(category: Option<&str>) -> Result<Vec<Command>> {
18    scrape_tldr_repo("https://github.com/tldr-pages/tldr.git", category)
19}
20
21/// Scrapes any tldr-pages repo that follows the same semantics (maybe a fork?)
22pub fn scrape_tldr_repo(url: impl AsRef<str>, category: Option<&str>) -> Result<Vec<Command>> {
23    let tmp_dir = tempfile::tempdir()?;
24    let repo_path = tmp_dir.path();
25
26    let mut checkout = CheckoutBuilder::default();
27    checkout.path("pages/**");
28
29    RepoBuilder::default()
30        .with_checkout(checkout)
31        .clone(url.as_ref(), repo_path)?;
32
33    let mut result = Vec::new();
34
35    match category {
36        Some(category) => {
37            if !repo_path.join("pages").join(category).exists() {
38                bail!("Category {category} doesn't exist")
39            }
40            result.append(&mut parse_tldr_folder(
41                category,
42                repo_path.join("pages").join(category),
43            )?);
44        }
45        None => {
46            result.append(&mut parse_tldr_folder(
47                "common",
48                repo_path.join("pages").join("common"),
49            )?);
50
51            cfg_android!(
52                result.append(&mut parse_tldr_folder(
53                    "android",
54                    repo_path.join("pages").join("android"),
55                )?);
56            );
57            cfg_macos!(
58                result.append(&mut parse_tldr_folder(
59                    "osx",
60                    repo_path.join("pages").join("osx"),
61                )?);
62            );
63            cfg_unix!(
64                result.append(&mut parse_tldr_folder(
65                    "linux",
66                    repo_path.join("pages").join("linux"),
67                )?);
68            );
69            cfg_windows!(
70                result.append(&mut parse_tldr_folder(
71                    "windows",
72                    repo_path.join("pages").join("windows"),
73                )?);
74            );
75        }
76    }
77
78    Ok(result)
79}
80
81/// Parses every file on a tldr-pages folder into [Vec<Command>]
82fn parse_tldr_folder(category: impl Into<String>, path: impl AsRef<Path>) -> Result<Vec<Command>> {
83    let path = path.as_ref();
84    let category = category.into();
85    path.read_dir()
86        .context("Error reading tldr dir")?
87        .map(|r| r.map_err(Error::from))
88        .map(|r| r.map(|e| e.path()))
89        .map(|r| r.and_then(|p| Ok(fs::read_to_string(p)?)))
90        .map(|r| r.map(|r| parse_page(&category, r)))
91        .flat_map(|result| match result {
92            Ok(vec) => vec.into_iter().map(Ok).collect(),
93            Err(er) => vec![Err(er)],
94        })
95        .collect::<Result<Vec<_>>>()
96}
97
98/// Parses a single tldr-page as [Vec<Command>]
99fn parse_page(category: impl Into<String>, str: impl AsRef<str>) -> Vec<Command> {
100    let category = category.into();
101    PAGES_REGEX
102        .captures_iter(str.as_ref())
103        .map(|c| Command::new(category.clone(), &c[2], &c[1]))
104        .collect()
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110
111    #[test]
112    fn test_parse_page() -> Result<()> {
113        let commands = parse_page(
114            "test",
115            r#"# git commit
116
117            > Commit files to the repository.
118            > More information: <https://git-scm.com/docs/git-commit>.
119            
120            - Commit staged files to the repository with a message:
121            
122            `git commit -m "{{message}}"`
123            
124            - Commit staged files with a message read from a file
125            
126            `git commit --file {{path/to/commit_message_file}}`
127            
128            - Auto stage all modified files and commit with a message;
129            
130            `git commit -a -m "{{message}}"`
131            
132            - Commit staged files and [S]ign them with the GPG key defined in `~/.gitconfig`
133            
134            `git commit -S -m "{{message}}"`
135            
136            - Update the last commit by adding the currently staged changes, changing the commit's hash
137            
138            `git commit --amend`
139            
140            - Commit only specific (already staged) files:
141            
142            `git commit {{path/to/file1}} {{path/to/file2}}`
143            
144            - Create a commit, even if there are no staged files
145            
146            `git commit -m "{{message}}" --allow-empty`
147        "#,
148        );
149
150        assert_eq!(commands.len(), 7);
151        assert_eq!(commands.get(0).unwrap().cmd, r#"git commit -m "{{message}}""#);
152        assert_eq!(
153            commands.get(0).unwrap().description,
154            r#"Commit staged files to the repository with a message"#
155        );
156        assert_eq!(commands.get(3).unwrap().cmd, r#"git commit -S -m "{{message}}""#);
157        assert_eq!(
158            commands.get(3).unwrap().description,
159            r#"Commit staged files and [S]ign them with the GPG key defined in `~/.gitconfig`"#
160        );
161
162        Ok(())
163    }
164}