Skip to main content

mars_schema/
filter.rs

1//! Which of a package's items a dependency entry selects: the authored `agents`, `skills`,
2//! `exclude`, `only_skills`, `only_agents` and `rename` keys, their consistency rule, and the
3//! `FilterMode` they resolve to.
4
5use serde::{Deserialize, Serialize};
6
7use crate::names::{ItemName, RenameMap};
8
9fn is_false(v: &bool) -> bool {
10    !*v
11}
12
13/// Shared include/exclude/rename filter configuration for a source.
14#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
15pub struct FilterConfig {
16    #[serde(default, skip_serializing_if = "Option::is_none")]
17    pub agents: Option<Vec<ItemName>>,
18    #[serde(default, skip_serializing_if = "Option::is_none")]
19    pub skills: Option<Vec<ItemName>>,
20    #[serde(default, skip_serializing_if = "Option::is_none")]
21    pub exclude: Option<Vec<ItemName>>,
22    #[serde(default, skip_serializing_if = "Option::is_none")]
23    pub rename: Option<RenameMap>,
24    #[serde(default, skip_serializing_if = "is_false")]
25    pub only_skills: bool,
26    #[serde(default, skip_serializing_if = "is_false")]
27    pub only_agents: bool,
28}
29
30/// How items are filtered from a source.
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(rename_all = "kebab-case")]
33pub enum FilterMode {
34    /// Install everything from the source.
35    All,
36    /// Only install specific agents and/or skills.
37    Include {
38        agents: Vec<ItemName>,
39        skills: Vec<ItemName>,
40    },
41    /// Install everything except these items.
42    Exclude(Vec<ItemName>),
43    /// Install only skills, no agents.
44    OnlySkills,
45    /// Install only agents plus their transitive skill dependencies.
46    OnlyAgents,
47}
48
49/// A filter combination no mode can express. The message names the dependency.
50#[derive(Debug, thiserror::Error, PartialEq, Eq)]
51pub enum FilterError {
52    #[error("dependency `{name}`: only_skills and only_agents are mutually exclusive")]
53    BothCategories { name: String },
54    #[error("dependency `{name}`: only_skills/only_agents cannot combine with agents/skills lists")]
55    CategoryWithInclude { name: String },
56    #[error("dependency `{name}`: only_skills/only_agents cannot combine with exclude")]
57    CategoryWithExclude { name: String },
58    #[error("source `{name}` uses both agents/skills and exclude — pick one")]
59    IncludeWithExclude { name: String },
60}
61
62impl FilterError {
63    /// A stable identifier for callers that map failures to their own codes.
64    pub fn code(&self) -> &'static str {
65        "filter.inconsistent"
66    }
67}
68
69impl FilterConfig {
70    /// Reject inconsistent combinations: both category flags, a category flag with an include
71    /// list or `exclude`, and an include list with `exclude`.
72    pub fn validate(&self, dep_name: &str) -> Result<(), FilterError> {
73        let name = || dep_name.to_string();
74        let has_include = self.agents.is_some() || self.skills.is_some();
75        let has_exclude = self.exclude.is_some();
76        let has_category = self.only_skills || self.only_agents;
77
78        if self.only_skills && self.only_agents {
79            return Err(FilterError::BothCategories { name: name() });
80        }
81        if has_category && has_include {
82            return Err(FilterError::CategoryWithInclude { name: name() });
83        }
84        if has_category && has_exclude {
85            return Err(FilterError::CategoryWithExclude { name: name() });
86        }
87        if has_include && has_exclude {
88            return Err(FilterError::IncludeWithExclude { name: name() });
89        }
90        Ok(())
91    }
92
93    /// Convert to the resolved FilterMode enum.
94    pub fn to_mode(&self) -> FilterMode {
95        if self.only_skills {
96            FilterMode::OnlySkills
97        } else if self.only_agents {
98            FilterMode::OnlyAgents
99        } else if self.agents.is_some() || self.skills.is_some() {
100            FilterMode::Include {
101                agents: self.agents.clone().unwrap_or_default(),
102                skills: self.skills.clone().unwrap_or_default(),
103            }
104        } else if self.exclude.is_some() {
105            FilterMode::Exclude(self.exclude.clone().unwrap_or_default())
106        } else {
107            FilterMode::All
108        }
109    }
110
111    /// Returns true if any filter field is set (not default).
112    pub fn has_any_filter(&self) -> bool {
113        self.agents.is_some()
114            || self.skills.is_some()
115            || self.exclude.is_some()
116            || self.only_skills
117            || self.only_agents
118    }
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124
125    #[test]
126    fn filter_config_has_any_filter() {
127        assert!(!FilterConfig::default().has_any_filter());
128        assert!(
129            FilterConfig {
130                only_skills: true,
131                ..FilterConfig::default()
132            }
133            .has_any_filter()
134        );
135        assert!(
136            FilterConfig {
137                agents: Some(vec!["coder".into()]),
138                ..FilterConfig::default()
139            }
140            .has_any_filter()
141        );
142    }
143
144    #[test]
145    fn filter_config_to_mode() {
146        assert!(matches!(FilterConfig::default().to_mode(), FilterMode::All));
147        assert!(matches!(
148            FilterConfig {
149                only_skills: true,
150                ..FilterConfig::default()
151            }
152            .to_mode(),
153            FilterMode::OnlySkills
154        ));
155        assert!(matches!(
156            FilterConfig {
157                only_agents: true,
158                ..FilterConfig::default()
159            }
160            .to_mode(),
161            FilterMode::OnlyAgents
162        ));
163        assert!(matches!(
164            FilterConfig {
165                agents: Some(vec!["coder".into()]),
166                ..FilterConfig::default()
167            }
168            .to_mode(),
169            FilterMode::Include { .. }
170        ));
171        assert!(matches!(
172            FilterConfig {
173                exclude: Some(vec!["old".into()]),
174                ..FilterConfig::default()
175            }
176            .to_mode(),
177            FilterMode::Exclude(_)
178        ));
179    }
180}