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