From: Stefan Hanreich <s.hanreich@proxmox.com>
To: Proxmox Datacenter Manager development discussion
<pdm-devel@lists.proxmox.com>,
Dominik Csapak <d.csapak@proxmox.com>
Subject: Re: [pdm-devel] [PATCH datacenter-manager v2 2/9] lib: add pdm-search crate
Date: Mon, 25 Aug 2025 15:14:10 +0200 [thread overview]
Message-ID: <18d57db1-a6fc-43cd-a8ca-fb04fabd8cb4@proxmox.com> (raw)
In-Reply-To: <20250825090014.1138485-3-d.csapak@proxmox.com>
some comments inline, I think the FromIterator<Item = SearchTerm> would
be a nice improvement, the rest is just potential for future / follow-ups.
On 8/25/25 11:01 AM, Dominik Csapak wrote:
> Introduce a new create for search & filter related code. It currently
> includes basic parsing & testing of search terms. Intended to be used on
> some API calls that allow for more complex filters, such as the
> resources API.
>
> Contains a `SearchTerm` and a `Search` struct. The former represents
> a single term to search for, with an optional category and if it's
> optional or not. The latter represents a full search with multiple
> terms.
>
> Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
> ---
> Cargo.toml | 2 +
> lib/pdm-search/Cargo.toml | 12 ++
> lib/pdm-search/src/lib.rs | 259 ++++++++++++++++++++++++++++++++++++++
> 3 files changed, 273 insertions(+)
> create mode 100644 lib/pdm-search/Cargo.toml
> create mode 100644 lib/pdm-search/src/lib.rs
>
> diff --git a/Cargo.toml b/Cargo.toml
> index 08b9373..236f00b 100644
> --- a/Cargo.toml
> +++ b/Cargo.toml
> @@ -19,6 +19,7 @@ members = [
> "lib/pdm-api-types",
> "lib/pdm-client",
> "lib/pdm-config",
> + "lib/pdm-search",
> "lib/pdm-ui-shared",
>
> "cli/client",
> @@ -86,6 +87,7 @@ pdm-api-types = { path = "lib/pdm-api-types" }
> pdm-buildcfg = { path = "lib/pdm-buildcfg" }
> pdm-config = { path = "lib/pdm-config" }
> pdm-client = { version = "0.2", path = "lib/pdm-client" }
> +pdm-search = { version = "0.2", path = "lib/pdm-search" }
> pdm-ui-shared = { version = "0.2", path = "lib/pdm-ui-shared" }
> proxmox-fido2 = { path = "cli/proxmox-fido2" }
>
> diff --git a/lib/pdm-search/Cargo.toml b/lib/pdm-search/Cargo.toml
> new file mode 100644
> index 0000000..5f51e75
> --- /dev/null
> +++ b/lib/pdm-search/Cargo.toml
> @@ -0,0 +1,12 @@
> +[package]
> +name = "pdm-search"
> +description = "Proxmox Datacenter Manager shared ui modules"
> +homepage = "https://www.proxmox.com"
> +
> +version.workspace = true
> +edition.workspace = true
> +license.workspace = true
> +repository.workspace = true
> +
> +[dependencies]
> +anyhow.workspace = true
> diff --git a/lib/pdm-search/src/lib.rs b/lib/pdm-search/src/lib.rs
> new file mode 100644
> index 0000000..8d6cca3
> --- /dev/null
> +++ b/lib/pdm-search/src/lib.rs
> @@ -0,0 +1,259 @@
> +//! Abstraction over a [`Search`] that contains multiple [`SearchTerm`]s.
> +//!
> +//! Provides methods to filter an item over a combination of such terms and
> +//! construct them from text, and serialize them back to text.
> +use std::{fmt::Display, str::FromStr};
> +
> +use anyhow::bail;
> +
> +#[derive(Clone)]
implement Default as well?
> +pub struct Search {
> + required_terms: Vec<SearchTerm>,
> + optional_terms: Vec<SearchTerm>,
> +}
> +
> +impl<S: AsRef<str>> From<S> for Search {
> + fn from(value: S) -> Self {
> + let mut optional_terms = Vec::new();
> + let mut required_terms = Vec::new();
> + for term in value.as_ref().split_whitespace() {
> + match term.parse::<SearchTerm>() {
> + Ok(term) => {
> + if term.optional {
> + optional_terms.push(term)
> + } else {
> + required_terms.push(term)
> + }
> + }
> + Err(_) => {} // ignore invalid search terms
> + }> + }
> +
> + Self {
> + required_terms,
> + optional_terms,
> + }
> + }
> +}
this implementation could use a potential FromIterator implementation
for Search, avoiding the duplicated code shared w/ with_terms?
i.e.
value.split_whitespace().filter_map(|term| term.parse().ok()).collect()
> +
> +impl Search {
> + pub fn new() -> Self {
> + Self::with_terms(Vec::new())
> + }
use Default::default() here then? (see next mail for further reasoning).
> +
> + pub fn is_empty(&self) -> bool {
> + self.required_terms.is_empty() && self.optional_terms.is_empty()
> + }
> +
> + pub fn with_terms(terms: Vec<SearchTerm>) -> Self {
> + let mut optional_terms = Vec::new();
> + let mut required_terms = Vec::new();
> +
> + for term in terms {
> + if term.optional {
> + optional_terms.push(term);
> + } else {
> + required_terms.push(term);
> + }
> + }
nit: partition could be used, although in the future there might be more
than required / optional.
> +
> + Self {
> + optional_terms,
> + required_terms,
> + }
> + }
potentially moving this into a FromIterator<Item = SearchTerm>
implementation would be more appropriate?
Potentially a FromIterator<Item = (String, String)> or FromIterator<Item
= (String, Option<String>)> might also be interesting (with respective
From implementations for SearchTerm)?
From<SearchTerm> might also be interesting? or even a From<T: impl
Into<SearchTerm>>
> +
> + /// Test if the given `Fn(&SearchTerm) -> bool` for all [`SearchTerm`] configured matches
> + ///
> + /// Returns true if it matches considering the constraints:
> + /// if there are no filters, returns true
> + pub fn matches<F: Fn(&SearchTerm) -> bool>(&self, matches: F) -> bool {
> + if self.is_empty() {
> + return true;
> + }
> +
> + let optional_matches: Vec<bool> = self.optional_terms.iter().map(&matches).collect();
> + let required_matches: Vec<bool> = self.required_terms.iter().map(&matches).collect();
> +
> + if !required_matches.is_empty() && required_matches.iter().any(|f| !f) {
> + return false;
> + }
> +
> + if !optional_matches.is_empty() && optional_matches.iter().all(|f| !f) {
> + return false;
> + }
> +
> + true
> + }
nit: we could avoid collecting altogether and shortcircuit, removing the
need for calling is_empty twice as well?
if !self.optional_matches.is_empty() &&
optional_matches.iter().map(&matches).all(..)
for required_matches we don't even need the empty check since any is
always false for empty collections
> +
> + /// Returns true if the combination of [`SearchTerm`]s require that this category value must be
> + /// true. Useful to find out if some condition is required (e.g. type == 'remote')
> + pub fn category_value_required(&self, category: &str, value: &str) -> bool {
> + for term in &self.required_terms {
> + if term.category.as_deref() == Some(category) && value.contains(&term.value) {
> + return true;
> + }
> + }
> +
> + let mut optional_count = 0;
> +
> + for term in &self.optional_terms {
> + if term.category.as_deref() == Some(category) && term.value == value {
> + optional_count += 1;
> + }
> + }
> +
> + self.required_terms.is_empty()
> + && self.optional_terms.len() == optional_count
> + && optional_count > 0
> + }
> +}
> +
> +impl Default for Search {
> + fn default() -> Self {
> + Self::new()
> + }
> +}
> +
> +impl std::fmt::Display for Search {
> + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
> + for (count, term) in self.required_terms.iter().enumerate() {
> + if count != 0 {
> + write!(f, " ")?;
> + }
> +
> + write!(f, "{term}")?;
> + }
> +
> + if !self.required_terms.is_empty() && !self.optional_terms.is_empty() {
> + write!(f, " ")?;
> + }
> +
> + for (count, term) in self.optional_terms.iter().enumerate() {
> + if count != 0 {
> + write!(f, " ")?;
> + }
> +
> + write!(f, "{term}")?;
> + }
> +
> + Ok(())
> + }
> +}
> +
> +#[derive(Debug, Clone, PartialEq)]
> +pub struct SearchTerm {
> + optional: bool,
> + pub value: String,
> + pub category: Option<String>,
> +}
> +
> +impl SearchTerm {
> + /// Creates a new [`SearchTerm`].
> + pub fn new<S: Into<String>>(term: S) -> Self {
are spaces potentially a problem here?
> + Self {
> + value: term.into(),
> + optional: false,
> + category: None,
> + }
> + }
> +
> + pub fn category<S: Into<String>>(mut self, category: Option<S>) -> Self {
same remark w.r.t. spaces
Is ToString potentially better as trait bound here, since I often see
.to_string() at the call sites?
I usually prefer impl Into<Option<>> for functions taking optional
values, since it avoids having to type out Some(_) everywhere, but in
this case with Into<String> this breaks type inference when passing None.
> + self.category = category.map(|s| s.into());
> + self
> + }
> +
> + pub fn optional(mut self, optional: bool) -> Self {
> + self.optional = optional;
> + self
> + }
> +}
> +
> +impl FromStr for SearchTerm {
> + type Err = anyhow::Error;
> +
> + fn from_str(s: &str) -> Result<Self, Self::Err> {
> + let mut optional = true;
> + let mut term: String = s.into();
> + if term.starts_with("+") {
> + optional = false;
> + term.remove(0);
> + }
> +
> + let (term, category) = if let Some(idx) = term.find(":") {
> + let mut real_term = term.split_off(idx);
> + real_term.remove(0); // remove ':'
> + (real_term, Some(term))
> + } else {
> + (term, None)
> + };
> +
> + if term.is_empty() {
> + bail!("term cannot be empty");
> + }
> +
> + if category == Some("".into()) {
> + bail!("category cannot be empty");
> + }
> +
> + Ok(SearchTerm::new(term).optional(optional).category(category))
> + }
> +}
> +
> +impl std::fmt::Display for SearchTerm {
> + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
> + if !self.optional {
> + f.write_str("+")?;
> + }
> +
> + if let Some(cat) = &self.category {
> + f.write_str(cat)?;
> + f.write_str(":")?;
> + }
> +
> + f.write_str(&self.value)
> + }
> +}
> +
> +#[cfg(test)]
> +mod tests {
> + use crate::SearchTerm;
> +
> + #[test]
> + fn parse_test_simple_filter() {
> + assert_eq!(
> + "foo".parse::<SearchTerm>().unwrap(),
> + SearchTerm::new("foo").optional(true),
> + );
> + }
> +
> + #[test]
> + fn parse_test_requires_filter() {
> + assert_eq!(
> + "+foo".parse::<SearchTerm>().unwrap(),
> + SearchTerm::new("foo"),
> + );
> + }
> +
> + #[test]
> + fn parse_test_category_filter() {
> + assert_eq!(
> + "foo:bar".parse::<SearchTerm>().unwrap(),
> + SearchTerm::new("bar")
> + .optional(true)
> + .category(Some("foo".into()))
> + );
> + assert_eq!(
> + "+foo:bar".parse::<SearchTerm>().unwrap(),
> + SearchTerm::new("bar").category(Some("foo".into()))
> + );
> + }
> +
> + #[test]
> + fn parse_test_invalid_filter() {
> + assert!(":bar".parse::<SearchTerm>().is_err());
> + assert!("+cat:".parse::<SearchTerm>().is_err());
> + assert!("+".parse::<SearchTerm>().is_err());
> + assert!(":".parse::<SearchTerm>().is_err());
> + }
> +}
_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel
next prev parent reply other threads:[~2025-08-25 13:14 UTC|newest]
Thread overview: 15+ messages / expand[flat|nested] mbox.gz Atom feed top
2025-08-25 8:58 [pdm-devel] [PATCH datacenter-manager v2 0/9] implement more complex search syntax Dominik Csapak
2025-08-25 8:58 ` [pdm-devel] [PATCH datacenter-manager v2 1/9] pdm-api-types: resources: add helper methods for fields Dominik Csapak
2025-08-25 8:58 ` [pdm-devel] [PATCH datacenter-manager v2 2/9] lib: add pdm-search crate Dominik Csapak
2025-08-25 13:14 ` Stefan Hanreich [this message]
2025-08-26 12:23 ` Dominik Csapak
2025-08-25 8:58 ` [pdm-devel] [PATCH datacenter-manager v2 3/9] server: api: resources: add more complex filter syntax Dominik Csapak
2025-08-25 13:14 ` Stefan Hanreich
2025-08-25 8:58 ` [pdm-devel] [PATCH datacenter-manager v2 4/9] ui: add possibility to insert into search box Dominik Csapak
2025-08-25 8:58 ` [pdm-devel] [PATCH datacenter-manager v2 5/9] ui: dashboard: remotes panel: open search on click Dominik Csapak
2025-08-25 8:58 ` [pdm-devel] [PATCH datacenter-manager v2 6/9] ui: dashboard: guest panel: search for guest states when clicking on them Dominik Csapak
2025-08-25 8:58 ` [pdm-devel] [PATCH datacenter-manager v2 7/9] ui: dashboard: search for nodes when clicking on the nodes panel Dominik Csapak
2025-08-25 8:58 ` [pdm-devel] [PATCH datacenter-manager v2 8/9] ui: search box: add clear trigger Dominik Csapak
2025-08-25 8:58 ` [pdm-devel] [PATCH datacenter-manager v2 9/9] ui: dashboard: guest panel: improve column widths Dominik Csapak
2025-08-25 13:48 ` [pdm-devel] [PATCH datacenter-manager v2 0/9] implement more complex search syntax Stefan Hanreich
2025-08-26 12:36 ` [pdm-devel] superseded: " Dominik Csapak
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=18d57db1-a6fc-43cd-a8ca-fb04fabd8cb4@proxmox.com \
--to=s.hanreich@proxmox.com \
--cc=d.csapak@proxmox.com \
--cc=pdm-devel@lists.proxmox.com \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox