Skip to main content

datui_lib/
commands.rs

1//! `datui cache`, `datui views` and `datui man`: commands that open no data.
2
3use datui_cli::{CacheAction, ViewsAction};
4
5use crate::cache::CacheManager;
6use crate::config::ConfigManager;
7use crate::view::{MatchCriteria, ViewManager};
8
9/// What `datui cache ACTION` prints, and its exit code.
10pub fn cache(cache: Option<&CacheManager>, action: &CacheAction) -> (String, i32) {
11    let CacheAction::Clear { recents } = action;
12    let Some(cache) = cache else {
13        return ("No cache to clear\n".into(), 0);
14    };
15    if *recents {
16        cache.clear_recents();
17        return ("Recently opened datasets forgotten\n".into(), 0);
18    }
19    match cache.clear_all() {
20        Ok(()) => (format!("Cleared {}\n", cache.cache_dir().display()), 0),
21        Err(e) => (format!("{e}\n"), 1),
22    }
23}
24
25/// What `datui views ACTION` prints, and its exit code.
26pub fn views(config: &ConfigManager, action: &ViewsAction) -> (String, i32) {
27    let mut views = match ViewManager::new(config) {
28        Ok(views) => views,
29        Err(e) => return (format!("{e}\n"), 1),
30    };
31    match action {
32        ViewsAction::List => {
33            let all = views.all_views();
34            if all.is_empty() {
35                return ("No saved views\n".into(), 0);
36            }
37            let width = all
38                .iter()
39                .map(|v| v.name.chars().count())
40                .max()
41                .unwrap_or(0);
42            let mut out = String::new();
43            for view in all {
44                out.push_str(&format!(
45                    "{:<width$}  {}\n",
46                    view.name,
47                    matches(&view.match_criteria)
48                ));
49            }
50            (out, 0)
51        }
52        ViewsAction::Rm { name } => {
53            let Some(id) = views.get_view_by_name(name).map(|v| v.id.clone()) else {
54                return (
55                    format!("No saved view named \"{name}\"; `datui views list` lists them\n"),
56                    1,
57                );
58            };
59            match views.delete_view(&id) {
60                Ok(()) => (format!("Removed {name}\n"), 0),
61                Err(e) => (format!("{e}\n"), 1),
62            }
63        }
64        ViewsAction::Clear => {
65            let count = views.all_views().len();
66            match views.remove_all_views() {
67                Ok(()) => (format!("Removed {count} saved views\n"), 0),
68                Err(e) => (format!("{e}\n"), 1),
69            }
70        }
71    }
72}
73
74/// What files a view is matched to, in a few words.
75fn matches(criteria: &MatchCriteria) -> String {
76    let mut parts: Vec<String> = Vec::new();
77    if let Some(path) = &criteria.exact_path {
78        parts.push(path.display().to_string());
79    } else if let Some(path) = &criteria.relative_path {
80        parts.push(path.clone());
81    }
82    if let Some(pattern) = criteria
83        .path_pattern
84        .as_ref()
85        .or(criteria.filename_pattern.as_ref())
86    {
87        parts.push(pattern.clone());
88    }
89    if let Some(columns) = &criteria.schema_columns {
90        parts.push(format!("{} columns", columns.len()));
91    }
92    if parts.is_empty() {
93        "any file".into()
94    } else {
95        parts.join(", ")
96    }
97}
98
99/// What `datui man` prints, and its exit code: the page list (`list`), every page
100/// written under `dir`, or one page. On a terminal (`terminal`) a page is shown with
101/// `man`, as `git help` does; where there is no `man` (Windows, a minimal container),
102/// as plain text, through a pager ($PAGER, less, more). For a pipe the page's roff is
103/// printed, for `man -l -` or a file.
104pub fn man(
105    page: Option<&str>,
106    list: bool,
107    dir: Option<&std::path::Path>,
108    terminal: bool,
109) -> (String, i32) {
110    use datui_cli::man::{PAGES, find};
111    if list {
112        let width = PAGES.iter().map(|p| p.title().len()).max().unwrap_or(0);
113        let text = PAGES
114            .iter()
115            .map(|p| format!("{:width$}  {}\n", p.title(), p.summary()))
116            .collect();
117        return (text, 0);
118    }
119    if let Some(dir) = dir {
120        let written: std::io::Result<()> = PAGES.iter().try_for_each(|p| {
121            let section = dir.join(format!("man{}", p.section));
122            std::fs::create_dir_all(&section)?;
123            std::fs::write(section.join(p.file_name()), p.roff())
124        });
125        return match written {
126            Ok(()) => (
127                format!("Wrote {} pages under {}\n", PAGES.len(), dir.display()),
128                0,
129            ),
130            Err(e) => (format!("{}: {e}\n", dir.display()), 1),
131        };
132    }
133    let name = page.unwrap_or("datui");
134    let Some(page) = find(name) else {
135        return (
136            format!("No manual page {name}; `datui man --list` lists them\n"),
137            1,
138        );
139    };
140    if terminal {
141        if let Some(code) = show_with_man(page) {
142            return (String::new(), code);
143        }
144        let width = crossterm::terminal::size().map_or(80, |(cols, _)| usize::from(cols));
145        let text = page.plain(width.clamp(40, 100));
146        if show_with_pager(&text) {
147            return (String::new(), 0);
148        }
149        return (text, 0);
150    }
151    (page.roff(), 0)
152}
153
154/// Page `text` through the first pager that runs. False when none does.
155fn show_with_pager(text: &str) -> bool {
156    pagers()
157        .into_iter()
158        .any(|mut pager| page_through(&mut pager, text))
159}
160
161/// The pagers to try, in order: `$PAGER`, run by the shell as git runs it so quoted
162/// paths and arguments work, then `less` and `more`.
163fn pagers() -> Vec<std::process::Command> {
164    use std::process::Command;
165    let mut out = Vec::new();
166    if let Some(pager) = std::env::var("PAGER").ok().filter(|p| !p.trim().is_empty()) {
167        if cfg!(unix) {
168            let mut sh = Command::new("sh");
169            sh.arg("-c").arg(&pager);
170            out.push(sh);
171        } else {
172            let mut words = pager.split_whitespace();
173            if let Some(program) = words.next() {
174                let mut command = Command::new(program);
175                command.args(words);
176                out.push(command);
177            }
178        }
179    }
180    let mut less = Command::new("less");
181    // As git does: quit when the page fits, keep the screen, pass colors.
182    if std::env::var_os("LESS").is_none() {
183        less.env("LESS", "FRX");
184    }
185    out.push(less);
186    out.push(Command::new("more"));
187    out
188}
189
190/// Run `pager` with `text` on its input. False when it cannot be started.
191fn page_through(pager: &mut std::process::Command, text: &str) -> bool {
192    use std::io::Write;
193    let Ok(mut child) = pager.stdin(std::process::Stdio::piped()).spawn() else {
194        return false;
195    };
196    if let Some(mut stdin) = child.stdin.take() {
197        // A pager quit before the end closes the pipe; that is not a failure.
198        let _ = stdin.write_all(text.as_bytes());
199    }
200    let _ = child.wait();
201    true
202}
203
204/// Show `page` with `man`, from a temporary file named as the page, so its title and
205/// section are its own. `None` when `man` cannot be run.
206fn show_with_man(page: &datui_cli::man::Page) -> Option<i32> {
207    let dir = tempfile::Builder::new()
208        .prefix("datui-man-")
209        .tempdir()
210        .ok()?;
211    let path = dir.path().join(page.file_name());
212    std::fs::write(&path, page.roff()).ok()?;
213    // A path with a slash is a file to man-db, mandoc and macOS's man alike.
214    let status = std::process::Command::new("man").arg(&path).status().ok()?;
215    Some(status.code().unwrap_or(1))
216}
217
218#[cfg(test)]
219mod tests;