Skip to main content

elasticctl_api/
cases_ops.rs

1//! Case orchestration: filters, list/get, and the guarded mutation plans.
2
3use crate::alerts;
4use crate::alerts_ops::source_str;
5use crate::cases::{self, Case, CaseStatus, NewCase};
6use crate::profiles;
7use elasticctl_core::{Error, ErrorKind, Result, Transport, urlencode};
8use serde_json::Value;
9use std::collections::{BTreeMap, BTreeSet};
10
11/// The `_find` route's per-page cap.
12pub const PAGE_SIZE: u32 = 100;
13
14#[derive(Debug, Clone, Default)]
15pub struct CaseFilter {
16    pub status: Option<CaseStatus>,
17    pub severity: Option<String>,
18    pub tag: Option<String>,
19    /// Matches title and description server-side.
20    pub search: Option<String>,
21}
22
23/// Deterministic query string for `GET /api/cases/_find`. Key order is
24/// fixed so tests and fixtures are stable.
25pub fn find_query(f: &CaseFilter, page: u32, per_page: u32) -> String {
26    let mut q = format!("page={page}&perPage={per_page}&sortField=createdAt&sortOrder=desc");
27    if let Some(status) = f.status {
28        q.push_str(&format!("&status={}", status.as_str()));
29    }
30    if let Some(severity) = &f.severity {
31        q.push_str(&format!("&severity={}", urlencode(severity)));
32    }
33    if let Some(tag) = &f.tag {
34        q.push_str(&format!("&tags={}", urlencode(tag)));
35    }
36    if let Some(search) = &f.search {
37        q.push_str(&format!(
38            "&search={}&searchFields=title&searchFields=description",
39            urlencode(search)
40        ));
41    }
42    q
43}
44
45#[derive(Debug, Clone, PartialEq)]
46pub struct CaseList {
47    pub cases: Vec<Case>,
48    pub total: u64,
49    pub truncated: bool,
50}
51
52/// One bounded peek: page until `limit + 1` rows are in hand or the server
53/// runs out, then truncate.
54pub async fn list(t: &Transport, f: &CaseFilter, limit: usize) -> Result<CaseList> {
55    let mut cases = Vec::new();
56    let mut total = 0;
57    let mut page = 1;
58    while cases.len() <= limit {
59        let (batch, batch_total) = cases::find_page(t, &find_query(f, page, PAGE_SIZE)).await?;
60        total = batch_total;
61        let got = batch.len();
62        cases.extend(batch);
63        if got < PAGE_SIZE as usize {
64            break;
65        }
66        page += 1;
67    }
68    let truncated = cases.len() > limit;
69    cases.truncate(limit);
70    Ok(CaseList {
71        cases,
72        total,
73        truncated,
74    })
75}
76
77/// The `--out` path: every page, unless `limit` stops it early.
78pub async fn export(t: &Transport, f: &CaseFilter, limit: Option<usize>) -> Result<Vec<Case>> {
79    export_with_page_size(t, f, PAGE_SIZE, limit).await
80}
81
82/// The paging loop with an explicit page size, exposed for tests. Stops
83/// paging as soon as `limit` rows are in hand, mirroring
84/// `alerts_ops::export`'s shape.
85pub async fn export_with_page_size(
86    t: &Transport,
87    f: &CaseFilter,
88    per_page: u32,
89    limit: Option<usize>,
90) -> Result<Vec<Case>> {
91    let mut all = Vec::new();
92    let mut page = 1;
93    loop {
94        let (batch, _) = cases::find_page(t, &find_query(f, page, per_page)).await?;
95        let got = batch.len();
96        all.extend(batch);
97        if let Some(limit) = limit
98            && all.len() >= limit
99        {
100            all.truncate(limit);
101            return Ok(all);
102        }
103        if got < per_page as usize {
104            return Ok(all);
105        }
106        page += 1;
107    }
108}
109
110pub async fn get_one(t: &Transport, id: &str) -> Result<Case> {
111    cases::get(t, id).await
112}
113
114#[derive(Debug, Clone, PartialEq, Eq)]
115pub struct ResolvedCase {
116    pub id: String,
117    pub version: String,
118    pub title: String,
119    pub status: String,
120}
121
122fn case_noun(n: usize) -> &'static str {
123    if n == 1 { "case" } else { "cases" }
124}
125
126/// Resolve explicit case ids. Fail-closed: every id must resolve or nothing
127/// proceeds; duplicates collapse preserving first-seen order.
128async fn resolve_cases(t: &Transport, ids: &[String]) -> Result<Vec<ResolvedCase>> {
129    let mut unique: Vec<String> = Vec::with_capacity(ids.len());
130    for id in ids {
131        if !unique.contains(id) {
132            unique.push(id.clone());
133        }
134    }
135    let mut resolved = Vec::with_capacity(unique.len());
136    let mut missing = Vec::new();
137    for id in &unique {
138        match cases::get(t, id).await {
139            Ok(case) => resolved.push(ResolvedCase {
140                id: case.id,
141                version: case.version,
142                title: case.title,
143                status: case.status,
144            }),
145            Err(e) if e.kind == ErrorKind::NotFound => missing.push(id.clone()),
146            Err(e) => return Err(e),
147        }
148    }
149    if !missing.is_empty() {
150        return Err(Error::new(
151            ErrorKind::NotFound,
152            format!("No case with id: {}", missing.join(", ")),
153        ));
154    }
155    Ok(resolved)
156}
157
158/// The compact case row for list output and mutation reports: stable columns
159/// in a fixed order (`preserve_order` makes this the render contract).
160pub fn case_row(case: &Case) -> Value {
161    let mut row = serde_json::Map::new();
162    row.insert("id".into(), Value::String(case.id.clone()));
163    row.insert("title".into(), Value::String(case.title.clone()));
164    row.insert("status".into(), Value::String(case.status.clone()));
165    if let Some(severity) = &case.severity {
166        row.insert("severity".into(), Value::String(severity.clone()));
167    }
168    row.insert("tags".into(), serde_json::json!(case.tags));
169    if let Some(n) = case.total_comment {
170        row.insert("comments".into(), serde_json::json!(n));
171    }
172    if let Some(at) = &case.created_at {
173        row.insert("created_at".into(), Value::String(at.clone()));
174    }
175    if let Some(at) = &case.updated_at {
176        row.insert("updated_at".into(), Value::String(at.clone()));
177    }
178    Value::Object(row)
179}
180
181#[derive(Debug, Clone, PartialEq, serde::Serialize)]
182pub struct CaseEditReport {
183    pub applied: bool,
184    pub total: u64,
185    pub updated: u64,
186    /// `render::exit_code_for_value` keys on this field: a positive count
187    /// exits 1. Field order is the rendered JSON key order (`preserve_order`).
188    pub failed: u64,
189    /// One entry per failed unit of work (currently only `apply_attach`'s
190    /// per-rule-group comment POSTs), naming what failed and why. Empty for
191    /// every other mutation's report. Appended after `failed` so existing
192    /// consumers of the field order are unaffected.
193    pub failures: Vec<String>,
194}
195
196#[derive(Debug, Clone, PartialEq)]
197pub struct CreatePlan {
198    pub new: NewCase,
199    pub preview_action: String,
200    pub preview_details: Vec<String>,
201}
202
203pub async fn plan_create(
204    t: &Transport,
205    title: &str,
206    description: Option<String>,
207    tags: Vec<String>,
208    severity: Option<String>,
209    assignees: &[String],
210) -> Result<CreatePlan> {
211    if title.trim().is_empty() {
212        return Err(Error::new(
213            ErrorKind::Error,
214            "a case needs a non-empty --title",
215        ));
216    }
217    let mut details = Vec::new();
218    if let Some(severity) = &severity {
219        details.push(format!("severity: {severity}"));
220    }
221    if !tags.is_empty() {
222        details.push(format!("tags: {}", tags.join(", ")));
223    }
224    let mut assignee_uids = Vec::with_capacity(assignees.len());
225    for user in assignees {
226        let uid = profiles::resolve_assignee(t, user).await?;
227        details.push(format!("assign {user} -> {uid}"));
228        assignee_uids.push(uid);
229    }
230    Ok(CreatePlan {
231        preview_action: format!("Create case '{title}'"),
232        new: NewCase {
233            title: title.to_string(),
234            description,
235            tags,
236            severity,
237            assignee_uids,
238        },
239        preview_details: details,
240    })
241}
242
243pub async fn apply_create(t: &Transport, plan: &CreatePlan) -> Result<Value> {
244    let case = cases::create(t, &plan.new).await?;
245    let mut row = case_row(&case);
246    if let Some(obj) = row.as_object_mut() {
247        obj.insert("applied".into(), Value::Bool(true));
248    }
249    Ok(row)
250}
251
252#[derive(Debug, Clone, PartialEq)]
253pub struct StatusPlan {
254    pub target: CaseStatus,
255    /// Only the cases actually transitioning: (id, version, target).
256    pub updates: Vec<(String, String, CaseStatus)>,
257    /// The resolved, deduplicated case count the preview names — what a
258    /// dry-run stub must report, not the raw argv count.
259    pub resolved: usize,
260    pub preview_action: String,
261    pub preview_details: Vec<String>,
262}
263
264/// Fetch each case for its version, mark already-in-state rows, and PATCH
265/// only the rest. A no-op set still previews and reports zero updates.
266pub async fn plan_status(t: &Transport, ids: &[String], target: CaseStatus) -> Result<StatusPlan> {
267    let resolved = resolve_cases(t, ids).await?;
268    let mut updates = Vec::new();
269    let mut details = Vec::new();
270    for case in &resolved {
271        if case.status == target.as_str() {
272            details.push(format!(
273                "{}  {}  already {}",
274                case.id, case.title, case.status
275            ));
276        } else {
277            details.push(format!(
278                "{}  {}  {} -> {}",
279                case.id,
280                case.title,
281                case.status,
282                target.as_str()
283            ));
284            updates.push((case.id.clone(), case.version.clone(), target));
285        }
286    }
287    Ok(StatusPlan {
288        preview_action: format!(
289            "{} {} {}",
290            target.verb(),
291            resolved.len(),
292            case_noun(resolved.len())
293        ),
294        target,
295        updates,
296        resolved: resolved.len(),
297        preview_details: details,
298    })
299}
300
301pub async fn apply_status(t: &Transport, plan: &StatusPlan) -> Result<CaseEditReport> {
302    if plan.updates.is_empty() {
303        return Ok(CaseEditReport {
304            applied: true,
305            total: 0,
306            updated: 0,
307            failed: 0,
308            failures: Vec::new(),
309        });
310    }
311    let mut expected = BTreeMap::new();
312    for (id, _, status) in &plan.updates {
313        if *status != plan.target {
314            return Err(Error::new(
315                ErrorKind::Error,
316                format!(
317                    "case '{id}' status '{}' does not match plan target '{}'",
318                    status.as_str(),
319                    plan.target.as_str()
320                ),
321            ));
322        }
323        if expected.insert(id.as_str(), *status).is_some() {
324            return Err(Error::new(
325                ErrorKind::Error,
326                format!("duplicate case id in status plan: '{id}'"),
327            ));
328        }
329    }
330    let updated = cases::patch_status(t, &plan.updates).await.map_err(|e| {
331        if e.kind == ErrorKind::Conflict {
332            Error::new(
333                ErrorKind::Conflict,
334                format!(
335                    "a case changed since the preview ({}); re-run the command",
336                    e.message
337                ),
338            )
339        } else {
340            e
341        }
342    })?;
343    if updated.len() != expected.len() {
344        return Err(Error::new(
345            ErrorKind::Http,
346            format!(
347                "decoding case status response: expected {} cases, got {}",
348                expected.len(),
349                updated.len()
350            ),
351        ));
352    }
353    let mut returned_ids = BTreeSet::new();
354    for case in &updated {
355        if !returned_ids.insert(case.id.as_str()) {
356            return Err(Error::new(
357                ErrorKind::Http,
358                format!(
359                    "decoding case status response: duplicate case id '{}'",
360                    case.id
361                ),
362            ));
363        }
364        let requested_status = expected.get(case.id.as_str()).ok_or_else(|| {
365            Error::new(
366                ErrorKind::Http,
367                format!(
368                    "decoding case status response: unexpected case id '{}'",
369                    case.id
370                ),
371            )
372        })?;
373        if case.status != requested_status.as_str() {
374            return Err(Error::new(
375                ErrorKind::Http,
376                format!(
377                    "decoding case status response: case '{}' has status '{}', expected '{}'",
378                    case.id,
379                    case.status,
380                    requested_status.as_str()
381                ),
382            ));
383        }
384    }
385    let total = expected.len() as u64;
386    Ok(CaseEditReport {
387        applied: true,
388        total,
389        updated: total,
390        failed: 0,
391        failures: Vec::new(),
392    })
393}
394
395#[derive(Debug, Clone, PartialEq)]
396pub struct DeletePlan {
397    pub targets: Vec<String>,
398    pub preview_action: String,
399    pub preview_details: Vec<String>,
400}
401
402/// The 0.4 area's only destructive verb: the preview names each title.
403pub async fn plan_delete(t: &Transport, ids: &[String]) -> Result<DeletePlan> {
404    let resolved = resolve_cases(t, ids).await?;
405    Ok(DeletePlan {
406        preview_action: format!(
407            "Delete {} {} permanently",
408            resolved.len(),
409            case_noun(resolved.len())
410        ),
411        preview_details: resolved
412            .iter()
413            .map(|c| format!("{}  {}  ({})", c.id, c.title, c.status))
414            .collect(),
415        targets: resolved.into_iter().map(|c| c.id).collect(),
416    })
417}
418
419pub async fn apply_delete(t: &Transport, plan: &DeletePlan) -> Result<CaseEditReport> {
420    cases::delete(t, &plan.targets).await?;
421    Ok(CaseEditReport {
422        applied: true,
423        total: plan.targets.len() as u64,
424        updated: plan.targets.len() as u64,
425        failed: 0,
426        failures: Vec::new(),
427    })
428}
429
430#[derive(Debug, Clone, PartialEq, Eq)]
431pub struct AttachGroup {
432    pub rule_id: String,
433    pub rule_name: String,
434    pub alert_ids: Vec<String>,
435    pub indices: Vec<String>,
436}
437
438#[derive(Debug, Clone, PartialEq)]
439pub struct AttachPlan {
440    pub case_id: String,
441    pub groups: Vec<AttachGroup>,
442    /// The resolved, deduplicated alert count the preview names — what a
443    /// dry-run stub must report, not the raw argv count.
444    pub resolved: usize,
445    pub preview_action: String,
446    pub preview_details: Vec<String>,
447}
448
449/// Resolve the case (for its title) and every alert (id, index, rule),
450/// fail-closed on any missing alert, then group by rule — the comments route
451/// takes one `rule` object per comment.
452pub async fn plan_attach(t: &Transport, case_id: &str, alert_ids: &[String]) -> Result<AttachPlan> {
453    if alert_ids.is_empty() {
454        return Err(Error::new(ErrorKind::Error, "pass at least one --alert id"));
455    }
456    let case = cases::get(t, case_id).await?;
457    let mut unique: Vec<String> = Vec::with_capacity(alert_ids.len());
458    for id in alert_ids {
459        if !unique.contains(id) {
460            unique.push(id.clone());
461        }
462    }
463    let body = serde_json::json!({
464        "query": {"ids": {"values": unique}},
465        "size": unique.len(),
466        "_source": ["kibana.alert.rule.name", "kibana.alert.rule.uuid", "kibana.alert.workflow_status"],
467    });
468    let page = alerts::search(t, &body).await?;
469    let missing: Vec<&str> = unique
470        .iter()
471        .filter(|id| !page.hits.iter().any(|h| &h.id == *id))
472        .map(String::as_str)
473        .collect();
474    if !missing.is_empty() {
475        return Err(Error::new(
476            ErrorKind::NotFound,
477            format!("No alert with id: {}", missing.join(", ")),
478        ));
479    }
480    let mut groups: Vec<AttachGroup> = Vec::new();
481    let mut details = Vec::new();
482    for id in &unique {
483        let hit = page
484            .hits
485            .iter()
486            .find(|h| &h.id == id)
487            .expect("checked above");
488        let rule_id = source_str(&hit.source, "kibana.alert.rule.uuid")
489            .ok_or_else(|| {
490                Error::new(
491                    ErrorKind::Http,
492                    "decoding alert field `kibana.alert.rule.uuid`",
493                )
494            })?
495            .to_string();
496        let rule_name = source_str(&hit.source, "kibana.alert.rule.name")
497            .unwrap_or("(unnamed rule)")
498            .to_string();
499        let index = hit.index.clone().ok_or_else(|| {
500            Error::new(
501                ErrorKind::Http,
502                "decoding alert field `_index` (needed to attach)",
503            )
504        })?;
505        details.push(format!("{}  {}", id, rule_name));
506        match groups.iter_mut().find(|g| g.rule_id == rule_id) {
507            Some(group) => {
508                group.alert_ids.push(id.clone());
509                group.indices.push(index);
510            }
511            None => groups.push(AttachGroup {
512                rule_id,
513                rule_name,
514                alert_ids: vec![id.clone()],
515                indices: vec![index],
516            }),
517        }
518    }
519    Ok(AttachPlan {
520        preview_action: format!(
521            "Attach {} {} to case '{}'",
522            unique.len(),
523            if unique.len() == 1 { "alert" } else { "alerts" },
524            case.title
525        ),
526        case_id: case.id,
527        groups,
528        resolved: unique.len(),
529        preview_details: details,
530    })
531}
532
533/// One comments POST per rule group (the API takes one `rule` per comment).
534/// A failed group must not discard the groups that already attached: a `?`
535/// on the first error would report only the raw error while leaving earlier
536/// groups attached, and a retry would then double-attach them. Accumulate
537/// per-group outcomes instead, so a partial failure renders as counts plus
538/// per-group detail rather than an opaque error.
539pub async fn apply_attach(t: &Transport, plan: &AttachPlan) -> Result<CaseEditReport> {
540    let total = plan.resolved as u64;
541    let mut attached = 0u64;
542    let mut failures = Vec::new();
543    for group in &plan.groups {
544        match cases::attach_alerts(
545            t,
546            &plan.case_id,
547            &group.alert_ids,
548            &group.indices,
549            &group.rule_id,
550            &group.rule_name,
551        )
552        .await
553        {
554            Ok(_) => attached += group.alert_ids.len() as u64,
555            Err(e) => failures.push(format!("{}: {}", group.rule_name, e.message)),
556        }
557    }
558    Ok(CaseEditReport {
559        applied: true,
560        total,
561        updated: attached,
562        // `abs_diff`, not `saturating_sub`: a surplus (more alerts attached
563        // than the plan resolved) is a mismatch too, and `total - attached`
564        // would saturate that at 0 and read it as zero failures.
565        failed: total.abs_diff(attached),
566        failures,
567    })
568}
569
570#[derive(Debug, Clone, PartialEq)]
571pub struct CommentPlan {
572    pub case_id: String,
573    pub message: String,
574    pub preview_action: String,
575    pub preview_details: Vec<String>,
576}
577
578pub async fn plan_comment(t: &Transport, case_id: &str, message: &str) -> Result<CommentPlan> {
579    if message.trim().is_empty() {
580        return Err(Error::new(ErrorKind::Error, "pass a non-empty --message"));
581    }
582    let case = cases::get(t, case_id).await?;
583    Ok(CommentPlan {
584        preview_action: format!("Comment on case '{}'", case.title),
585        preview_details: vec![message.to_string()],
586        case_id: case.id,
587        message: message.to_string(),
588    })
589}
590
591pub async fn apply_comment(t: &Transport, plan: &CommentPlan) -> Result<CaseEditReport> {
592    cases::add_comment(t, &plan.case_id, &plan.message).await?;
593    Ok(CaseEditReport {
594        applied: true,
595        total: 1,
596        updated: 1,
597        failed: 0,
598        failures: Vec::new(),
599    })
600}