Skip to main content

elasticctl_api/
data_views_ops.rs

1//! Data-view selection, portable normalization, and read orchestration.
2
3use crate::content_codec::{self, ContentFormat};
4use crate::data_views::{
5    self, DataView, DataViewReference, DataViewSpec, DataViewSummary, DataViewUpdate,
6};
7use crate::ops::{DeleteOutcome, ExportOutcome, MutationPlan};
8use elasticctl_core::{Error, ErrorKind, Result, Transport};
9use serde::Serialize;
10use serde_json::{Map, Value, json};
11use std::collections::{BTreeMap, BTreeSet};
12use std::path::Path;
13
14/// A local, case-insensitive substring filter for data-view summaries.
15#[derive(Debug, Clone, Default, PartialEq, Eq)]
16pub struct DataViewFilter {
17    pub search: Option<String>,
18}
19
20/// Data-view list output after local filtering and stable-id sorting.
21#[derive(Debug, Clone, PartialEq, Serialize)]
22pub struct DataViewList {
23    pub total: usize,
24    pub data_views: Vec<DataViewSummary>,
25}
26
27/// The two documented routes needed to replace a portable data view.
28#[derive(Debug, Clone, Default, PartialEq, Serialize)]
29pub struct DataViewPatch {
30    pub base: Option<DataViewUpdate>,
31    pub field_metadata: Map<String, Value>,
32}
33
34/// The immutable, guard-ready import work computed from one portable artifact.
35#[derive(Debug, Clone, PartialEq)]
36pub struct DataViewImportPlan {
37    pub preview: MutationPlan,
38    pub specs: Vec<DataViewSpec>,
39    pub before: BTreeMap<String, Option<DataViewSpec>>,
40    pub patches: BTreeMap<String, DataViewPatch>,
41    pub skipped: Vec<Value>,
42    pub total: usize,
43    pub overwrite: bool,
44}
45
46/// The per-object result of applying a guarded data-view import.
47#[derive(Debug, Clone, PartialEq, Serialize)]
48pub struct DataViewImportReport {
49    pub applied: bool,
50    pub succeeded: Vec<Value>,
51    pub skipped: Vec<Value>,
52    pub failed: Vec<Value>,
53    pub total: usize,
54}
55
56/// An immutable, guard-ready default data-view change.
57#[derive(Debug, Clone, PartialEq)]
58pub struct DefaultPlan {
59    pub preview: MutationPlan,
60    pub before: Option<String>,
61    pub after: Option<String>,
62}
63
64/// One stable source and the reference/default facts shown in a delete guard.
65#[derive(Debug, Clone, PartialEq)]
66pub struct DataViewDeleteTarget {
67    pub source: DataViewSummary,
68    pub references: Vec<DataViewReference>,
69    pub was_default: bool,
70}
71
72/// An immutable, guard-ready data-view deletion or reference replacement.
73#[derive(Debug, Clone, PartialEq)]
74pub struct DataViewDeletePlan {
75    pub preview: MutationPlan,
76    pub targets: Vec<DataViewDeleteTarget>,
77    pub replacement: Option<DataViewSummary>,
78    pub default_before: Option<String>,
79}
80
81/// Select one entry by stable id or exact name without decoding unrelated
82/// entries. Shared by data-view operations and the legacy search resolver.
83pub(crate) fn select_by_id_or_name<'a, T>(
84    entries: &'a [T],
85    selector: &str,
86    id: impl Fn(&T) -> Option<&str>,
87    name: impl Fn(&T) -> Option<&str>,
88) -> Result<&'a T> {
89    if let Some(entry) = entries.iter().find(|entry| id(entry) == Some(selector)) {
90        return Ok(entry);
91    }
92
93    let matches: Vec<&T> = entries
94        .iter()
95        .filter(|entry| name(entry) == Some(selector))
96        .collect();
97    match matches.as_slice() {
98        [] => Err(Error::new(
99            ErrorKind::NotFound,
100            format!("no data view with id or name '{selector}'"),
101        )),
102        [entry] => Ok(entry),
103        _ => Err(Error::new(
104            ErrorKind::Conflict,
105            format!("data view '{selector}' is ambiguous"),
106        )),
107    }
108}
109
110/// Resolve one selector from already-read data-view summaries.
111///
112/// Stable ids win over names. Names are a convenience selector and must be
113/// unique when used.
114pub fn resolve_from_summaries(
115    views: &[DataViewSummary],
116    selector: &str,
117) -> Result<DataViewSummary> {
118    Ok(select_by_id_or_name(
119        views,
120        selector,
121        |view| Some(view.id.as_str()),
122        |view| view.name.as_deref(),
123    )?
124    .clone())
125}
126
127/// Resolve one selector over the wire with one data-view list read.
128pub async fn resolve(transport: &Transport, selector: &str) -> Result<DataViewSummary> {
129    let views = data_views::list(transport).await?;
130    resolve_from_summaries(&views, selector)
131}
132
133/// List data views using the portable command filter and stable-id ordering.
134pub async fn list_op(transport: &Transport, filter: &DataViewFilter) -> Result<DataViewList> {
135    let needle = filter.search.as_ref().map(|search| search.to_lowercase());
136    let mut data_views: Vec<_> = data_views::list(transport)
137        .await?
138        .into_iter()
139        .filter(|view| {
140            needle.as_ref().is_none_or(|needle| {
141                view.id.to_lowercase().contains(needle)
142                    || view
143                        .name
144                        .as_ref()
145                        .is_some_and(|name| name.to_lowercase().contains(needle))
146                    || view.title.to_lowercase().contains(needle)
147            })
148        })
149        .collect();
150    data_views.sort_by(|left, right| left.id.cmp(&right.id));
151    Ok(DataViewList {
152        total: data_views.len(),
153        data_views,
154    })
155}
156
157/// Resolve a selector, then read its full live data-view object.
158pub async fn get_op(transport: &Transport, selector: &str) -> Result<DataView> {
159    let view = resolve(transport, selector).await?;
160    data_views::get(transport, &view.id).await
161}
162
163/// Resolve a data view and prepare a guarded default change.
164pub async fn plan_default_set(transport: &Transport, selector: &str) -> Result<DefaultPlan> {
165    let views = data_views::list(transport).await?;
166    let target = resolve_from_summaries(&views, selector)?;
167    let before = data_views::get_default(transport).await?;
168    let label = default_label(&target);
169    Ok(default_plan(before, Some(target.id), Some(label)))
170}
171
172/// Prepare a guarded request that explicitly clears the current default.
173pub async fn plan_default_unset(transport: &Transport) -> Result<DefaultPlan> {
174    Ok(default_plan(
175        data_views::get_default(transport).await?,
176        None,
177        None,
178    ))
179}
180
181/// Apply a guarded default change after checking the observed default remains
182/// the exact snapshot shown to the operator.
183pub async fn apply_default(transport: &Transport, plan: &DefaultPlan) -> Result<()> {
184    validate_default_plan(plan)?;
185    let current = data_views::get_default(transport).await?;
186    if current != plan.before {
187        return Err(Error::new(
188            ErrorKind::Conflict,
189            "default data view changed since preview",
190        ));
191    }
192    if plan.before == plan.after {
193        return Ok(());
194    }
195    data_views::set_default(transport, plan.after.as_deref()).await
196}
197
198fn default_plan(
199    before: Option<String>,
200    after: Option<String>,
201    target_label: Option<String>,
202) -> DefaultPlan {
203    let (preview_action, preview_details, targets) = match (&before, &after) {
204        (before, Some(after)) => {
205            let label = target_label.expect("set default plans always have a resolved label");
206            let detail = if before.as_deref() == Some(after) {
207                format!("{after}  already default")
208            } else {
209                format!(
210                    "{}  default -> {after}",
211                    before.as_deref().unwrap_or("none")
212                )
213            };
214            (
215                format!("Set default data view to {after} ({label})"),
216                vec![detail],
217                vec![after.clone()],
218            )
219        }
220        (Some(before), None) => (
221            format!("Unset default data view {before}"),
222            vec![format!("{before}  default -> unset")],
223            vec![before.clone()],
224        ),
225        (None, None) => (
226            "Unset default data view".into(),
227            vec!["no default data view set".into()],
228            Vec::new(),
229        ),
230    };
231    DefaultPlan {
232        preview: MutationPlan {
233            preview_action,
234            preview_details,
235            targets,
236        },
237        before,
238        after,
239    }
240}
241
242fn default_label(view: &DataViewSummary) -> String {
243    view.name.clone().unwrap_or_else(|| view.title.clone())
244}
245
246fn validate_default_plan(plan: &DefaultPlan) -> Result<()> {
247    for id in [&plan.before, &plan.after].into_iter().flatten() {
248        if id.trim().is_empty() {
249            return invalid_plan("default data-view ids must not be empty");
250        }
251    }
252    match (&plan.before, &plan.after) {
253        (Some(before), Some(after)) if before == after => {
254            if plan.preview.targets != [after.clone()]
255                || plan.preview.preview_details != [format!("{after}  already default")]
256                || !plan
257                    .preview
258                    .preview_action
259                    .starts_with(&format!("Set default data view to {after} ("))
260                || !plan.preview.preview_action.ends_with(')')
261            {
262                return invalid_plan("default preview does not match its snapshots");
263            }
264        }
265        (before, Some(after)) => {
266            if plan.preview.targets != [after.clone()]
267                || plan.preview.preview_details
268                    != [format!(
269                        "{}  default -> {after}",
270                        before.as_deref().unwrap_or("none")
271                    )]
272                || !plan
273                    .preview
274                    .preview_action
275                    .starts_with(&format!("Set default data view to {after} ("))
276                || !plan.preview.preview_action.ends_with(')')
277            {
278                return invalid_plan("default preview does not match its snapshots");
279            }
280        }
281        (Some(before), None) => {
282            if plan.preview.targets != [before.clone()]
283                || plan.preview.preview_action != format!("Unset default data view {before}")
284                || plan.preview.preview_details != [format!("{before}  default -> unset")]
285            {
286                return invalid_plan("default preview does not match its snapshots");
287            }
288        }
289        (None, None) => {
290            if !plan.preview.targets.is_empty()
291                || plan.preview.preview_action != "Unset default data view"
292                || plan.preview.preview_details != ["no default data view set"]
293            {
294                return invalid_plan("default preview does not match its snapshots");
295            }
296        }
297    }
298    Ok(())
299}
300
301/// Resolve every delete selector and inspect all references before the guard.
302pub async fn plan_delete(
303    transport: &Transport,
304    selectors: &[String],
305    replacement_selector: Option<&str>,
306) -> Result<DataViewDeletePlan> {
307    if selectors.is_empty() {
308        return Err(Error::new(
309            ErrorKind::Error,
310            "data-view delete needs at least one source",
311        ));
312    }
313
314    // A single stable list snapshot resolves every selector, including the
315    // optional replacement, before any reference preview can observe a later
316    // state.
317    let views = data_views::list(transport).await?;
318    let mut seen = BTreeSet::new();
319    let mut sources = Vec::new();
320    for selector in selectors {
321        let source = resolve_from_summaries(&views, selector)?;
322        if seen.insert(source.id.clone()) {
323            sources.push(source);
324        }
325    }
326    let replacement = replacement_selector
327        .map(|selector| resolve_from_summaries(&views, selector))
328        .transpose()?;
329
330    if replacement.is_some() && sources.len() != 1 {
331        return Err(Error::new(
332            ErrorKind::Error,
333            "--replace-with accepts exactly one source data view",
334        ));
335    }
336    if let Some(replacement) = &replacement
337        && sources.iter().any(|source| source.id == replacement.id)
338    {
339        return Err(Error::new(
340            ErrorKind::Error,
341            "--replace-with must differ from every source data view",
342        ));
343    }
344
345    let default_before = data_views::get_default(transport).await?;
346    let mut targets = Vec::with_capacity(sources.len());
347    for source in sources {
348        let mut references = data_views::preview_swap(transport, &source.id, &source.id).await?;
349        normalize_references(&mut references)?;
350        targets.push(DataViewDeleteTarget {
351            was_default: default_before.as_deref() == Some(source.id.as_str()),
352            source,
353            references,
354        });
355    }
356
357    if replacement.is_none() {
358        let referenced: Vec<_> = targets
359            .iter()
360            .filter(|target| !target.references.is_empty())
361            .map(|target| {
362                format!(
363                    "{}: {}",
364                    target.source.id,
365                    reference_names(&target.references).join(", ")
366                )
367            })
368            .collect();
369        if !referenced.is_empty() {
370            return Err(Error::new(
371                ErrorKind::Conflict,
372                format!(
373                    "data views have live references; use --replace-with: {}",
374                    referenced.join("; ")
375                ),
376            ));
377        }
378        let defaults: Vec<_> = targets
379            .iter()
380            .filter(|target| target.was_default)
381            .map(|target| target.source.id.as_str())
382            .collect();
383        if !defaults.is_empty() {
384            return Err(Error::new(
385                ErrorKind::Conflict,
386                format!(
387                    "data views are the current default; use data-views default unset or --replace-with: {}",
388                    defaults.join(", ")
389                ),
390            ));
391        }
392    }
393
394    Ok(DataViewDeletePlan {
395        preview: delete_preview(&targets, replacement.as_ref()),
396        targets,
397        replacement,
398        default_before,
399    })
400}
401
402/// Apply a guarded data-view deletion without resolving selectors again.
403pub async fn apply_delete(
404    transport: &Transport,
405    plan: &DataViewDeletePlan,
406) -> Result<DeleteOutcome> {
407    validate_delete_plan(plan)?;
408    let mut deleted = Vec::new();
409    let mut failed = Vec::new();
410
411    match (&plan.replacement, plan.targets.as_slice()) {
412        (None, targets) => {
413            for target in targets {
414                if let Err(error) =
415                    check_delete_snapshot(transport, target, plan.default_before.as_deref()).await
416                {
417                    failed.push(json!({"id": target.source.id, "error": error.message}));
418                    continue;
419                }
420                match data_views::delete(transport, &target.source.id).await {
421                    Ok(()) => deleted.push(json!({"id": target.source.id})),
422                    Err(error) => {
423                        failed.push(json!({"id": target.source.id, "error": error.message}))
424                    }
425                }
426            }
427        }
428        (Some(replacement), [target]) => {
429            if let Err(error) =
430                check_delete_snapshot(transport, target, plan.default_before.as_deref()).await
431            {
432                failed.push(json!({"id": target.source.id, "error": error.message}));
433                return Ok(DeleteOutcome {
434                    applied: true,
435                    deleted,
436                    failed,
437                    total: plan.targets.len(),
438                });
439            }
440            match data_views::swap(transport, &target.source.id, &replacement.id).await {
441                Err(error) => failed.push(json!({"id": target.source.id, "error": error.message})),
442                Ok(swap)
443                    if !swap.delete_status.delete_performed
444                        || swap.delete_status.remaining_refs != 0 =>
445                {
446                    failed.push(json!({
447                        "id": target.source.id,
448                        "error": "reference swap did not delete source or left references"
449                    }));
450                }
451                Ok(_) if target.was_default => {
452                    match data_views::get_default(transport).await {
453                        Err(error) => failed.push(json!({
454                            "id": target.source.id,
455                            "error": format!("references moved and source deleted; default recheck failed: {}", error.message)
456                        })),
457                        Ok(Some(current)) if current == target.source.id => {
458                            if let Err(error) =
459                                data_views::set_default(transport, Some(&replacement.id)).await
460                            {
461                                failed.push(json!({
462                                    "id": target.source.id,
463                                    "error": format!("references moved and source deleted; default update failed: {}", error.message)
464                                }));
465                            } else {
466                                deleted.push(json!({"id": target.source.id}));
467                            }
468                        }
469                        Ok(Some(current)) if current == replacement.id => {
470                            deleted.push(json!({"id": target.source.id}));
471                        }
472                        Ok(_) => failed.push(json!({
473                            "id": target.source.id,
474                            "error": "references moved and source deleted; default changed before update"
475                        })),
476                    }
477                }
478                Ok(_) => deleted.push(json!({"id": target.source.id})),
479            }
480        }
481        _ => unreachable!("the public plan validator checked replacement cardinality"),
482    }
483
484    Ok(DeleteOutcome {
485        applied: true,
486        deleted,
487        failed,
488        total: plan.targets.len(),
489    })
490}
491
492/// Re-read the source's guarded safety facts in the route order that leaves
493/// the smallest documented check-to-write window.
494async fn check_delete_snapshot(
495    transport: &Transport,
496    target: &DataViewDeleteTarget,
497    default_before: Option<&str>,
498) -> Result<()> {
499    let mut references =
500        data_views::preview_swap(transport, &target.source.id, &target.source.id).await?;
501    normalize_references(&mut references)?;
502    let default = data_views::get_default(transport).await?;
503    if references != target.references {
504        return Err(Error::new(
505            ErrorKind::Conflict,
506            "references changed since preview",
507        ));
508    }
509    if default.as_deref() != default_before {
510        return Err(Error::new(
511            ErrorKind::Conflict,
512            "default data view changed since preview",
513        ));
514    }
515    Ok(())
516}
517
518fn normalize_references(references: &mut Vec<DataViewReference>) -> Result<()> {
519    if references
520        .iter()
521        .any(|reference| reference.id.trim().is_empty() || reference.object_type.trim().is_empty())
522    {
523        return Err(Error::new(
524            ErrorKind::Http,
525            "decoding data view reference swap preview: reference id and type must not be empty",
526        ));
527    }
528    references.sort_by(|left, right| {
529        (left.object_type.as_str(), left.id.as_str())
530            .cmp(&(right.object_type.as_str(), right.id.as_str()))
531    });
532    references.dedup_by(|left, right| left.object_type == right.object_type && left.id == right.id);
533    Ok(())
534}
535
536fn reference_names(references: &[DataViewReference]) -> Vec<String> {
537    references
538        .iter()
539        .map(|reference| format!("{}/{}", reference.object_type, reference.id))
540        .collect()
541}
542
543fn delete_preview(
544    targets: &[DataViewDeleteTarget],
545    replacement: Option<&DataViewSummary>,
546) -> MutationPlan {
547    let preview_details = targets
548        .iter()
549        .flat_map(|target| {
550            let mut details: Vec<_> = reference_names(&target.references)
551                .into_iter()
552                .map(|reference| format!("{}  {reference}", target.source.id))
553                .collect();
554            match replacement {
555                None => details.push(format!("{}  direct delete", target.source.id)),
556                Some(replacement) if target.was_default => details.push(format!(
557                    "{}  default -> {}",
558                    target.source.id, replacement.id
559                )),
560                Some(_) => {}
561            }
562            details
563        })
564        .collect();
565    MutationPlan {
566        preview_action: match replacement {
567            Some(replacement) => format!(
568                "Replace references and delete {} with {}",
569                targets[0].source.id, replacement.id
570            ),
571            None => format!("Delete {} data view(s)", targets.len()),
572        },
573        preview_details,
574        targets: targets
575            .iter()
576            .map(|target| target.source.id.clone())
577            .collect(),
578    }
579}
580
581fn validate_delete_plan(plan: &DataViewDeletePlan) -> Result<()> {
582    if plan.targets.is_empty() {
583        return invalid_plan("data-view delete plan needs at least one target");
584    }
585    let mut ids = BTreeSet::new();
586    if plan
587        .default_before
588        .as_deref()
589        .is_some_and(|id| id.trim().is_empty())
590    {
591        return invalid_plan("default data-view snapshot id must not be empty");
592    }
593    for target in &plan.targets {
594        if target.source.id.trim().is_empty() || target.source.title.trim().is_empty() {
595            return invalid_plan("data-view delete target identity must not be empty");
596        }
597        if !ids.insert(target.source.id.clone()) {
598            return invalid_plan("data-view delete targets must be unique by id");
599        }
600        if target.was_default != (plan.default_before.as_deref() == Some(target.source.id.as_str()))
601        {
602            return invalid_plan("data-view delete default claims do not match its snapshot");
603        }
604        let mut canonical = target.references.clone();
605        normalize_references(&mut canonical).map_err(|error| {
606            Error::new(
607                ErrorKind::Error,
608                format!("invalid data-view delete plan: {}", error.message),
609            )
610        })?;
611        if canonical != target.references {
612            return invalid_plan("data-view delete references must be sorted and unique");
613        }
614    }
615    if let Some(replacement) = &plan.replacement {
616        if plan.targets.len() != 1 {
617            return invalid_plan("replacement delete plan must have exactly one target");
618        }
619        if replacement.id.trim().is_empty() || replacement.title.trim().is_empty() {
620            return invalid_plan("replacement data-view identity must not be empty");
621        }
622        if replacement.id == plan.targets[0].source.id {
623            return invalid_plan("replacement data view must differ from its source");
624        }
625    } else if plan
626        .targets
627        .iter()
628        .any(|target| target.was_default || !target.references.is_empty())
629    {
630        return invalid_plan("direct delete plan contains default or referenced data view");
631    }
632
633    if plan.preview != delete_preview(&plan.targets, plan.replacement.as_ref()) {
634        return invalid_plan("data-view delete preview does not match guarded targets");
635    }
636    Ok(())
637}
638
639/// Convert a full live data-view object into its portable form.
640pub fn normalize(data_view: &Value) -> Result<DataViewSpec> {
641    let source = data_view
642        .as_object()
643        .ok_or_else(|| Error::new(ErrorKind::Http, "decoding data view: expected object"))?;
644    let mut portable = Map::new();
645
646    for key in ["id", "title"] {
647        if let Some(value) = source.get(key) {
648            portable.insert(key.to_string(), canonicalize(value));
649        }
650    }
651    for key in [
652        "name",
653        "timeFieldName",
654        "sourceFilters",
655        "fieldFormats",
656        "runtimeFieldMap",
657        "fieldAttrs",
658        "type",
659    ] {
660        if let Some(value) = source.get(key) {
661            portable.insert(key.to_string(), canonicalize(value));
662        }
663    }
664    for key in ["allowNoIndex", "allowHidden"] {
665        portable.insert(
666            key.to_string(),
667            source
668                .get(key)
669                .map(canonicalize)
670                .unwrap_or(Value::Bool(false)),
671        );
672    }
673    if let Some(type_meta) = source.get("typeMeta")
674        && !type_meta.is_null()
675        && !matches!(type_meta, Value::Object(values) if values.is_empty())
676    {
677        portable.insert("typeMeta".to_string(), canonicalize(type_meta));
678    }
679    if let Some(fields) = source.get("fields") {
680        let fields = match fields {
681            Value::Object(fields) => {
682                let mut scripted: Vec<_> = fields
683                    .iter()
684                    .filter_map(|(name, field)| {
685                        (field.get("scripted").and_then(Value::as_bool) == Some(true))
686                            .then_some(name.as_str())
687                    })
688                    .collect();
689                scripted.sort_unstable();
690                if !scripted.is_empty() {
691                    return Err(Error::new(
692                        ErrorKind::Unsupported,
693                        format!(
694                            "legacy scripted fields are unsupported: {}",
695                            scripted.join(", ")
696                        ),
697                    ));
698                }
699                Value::Object(Map::new())
700            }
701            other => canonicalize(other),
702        };
703        portable.insert("fields".to_string(), canonicalize(&fields));
704    }
705
706    DataViewSpec::try_from(canonicalize(&Value::Object(portable))).map_err(|error| {
707        Error::new(
708            ErrorKind::Http,
709            format!("decoding data view: {}", error.message),
710        )
711    })
712}
713
714/// Rebuild a JSON value with sorted object keys while preserving array order.
715fn canonicalize(value: &Value) -> Value {
716    match value {
717        Value::Object(values) => {
718            let mut keys: Vec<_> = values.keys().collect();
719            keys.sort_unstable();
720            Value::Object(
721                keys.into_iter()
722                    .map(|key| (key.clone(), canonicalize(&values[key])))
723                    .collect(),
724            )
725        }
726        Value::Array(values) => Value::Array(values.iter().map(canonicalize).collect()),
727        _ => value.clone(),
728    }
729}
730
731/// Fully read and validate a portable data-view artifact.
732pub fn validate(path: &Path) -> Result<Vec<DataViewSpec>> {
733    let body = std::fs::read_to_string(path).map_err(|error| {
734        Error::new(
735            ErrorKind::Error,
736            format!("reading {}: {error}", path.display()),
737        )
738    })?;
739    let mut specs = content_codec::decode_sequence::<DataViewSpec>(
740        &body,
741        ContentFormat::from_path(path),
742        "data view",
743    )?;
744
745    let mut seen = BTreeSet::new();
746    let mut duplicates = BTreeSet::new();
747    for spec in &specs {
748        spec.validate()?;
749        if !seen.insert(spec.id.as_str()) {
750            duplicates.insert(spec.id.as_str());
751        }
752    }
753    if !duplicates.is_empty() {
754        return Err(Error::new(
755            ErrorKind::Error,
756            format!(
757                "duplicate data view ids: {}",
758                duplicates.into_iter().collect::<Vec<_>>().join(", ")
759            ),
760        ));
761    }
762
763    specs.sort_by(|left, right| left.id.cmp(&right.id));
764    Ok(specs)
765}
766
767/// Build the exact documented replacement delta between canonical data views.
768pub fn build_patch(current: &DataViewSpec, desired: &DataViewSpec) -> Result<DataViewPatch> {
769    current.validate()?;
770    desired.validate()?;
771    if current.id != desired.id {
772        return unsupported("changing data view id is not supported by the data-view update API");
773    }
774    if current.allow_hidden != desired.allow_hidden {
775        return unsupported("changing allowHidden is not supported by the data-view update API");
776    }
777    if current.name.is_some() && desired.name.is_none() {
778        return unsupported("removing name is not supported by the data-view update API");
779    }
780    if current.time_field_name.is_some() && desired.time_field_name.is_none() {
781        return unsupported("removing timeFieldName is not supported by the data-view update API");
782    }
783    if current.view_type.is_some() && desired.view_type.is_none() {
784        return unsupported("removing type is not supported by the data-view update API");
785    }
786    if current.type_meta.is_some() && desired.type_meta.is_none() {
787        return unsupported("removing typeMeta is not supported by the data-view update API");
788    }
789
790    let base = DataViewUpdate {
791        allow_no_index: changed(&current.allow_no_index, &desired.allow_no_index)
792            .then_some(desired.allow_no_index),
793        field_formats: changed(&current.field_formats, &desired.field_formats)
794            .then(|| desired.field_formats.clone()),
795        fields: changed(&current.fields, &desired.fields).then(|| desired.fields.clone()),
796        name: changed(&current.name, &desired.name)
797            .then(|| desired.name.clone())
798            .flatten(),
799        runtime_field_map: changed(&current.runtime_field_map, &desired.runtime_field_map)
800            .then(|| desired.runtime_field_map.clone()),
801        source_filters: changed(&current.source_filters, &desired.source_filters)
802            .then(|| desired.source_filters.clone()),
803        time_field_name: changed(&current.time_field_name, &desired.time_field_name)
804            .then(|| desired.time_field_name.clone())
805            .flatten(),
806        title: changed(&current.title, &desired.title).then(|| desired.title.clone()),
807        view_type: changed(&current.view_type, &desired.view_type)
808            .then(|| desired.view_type.clone())
809            .flatten(),
810        type_meta: changed(&current.type_meta, &desired.type_meta)
811            .then(|| desired.type_meta.clone())
812            .flatten(),
813    };
814    let base = (!is_empty_update(&base)).then_some(base);
815
816    let mut field_metadata = Map::new();
817    let names: BTreeSet<_> = current
818        .field_attrs
819        .keys()
820        .chain(desired.field_attrs.keys())
821        .collect();
822    for name in names {
823        let current_values = current.field_attrs.get(name).and_then(Value::as_object);
824        let desired_values = desired.field_attrs.get(name).and_then(Value::as_object);
825        let keys: BTreeSet<_> = current_values
826            .into_iter()
827            .flat_map(|values| values.keys())
828            .chain(desired_values.into_iter().flat_map(|values| values.keys()))
829            .collect();
830        let mut delta = Map::new();
831        for key in keys {
832            let before = current_values.and_then(|values| values.get(key));
833            let after = desired_values.and_then(|values| values.get(key));
834            if before != after {
835                delta.insert(key.clone(), after.cloned().unwrap_or(Value::Null));
836            }
837        }
838        if !delta.is_empty() {
839            field_metadata.insert(name.clone(), Value::Object(delta));
840        }
841    }
842    Ok(DataViewPatch {
843        base,
844        field_metadata,
845    })
846}
847
848/// Fully validate a portable artifact and prepare the exact guarded import.
849pub async fn plan_import(
850    transport: Option<&Transport>,
851    path: &Path,
852    overwrite: bool,
853    skip_existing: bool,
854) -> Result<DataViewImportPlan> {
855    let mut specs = validate(path)?;
856    if specs.is_empty() {
857        return Err(Error::new(
858            ErrorKind::Error,
859            "data-view import needs at least one data view",
860        ));
861    }
862    if overwrite && skip_existing {
863        return Err(Error::new(
864            ErrorKind::Error,
865            "--overwrite and --skip-existing cannot be used together",
866        ));
867    }
868    let total = specs.len();
869    let requires_server = overwrite || skip_existing || transport.is_some();
870    let transport = if requires_server { transport } else { None };
871    if (overwrite || skip_existing) && transport.is_none() {
872        return Err(Error::new(
873            ErrorKind::Error,
874            "data-view import conflict mode needs a transport",
875        ));
876    }
877
878    let mut before = BTreeMap::new();
879    let mut skipped = Vec::new();
880    let mut conflicts = Vec::new();
881    if let Some(transport) = transport {
882        for spec in &specs {
883            match read_spec(transport, &spec.id).await {
884                Ok(current) => {
885                    if !overwrite && !skip_existing {
886                        conflicts.push(spec.id.clone());
887                    }
888                    before.insert(spec.id.clone(), Some(current));
889                }
890                Err(error) if error.kind == ErrorKind::NotFound => {
891                    before.insert(spec.id.clone(), None);
892                }
893                Err(error) => return Err(error),
894            }
895        }
896    } else {
897        before.extend(specs.iter().map(|spec| (spec.id.clone(), None)));
898    }
899    if !conflicts.is_empty() {
900        return Err(Error::new(
901            ErrorKind::Conflict,
902            format!("data views already exist: {}", conflicts.join(", ")),
903        ));
904    }
905    if skip_existing {
906        specs.retain(|spec| match before.get(&spec.id) {
907            Some(Some(_)) => {
908                skipped.push(json!({"id": spec.id, "reason": "exists"}));
909                false
910            }
911            _ => true,
912        });
913        before.retain(|id, _| specs.iter().any(|spec| spec.id == *id));
914    }
915
916    let mut patches = BTreeMap::new();
917    for spec in &specs {
918        match before.get(&spec.id).and_then(Option::as_ref) {
919            Some(current) if current == spec => {
920                patches.insert(spec.id.clone(), DataViewPatch::default());
921            }
922            Some(current) => {
923                let patch = build_patch(current, spec)?;
924                patches.insert(spec.id.clone(), patch);
925            }
926            None => {}
927        }
928    }
929    let preview = MutationPlan {
930        preview_action: format!(
931            "Import {} data view(s) from {}",
932            specs.len(),
933            path.display()
934        ),
935        preview_details: preview_details(&specs, &before),
936        targets: specs.iter().map(|spec| spec.id.clone()).collect(),
937    };
938    Ok(DataViewImportPlan {
939        preview,
940        specs,
941        before,
942        patches,
943        skipped,
944        total,
945        overwrite,
946    })
947}
948
949/// Apply a previously planned data-view import without rereading or replanning.
950///
951/// Each object gets one final pre-write read. The server exposes no conditional
952/// write token, so the final verification read remains the smallest unavoidable
953/// race window. Earlier successful writes are deliberately never rolled back.
954pub async fn apply_import(
955    transport: &Transport,
956    plan: &DataViewImportPlan,
957) -> Result<DataViewImportReport> {
958    validate_import_plan(plan)?;
959    let mut succeeded = Vec::new();
960    let mut failed = Vec::new();
961
962    for desired in &plan.specs {
963        let before = match plan.before.get(&desired.id) {
964            Some(before) => before,
965            None => {
966                failed.push(failed_row(&desired.id, false, "missing preflight snapshot"));
967                continue;
968            }
969        };
970        let current = match read_spec(transport, &desired.id).await {
971            Ok(current) => Some(current),
972            Err(error) if error.kind == ErrorKind::NotFound => None,
973            Err(error) => {
974                failed.push(failed_row(&desired.id, false, error.message));
975                continue;
976            }
977        };
978
979        match (before, current) {
980            (None, Some(_)) => {
981                failed.push(failed_row(
982                    &desired.id,
983                    false,
984                    "data view appeared since preview",
985                ));
986                continue;
987            }
988            (Some(_), None) => {
989                failed.push(failed_row(
990                    &desired.id,
991                    false,
992                    "data view disappeared since preview",
993                ));
994                continue;
995            }
996            (Some(before), Some(current)) if before != &current => {
997                failed.push(failed_row(
998                    &desired.id,
999                    false,
1000                    "data view changed since preview",
1001                ));
1002                continue;
1003            }
1004            (None, None) => {
1005                let mut applied = false;
1006                match data_views::create(transport, desired).await {
1007                    Ok(_) => applied = true,
1008                    Err(error) => {
1009                        failed.push(failed_row(&desired.id, applied, error.message));
1010                        continue;
1011                    }
1012                }
1013                match read_spec(transport, &desired.id).await {
1014                    Ok(stored) if stored == *desired => {
1015                        succeeded.push(json!({"id": desired.id, "action": "created"}));
1016                    }
1017                    Ok(_) => failed.push(failed_row(
1018                        &desired.id,
1019                        applied,
1020                        "server stored a different data-view spec",
1021                    )),
1022                    Err(error) => failed.push(failed_row(&desired.id, applied, error.message)),
1023                }
1024            }
1025            (Some(_), Some(_)) => {
1026                let patch = match plan.patches.get(&desired.id) {
1027                    Some(patch) => patch,
1028                    None => {
1029                        failed.push(failed_row(&desired.id, false, "missing replacement patch"));
1030                        continue;
1031                    }
1032                };
1033                if patch.base.is_none() && patch.field_metadata.is_empty() {
1034                    succeeded.push(json!({"id": desired.id, "action": "unchanged"}));
1035                    continue;
1036                }
1037
1038                let mut applied = false;
1039                if let Some(base) = &patch.base {
1040                    if let Err(error) = data_views::update(transport, &desired.id, base).await {
1041                        failed.push(failed_row(
1042                            &desired.id,
1043                            applied,
1044                            format!("base update failed: {}", error.message),
1045                        ));
1046                        continue;
1047                    }
1048                    applied = true;
1049                }
1050                if !patch.field_metadata.is_empty()
1051                    && let Err(error) = data_views::update_fields_metadata(
1052                        transport,
1053                        &desired.id,
1054                        &patch.field_metadata,
1055                    )
1056                    .await
1057                {
1058                    let message = if applied {
1059                        format!("base updated; field metadata failed: {}", error.message)
1060                    } else {
1061                        format!("field metadata failed: {}", error.message)
1062                    };
1063                    failed.push(failed_row(&desired.id, applied, message));
1064                    continue;
1065                }
1066                if !patch.field_metadata.is_empty() {
1067                    applied = true;
1068                }
1069                match read_spec(transport, &desired.id).await {
1070                    Ok(stored) if stored == *desired => {
1071                        succeeded.push(json!({"id": desired.id, "action": "replaced"}));
1072                    }
1073                    Ok(_) => failed.push(failed_row(
1074                        &desired.id,
1075                        applied,
1076                        "server stored a different data-view spec",
1077                    )),
1078                    Err(error) => failed.push(failed_row(&desired.id, applied, error.message)),
1079                }
1080            }
1081        }
1082    }
1083
1084    Ok(DataViewImportReport {
1085        applied: true,
1086        succeeded,
1087        skipped: plan.skipped.clone(),
1088        failed,
1089        total: plan.total,
1090    })
1091}
1092
1093fn changed<T: PartialEq>(current: &T, desired: &T) -> bool {
1094    current != desired
1095}
1096
1097fn is_empty_update(update: &DataViewUpdate) -> bool {
1098    update.allow_no_index.is_none()
1099        && update.field_formats.is_none()
1100        && update.fields.is_none()
1101        && update.name.is_none()
1102        && update.runtime_field_map.is_none()
1103        && update.source_filters.is_none()
1104        && update.time_field_name.is_none()
1105        && update.title.is_none()
1106        && update.view_type.is_none()
1107        && update.type_meta.is_none()
1108}
1109
1110fn unsupported(message: impl Into<String>) -> Result<DataViewPatch> {
1111    Err(Error::new(ErrorKind::Unsupported, message))
1112}
1113
1114fn failed_row(id: &str, applied: bool, error: impl Into<String>) -> Value {
1115    json!({"id": id, "applied": applied, "error": error.into()})
1116}
1117
1118async fn read_spec(transport: &Transport, id: &str) -> Result<DataViewSpec> {
1119    let spec = normalize(&Value::Object(
1120        data_views::get(transport, id).await?.data_view,
1121    ))?;
1122    if spec.id != id {
1123        return Err(Error::new(
1124            ErrorKind::Http,
1125            format!("decoding data view: expected id '{id}', got '{}'", spec.id),
1126        ));
1127    }
1128    Ok(spec)
1129}
1130
1131fn validate_import_plan(plan: &DataViewImportPlan) -> Result<()> {
1132    if plan.total == 0 {
1133        return invalid_plan("total must be greater than zero");
1134    }
1135    if plan.total != plan.specs.len() + plan.skipped.len() {
1136        return invalid_plan("total does not equal pending and skipped data views");
1137    }
1138
1139    let mut pending_ids = Vec::with_capacity(plan.specs.len());
1140    for spec in &plan.specs {
1141        validate_canonical_spec(spec)?;
1142        if pending_ids
1143            .last()
1144            .is_some_and(|previous| previous >= &spec.id)
1145        {
1146            return invalid_plan("pending data views must be unique and sorted by id");
1147        }
1148        pending_ids.push(spec.id.clone());
1149    }
1150    let pending: BTreeSet<_> = pending_ids.iter().cloned().collect();
1151    if !plan.skipped.is_empty() && plan.overwrite {
1152        return invalid_plan("skipped data views require skip-existing mode");
1153    }
1154    let mut skipped_ids = BTreeSet::new();
1155    let mut previous_skipped = None;
1156    for row in &plan.skipped {
1157        let object = row
1158            .as_object()
1159            .filter(|object| object.len() == 2)
1160            .ok_or_else(|| Error::new(ErrorKind::Error, "invalid data-view import skipped row"))?;
1161        if !object.keys().map(String::as_str).eq(["id", "reason"]) {
1162            return invalid_plan("invalid data-view import skipped row order");
1163        }
1164        let id = object
1165            .get("id")
1166            .and_then(Value::as_str)
1167            .filter(|id| !id.trim().is_empty())
1168            .ok_or_else(|| Error::new(ErrorKind::Error, "invalid data-view import skipped row"))?;
1169        if object.get("reason").and_then(Value::as_str) != Some("exists")
1170            || previous_skipped.is_some_and(|previous: &str| previous >= id)
1171            || !skipped_ids.insert(id.to_owned())
1172            || pending.contains(id)
1173        {
1174            return invalid_plan("invalid data-view import skipped rows");
1175        }
1176        previous_skipped = Some(id);
1177    }
1178    if plan.preview.targets != pending_ids {
1179        return invalid_plan("preview targets do not match pending data views");
1180    }
1181    let prefix = format!("Import {} data view(s) from ", plan.specs.len());
1182    if !plan.preview.preview_action.starts_with(&prefix)
1183        || plan.preview.preview_action[prefix.len()..].is_empty()
1184    {
1185        return invalid_plan("preview action does not match pending data views");
1186    }
1187    if plan.preview.preview_details != preview_details(&plan.specs, &plan.before) {
1188        return invalid_plan("preview details do not match pending data views");
1189    }
1190
1191    let before_ids: BTreeSet<_> = plan.before.keys().cloned().collect();
1192    if before_ids != pending {
1193        return invalid_plan("preflight snapshots do not match pending data views");
1194    }
1195    let mut patch_ids = BTreeSet::new();
1196    for spec in &plan.specs {
1197        let before = plan.before.get(&spec.id).ok_or_else(|| {
1198            Error::new(
1199                ErrorKind::Error,
1200                "preflight snapshots do not match pending data views",
1201            )
1202        })?;
1203        match before {
1204            None => {
1205                if plan.patches.contains_key(&spec.id) {
1206                    return invalid_plan("planned creates must not carry a replacement patch");
1207                }
1208            }
1209            Some(snapshot) => {
1210                validate_canonical_spec(snapshot)?;
1211                if snapshot.id != spec.id {
1212                    return invalid_plan("preflight snapshot id does not match its target");
1213                }
1214                if !plan.overwrite {
1215                    return invalid_plan("replacement plan requires overwrite");
1216                }
1217                let expected = build_patch(snapshot, spec)?;
1218                let patch = plan.patches.get(&spec.id).ok_or_else(|| {
1219                    Error::new(ErrorKind::Error, "planned replacement is missing its patch")
1220                })?;
1221                if patch != &expected {
1222                    return invalid_plan("planned replacement patch does not match its snapshots");
1223                }
1224                patch_ids.insert(spec.id.clone());
1225            }
1226        }
1227    }
1228    if plan.patches.keys().cloned().collect::<BTreeSet<_>>() != patch_ids {
1229        return invalid_plan("replacement patches do not match preflight snapshots");
1230    }
1231    Ok(())
1232}
1233
1234fn validate_canonical_spec(spec: &DataViewSpec) -> Result<()> {
1235    spec.validate()?;
1236    if matches!(spec.type_meta.as_ref(), Some(values) if values.is_empty()) {
1237        return Err(Error::new(
1238            ErrorKind::Error,
1239            "data view typeMeta must not be empty",
1240        ));
1241    }
1242    Ok(())
1243}
1244
1245fn preview_details(
1246    specs: &[DataViewSpec],
1247    before: &BTreeMap<String, Option<DataViewSpec>>,
1248) -> Vec<String> {
1249    specs
1250        .iter()
1251        .filter_map(|spec| match before.get(&spec.id) {
1252            Some(None) => Some(format!("{}  create  {}", spec.id, spec.title)),
1253            Some(Some(current)) if current == spec => {
1254                Some(format!("{}  no-op  {}", spec.id, spec.title))
1255            }
1256            Some(Some(current)) => Some(format!(
1257                "{}  replace  {} -> {}",
1258                spec.id, current.title, spec.title
1259            )),
1260            None => None,
1261        })
1262        .collect()
1263}
1264
1265fn invalid_plan(message: impl Into<String>) -> Result<()> {
1266    Err(Error::new(ErrorKind::Error, message))
1267}
1268
1269/// Export selected data views as a portable JSON or YAML artifact.
1270pub async fn export(
1271    transport: &Transport,
1272    selectors: &[String],
1273    format: ContentFormat,
1274) -> Result<ExportOutcome> {
1275    let summaries = data_views::list(transport).await?;
1276    let selected = if selectors.is_empty() {
1277        summaries
1278    } else {
1279        selectors
1280            .iter()
1281            .map(|selector| resolve_from_summaries(&summaries, selector))
1282            .collect::<Result<Vec<_>>>()?
1283    };
1284    let selected: BTreeMap<_, _> = selected
1285        .into_iter()
1286        .map(|summary| (summary.id.clone(), summary))
1287        .collect();
1288    let expected = selected.len();
1289    let mut specs = Vec::with_capacity(expected);
1290    for (id, _) in selected {
1291        let detail = data_views::get(transport, &id).await?;
1292        let spec = normalize(&Value::Object(detail.data_view))?;
1293        if spec.id != id {
1294            return Err(Error::new(
1295                ErrorKind::Http,
1296                format!("data view export was short: expected id '{id}'"),
1297            ));
1298        }
1299        specs.push(spec);
1300    }
1301    if specs.len() != expected {
1302        return Err(Error::new(
1303            ErrorKind::Http,
1304            format!(
1305                "data view export was short: expected {expected}, got {}",
1306                specs.len()
1307            ),
1308        ));
1309    }
1310    specs.sort_by(|left, right| left.id.cmp(&right.id));
1311    let body = content_codec::encode_sequence(&specs, format)?;
1312    Ok(ExportOutcome {
1313        body,
1314        exported: specs.len() as u64,
1315        missing: Vec::new(),
1316    })
1317}