use anyhow::{bail, Result};
use crate::inline::{self, InlineSpec};
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum ConfigAction {
Open,
Reset,
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) struct AutoCompleteArg {
pub(crate) shell: Option<String>,
pub(crate) dump: bool,
}
#[derive(Debug, Default, PartialEq, Eq)]
pub(crate) struct CliOptions {
pub(crate) picker: bool,
pub(crate) watch: bool,
pub(crate) update: bool,
pub(crate) config: Option<ConfigAction>,
pub(crate) auto_complete: Option<AutoCompleteArg>,
pub(crate) debug_input: bool,
pub(crate) print_help: bool,
pub(crate) print_version: bool,
pub(crate) file_arg: Option<String>,
pub(crate) theme: Option<String>,
pub(crate) editor: Option<String>,
pub(crate) inline: Option<InlineSpec>,
pub(crate) width: Option<usize>,
}
pub(crate) fn usage_text() -> &'static str {
"Usage: leaf [OPTIONS] [file.md | directory]\n\
\x20 leaf [--watch] --picker\n\
\x20 leaf --update\n\
\x20 echo '# Hello' | leaf\n\
\n\
Options:\n\
\x20 -h, --help Show this help message and exit\n\
\x20 -V, --version Show version information and exit\n\
\x20 -w, --watch Watch the file for changes and reload automatically\n\
\x20 --theme <NAME> Set color theme preset or custom config theme\n\
\x20 -e, --editor <NAME> Set external editor (nano|vim|code|subl|emacs)\n\
\x20 --inline [SPEC] Render to stdout (no TUI) [ansi|plain][:<width>]\n\
\x20 --width <N> Set maximum content width (min: 20)\n\
\x20 --picker Open the file browser picker\n\
\x20 --config [reset] Open or reset configuration file\n\
\x20 --update Update leaf to the latest version\n\
\x20 --auto-complete [SPEC] Install or dump shell completions [bash|zsh|fish|powershell][:dump]"
}
pub(crate) fn version_text() -> &'static str {
concat!("leaf ", env!("CARGO_PKG_VERSION"))
}
pub(crate) fn print_usage() {
println!("{}", usage_text());
}
pub(crate) fn print_version() {
println!("{}", version_text());
}
pub(crate) fn parse_cli(args: &[String]) -> Result<CliOptions> {
let mut options = CliOptions::default();
let mut positional_only = false;
let mut iter = args.iter().skip(1).peekable();
while let Some(arg) = iter.next() {
if positional_only {
if options.file_arg.is_none() {
options.file_arg = Some(arg.clone());
} else {
anyhow::bail!("Too many file arguments");
}
continue;
}
match arg.as_str() {
"--picker" => options.picker = true,
"--watch" | "-w" => options.watch = true,
"--update" => options.update = true,
"--config" => {
let action = match iter.peek() {
Some(next) if next.as_str() == "reset" => {
iter.next();
ConfigAction::Reset
}
_ => ConfigAction::Open,
};
options.config = Some(action);
}
"--auto-complete" => {
let ac_arg = match iter.peek() {
Some(next) if !next.starts_with('-') => {
let value = iter.next().unwrap();
parse_auto_complete_value(value)?
}
_ => AutoCompleteArg {
shell: None,
dump: false,
},
};
options.auto_complete = Some(ac_arg);
}
"--debug-input" => options.debug_input = true,
"--help" | "-h" => options.print_help = true,
"--version" | "-V" => options.print_version = true,
"--theme" => {
let Some(name) = iter.next() else {
anyhow::bail!("Missing value for --theme");
};
options.theme = Some(parse_theme_name(name)?);
}
_ if arg.starts_with("--theme=") => {
let name = &arg["--theme=".len()..];
options.theme = Some(parse_theme_name(name)?);
}
"--editor" | "-e" => {
let Some(value) = iter.next() else {
anyhow::bail!("Missing value for --editor");
};
options.editor = Some(value.clone());
}
_ if arg.starts_with("--editor=") => {
options.editor = Some(arg["--editor=".len()..].to_string());
}
"--inline" => {
let spec = match iter.peek() {
Some(next) if inline::is_inline_spec(next) => {
let value = iter.next().unwrap();
inline::parse_inline_spec(value)?
}
_ => InlineSpec {
format: inline::InlineFormat::Auto,
width: None,
},
};
options.inline = Some(spec);
}
_ if arg.starts_with("--inline=") => {
let value = &arg["--inline=".len()..];
options.inline = Some(inline::parse_inline_spec(value)?);
}
"--width" => {
let Some(value) = iter.next() else {
anyhow::bail!("Missing value for --width");
};
options.width = Some(parse_width_value(value)?);
}
_ if arg.starts_with("--width=") => {
let value = &arg["--width=".len()..];
options.width = Some(parse_width_value(value)?);
}
"--" => positional_only = true,
_ if arg.starts_with('-') => anyhow::bail!("Unknown flag: {arg}"),
_ if options.file_arg.is_none() => options.file_arg = Some(arg.clone()),
_ => anyhow::bail!("Too many file arguments"),
}
}
let standalone = [
(options.update, "--update"),
(options.config.is_some(), "--config"),
(options.auto_complete.is_some(), "--auto-complete"),
];
let standalone_count = standalone.iter().filter(|(set, _)| *set).count();
for &(set, name) in &standalone {
if !set {
continue;
}
let has_other = standalone_count > 1
|| options.watch
|| options.picker
|| options.debug_input
|| options.file_arg.is_some()
|| options.theme.is_some()
|| options.editor.is_some();
if has_other {
anyhow::bail!("{name} must be used on its own");
}
}
if options.inline.is_some() {
if options.watch {
anyhow::bail!("--inline cannot be combined with --watch");
}
if options.picker {
anyhow::bail!("--inline cannot be combined with --picker");
}
}
Ok(options)
}
fn parse_theme_name(name: &str) -> Result<String> {
let name = name.trim();
if name.is_empty() {
anyhow::bail!("Missing value for --theme");
}
Ok(name.to_string())
}
const KNOWN_SHELLS: &[&str] = &["bash", "zsh", "fish", "powershell"];
fn parse_auto_complete_value(s: &str) -> Result<AutoCompleteArg> {
if s == "dump" {
return Ok(AutoCompleteArg {
shell: None,
dump: true,
});
}
if let Some(prefix) = s.strip_suffix(":dump") {
if KNOWN_SHELLS.contains(&prefix) {
return Ok(AutoCompleteArg {
shell: Some(prefix.to_string()),
dump: true,
});
}
bail!("Unknown shell: '{prefix}'. Expected: bash, zsh, fish, powershell");
}
if KNOWN_SHELLS.contains(&s) {
return Ok(AutoCompleteArg {
shell: Some(s.to_string()),
dump: false,
});
}
bail!(
"Invalid argument for --auto-complete: '{s}'. \
Expected: bash, zsh, fish, powershell, dump, or SHELL:dump"
);
}
fn parse_width_value(s: &str) -> Result<usize> {
let w: usize = s
.trim()
.parse()
.map_err(|_| anyhow::anyhow!("Invalid width: {s}"))?;
if w < 20 {
bail!("Width must be at least 20");
}
Ok(w)
}