elasticctl_api/state/
mod.rs1mod 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}