Skip to main content

omgbase_surface/
fixes.rs

1//! The corrected example an error teaches with (`spec/mutate` §8 "fix",
2//! `spec/surface` §9; mutate 3.3 / surface 4.4): `data.fix` is a **literal
3//! corrected call** — the arguments as canonical JSON (§11.5), prefixed by
4//! the tool's name and one space only when the corrected call is to another
5//! tool — rebuilt from the request that failed, never a prose template. Both
6//! engines compose the same bytes; the reference is `mutate/canonical.ts`
7//! (`fixRequest`, `fixTool`, `narrowedTo`) and `plan.ts` (`planningFix`,
8//! `replanFix`).
9//!
10//! One function per error; the wording lives here and nowhere else. The
11//! planner's errors are decorated where the request is at hand: the plain
12//! request's in the catalog ([`attach`], over every error `apply` / `plan` /
13//! `execute` return), a selector's in `select::plan_selected`
14//! ([`planning_fix`] with the hits' origins). The append tools build theirs
15//! at the site ([`append_after`], [`append_without_section`],
16//! [`append_into_heading`]). An error the table does not cover, or a request
17//! the rebuild cannot read, passes through untouched — a `fix` is never
18//! wrong JSON.
19
20use omgbase_mutate::canonical_json;
21use serde_json::{Map, Value as Json, json};
22
23use crate::error::SurfaceError;
24
25/// A `#` locator where a target is expected (3.2 "Sugar"): the two reads
26/// that yield the id.
27pub const LOCATOR: &str = "resolve the locator to an id first: `blocks_resolve { locator }`, or a `query` whose hit is the block";
28
29/// A `plan` / `apply` / `execute` request as the caller gave it: `select`
30/// and `expect` only when present (the reference's `FixRequest`).
31#[derive(Clone, Debug, PartialEq)]
32pub struct Request {
33    pub select: Option<Json>,
34    pub effects: Json,
35    pub expect: Option<Json>,
36}
37
38impl Request {
39    /// The request's write members from a tool's arguments (`effects` +
40    /// `select?` + `expect?`); `None` without an `effects` member.
41    #[must_use]
42    pub fn of(args: &Json) -> Option<Self> {
43        Some(Self {
44            select: args.get("select").filter(|s| !s.is_null()).cloned(),
45            effects: args.get("effects")?.clone(),
46            expect: args.get("expect").cloned(),
47        })
48    }
49
50    /// The effect templates as an array.
51    fn list(&self) -> Option<&Vec<Json>> {
52        self.effects.as_array()
53    }
54
55    fn with_effects(&self, effects: Vec<Json>) -> Self {
56        Self {
57            effects: Json::Array(effects),
58            ..self.clone()
59        }
60    }
61}
62
63/// The `fix` for a request to the SAME tool: the corrected arguments as
64/// canonical JSON (`fixRequest`).
65#[must_use]
66pub fn fix_request(req: &Request) -> String {
67    let mut m = Map::new();
68    if let Some(s) = &req.select {
69        m.insert("select".to_owned(), s.clone());
70    }
71    m.insert("effects".to_owned(), req.effects.clone());
72    if let Some(e) = &req.expect {
73        m.insert("expect".to_owned(), e.clone());
74    }
75    canonical_json(&Json::Object(m))
76}
77
78/// The `fix` naming another tool: `<tool> <canonical args>` (`fixTool`).
79#[must_use]
80pub fn fix_tool(tool: &str, args: &Json) -> String {
81    format!("{tool} {}", canonical_json(args))
82}
83
84/// The selector that names exactly one row: `from <root> where id == "<id>"`
85/// (`narrowedTo`).
86#[must_use]
87pub fn narrowed_to(root: &str, id: &str) -> String {
88    format!("from {root} where id == {}", Json::String(id.to_owned()))
89}
90
91/// The root a row id belongs to, by its prefix (`rootOfId`).
92#[must_use]
93pub fn root_of_id(id: &str) -> &'static str {
94    if id.starts_with("d_") {
95        "docs"
96    } else if id.starts_with("n_") {
97        "nodes"
98    } else {
99        "blocks"
100    }
101}
102
103/// `value` with every string equal to `from` replaced by `to`
104/// (`substituteSymbols`).
105fn substitute(value: &Json, from: &str, to: &str) -> Json {
106    match value {
107        Json::String(s) if s == from => json!(to),
108        Json::Array(a) => Json::Array(a.iter().map(|x| substitute(x, from, to)).collect()),
109        Json::Object(o) => Json::Object(
110            o.iter()
111                .map(|(k, v)| (k.clone(), substitute(v, from, to)))
112                .collect(),
113        ),
114        other => other.clone(),
115    }
116}
117
118/// Where a concrete effect came from under a selector: the hit's row and the
119/// template (the reference's `EffectOrigin`).
120#[derive(Clone, Debug, PartialEq, Eq)]
121pub struct Origin {
122    pub row_id: String,
123    pub template_index: usize,
124}
125
126// ---- planning errors (`planningFix`) ----------------------------------------------------
127
128/// `conflicting_effects { effects: [a, b] }`: keep the later effect — the
129/// request minus the earlier one. Under a selector a cross-hit collision
130/// narrows the selector to the later hit (`from <root> where id == …`,
131/// `expect.max: 1`) and a same-hit collision drops the earlier template.
132#[must_use]
133pub fn conflicting_effects(
134    req: &Request,
135    a: usize,
136    b: usize,
137    origins: Option<&[Origin]>,
138) -> Option<String> {
139    let effects = req.list()?;
140    let Some(origins) = origins else {
141        let kept = effects
142            .iter()
143            .enumerate()
144            .filter(|(k, _)| *k != a)
145            .map(|(_, e)| e.clone())
146            .collect();
147        return Some(fix_request(&Request {
148            select: None,
149            effects: Json::Array(kept),
150            expect: None,
151        }));
152    };
153    let (oa, ob) = (origins.get(a)?, origins.get(b)?);
154    if oa.row_id != ob.row_id {
155        return Some(fix_request(&Request {
156            select: Some(json!(narrowed_to(root_of_id(&ob.row_id), &ob.row_id))),
157            effects: req.effects.clone(),
158            expect: Some(json!({ "max": 1 })),
159        }));
160    }
161    let kept = effects
162        .iter()
163        .enumerate()
164        .filter(|(k, _)| *k != oa.template_index)
165        .map(|(_, e)| e.clone())
166        .collect();
167    Some(fix_request(&req.with_effects(kept)))
168}
169
170/// `stale_plan` at plan time (an `expect.revision` that no longer holds):
171/// the same request with the current revision in that guard — effect `t`
172/// (the template under a selector).
173#[must_use]
174pub fn stale_revision(req: &Request, t: usize, current: &str) -> Option<String> {
175    let effects = req.list()?;
176    let e = effects.get(t)?.as_object()?;
177    let mut fixed = e.clone();
178    let mut expect = e
179        .get("expect")
180        .and_then(Json::as_object)
181        .cloned()
182        .unwrap_or_default();
183    expect.insert("revision".to_owned(), json!(current));
184    fixed.insert("expect".to_owned(), Json::Object(expect));
185    let mapped = effects
186        .iter()
187        .enumerate()
188        .map(|(k, x)| {
189            if k == t {
190                Json::Object(fixed.clone())
191            } else {
192                x.clone()
193            }
194        })
195        .collect();
196    Some(fix_request(&req.with_effects(mapped)))
197}
198
199/// `target_missing { symbol }` for a `$new.n` no earlier effect created
200/// (plain requests): the next `create` moved before the effect that uses it,
201/// the symbol renamed to the one that create will allocate — `$new.<count>`,
202/// `count` the symbols allocated so far.
203#[must_use]
204pub fn create_first(req: &Request, i: usize, symbol: &str, allocated: usize) -> Option<String> {
205    let effects = req.list()?;
206    let c = effects
207        .iter()
208        .enumerate()
209        .skip(i + 1)
210        .find(|(_, e)| e.get("op").and_then(Json::as_str) == Some("create"))
211        .map(|(k, _)| k)?;
212    let renamed = format!("$new.{allocated}");
213    let mut reordered: Vec<Json> = effects
214        .iter()
215        .enumerate()
216        .filter(|(k, _)| *k != c)
217        .map(|(_, e)| e.clone())
218        .collect();
219    reordered.insert(i, effects[c].clone());
220    let fixed = reordered
221        .iter()
222        .enumerate()
223        .map(|(k, e)| {
224            if k == i + 1 {
225                substitute(e, symbol, &renamed)
226            } else {
227                e.clone()
228            }
229        })
230        .collect();
231    Some(fix_request(&Request {
232        select: None,
233        effects: Json::Array(fixed),
234        expect: None,
235    }))
236}
237
238/// `ambiguous_target`: the slot spelled as the **first** identity the
239/// expression yielded (`here` the member's path, `effects[t].…`); under a
240/// selector the selector narrowed to the row it was read from
241/// (`expect.max: 1`); a plain request keeps its effects with the slot
242/// rewritten (the reference's `ambiguousFix`).
243#[must_use]
244pub fn ambiguous_slot(
245    req: &Request,
246    here: &str,
247    first: &str,
248    narrow: (&str, &str),
249) -> Option<String> {
250    let mut fixed = req.clone();
251    let path = here.strip_prefix("effects")?;
252    *walk_mut(&mut fixed.effects, path)? = json!(first);
253    if req.select.is_none() {
254        return Some(fix_request(&Request {
255            select: None,
256            effects: fixed.effects,
257            expect: None,
258        }));
259    }
260    let (root, id) = narrow;
261    Some(fix_request(&Request {
262        select: Some(json!(narrowed_to(root, id))),
263        effects: fixed.effects,
264        expect: Some(json!({ "max": 1 })),
265    }))
266}
267
268/// A refused selector source (§11.8 step 1): the same request over an
269/// admitted root — `links` becomes `blocks` (a link lives in a block), any
270/// other source `docs` — spliced over the source's code-point span of the
271/// written query (the reference's `rootFix`).
272#[must_use]
273pub fn root_fix(req: &Request, select: &str, span: (usize, usize), links: bool) -> String {
274    let cps: Vec<char> = select.chars().collect();
275    let start = span.0.min(cps.len());
276    let end = span.1.clamp(start, cps.len());
277    let root = if links { "blocks" } else { "docs" };
278    let spliced: String =
279        cps[..start].iter().collect::<String>() + root + &cps[end..].iter().collect::<String>();
280    fix_request(&Request {
281        select: Some(json!(spliced)),
282        ..req.clone()
283    })
284}
285
286/// The planner's refusals of one request, decorated (the reference's
287/// `planningFix`): `i` is the failing effect's index in the concrete list,
288/// `origins` the hits' origins under a selector (`None` for a plain
289/// request), `allocated` the symbols the effects before `i` allocate (read
290/// only when a `$new.n` is missing). Returns the error, with `data.fix`
291/// when the table covers it and none was set.
292pub fn planning_fix(
293    req: &Request,
294    mut err: SurfaceError,
295    origins: Option<&[Origin]>,
296    allocated: impl FnOnce(usize) -> Option<usize>,
297) -> SurfaceError {
298    let Some(data) = err.data.as_ref().and_then(Json::as_object) else {
299        return err;
300    };
301    if data.contains_key("fix") {
302        return err;
303    }
304    let index = |v: Option<&Json>| {
305        v.and_then(Json::as_u64)
306            .and_then(|n| usize::try_from(n).ok())
307    };
308    let fix = match err.code.as_str() {
309        "conflicting_effects" => {
310            let pair = data.get("effects").and_then(Json::as_array);
311            match (
312                pair.and_then(|p| index(p.first())),
313                pair.and_then(|p| index(p.get(1))),
314            ) {
315                (Some(a), Some(b)) => conflicting_effects(req, a, b, origins),
316                _ => None,
317            }
318        }
319        "stale_plan" => {
320            let current = data
321                .get("current")
322                .and_then(|c| c.get("revision"))
323                .and_then(Json::as_str);
324            let t = match origins {
325                Some(_) => index(data.get("template_index")),
326                None => index(data.get("effect_index")),
327            };
328            match (current, t) {
329                (Some(current), Some(t)) => stale_revision(req, t, current),
330                _ => None,
331            }
332        }
333        "target_missing" if origins.is_none() => {
334            match (
335                data.get("symbol").and_then(Json::as_str),
336                index(data.get("effect_index")),
337            ) {
338                (Some(symbol), Some(i)) => {
339                    let n = omgbase_mutate::parse_new_symbol(symbol);
340                    // Only a symbol nothing allocated yet is "created later".
341                    match (n, allocated(i)) {
342                        (Some(n), Some(count)) if n >= count => create_first(req, i, symbol, count),
343                        _ => None,
344                    }
345                }
346                _ => None,
347            }
348        }
349        _ => None,
350    };
351    if let Some(fix) = fix {
352        if let Some(d) = err.data.as_mut().and_then(Json::as_object_mut) {
353            d.insert("fix".to_owned(), json!(fix));
354        }
355    }
356    err
357}
358
359// ---- execution errors (`replanFix`) -----------------------------------------------------
360
361/// A plan that cannot be executed as it stands (`stale_plan` at execute,
362/// `plan_mismatch` of the plan form): re-plan and commit in one call —
363/// `apply` over the plan's selector form when it has one (`expect` as given,
364/// else the selection's cardinality), else over its concrete effects.
365#[must_use]
366pub fn replan(plan: &Json) -> Option<String> {
367    if let Some(sel) = plan.get("selection") {
368        let expect = sel
369            .get("expect")
370            .cloned()
371            .or_else(|| sel.get("cardinality").map(|c| json!({ "max": c })));
372        return Some(fix_request(&Request {
373            select: Some(sel.get("query")?.clone()),
374            effects: sel.get("templates")?.clone(),
375            expect,
376        }));
377    }
378    let effects = plan.get("effects")?;
379    if effects.as_array().is_some_and(Vec::is_empty) {
380        return None;
381    }
382    Some(fix_request(&Request {
383        select: None,
384        effects: effects.clone(),
385        expect: None,
386    }))
387}
388
389/// `expect.max` missing on a one-shot `apply { select }`: the same call
390/// bounded at the selection's actual size.
391#[must_use]
392pub fn bounded(req: &Request, cardinality: usize) -> String {
393    fix_request(&Request {
394        expect: Some(json!({ "max": cardinality })),
395        ..req.clone()
396    })
397}
398
399/// A `-> ` leader in a member that takes no expression (`here` the member's
400/// path, `effects[t].…`): the same request with the leader dropped there —
401/// the string is the literal it reads as.
402#[must_use]
403pub fn literal_slot(req: &Request, here: &str) -> Option<String> {
404    let mut fixed = req.clone();
405    let path = here.strip_prefix("effects")?;
406    let slot = walk_mut(&mut fixed.effects, path)?;
407    let literal = crate::select::literal_of(slot.as_str()?.strip_prefix("-> ")?);
408    *slot = json!(literal);
409    Some(fix_request(&fixed))
410}
411
412/// Follow a `[0].expect.content_hash` / `[1].at.after` path.
413fn walk_mut<'a>(v: &'a mut Json, path: &str) -> Option<&'a mut Json> {
414    let mut cur = v;
415    for seg in path.split('.') {
416        let (name, indices) = match seg.find('[') {
417            Some(i) => (&seg[..i], &seg[i..]),
418            None => (seg, ""),
419        };
420        if !name.is_empty() {
421            cur = cur.get_mut(name)?;
422        }
423        for idx in indices
424            .split(']')
425            .filter(|s| !s.is_empty())
426            .map(|s| s.trim_start_matches('['))
427        {
428            cur = cur.get_mut(idx.parse::<usize>().ok()?)?;
429        }
430    }
431    Some(cur)
432}
433
434/// Decorate an effect tool's error with its corrected call (module docs):
435/// `apply`, `plan` and `execute` only; every other tool's error passes
436/// through, and so does one that already carries a `fix` (the selector's,
437/// the compile's). `allocated(i)` plans the effects before `i` and counts
438/// their symbols (read only for a missing `$new.n`).
439#[must_use]
440pub fn attach(
441    tool: &str,
442    args: &Json,
443    err: SurfaceError,
444    allocated: impl FnOnce(usize) -> Option<usize>,
445) -> SurfaceError {
446    if !matches!(tool, "apply" | "plan" | "execute") {
447        return err;
448    }
449    let Some(data) = err.data.as_ref().and_then(Json::as_object) else {
450        return err;
451    };
452    if data.contains_key("fix") {
453        return err;
454    }
455    // The plan form: the plan itself is the request.
456    if let Some(plan) = args.get("plan") {
457        let fix = match err.code.as_str() {
458            "stale_plan" | "plan_mismatch" => replan(plan),
459            _ => None,
460        };
461        return with_fix(err, fix);
462    }
463    let Some(req) = Request::of(args) else {
464        return err;
465    };
466    let fix = match err.code.as_str() {
467        // The digest form's mismatch: the request as given, re-planned.
468        "plan_mismatch" => Some(fix_request(&req)),
469        // A selector's errors were decorated where the hits were
470        // (`select::plan_selected`, the compile).
471        _ if req.select.is_some() => None,
472        _ => return planning_fix(&req, err, None, allocated),
473    };
474    with_fix(err, fix)
475}
476
477fn with_fix(mut err: SurfaceError, fix: Option<String>) -> SurfaceError {
478    if let Some(fix) = fix {
479        if let Some(d) = err.data.as_mut().and_then(Json::as_object_mut) {
480            d.insert("fix".to_owned(), json!(fix));
481        }
482    }
483    err
484}
485
486// ---- the append tools ---------------------------------------------------------------------
487
488/// `blocks_append` on a leaf (`block_not_container`): append means into —
489/// the `blocks_insert` that places the text right after it, under its parent
490/// (the document when the leaf is top-level).
491#[must_use]
492pub fn append_after(parent: &str, id: &str, text: &str) -> String {
493    fix_tool(
494        "blocks_insert",
495        &json!({ "to": parent, "at": { "after": id }, "markdown": text }),
496    )
497}
498
499/// `docs_append { section }` naming no heading (`parent_missing`): the same
500/// append — the call's own locator and text — without the section (the
501/// document's end).
502#[must_use]
503pub fn append_without_section(id: Option<&str>, path: Option<&str>, text: &str) -> String {
504    let mut m = Map::new();
505    if let Some(id) = id {
506        m.insert("id".to_owned(), json!(id));
507    }
508    if let Some(path) = path {
509        m.insert("path".to_owned(), json!(path));
510    }
511    m.insert("text".to_owned(), json!(text));
512    fix_tool("docs_append", &Json::Object(m))
513}
514
515/// `docs_append { section }` matching several headings
516/// (`ambiguous_heading`): `blocks_append` into the first candidate by id.
517#[must_use]
518pub fn append_into_heading(heading_id: &str, text: &str) -> String {
519    fix_tool("blocks_append", &json!({ "id": heading_id, "text": text }))
520}
521
522#[cfg(test)]
523mod tests {
524    use super::*;
525
526    fn effects() -> Json {
527        json!([
528            { "op": "insert", "into": { "kind": "document", "path": "a.md" }, "markdown": "x" },
529            { "op": "set", "target": { "kind": "document", "path": "a.md" }, "field": "frontmatter.k", "value": 1 },
530            { "op": "create", "path": "c.md", "markdown": "# C" }
531        ])
532    }
533
534    fn err(code: &str, data: Json) -> SurfaceError {
535        SurfaceError::with_data(code, "m", data)
536    }
537
538    fn fix_of(e: &SurfaceError) -> Option<String> {
539        e.data
540            .as_ref()
541            .and_then(|d| d.get("fix"))
542            .and_then(Json::as_str)
543            .map(str::to_owned)
544    }
545
546    fn canon(v: Json) -> String {
547        canonical_json(&v)
548    }
549
550    fn none(_: usize) -> Option<usize> {
551        None
552    }
553
554    #[test]
555    fn a_fix_is_canonical_json_prefixed_only_for_another_tool() {
556        let req = Request {
557            select: Some(json!("from docs collect")),
558            effects: json!([{ "op": "set", "field": "f", "value": 1 }]),
559            expect: Some(json!({ "max": 2 })),
560        };
561        assert_eq!(
562            fix_request(&req),
563            "{\"effects\":[{\"field\":\"f\",\"op\":\"set\",\"value\":1}],\"expect\":{\"max\":2},\"select\":\"from docs collect\"}"
564        );
565        assert_eq!(
566            fix_tool("plan", &json!({ "effects": [] })),
567            "plan {\"effects\":[]}"
568        );
569        assert_eq!(narrowed_to("docs", "d_1"), "from docs where id == \"d_1\"");
570        assert_eq!(root_of_id("d_1"), "docs");
571        assert_eq!(root_of_id("n_1"), "nodes");
572        assert_eq!(root_of_id("b_1"), "blocks");
573    }
574
575    #[test]
576    fn conflicting_effects_keeps_the_later_effect() {
577        let args = json!({ "effects": effects(), "reason": "r" });
578        let e = attach(
579            "apply",
580            &args,
581            err("conflicting_effects", json!({ "effects": [0, 1] })),
582            none,
583        );
584        assert_eq!(
585            fix_of(&e).unwrap(),
586            canon(json!({ "effects": [effects()[1], effects()[2]] }))
587        );
588        // Under a selector: a cross-hit collision narrows to the later hit, a
589        // same-hit collision drops the earlier template.
590        let req = Request {
591            select: Some(json!("from docs collect")),
592            effects: json!([effects()[1], effects()[1]]),
593            expect: Some(json!({ "max": 5 })),
594        };
595        let origins = [
596            Origin {
597                row_id: "d_1".into(),
598                template_index: 0,
599            },
600            Origin {
601                row_id: "d_1".into(),
602                template_index: 1,
603            },
604            Origin {
605                row_id: "d_2".into(),
606                template_index: 0,
607            },
608            Origin {
609                row_id: "d_2".into(),
610                template_index: 1,
611            },
612        ];
613        assert_eq!(
614            conflicting_effects(&req, 1, 2, Some(&origins)).unwrap(),
615            canon(
616                json!({ "select": "from docs where id == \"d_2\"", "effects": [effects()[1], effects()[1]], "expect": { "max": 1 } })
617            )
618        );
619        assert_eq!(
620            conflicting_effects(&req, 0, 1, Some(&origins)).unwrap(),
621            canon(
622                json!({ "select": "from docs collect", "effects": [effects()[1]], "expect": { "max": 5 } })
623            )
624        );
625    }
626
627    #[test]
628    fn a_stale_revision_at_plan_time_is_repinned_to_the_current_one() {
629        let args = json!({ "effects": [
630            { "op": "set", "target": { "kind": "document", "path": "a.md" }, "field": "frontmatter.k", "value": 1, "expect": { "revision": "r_0" } },
631            effects()[0],
632        ] });
633        let e = attach(
634            "apply",
635            &args,
636            err(
637                "stale_plan",
638                json!({ "doc": "d_0", "expected_revision": "r_0", "current": { "revision": "r_1", "hash": null }, "retriable": true, "effect_index": 0 }),
639            ),
640            none,
641        );
642        let mut fixed = args["effects"].clone();
643        fixed[0]["expect"]["revision"] = json!("r_1");
644        assert_eq!(fix_of(&e).unwrap(), canon(json!({ "effects": fixed })));
645        // An effect without an `expect` gains one.
646        let req = Request {
647            select: None,
648            effects: json!([effects()[1]]),
649            expect: None,
650        };
651        let mut with = effects()[1].clone();
652        with["expect"] = json!({ "revision": "r_9" });
653        assert_eq!(
654            stale_revision(&req, 0, "r_9").unwrap(),
655            canon(json!({ "effects": [with] }))
656        );
657    }
658
659    #[test]
660    fn an_unexecutable_plan_is_replanned_from_its_core() {
661        let plan = json!({ "plan_id": "p", "effects": effects(), "preconditions": {} });
662        let e = attach(
663            "execute",
664            &json!({ "plan": plan }),
665            err("stale_plan", json!({ "doc": "d_0", "retriable": true })),
666            none,
667        );
668        assert_eq!(fix_of(&e).unwrap(), canon(json!({ "effects": effects() })));
669        let e = attach(
670            "execute",
671            &json!({ "plan": plan }),
672            err("plan_mismatch", json!({ "expected": "a", "actual": "b" })),
673            none,
674        );
675        assert_eq!(fix_of(&e).unwrap(), canon(json!({ "effects": effects() })));
676        // A query-directed plan: its selector form, `expect` as given or the cardinality.
677        let selected = json!({ "effects": [], "selection": { "query": "from docs collect", "templates": [effects()[1]], "cardinality": 3, "identities": ["d_1"] } });
678        assert_eq!(
679            replan(&selected).unwrap(),
680            canon(
681                json!({ "select": "from docs collect", "effects": [effects()[1]], "expect": { "max": 3 } })
682            )
683        );
684        let bounded = json!({ "effects": [], "selection": { "query": "from docs collect", "expect": { "max": 9 }, "templates": [effects()[1]], "cardinality": 3 } });
685        assert_eq!(
686            replan(&bounded).unwrap(),
687            canon(
688                json!({ "select": "from docs collect", "effects": [effects()[1]], "expect": { "max": 9 } })
689            )
690        );
691        // The digest form's mismatch: the request as given.
692        let e = attach(
693            "execute",
694            &json!({ "plan_id": "old", "effects": effects(), "reason": "r" }),
695            err(
696                "plan_mismatch",
697                json!({ "expected": "old", "actual": "new" }),
698            ),
699            none,
700        );
701        assert_eq!(fix_of(&e).unwrap(), canon(json!({ "effects": effects() })));
702        let e = attach(
703            "execute",
704            &json!({ "plan_id": "old", "select": "from docs collect", "effects": [effects()[1]], "expect": { "max": 2 } }),
705            err(
706                "plan_mismatch",
707                json!({ "expected": "old", "actual": "new" }),
708            ),
709            none,
710        );
711        assert_eq!(
712            fix_of(&e).unwrap(),
713            canon(
714                json!({ "select": "from docs collect", "effects": [effects()[1]], "expect": { "max": 2 } })
715            )
716        );
717    }
718
719    #[test]
720    fn a_symbol_before_its_create_moves_the_create_first_and_renames_it() {
721        let args = json!({ "effects": [
722            { "op": "insert", "into": { "kind": "document", "path": "a.md" }, "markdown": "x" },
723            { "op": "insert", "into": { "kind": "document", "id": "$new.3" }, "markdown": "y" },
724            { "op": "create", "path": "c.md", "markdown": "# C" }
725        ] });
726        // One symbol allocated by effect 0; the create moved before effect 1
727        // allocates `$new.1`, and the insert names it so.
728        let e = attach(
729            "plan",
730            &args,
731            err(
732                "target_missing",
733                json!({ "symbol": "$new.3", "effect_index": 1 }),
734            ),
735            Some,
736        );
737        let fixed = json!([
738            args["effects"][0],
739            args["effects"][2],
740            { "op": "insert", "into": { "kind": "document", "id": "$new.1" }, "markdown": "y" },
741        ]);
742        assert_eq!(fix_of(&e).unwrap(), canon(json!({ "effects": fixed })));
743        // A symbol an earlier effect DID allocate (the wrong kind) is not reordered.
744        let e = attach(
745            "plan",
746            &args,
747            err(
748                "target_missing",
749                json!({ "symbol": "$new.0", "effect_index": 1 }),
750            ),
751            Some,
752        );
753        assert_eq!(fix_of(&e), None);
754        // No later create: nothing to teach.
755        let e = attach(
756            "plan",
757            &json!({ "effects": [args["effects"][1]] }),
758            err(
759                "target_missing",
760                json!({ "symbol": "$new.3", "effect_index": 0 }),
761            ),
762            |_| Some(0),
763        );
764        assert_eq!(fix_of(&e), None);
765    }
766
767    #[test]
768    fn selector_requests_are_decorated_with_their_origins() {
769        let req = Request {
770            select: Some(json!("from docs collect")),
771            effects: json!([{ "op": "insert", "into": "-> project", "markdown": "x" }]),
772            expect: Some(json!({ "max": 5 })),
773        };
774        let origins = [Origin {
775            row_id: "d_2".into(),
776            template_index: 0,
777        }];
778        // The catalog leaves a selector request's planning errors to the selector.
779        let e = attach(
780            "apply",
781            &json!({ "select": "from docs collect", "effects": req.effects, "expect": { "max": 5 } }),
782            err("conflicting_effects", json!({ "effects": [0, 1] })),
783            none,
784        );
785        assert_eq!(fix_of(&e), None);
786        // A stale revision under a selector repins the TEMPLATE.
787        let e = planning_fix(
788            &req,
789            err(
790                "stale_plan",
791                json!({ "current": { "revision": "r_2" }, "effect_index": 3, "template_index": 0, "row": { "id": "d_2", "path": "/w.md" } }),
792            ),
793            Some(&origins),
794            none,
795        );
796        let mut repinned = req.effects.clone();
797        repinned[0]["expect"] = json!({ "revision": "r_2" });
798        assert_eq!(
799            fix_of(&e).unwrap(),
800            canon(
801                json!({ "select": "from docs collect", "effects": repinned, "expect": { "max": 5 } })
802            )
803        );
804    }
805
806    #[test]
807    fn an_ambiguous_slot_takes_the_first_identity() {
808        let req = Request {
809            select: Some(json!("from docs collect")),
810            effects: json!([{ "op": "insert", "into": "-> project", "markdown": "x" }]),
811            expect: Some(json!({ "max": 5 })),
812        };
813        assert_eq!(
814            ambiguous_slot(&req, "effects[0].into", "d_7", ("docs", "d_2")).unwrap(),
815            canon(
816                json!({ "select": "from docs where id == \"d_2\"", "effects": [{ "op": "insert", "into": "d_7", "markdown": "x" }], "expect": { "max": 1 } })
817            )
818        );
819        let plain = Request {
820            select: None,
821            effects: json!([{ "op": "set", "target": { "kind": "document", "path": "-> project" }, "field": "frontmatter.k", "value": 1 }]),
822            expect: None,
823        };
824        assert_eq!(
825            ambiguous_slot(&plain, "effects[0].target.path", "/p.md", ("docs", "d_2")).unwrap(),
826            canon(
827                json!({ "effects": [{ "op": "set", "target": { "kind": "document", "path": "/p.md" }, "field": "frontmatter.k", "value": 1 }] })
828            )
829        );
830        assert_eq!(
831            ambiguous_slot(&plain, "effects[3].x", "y", ("docs", "d")),
832            None
833        );
834    }
835
836    #[test]
837    fn bounds_and_roots() {
838        let req = Request {
839            select: Some(json!("from docs collect")),
840            effects: json!([effects()[1]]),
841            expect: None,
842        };
843        assert_eq!(
844            bounded(&req, 3),
845            canon(
846                json!({ "select": "from docs collect", "effects": [effects()[1]], "expect": { "max": 3 } })
847            )
848        );
849        // The source spliced over its code-point span: `links` → `blocks`, anything else → `docs`.
850        let links = Request {
851            select: Some(json!("from links where x == 1 collect")),
852            ..req.clone()
853        };
854        assert_eq!(
855            root_fix(&links, "from links where x == 1 collect", (5, 10), true),
856            canon(
857                json!({ "select": "from blocks where x == 1 collect", "effects": [effects()[1]] })
858            )
859        );
860        assert_eq!(
861            root_fix(&req, "from ★.y collect", (5, 8), false),
862            canon(json!({ "select": "from docs collect", "effects": [effects()[1]] }))
863        );
864    }
865
866    #[test]
867    fn a_leader_in_a_literal_slot_is_dropped_there() {
868        let req = Request {
869            select: None,
870            effects: json!([
871                { "op": "set", "target": { "kind": "block", "id": "b_1" }, "field": "markdown", "value": "x", "expect": { "content_hash": "-> hash" } }
872            ]),
873            expect: None,
874        };
875        let mut fixed = req.effects.clone();
876        fixed[0]["expect"]["content_hash"] = json!("hash");
877        assert_eq!(
878            literal_slot(&req, "effects[0].expect.content_hash").unwrap(),
879            canon(json!({ "effects": fixed }))
880        );
881        assert_eq!(literal_slot(&req, "effects[0].field"), None);
882        assert_eq!(
883            walk_mut(&mut json!({ "a": [[1, 2]] }), "a[0][1]").cloned(),
884            Some(json!(2))
885        );
886    }
887
888    #[test]
889    fn other_tools_and_other_errors_pass_through() {
890        let e = attach(
891            "blocks_read",
892            &json!({}),
893            err("doc_missing", json!({ "doc": "x" })),
894            none,
895        );
896        assert_eq!(fix_of(&e), None);
897        let e = attach(
898            "apply",
899            &json!({ "effects": [] }),
900            err("doc_missing", json!({ "doc": "x" })),
901            none,
902        );
903        assert_eq!(fix_of(&e), None);
904        let bare = attach(
905            "apply",
906            &json!({}),
907            SurfaceError::new("doc_missing", "m"),
908            none,
909        );
910        assert_eq!(bare.data, None);
911        // A fix already set (the selector's) is kept.
912        let e = attach(
913            "apply",
914            &json!({ "effects": effects() }),
915            err(
916                "conflicting_effects",
917                json!({ "effects": [0, 1], "fix": "kept" }),
918            ),
919            none,
920        );
921        assert_eq!(fix_of(&e).unwrap(), "kept");
922    }
923
924    #[test]
925    fn the_append_tools_spell_their_calls() {
926        assert_eq!(
927            append_after("b_parent", "b_leaf", "more"),
928            "blocks_insert {\"at\":{\"after\":\"b_leaf\"},\"markdown\":\"more\",\"to\":\"b_parent\"}"
929        );
930        assert_eq!(
931            append_without_section(None, Some("/a.md"), "t"),
932            "docs_append {\"path\":\"/a.md\",\"text\":\"t\"}"
933        );
934        assert_eq!(
935            append_into_heading("b_h", "t"),
936            "blocks_append {\"id\":\"b_h\",\"text\":\"t\"}"
937        );
938    }
939}