Skip to main content

elasticctl_api/state/
mod.rs

1mod diff;
2mod mirror;
3mod pull;
4mod push;
5mod reports;
6mod transaction;
7mod values;
8
9pub use diff::diff;
10pub use mirror::{read_local, read_mirror};
11pub use pull::pull;
12pub use push::{PushPlan, apply_push, plan_push};
13pub use reports::{
14    DanglingPointer, DiffReport, ExceptionDrift, ListChange, Mirror, PullReport, PushReport,
15    StackIdentity,
16};
17pub use values::{ValueListChange, ValueListDrift, ValueListIssue};
18
19use crate::model::{ListKey, Rule};
20use crate::rules::{RuleFilter, RuleSource};
21use crate::selection;
22use elasticctl_core::{Result, Transport};
23use serde_json::Value;
24use std::collections::BTreeSet;
25use std::path::{Path, PathBuf};
26
27fn rules_dir(dir: &Path) -> PathBuf {
28    dir.join("rules")
29}
30
31fn exceptions_dir(dir: &Path) -> PathBuf {
32    dir.join("exceptions")
33}
34
35fn safe_filename(id: &str, ext: &str) -> String {
36    let safe: String = id
37        .chars()
38        .map(|c| {
39            if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
40                c
41            } else {
42                '_'
43            }
44        })
45        .collect();
46    format!("{safe}.{ext}")
47}
48
49fn is_rule_file(path: &Path) -> bool {
50    matches!(
51        path.extension().and_then(|e| e.to_str()),
52        Some("ndjson") | Some("json") | Some("yaml") | Some("yml")
53    )
54}
55
56fn referenced_keys(rules: &[Rule]) -> BTreeSet<ListKey> {
57    let mut wanted = BTreeSet::new();
58    for rule in rules {
59        for reference in crate::model::exception_refs(rule) {
60            wanted.insert(ListKey {
61                list_id: reference.list_id,
62                namespace_type: reference.namespace_type,
63            });
64        }
65    }
66    wanted
67}
68
69struct Scope {
70    rule_ids: Option<Vec<String>>,
71    source: RuleSource,
72    local_total: usize,
73}
74
75impl Scope {
76    fn is_scoped(&self) -> bool {
77        self.rule_ids.is_some()
78    }
79
80    fn selected(&self) -> usize {
81        self.rule_ids.as_ref().map(Vec::len).unwrap_or(0)
82    }
83
84    fn narrow(&self, rules: Vec<Rule>) -> Vec<Rule> {
85        match &self.rule_ids {
86            None => rules,
87            Some(ids) => rules
88                .into_iter()
89                .filter(|r| r.rule_id().is_ok_and(|id| ids.iter().any(|s| s == id)))
90                .collect(),
91        }
92    }
93
94    fn split_by_source(&self, rules: Vec<Rule>) -> (Vec<Rule>, usize) {
95        let mut kept = Vec::with_capacity(rules.len());
96        let mut out_of_scope = 0;
97        for rule in rules {
98            if in_source(self.source, &rule) {
99                kept.push(rule);
100            } else {
101                out_of_scope += 1;
102            }
103        }
104        (kept, out_of_scope)
105    }
106
107    async fn remote(&self, t: &Transport) -> Result<Vec<Rule>> {
108        match &self.rule_ids {
109            None => {
110                crate::rules::find_all(
111                    t,
112                    &RuleFilter {
113                        source: self.source,
114                        ..Default::default()
115                    },
116                )
117                .await
118            }
119            Some(ids) => crate::rules::find_by_rule_ids(t, ids).await,
120        }
121    }
122
123    fn describe(&self) -> String {
124        match &self.rule_ids {
125            None => String::new(),
126            Some(ids) => format!(
127                " (selection: {} of {} local rules)",
128                ids.len(),
129                self.local_total
130            ),
131        }
132    }
133}
134
135fn in_source(source: RuleSource, rule: &Rule) -> bool {
136    match source {
137        RuleSource::All => true,
138        RuleSource::Custom => !is_prebuilt(rule),
139        RuleSource::Prebuilt => is_prebuilt(rule),
140        RuleSource::Customized => rule
141            .as_map()
142            .get("rule_source")
143            .and_then(Value::as_object)
144            .and_then(|rs| rs.get("is_customized"))
145            .and_then(Value::as_bool)
146            .unwrap_or(false),
147    }
148}
149
150fn is_prebuilt(rule: &Rule) -> bool {
151    rule.as_map().get("immutable").and_then(Value::as_bool) == Some(true)
152}
153
154async fn scope_of(
155    t: &Transport,
156    selectors: &[String],
157    tag: Option<&str>,
158    search: Option<&str>,
159    source: RuleSource,
160    local: &[Rule],
161    noun: &str,
162) -> Result<Scope> {
163    let rule_ids = selection::resolve(t, selectors, tag, search, local, noun).await?;
164    Ok(Scope {
165        rule_ids,
166        source,
167        local_total: local.len(),
168    })
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174
175    fn write_rule(dir: &Path, filename: &str, rule_id: &str) {
176        std::fs::write(
177            dir.join(filename),
178            format!("{{\"rule_id\":\"{rule_id}\",\"name\":\"{rule_id}\",\"type\":\"query\"}}\n"),
179        )
180        .unwrap();
181    }
182
183    #[test]
184    fn is_rule_file_accepts_the_four_recognised_extensions_and_rejects_others() {
185        for ext in ["ndjson", "json", "yaml", "yml"] {
186            assert!(is_rule_file(Path::new(&format!("a.{ext}"))), "{ext}");
187        }
188        for ext in ["md", "txt", "DS_Store", "ndjson.bak"] {
189            assert!(!is_rule_file(Path::new(&format!("a.{ext}"))), "{ext}");
190        }
191        assert!(!is_rule_file(Path::new("noextension")));
192    }
193
194    #[test]
195    fn read_local_skips_non_rule_files_and_reads_the_valid_ones() {
196        let dir = tempfile::tempdir().unwrap();
197        let rules = dir.path().join("rules");
198        std::fs::create_dir_all(&rules).unwrap();
199        write_rule(&rules, "a.ndjson", "a");
200        std::fs::write(rules.join("README.md"), "not a rule\n").unwrap();
201        std::fs::write(rules.join("notes.txt"), "also not a rule\n").unwrap();
202        std::fs::create_dir_all(rules.join(".hidden")).unwrap();
203
204        let found = read_local(dir.path()).unwrap();
205        assert_eq!(found.len(), 1, "only the .ndjson file should be read");
206        assert_eq!(found[0].rule_id().unwrap(), "a");
207    }
208
209    #[test]
210    fn read_local_returns_empty_for_a_directory_of_only_unrecognised_files() {
211        let dir = tempfile::tempdir().unwrap();
212        let rules = dir.path().join("rules");
213        std::fs::create_dir_all(&rules).unwrap();
214        std::fs::write(rules.join("README.md"), "not a rule\n").unwrap();
215        std::fs::write(rules.join("notes.txt"), "also not a rule\n").unwrap();
216
217        let found = read_local(dir.path()).unwrap();
218        assert!(found.is_empty());
219    }
220}