use anyhow::{bail, Result};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum TargetMode {
All,
Aur,
Repo,
}
impl TargetMode {
pub fn aur(self) -> bool {
matches!(self, Self::All | Self::Aur)
}
pub fn repo(self) -> bool {
matches!(self, Self::All | Self::Repo)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SortBy {
Relevance,
Votes,
Popularity,
Name,
Modified,
}
impl SortBy {
fn parse(value: &str) -> Result<Self> {
match value {
"relevance" | "rank" => Ok(Self::Relevance),
"votes" => Ok(Self::Votes),
"popularity" | "pop" => Ok(Self::Popularity),
"name" => Ok(Self::Name),
"modified" | "mtime" => Ok(Self::Modified),
other => bail!("unknown sort mode '{other}'"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum SearchBy {
NameDesc,
Name,
Maintainer,
Depends,
MakeDepends,
OptDepends,
CheckDepends,
Provides,
Keywords,
}
impl SearchBy {
pub fn as_rpc(self) -> &'static str {
match self {
Self::NameDesc => "name-desc",
Self::Name => "name",
Self::Maintainer => "maintainer",
Self::Depends => "depends",
Self::MakeDepends => "makedepends",
Self::OptDepends => "optdepends",
Self::CheckDepends => "checkdepends",
Self::Provides => "provides",
Self::Keywords => "keywords",
}
}
fn parse(value: &str) -> Result<Self> {
match value {
"name-desc" | "namedesc" | "desc" => Ok(Self::NameDesc),
"name" => Ok(Self::Name),
"maintainer" => Ok(Self::Maintainer),
"depends" | "dep" => Ok(Self::Depends),
"makedepends" | "makedep" => Ok(Self::MakeDepends),
"optdepends" | "optdep" => Ok(Self::OptDepends),
"checkdepends" | "checkdep" => Ok(Self::CheckDepends),
"provides" | "provide" => Ok(Self::Provides),
"keywords" | "keyword" => Ok(Self::Keywords),
other => bail!("unknown search field '{other}'"),
}
}
}
#[derive(Debug, Clone)]
pub struct Options {
pub mode: TargetMode,
pub no_confirm: bool,
pub needed: bool,
pub dry_run: bool,
pub quiet: bool,
pub no_check: bool,
pub prefer_bin: bool,
pub limit: Option<usize>,
pub sort_by: SortBy,
pub search_by: SearchBy,
pub no_progress: bool,
pub resume: bool,
pub abort: bool,
pub repair: bool,
}
impl Default for Options {
fn default() -> Self {
Self {
mode: TargetMode::All,
no_confirm: false,
needed: false,
dry_run: false,
quiet: false,
no_check: false,
prefer_bin: false,
limit: None,
sort_by: SortBy::Relevance,
search_by: SearchBy::NameDesc,
no_progress: false,
resume: false,
abort: false,
repair: false,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Command {
DefaultUpgrade,
Search {
terms: Vec<String>,
},
Info {
targets: Vec<String>,
},
Install {
targets: Vec<String>,
refresh: bool,
sysupgrade: bool,
},
Get {
targets: Vec<String>,
print: bool,
},
QueryForeign,
QueryAurUpdates,
Forward(Vec<String>),
Interactive {
terms: Vec<String>,
},
Help,
Version,
Diagnostics,
}
#[derive(Debug, Clone)]
pub struct Parsed {
pub options: Options,
pub command: Command,
}
pub fn parse(args: Vec<String>) -> Result<Parsed> {
if args.is_empty() {
return Ok(Parsed {
options: Options::default(),
command: Command::DefaultUpgrade,
});
}
let original = args.clone();
let mut options = Options::default();
let mut op = None;
let mut flags = Vec::new();
let mut targets = Vec::new();
let mut saw_unknown_without_op = false;
let mut i = 0;
while i < args.len() {
let arg = &args[i];
if arg == "--" {
targets.extend(args[i + 1..].iter().cloned());
break;
}
if arg == "--help" || arg == "-h" {
return Ok(Parsed {
options,
command: Command::Help,
});
}
if arg == "--version" || arg == "-V" {
return Ok(Parsed {
options,
command: Command::Version,
});
}
if arg == "--diagnostics" {
return Ok(Parsed {
options,
command: Command::Diagnostics,
});
}
match arg.as_str() {
"--aur" => options.mode = TargetMode::Aur,
"--repo" => options.mode = TargetMode::Repo,
"--noconfirm" => options.no_confirm = true,
"--needed" => options.needed = true,
"--dry-run" => options.dry_run = true,
"--quiet" => options.quiet = true,
"--nocheck" => options.no_check = true,
"--prefer-bin" => options.prefer_bin = true,
"--no-progress" => options.no_progress = true,
"--resume" => options.resume = true,
"--abort" => options.abort = true,
"--repair" => options.repair = true,
"--search" => {
op.get_or_insert('S');
flags.push('s');
}
"--info" => {
op.get_or_insert('S');
flags.push('i');
}
"--sync" => {
op.get_or_insert('S');
}
"--query" => {
op.get_or_insert('Q');
}
"--getpkgbuild" => {
op.get_or_insert('G');
}
"--printpkgbuild" => {
op.get_or_insert('G');
flags.push('p');
}
"--limit" => {
i += 1;
let value = args
.get(i)
.ok_or_else(|| anyhow::anyhow!("--limit needs a value"))?;
options.limit = Some(value.parse()?);
}
"--sort" => {
i += 1;
let value = args
.get(i)
.ok_or_else(|| anyhow::anyhow!("--sort needs a value"))?;
options.sort_by = SortBy::parse(value)?;
}
"--searchby" => {
i += 1;
let value = args
.get(i)
.ok_or_else(|| anyhow::anyhow!("--searchby needs a value"))?;
options.search_by = SearchBy::parse(value)?;
}
_ if arg.starts_with("--limit=") => {
options.limit = Some(arg[8..].parse()?);
}
_ if arg.starts_with("--sort=") => {
options.sort_by = SortBy::parse(&arg[7..])?;
}
_ if arg.starts_with("--searchby=") => {
options.search_by = SearchBy::parse(&arg[11..])?;
}
_ if arg.starts_with("--") => {
if op.is_none() {
saw_unknown_without_op = true;
}
}
_ if arg.starts_with('-') && arg.len() > 1 => {
let body = &arg[1..];
let mut chars = body.chars();
if op.is_none() {
let Some(first) = chars.next() else {
i += 1;
continue;
};
if matches!(first, 'S' | 'Q' | 'G' | 'R' | 'U' | 'D' | 'F' | 'T') {
op = Some(first);
} else if first == 'q' {
options.quiet = true;
} else {
saw_unknown_without_op = true;
flags.push(first);
}
for flag in chars {
apply_short_flag(flag, &mut options, &mut flags);
}
} else {
for flag in body.chars() {
apply_short_flag(flag, &mut options, &mut flags);
}
}
}
_ => targets.push(arg.clone()),
}
i += 1;
}
if op.is_none() {
if saw_unknown_without_op && targets.is_empty() {
return Ok(Parsed {
options,
command: Command::Forward(original),
});
}
return Ok(Parsed {
options,
command: if targets.is_empty() {
Command::DefaultUpgrade
} else {
Command::Interactive { terms: targets }
},
});
}
let command = match op.unwrap() {
'S' => {
if flags.contains(&'s') {
Command::Search { terms: targets }
} else if flags.contains(&'i') {
Command::Info { targets }
} else {
Command::Install {
targets,
refresh: flags.contains(&'y'),
sysupgrade: flags.contains(&'u'),
}
}
}
'Q' => {
if flags.contains(&'m') {
Command::QueryForeign
} else if flags.contains(&'u')
&& (flags.contains(&'a') || options.mode == TargetMode::Aur)
{
Command::QueryAurUpdates
} else {
Command::Forward(original)
}
}
'G' => Command::Get {
targets,
print: flags.contains(&'p'),
},
_ => Command::Forward(original),
};
Ok(Parsed { options, command })
}
fn apply_short_flag(flag: char, options: &mut Options, flags: &mut Vec<char>) {
match flag {
'q' => options.quiet = true,
other => flags.push(other),
}
}
pub fn print_help() {
println!(
"knott - fast Rust Arch Linux package helper\n\n\
Usage:\n knott upgrade repo packages, then AUR packages\n knott <term...> search and select packages interactively\n knott -Ss <term...> search repos and AUR\n knott -S <pkg...> install repo/AUR packages\n knott -Syu [pkg...] refresh, upgrade, then optionally install packages\n knott -Si <pkg...> show repo/AUR package info\n knott -G <pkg...> clone/update AUR PKGBUILDs\n knott -Gp <pkg...> print AUR PKGBUILDs\n knott -Qm list foreign packages\n knott -Qua --aur list available AUR updates\n knott --diagnostics show resource limits and process diagnostics\n\n\
Options:\n -h, --help show this help\n -V, --version show version\n --aur | --repo restrict package source\n --needed skip already-installed packages where possible\n --noconfirm avoid prompts and use deterministic defaults\n --nocheck skip checkdepends during AUR dependency planning\n --dry-run print operations without running pacman/makepkg/git\n --prefer-bin prefer AUR -bin package variants when available\n --resume resume an interrupted Knott transaction\n --abort mark interrupted Knott transaction aborted\n --repair check pacman state, finish repo upgrade if needed, then resume safely\n -q, --quiet reduce output where supported\n --no-progress disable Aisling progress loaders\n --limit <n> limit search output\n --sort <mode> relevance, votes, popularity, name, modified\n --searchby <field> name-desc, name, maintainer, depends, provides\n\n\
Environment:\n KNOTT_AUR_URL, KNOTT_BUILDDIR, KNOTT_PACMAN, KNOTT_MAKEPKG, KNOTT_GIT, KNOTT_SUDO, KNOTT_PROGRESS"
);
}
#[cfg(test)]
mod tests {
use super::*;
fn strings(values: &[&str]) -> Vec<String> {
values.iter().map(|value| (*value).to_string()).collect()
}
#[test]
fn parses_default_upgrade() {
let parsed = parse(Vec::new()).unwrap();
assert_eq!(parsed.command, Command::DefaultUpgrade);
}
#[test]
fn parses_search() {
let parsed = parse(strings(&["-Ss", "linux", "headers"])).unwrap();
assert_eq!(
parsed.command,
Command::Search {
terms: strings(&["linux", "headers"])
}
);
}
#[test]
fn parses_install_flags() {
let parsed = parse(strings(&["-Syu", "--needed", "neovim"])).unwrap();
assert!(parsed.options.needed);
assert_eq!(
parsed.command,
Command::Install {
targets: strings(&["neovim"]),
refresh: true,
sysupgrade: true
}
);
}
#[test]
fn parses_bare_terms_as_interactive() {
let parsed = parse(strings(&["ripgrep"])).unwrap();
assert_eq!(
parsed.command,
Command::Interactive {
terms: strings(&["ripgrep"])
}
);
}
#[test]
fn parses_option_only_as_default_upgrade() {
let parsed = parse(strings(&["--dry-run"])).unwrap();
assert!(parsed.options.dry_run);
assert_eq!(parsed.command, Command::DefaultUpgrade);
}
#[test]
fn parses_transaction_recovery_flags() {
let parsed = parse(strings(&["--resume"])).unwrap();
assert!(parsed.options.resume);
assert_eq!(parsed.command, Command::DefaultUpgrade);
let parsed = parse(strings(&["--abort"])).unwrap();
assert!(parsed.options.abort);
assert_eq!(parsed.command, Command::DefaultUpgrade);
let parsed = parse(strings(&["--repair"])).unwrap();
assert!(parsed.options.repair);
assert_eq!(parsed.command, Command::DefaultUpgrade);
}
#[test]
fn parses_resume_with_sync_upgrade() {
let parsed = parse(strings(&["-Syu", "--resume"])).unwrap();
assert!(parsed.options.resume);
assert_eq!(
parsed.command,
Command::Install {
targets: Vec::new(),
refresh: true,
sysupgrade: true
}
);
}
#[test]
fn parses_prefer_bin_for_install() {
let parsed = parse(strings(&["-S", "foo", "--prefer-bin"])).unwrap();
assert!(parsed.options.prefer_bin);
assert_eq!(
parsed.command,
Command::Install {
targets: strings(&["foo"]),
refresh: false,
sysupgrade: false
}
);
}
#[test]
fn keeps_unknown_long_flags_forwarding() {
let parsed = parse(strings(&["--unknown-knott-flag"])).unwrap();
assert_eq!(
parsed.command,
Command::Forward(strings(&["--unknown-knott-flag"]))
);
}
#[test]
fn parses_diagnostics() {
let parsed = parse(strings(&["--diagnostics"])).unwrap();
assert_eq!(parsed.command, Command::Diagnostics);
}
#[test]
fn parses_no_progress() {
let parsed = parse(strings(&["--no-progress", "-Ss", "ripgrep"])).unwrap();
assert!(parsed.options.no_progress);
assert_eq!(
parsed.command,
Command::Search {
terms: strings(&["ripgrep"])
}
);
}
#[test]
fn parses_quiet_before_operation() {
let parsed = parse(strings(&["-q", "-Ss", "ripgrep"])).unwrap();
assert!(parsed.options.quiet);
assert_eq!(
parsed.command,
Command::Search {
terms: strings(&["ripgrep"])
}
);
let parsed = parse(strings(&["-q"])).unwrap();
assert!(parsed.options.quiet);
assert_eq!(parsed.command, Command::DefaultUpgrade);
}
#[test]
fn parses_documented_query_and_get_commands() {
let parsed = parse(strings(&["-Qm"])).unwrap();
assert_eq!(parsed.command, Command::QueryForeign);
let parsed = parse(strings(&["-Qua", "--aur"])).unwrap();
assert_eq!(parsed.command, Command::QueryAurUpdates);
let parsed = parse(strings(&["-G", "shellcheck-bin"])).unwrap();
assert_eq!(
parsed.command,
Command::Get {
targets: strings(&["shellcheck-bin"]),
print: false
}
);
let parsed = parse(strings(&["-Gp", "shellcheck-bin"])).unwrap();
assert_eq!(
parsed.command,
Command::Get {
targets: strings(&["shellcheck-bin"]),
print: true
}
);
}
#[test]
fn parses_long_commands_and_search_controls() {
let parsed = parse(strings(&[
"--aur",
"--limit=5",
"--sort",
"votes",
"--searchby=provides",
"--search",
"sh",
]))
.unwrap();
assert_eq!(parsed.options.mode, TargetMode::Aur);
assert_eq!(parsed.options.limit, Some(5));
assert_eq!(parsed.options.sort_by, SortBy::Votes);
assert_eq!(parsed.options.search_by, SearchBy::Provides);
assert_eq!(
parsed.command,
Command::Search {
terms: strings(&["sh"])
}
);
let parsed = parse(strings(&["--info", "ripgrep"])).unwrap();
assert_eq!(
parsed.command,
Command::Info {
targets: strings(&["ripgrep"])
}
);
let parsed = parse(strings(&["--sync", "--needed", "ripgrep"])).unwrap();
assert!(parsed.options.needed);
assert_eq!(
parsed.command,
Command::Install {
targets: strings(&["ripgrep"]),
refresh: false,
sysupgrade: false
}
);
}
}