Skip to main content

elasticctl_api/state/
mod.rs

1//! State orchestration: pull, diff, and push.
2//!
3//! Split by concern: report types, mirror file I/O, and one module per command.
4//! `mod.rs` holds the shared selection scope and the filename helpers every
5//! command uses.
6
7mod 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
39/// Rule IDs are caller-supplied strings. Replace characters that could escape
40/// the directory or are unsafe in filenames.
41fn 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
55/// Whether this scan should parse a directory entry as a rule or list file.
56/// Unlike `FileFormat::from_path`, ignore unknown extensions because mirror
57/// directories commonly contain files such as `README.md` and `.DS_Store`.
58fn 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
65/// The `ListKey` of every exception list the rules reference, deduplicated.
66fn 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
79/// Rules selected for a scoped run.
80///
81/// `None` in `rule_ids` means no selector was given and the command acts on
82/// every rule inside the `source` scope.
83struct 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    /// Keep only scoped rules. An unscoped run keeps all rules.
99    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    /// Split unscoped local rules by source scope, returning the in-scope rules
110    /// and the count outside it. A rule outside the scope is reported as
111    /// `out_of_scope`, never as a pending create (spec 5.5).
112    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    /// Read only the scoped remote rules. An unscoped run reads the active
126    /// `source` scope; a scoped run uses a `rule_id`-filtered `_find` instead
127    /// of reading the full corpus.
128    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    /// Describe the scope for the guard banner. Return nothing when unscoped.
145    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
157/// Whether a local rule file falls inside the active `--source` scope, judged
158/// on the field that scope filters server-side. A missing `immutable` reads as
159/// its server default (`false`, custom), so a sparse local file is not
160/// mistaken for a prebuilt rule.
161fn 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
180/// Resolve selectors against local rules, then the stack.
181///
182/// `local` is empty for `pull`, which reads from the stack and whose selectors
183/// therefore name stack rules.
184async 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    // Rule directories commonly contain a README. Do not parse it as a rule
224    // or fail `diff` and `push`.
225    #[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}