1use std::collections::{BTreeSet, HashMap};
8
9use base64::Engine as _;
10use chrono::{DateTime, Utc};
11use serde_json::{Value, json};
12
13use crate::core::knowledge::{ImportMerge, KnowledgeFact, ProjectKnowledge};
14
15use super::request::{BridgeRequest, Op, is_fact_ref, is_profile_name, is_safe_webhook};
16use super::state::ProState;
17use super::wire::{AlertRecord, AppliedItem, Baseline, FileOut, Plan, Profile, ProjectColumn};
18use super::{
19 GUARD_KINDS, ProError, ProResult, ProjectScope, collect, export, ledger, plain_text,
20 security_fields, snapshots,
21};
22
23const MAX_PLANS: usize = 8;
25const SNAPSHOT_HINT: &str = "A snapshot was saved first; restore it under Memory Care → Snapshots.";
26
27pub trait Notifier: Send + Sync {
29 fn desktop(&self, text: &str) -> bool;
30 fn webhook(&self, url: &str, body: &[u8]) -> bool;
31}
32
33pub struct SystemNotifier;
36
37impl Notifier for SystemNotifier {
38 fn desktop(&self, text: &str) -> bool {
39 crate::core::value::notify::send("LeanCTX", text)
40 }
41
42 fn webhook(&self, url: &str, body: &[u8]) -> bool {
43 if !is_safe_webhook(url) {
44 return false;
45 }
46 let timeout = Some(std::time::Duration::from_secs(6));
47 let agent =
48 crate::core::http_client::ureq_agent_without_redirects(timeout, timeout, timeout);
49 agent
50 .post(url)
51 .header("Content-Type", "application/json")
52 .send(body)
53 .is_ok_and(|r| r.status().is_success())
54 }
55}
56
57pub(crate) enum Outcome {
59 Applied(Value),
60 File(FileOut),
61}
62
63pub(crate) struct Ctx<'a> {
64 pub(crate) scope: &'a ProjectScope,
65 pub(crate) request: &'a BridgeRequest,
66 pub(crate) now: DateTime<Utc>,
67 pub(crate) notifier: &'a dyn Notifier,
68}
69
70pub(crate) fn parse_plans(op: Op, raw: Vec<Value>) -> ProResult<Vec<Plan>> {
72 if raw.is_empty() || raw.len() > MAX_PLANS {
73 return Err(ProError::conflict("Unexpected number of planned changes"));
74 }
75 raw.into_iter()
76 .map(|value| {
77 let plan: Plan = serde_json::from_value(value)
78 .map_err(|_| ProError::conflict("Unknown planned change"))?;
79 if !op.allowed_effects().contains(&plan.effect()) {
80 return Err(ProError::conflict(
81 "Planned change does not fit the request",
82 ));
83 }
84 Ok(plan)
85 })
86 .collect()
87}
88
89fn ensure(condition: bool, message: &str) -> ProResult<()> {
90 if condition {
91 Ok(())
92 } else {
93 Err(ProError::conflict(message))
94 }
95}
96
97fn is_time(value: &str) -> bool {
98 DateTime::parse_from_rfc3339(value).is_ok()
99}
100
101fn fact_ref(fact: &KnowledgeFact) -> String {
102 format!("{}/{}", fact.category, fact.key)
103}
104
105fn current_map(facts: &[KnowledgeFact]) -> HashMap<String, KnowledgeFact> {
106 facts
107 .iter()
108 .filter(|f| f.is_current())
109 .map(|f| (fact_ref(f), f.clone()))
110 .collect()
111}
112
113pub(crate) struct RestorePlan {
115 snapshot: HashMap<String, KnowledgeFact>,
116 now: HashMap<String, KnowledgeFact>,
117 pub(crate) missing: Vec<String>,
118 pub(crate) changed: Vec<String>,
119}
120
121pub(crate) fn restore_plan(scope: &ProjectScope, id: &str) -> ProResult<RestorePlan> {
122 let snapshot = current_map(&snapshots::read_facts(scope, id)?);
123 let records = collect::knowledge_records(scope);
124 let now = current_map(&records);
125 let mut missing: Vec<String> = snapshot
126 .keys()
127 .filter(|r| !now.contains_key(*r))
128 .cloned()
129 .collect();
130 let mut changed: Vec<String> = snapshot
131 .iter()
132 .filter(|(r, f)| now.get(*r).is_some_and(|n| n.value != f.value))
133 .map(|(r, _)| r.clone())
134 .collect();
135 missing.sort();
136 changed.sort();
137 Ok(RestorePlan {
140 snapshot,
141 now,
142 missing,
143 changed,
144 })
145}
146
147fn same_set(a: &[String], b: &[String]) -> bool {
148 a.iter().collect::<BTreeSet<_>>() == b.iter().collect::<BTreeSet<_>>()
149}
150
151fn requested_profile(ctx: &Ctx<'_>) -> Option<Profile> {
152 let name = ctx.request.b_str("name");
153 ProState::new(ctx.scope)
154 .profiles()
155 .into_iter()
156 .find(|p| p.name == name)
157}
158
159pub(crate) fn validate(ctx: &Ctx<'_>, plan: &Plan) -> ProResult<()> {
161 let request = ctx.request;
162 let state = ProState::new(ctx.scope);
163 match plan {
164 Plan::AlertsSettings { settings } => {
165 let stored = state.alert_settings();
166 let wanted = |key: &str| request.body.get(key);
167 let kinds: Vec<Value> = settings.kinds.iter().map(|k| json!(k)).collect();
168 ensure(
169 settings
170 .kinds
171 .iter()
172 .all(|k| GUARD_KINDS.contains(&k.as_str()))
173 && settings.kinds.iter().collect::<BTreeSet<_>>().len() == settings.kinds.len(),
174 "Alert kinds outside the guard list",
175 )?;
176 ensure(
177 settings.webhook.is_empty() || is_safe_webhook(&settings.webhook),
178 "Unsafe webhook",
179 )?;
180 let matches = |key: &str, planned: Value, stored: Value| {
181 wanted(key).map_or(planned == stored, |w| *w == planned)
182 };
183 ensure(
184 matches("enabled", json!(settings.enabled), json!(stored.enabled))
185 && matches("desktop", json!(settings.desktop), json!(stored.desktop))
186 && matches("webhook", json!(settings.webhook), json!(stored.webhook))
187 && matches("kinds", Value::Array(kinds), json!(stored.kinds)),
188 "Alert settings differ from the request",
189 )
190 }
191 Plan::AlertsNotify {
192 at,
193 text,
194 counts,
195 test,
196 desktop,
197 webhook,
198 } => {
199 let stored = state.alert_settings();
200 ensure(*test == (request.op == Op::AlertsTest), "Unexpected alert")?;
201 ensure(*test || stored.enabled, "Alerts are off")?;
202 ensure(is_time(at) && plain_text(text, 240), "Invalid alert text")?;
203 ensure(
204 counts.iter().all(|(k, n)| {
205 GUARD_KINDS.contains(&k.as_str()) && (0..=1_000_000_000).contains(n)
206 }),
207 "Alert counts outside the guard list",
208 )?;
209 ensure(
210 *desktop == stored.desktop && *webhook == stored.webhook,
211 "Alert channels differ from the settings",
212 )
213 }
214 Plan::ProfilesPut {
215 name,
216 values,
217 saved_at,
218 } => {
219 ensure(
220 request.b_str("action") == "save" && name == request.b_str("name"),
221 "Profile differs from the request",
222 )?;
223 ensure(
224 is_profile_name(name) && is_time(saved_at),
225 "Invalid profile",
226 )?;
227 for (key, value) in values {
228 let field = security_fields::lookup(key)
229 .ok_or_else(|| ProError::conflict("Profile holds an unmanaged setting"))?;
230 security_fields::wire_of(field, value)?;
231 }
232 Ok(())
233 }
234 Plan::ProfilesDelete { name } => ensure(
235 request.b_str("action") == "delete" && name == request.b_str("name"),
236 "Profile differs from the request",
237 ),
238 Plan::ConfigSet {
239 key,
240 wire,
241 expected,
242 weakening,
243 } => {
244 ensure(
245 request.b_str("action") == "apply",
246 "Unexpected setting change",
247 )?;
248 let field = security_fields::lookup(key)
249 .ok_or_else(|| ProError::conflict("This setting is not managed here"))?;
250 ensure(
251 security_fields::wire_of(field, expected)? == *wire
252 && security_fields::expected_of(field, wire) == *expected,
253 "Setting value does not match its wire form",
254 )?;
255 let profile =
256 requested_profile(ctx).ok_or_else(|| ProError::not_found("Unknown profile"))?;
257 let stored = profile
258 .values
259 .get(key)
260 .map(|v| security_fields::wire_of(field, v))
261 .transpose()?;
262 ensure(
263 stored.as_deref() == Some(wire.as_str()),
264 "Setting is not part of the profile",
265 )?;
266 let before = security_fields::effective(key).unwrap_or_default();
267 let lowers = security_fields::weakening(field, &before, wire);
268 if lowers && !request.b_true("confirm_weaker") {
269 return Err(ProError::conflict(
270 "This lowers protection. Confirm the change to continue.",
271 ));
272 }
273 ensure(lowers == *weakening, "Protection change misreported")
274 }
275 Plan::MemorySnapshot { reason } => ensure(
276 reason == request.b_str("reason"),
277 "Snapshot differs from the request",
278 ),
279 Plan::MemoryRestore {
280 id,
281 missing,
282 changed,
283 } => {
284 ensure(
285 id == request.b_str("id"),
286 "Snapshot differs from the request",
287 )?;
288 let plan = restore_plan(ctx.scope, id)?;
289 ensure(
290 same_set(missing, &plan.missing) && same_set(changed, &plan.changed),
291 "Memory changed; compare the snapshot again",
292 )
293 }
294 Plan::MemoryForget { refs, reason } => {
295 let asked: Vec<String> = request
296 .body
297 .get("refs")
298 .and_then(Value::as_array)
299 .map(|a| {
300 a.iter()
301 .filter_map(Value::as_str)
302 .map(str::to_string)
303 .collect()
304 })
305 .unwrap_or_default();
306 ensure(
307 same_set(refs, &asked) && reason == request.b_str("reason"),
308 "Cleanup differs from the request",
309 )?;
310 ensure(refs.iter().all(|r| is_fact_ref(r)), "Invalid insight key")?;
311 let current = current_map(&collect::knowledge_records(ctx.scope));
312 ensure(
313 refs.iter().all(|r| current.contains_key(r)),
314 "Memory changed; refresh before cleaning up",
315 )
316 }
317 Plan::MemoryConsolidate { run } => ensure(
318 *run && request.b_true("run"),
319 "Consolidation differs from the request",
320 ),
321 Plan::ContextOverlay { action, path } => {
322 ensure(
323 action == request.b_str("action") && path == request.b_str("path"),
324 "Rule differs from the request",
325 )?;
326 ensure(
327 ctx.scope.jailed(path)?.is_file(),
328 "The path is not a file of this project",
329 )?;
330 ensure(
331 collect::heatmap(ctx.scope).contains_key(path),
332 "The path is not a current recommendation",
333 )
334 }
335 Plan::AdaptiveRecord { item } => ensure(
336 item.path == request.b_str("path") && item.action == request.b_str("action"),
337 "Record differs from the request",
338 ),
339 Plan::AdaptiveDismiss { path } => {
340 ensure(
341 path == request.b_str("path"),
342 "Dismissal differs from the request",
343 )?;
344 ctx.scope.jailed(path).map(|_| ())
345 }
346 Plan::LedgerExport {
347 format,
348 file_name,
349 mime,
350 filters,
351 project_column,
352 } => {
353 ensure(format == request.q("format"), "Export format differs")?;
354 ensure(
355 *file_name == export::file_name(ctx.now.date_naive(), format)
356 && mime == export::mime_of(format),
357 "Export file differs",
358 )?;
359 let mine = ledger::filters(ctx.scope, &ctx.request.query)?;
360 let normalize = |f: &super::wire::LedgerFilters| {
361 let mut f = f.clone();
362 if let Some(scope) = f.scope.as_mut() {
363 scope.sort();
364 scope.dedup();
365 }
366 f
367 };
368 ensure(
369 normalize(filters) == normalize(&mine),
370 "Export filters differ",
371 )?;
372 let labels = ctx.scope.labels();
373 ensure(
374 match project_column {
375 ProjectColumn::Fixed { name } => *name == ctx.scope.label,
376 ProjectColumn::Labels { labels: planned } => {
377 planned.iter().all(|l| labels.contains(l))
378 }
379 },
380 "Export project column outside this project",
381 )
382 }
383 }
384}
385
386fn snapshot_now(ctx: &Ctx<'_>, reason: &str) -> ProResult<super::wire::SnapshotMeta> {
387 let knowledge = ProjectKnowledge::load_or_create(&ctx.scope.root);
388 snapshots::create(ctx.scope, &knowledge, reason, ctx.now)
389}
390
391fn policy() -> ProResult<crate::core::memory_policy::MemoryPolicy> {
392 crate::core::config::Config::load()
393 .memory_policy_effective()
394 .map_err(|_| ProError::internal("Invalid memory policy"))
395}
396
397fn import(ctx: &Ctx<'_>, facts: Vec<KnowledgeFact>, merge: ImportMerge) -> ProResult<()> {
398 if facts.is_empty() {
399 return Ok(());
400 }
401 ProjectKnowledge::mutate_locked(&ctx.scope.root, |k| {
402 k.import_facts(facts, merge, "dashboard-pro");
403 })
404 .map(|_| ())
405 .map_err(|_| ProError::internal(format!("The restore was not saved. {SNAPSHOT_HINT}")))
406}
407
408pub(crate) fn execute(ctx: &Ctx<'_>, plan: Plan) -> ProResult<Outcome> {
410 let state = ProState::new(ctx.scope);
411 let effect = plan.effect();
412 let applied = |readback: Value| {
413 Ok(Outcome::Applied(
414 json!({ "effect": effect, "readback": readback }),
415 ))
416 };
417 match plan {
418 Plan::AlertsSettings { settings } => {
419 state.put_alert_settings(&settings)?;
420 let back = state.alert_settings();
421 ensure(back == settings, "Alert settings did not read back")?;
422 applied(json!({ "settings": back }))
423 }
424 Plan::AlertsNotify {
425 at,
426 text,
427 counts,
428 test,
429 desktop,
430 webhook,
431 } => {
432 let mut channels = Vec::new();
433 if desktop {
434 channels.push(
435 if ctx.notifier.desktop(&text) {
436 "desktop"
437 } else {
438 "desktop failed"
439 }
440 .to_string(),
441 );
442 }
443 if !webhook.is_empty() {
444 let body = json!({ "source": "leanctx", "test": test, "counts": counts, "at": at });
446 let sent = ctx.notifier.webhook(&webhook, body.to_string().as_bytes());
447 channels.push(if sent { "webhook" } else { "webhook failed" }.to_string());
448 }
449 let record = AlertRecord {
450 at,
451 text,
452 channels,
453 test,
454 };
455 state.append_alert(record.clone())?;
456 ensure(
457 state.alerts_file().history.last() == Some(&record),
458 "Alert history did not read back",
459 )?;
460 applied(json!({ "alert": record }))
461 }
462 Plan::ProfilesPut {
463 name,
464 values,
465 saved_at,
466 } => {
467 let profile = Profile {
468 name: name.clone(),
469 values,
470 saved_at,
471 };
472 state.put_profile(profile.clone())?;
473 ensure(
474 state.profiles().contains(&profile),
475 "Profile did not read back",
476 )?;
477 applied(json!({ "profiles": state.profiles() }))
478 }
479 Plan::ProfilesDelete { name } => {
480 state.delete_profile(&name)?;
481 ensure(
482 !state.profiles().iter().any(|p| p.name == name),
483 "Profile was not deleted",
484 )?;
485 applied(json!({ "profiles": state.profiles() }))
486 }
487 Plan::ConfigSet {
488 key,
489 wire,
490 expected,
491 weakening,
492 } => {
493 crate::core::config::setter::set_by_key(&key, &wire)
494 .map_err(|e| ProError::conflict(format!("The setting was refused: {e}")))?;
495 let field = security_fields::lookup(&key)
496 .ok_or_else(|| ProError::conflict("This setting is not managed here"))?;
497 let after =
498 security_fields::effective(&key).map(|w| security_fields::expected_of(field, &w));
499 ensure(
500 after.as_ref() == Some(&expected),
501 "The instance did not report the new value. A setting in this project's own configuration may take precedence over the device setting.",
502 )?;
503 applied(json!({ "key": key, "value": expected, "weakened": weakening }))
504 }
505 Plan::MemorySnapshot { reason } => {
506 let meta = snapshot_now(ctx, &reason)?;
507 ensure(
508 snapshots::list(ctx.scope, snapshots::LIST_LIMIT)
509 .iter()
510 .any(|m| m.id == meta.id),
511 "Snapshot did not read back",
512 )?;
513 applied(json!({ "snapshot": meta }))
514 }
515 Plan::MemoryRestore { id, .. } => {
516 let plan = restore_plan(ctx.scope, &id)?;
517 let safety = snapshot_now(ctx, "Before restoring a snapshot")?;
518 let pick = |refs: &[String]| -> Vec<KnowledgeFact> {
519 refs.iter()
520 .filter_map(|r| plan.snapshot.get(r).cloned())
521 .collect()
522 };
523 import(ctx, pick(&plan.missing), ImportMerge::SkipExisting)?;
524 import(ctx, pick(&plan.changed), ImportMerge::Replace)?;
525 let after = current_map(&collect::knowledge_records(ctx.scope));
526 let lost = plan.now.keys().any(|r| !after.contains_key(r));
527 let differs = plan
528 .snapshot
529 .iter()
530 .any(|(r, f)| after.get(r).is_none_or(|a| a.value != f.value));
531 if lost || differs {
532 return Err(ProError::conflict(format!(
533 "The restore did not read back completely. {SNAPSHOT_HINT}"
534 )));
535 }
536 applied(json!({
537 "restored": plan.missing.len(),
538 "updated": plan.changed.len(),
539 "insights": after.len(),
540 "safety_snapshot": safety.id,
541 }))
542 }
543 Plan::MemoryForget { refs, reason } => {
544 let safety = snapshot_now(ctx, &reason)?;
545 let policy = policy()?;
546 let pairs: Vec<(String, String)> = refs
547 .iter()
548 .filter_map(|r| r.split_once('/'))
549 .map(|(c, k)| (c.to_string(), k.to_string()))
550 .collect();
551 ProjectKnowledge::mutate_locked(&ctx.scope.root, |k| {
552 let mut removed = false;
553 for (category, key) in &pairs {
554 removed |= k.remove_fact(category, key);
555 }
556 if removed {
557 let _ = k.run_memory_lifecycle(&policy);
558 }
559 })
560 .map_err(|_| {
561 ProError::internal(format!("The removal was not saved. {SNAPSHOT_HINT}"))
562 })?;
563 let left = current_map(&collect::knowledge_records(ctx.scope));
564 if refs.iter().any(|r| left.contains_key(r)) {
565 return Err(ProError::conflict(format!(
566 "Some insights were not removed. {SNAPSHOT_HINT}"
567 )));
568 }
569 applied(json!({ "removed": refs.len(), "safety_snapshot": safety.id }))
570 }
571 Plan::MemoryConsolidate { .. } => {
572 let safety = snapshot_now(ctx, "Before consolidation")?;
573 let report = crate::core::knowledge_consolidate::consolidate_project_knowledge_with(
574 &ctx.scope.root,
575 &crate::core::consolidation_engine::ConsolidateOptions::manual(),
576 )
577 .map_err(|_| ProError::internal(format!("Consolidation failed. {SNAPSHOT_HINT}")))?;
578 let text = crate::core::knowledge_consolidate::format_consolidation_report(&report);
579 applied(json!({
580 "summary": consolidation_summary(&text),
581 "safety_snapshot": safety.id,
582 }))
583 }
584 Plan::ContextOverlay { action, path } => {
585 use crate::core::context_overlay::{
586 ContextOverlay, OverlayAuthor, OverlayOp, OverlayScope, OverlayStore,
587 };
588 let root = std::path::PathBuf::from(&ctx.scope.root);
589 let target = crate::core::context_field::ContextItemId::from_file(&path);
590 let operation = if action == "compress" {
591 OverlayOp::SetView(crate::core::context_field::ViewKind::Map)
592 } else {
593 OverlayOp::Exclude {
594 reason: "adaptive recommendation".into(),
595 }
596 };
597 let mut store = OverlayStore::load_project(&root);
598 store.add(ContextOverlay::new(
599 target.clone(),
600 operation.clone(),
601 OverlayScope::Project,
602 String::new(),
603 OverlayAuthor::User,
604 ));
605 store
606 .save_project(&root)
607 .map_err(|_| ProError::internal("The rule was not saved"))?;
608 let back = OverlayStore::load_project(&root);
609 ensure(
610 back.all()
611 .iter()
612 .any(|o| o.target == target && o.operation == operation),
613 "The rule did not read back",
614 )?;
615 applied(json!({ "action": action, "path": path }))
616 }
617 Plan::AdaptiveRecord { item } => {
618 let heat: super::wire::HeatmapEntry = collect::heatmap(ctx.scope)
619 .remove(&item.path)
620 .and_then(|v| serde_json::from_value(v).ok())
621 .unwrap_or_default();
622 let original = heat.total_original_tokens.unwrap_or(0);
623 let saved = heat.total_tokens_saved.unwrap_or(0);
624 let record = AppliedItem {
625 path: item.path,
626 action: item.action,
627 applied_at: ctx.now.to_rfc3339(),
628 baseline: Baseline {
629 reads: heat.access_count.unwrap_or(0),
630 original,
631 saved,
632 ratio: (original > 0)
633 .then(|| ((saved as f64 / original as f64) * 1000.0).round() / 1000.0),
634 },
635 };
636 let mut adaptive = state.adaptive();
637 adaptive.applied.push(record.clone());
638 state.put_adaptive(adaptive)?;
639 ensure(
640 state.adaptive().applied.last() == Some(&record),
641 "The record did not read back",
642 )?;
643 applied(json!({ "item": record }))
644 }
645 Plan::AdaptiveDismiss { path } => {
646 let mut adaptive = state.adaptive();
647 if !adaptive.dismissed.contains(&path) {
648 adaptive.dismissed.push(path.clone());
649 }
650 adaptive
652 .applied
653 .retain(|item| !(item.path == path && item.action == "compress"));
654 state.put_adaptive(adaptive)?;
655 let back = state.adaptive();
656 ensure(
657 back.dismissed.contains(&path)
658 && !back
659 .applied
660 .iter()
661 .any(|item| item.path == path && item.action == "compress"),
662 "The dismissal did not read back",
663 )?;
664 applied(json!({ "path": path }))
665 }
666 Plan::LedgerExport {
667 format,
668 file_name,
669 mime,
670 filters,
671 project_column,
672 } => {
673 let bytes = export::build(ctx.scope, &filters, &format, &project_column)?;
674 Ok(Outcome::File(FileOut {
675 name: file_name,
676 mime,
677 bytes_b64: base64::engine::general_purpose::STANDARD.encode(bytes),
678 }))
679 }
680 }
681}
682
683fn plural(count: i64, word: &str) -> String {
684 format!("{count} {word}{}", if count == 1 { "" } else { "s" })
685}
686
687pub(crate) fn consolidation_summary(output: &str) -> Value {
689 let number = |pattern: &str| -> i64 {
690 regex::Regex::new(pattern)
691 .ok()
692 .and_then(|re| re.captures(output))
693 .and_then(|c| c.get(1))
694 .and_then(|m| m.as_str().parse().ok())
695 .unwrap_or(0)
696 };
697 let merged = number(r"consolidated (\d+)");
698 let archived = number(r"archived-to-target (\d+)")
699 .max(number(r"Lifecycle:[^\n]*?archived (\d+)"))
700 + number(r"Patterns:[^\n]*?compacted (\d+)")
701 + number(r"History:[^\n]*?compacted (\d+)");
702 let kept = match number(r"remaining (\d+)") {
703 0 => number(r"Facts: (\d+) active"),
704 n => n,
705 };
706 let session = number(r"Session import: \S+ \((\d+) item");
707 let mut lines = Vec::new();
708 if merged > 0 {
709 lines.push(format!(
710 "{} were merged into one.",
711 plural(merged, "similar insight")
712 ));
713 }
714 if archived > 0 {
715 lines.push(format!(
716 "{} were archived; archived items can be restored.",
717 plural(archived, "item")
718 ));
719 }
720 if session > 0 {
721 lines.push(format!(
722 "{} from the current session were added.",
723 plural(session, "item")
724 ));
725 }
726 if lines.is_empty() {
727 lines.push("Nothing needed consolidating.".into());
728 }
729 lines.push(format!("{} stayed in memory.", plural(kept, "insight")));
730 json!({ "merged": merged, "archived": archived, "kept": kept, "preview": false, "lines": lines })
731}
732
733pub(crate) fn apply(ctx: &Ctx<'_>, plans: Vec<Plan>) -> ProResult<Vec<Outcome>> {
735 for plan in &plans {
736 validate(ctx, plan)?;
737 }
738 plans.into_iter().map(|plan| execute(ctx, plan)).collect()
739}