[pdm-devel] [PATCH datacenter-manager 2/7] lib: add pdm-search crate

Dominik Csapak d.csapak at proxmox.com
Wed Apr 16 13:49:20 CEST 2025


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 at 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 6e16831..2f544fa 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",
@@ -85,6 +86,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.1", path = "lib/pdm-client" }
+pdm-search = { version = "0.1", path = "lib/pdm-search" }
 pdm-ui-shared = { version = "0.1", 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)]
+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,
+        }
+    }
+}
+
+impl Search {
+    pub fn new() -> Self {
+        Self::with_terms(Vec::new())
+    }
+
+    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);
+            }
+        }
+
+        Self {
+            optional_terms,
+            required_terms,
+        }
+    }
+
+    /// 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
+    }
+
+    /// 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 {
+        Self {
+            value: term.into(),
+            optional: false,
+            category: None,
+        }
+    }
+
+    pub fn category<S: Into<String>>(mut self, category: Option<S>) -> Self {
+        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());
+    }
+}
-- 
2.39.5





More information about the pdm-devel mailing list