Skip to main content

headless_lms_mcp/engine/
glossary_references.rs

1//! `patch_course_glossary_and_references`: glossary terms and BibTeX references.
2//!
3//! Renaming a citation key rewrites its `\cite{…}` citations in page content; other changes only
4//! report the pages that use the term or key.
5
6use std::collections::{BTreeSet, HashMap};
7use std::sync::LazyLock;
8
9use headless_lms_utils::strings::decode_html_entities;
10use regex::Regex;
11use serde::Serialize;
12use uuid::Uuid;
13
14use super::blocks;
15use super::error::{EditError, EditErrorCode};
16use super::op::Op;
17use super::path::{parse_uuid, split_path};
18use super::{
19    CreatedIdLog, CreatedIds, Document, DocumentKey, DocumentRef, new_item_objects,
20    non_empty_string, reject_unknown_keys,
21};
22use crate::ids::IdGenerator;
23use crate::text::{plain_text, whole_word_matches};
24use headless_lms_utils::strings::escape_html_text;
25
26/// The document `get_course_glossary_and_references` shows and its patch tool edits.
27#[derive(Debug, Clone, PartialEq, Serialize)]
28pub struct GlossaryAndReferences {
29    pub glossary: Vec<GlossaryTerm>,
30    pub references: Vec<Reference>,
31}
32
33/// A glossary term; its editable fields are [`TERM_FIELDS`].
34#[derive(Debug, Clone, PartialEq, Serialize)]
35pub struct GlossaryTerm {
36    pub id: Uuid,
37    pub term: String,
38    pub definition: String,
39}
40
41/// A BibTeX reference; its editable fields are [`REFERENCE_FIELDS`].
42#[derive(Debug, Clone, PartialEq, Serialize)]
43pub struct Reference {
44    pub id: Uuid,
45    pub citation_key: String,
46    /// One BibTeX entry whose key equals `citation_key`.
47    pub bibtex: String,
48}
49
50/// The key of the single entry in `bibtex`, or a message saying why it doesn't parse. Checks the
51/// shape `@type{key, field = {…}, …}` with balanced braces and exactly one entry.
52fn bibtex_entry_key(bibtex: &str) -> Result<String, String> {
53    static HEAD: LazyLock<Regex> = LazyLock::new(|| {
54        Regex::new(r"^\s*@([A-Za-z]+)\s*([{(])\s*([^,\s{}()]+)\s*,")
55            .expect("a constant pattern the unit tests compile")
56    });
57    let head = HEAD
58        .captures(bibtex)
59        .ok_or_else(|| "expected an entry like @article{key, title = {…}}".to_string())?;
60    let is_parenthesized = head.get(2).is_some_and(|m| m.as_str() == "(");
61    let body_start = head.get(2).map(|m| m.end()).unwrap_or(0);
62    // Braces nest inside field values; a parenthesized entry ends at a `)` outside them and
63    // outside quotes.
64    let mut brace_depth = 0i32;
65    let mut is_in_quotes = false;
66    let mut end = None;
67    for (offset, c) in bibtex[body_start..].char_indices() {
68        match c {
69            '{' => brace_depth += 1,
70            '}' if brace_depth > 0 => brace_depth -= 1,
71            '}' if !is_parenthesized => {
72                end = Some(body_start + offset);
73                break;
74            }
75            '}' => return Err("unbalanced braces".to_string()),
76            '"' if brace_depth == 0 => is_in_quotes = !is_in_quotes,
77            ')' if is_parenthesized && brace_depth == 0 && !is_in_quotes => {
78                end = Some(body_start + offset);
79                break;
80            }
81            _ => {}
82        }
83    }
84    let end = end.ok_or_else(|| "unbalanced braces".to_string())?;
85    if !bibtex[end + 1..].trim().is_empty() {
86        return Err("only one entry per reference".to_string());
87    }
88    Ok(head
89        .get(3)
90        .map(|m| m.as_str().to_string())
91        .unwrap_or_default())
92}
93
94/// A document that uses a term or citation key the call removed or renamed.
95#[derive(Debug, Clone, PartialEq, Serialize)]
96pub struct Usage {
97    #[serde(flatten)]
98    pub document: DocumentKey,
99    pub kind: UsageKind,
100    pub value: String,
101}
102
103/// Whether a [`Usage`] is of a glossary term or a citation key.
104#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
105#[serde(rename_all = "snake_case")]
106pub enum UsageKind {
107    GlossaryTerm,
108    CitationKey,
109}
110
111/// The new state after a successful call, and what it means for the pages.
112#[derive(Debug, Clone, PartialEq)]
113pub struct GlossaryReferencesResult {
114    pub document: GlossaryAndReferences,
115    pub created: Vec<CreatedIds>,
116    /// Documents whose citations were rewritten after a citation-key rename.
117    pub rewritten_pages: Vec<Document>,
118    pub usages: Vec<Usage>,
119}
120
121/// Glossary term fields ops may set.
122pub(crate) const TERM_FIELDS: [&str; 2] = ["term", "definition"];
123/// Reference fields ops may set.
124pub(crate) const REFERENCE_FIELDS: [&str; 2] = ["citation_key", "bibtex"];
125
126/// The edited document, before its effects on the pages are worked out.
127#[derive(Debug, Clone, PartialEq)]
128pub struct GlossaryEdit {
129    document: GlossaryAndReferences,
130    created: Vec<CreatedIds>,
131    renames: Vec<(String, String)>,
132    reported_keys: BTreeSet<String>,
133    reported_terms: BTreeSet<String>,
134}
135
136impl GlossaryEdit {
137    /// Whether a term or citation key was renamed or removed, so page text may cite or use it.
138    pub fn affects_pages(&self) -> bool {
139        !self.renames.is_empty()
140            || !self.reported_keys.is_empty()
141            || !self.reported_terms.is_empty()
142    }
143
144    /// `pages` should hold every course page so renames reach all citations; it may be empty when
145    /// [GlossaryEdit::affects_pages] is false.
146    pub fn with_page_effects(self, pages: &[DocumentRef]) -> GlossaryReferencesResult {
147        let (rewritten_pages, usages) = page_effects(
148            pages,
149            &self.renames,
150            &self.reported_keys,
151            &self.reported_terms,
152        );
153        GlossaryReferencesResult {
154            document: self.document,
155            created: self.created,
156            rewritten_pages,
157            usages,
158        }
159    }
160}
161
162/// Applies `ops` to `doc`; [GlossaryEdit::with_page_effects] then works out what it does to pages.
163pub fn apply(
164    doc: &GlossaryAndReferences,
165    ops: &[Op],
166    ids: &dyn IdGenerator,
167) -> Result<GlossaryEdit, EditError> {
168    let mut next = doc.clone();
169    let mut ids = CreatedIdLog::new(ids);
170    for (index, op) in ops.iter().enumerate() {
171        ids.set_op(index);
172        apply_op(&mut next, op, &mut ids).map_err(|e| e.at_op(index))?;
173    }
174    validate(doc, &next)?;
175
176    let after_terms: HashMap<Uuid, &str> = next
177        .glossary
178        .iter()
179        .map(|t| (t.id, t.term.as_str()))
180        .collect();
181    let after_keys: HashMap<Uuid, &str> = next
182        .references
183        .iter()
184        .map(|r| (r.id, r.citation_key.as_str()))
185        .collect();
186
187    let mut renames: Vec<(String, String)> = Vec::new();
188    let mut reported_keys: BTreeSet<String> = BTreeSet::new();
189    for reference in &doc.references {
190        let old = &reference.citation_key;
191        match after_keys.get(&reference.id) {
192            Some(new) if new != old => renames.push((old.clone(), new.to_string())),
193            Some(_) => {}
194            None => {
195                reported_keys.insert(old.clone());
196            }
197        }
198    }
199    let reported_terms: BTreeSet<String> = doc
200        .glossary
201        .iter()
202        .filter(|term| after_terms.get(&term.id) != Some(&term.term.as_str()))
203        .map(|term| term.term.clone())
204        .collect();
205
206    Ok(GlossaryEdit {
207        document: next,
208        created: ids.into_created(),
209        renames,
210        reported_keys,
211        reported_terms,
212    })
213}
214
215/// The pages whose citations `renames` rewrites, and the pages that use a reported key or term.
216fn page_effects(
217    pages: &[DocumentRef],
218    renames: &[(String, String)],
219    reported_keys: &BTreeSet<String>,
220    reported_terms: &BTreeSet<String>,
221) -> (Vec<Document>, Vec<Usage>) {
222    let mut rewritten_pages = Vec::new();
223    let mut usages = Vec::new();
224    if renames.is_empty() && reported_keys.is_empty() && reported_terms.is_empty() {
225        return (rewritten_pages, usages);
226    }
227    for page in pages {
228        let mut cited: BTreeSet<String> = BTreeSet::new();
229        let mut texts: Vec<String> = Vec::new();
230        blocks::walk_outside_exercises(page.blocks, &mut |block, _| {
231            blocks::for_each_string(&block.attributes, &mut |text| {
232                if !renames.is_empty() || !reported_keys.is_empty() {
233                    cited.extend(citation_keys(text));
234                }
235                if !reported_terms.is_empty() {
236                    texts.push(plain_text(text).into_owned());
237                }
238            });
239        });
240        for key in cited.intersection(reported_keys) {
241            usages.push(Usage {
242                document: page.key,
243                kind: UsageKind::CitationKey,
244                value: key.clone(),
245            });
246        }
247        for term in reported_terms {
248            if texts.iter().any(|t| uses_term(t, term)) {
249                usages.push(Usage {
250                    document: page.key,
251                    kind: UsageKind::GlossaryTerm,
252                    value: term.clone(),
253                });
254            }
255        }
256        if renames.iter().any(|(old, _)| cited.contains(old)) {
257            let mut page = page.to_document();
258            blocks::walk_mut_outside_exercises(&mut page.blocks, &mut |block| {
259                blocks::for_each_string_mut(&mut block.attributes, &mut |text| {
260                    *text = rewrite_citations(text, renames);
261                });
262            });
263            rewritten_pages.push(page);
264        }
265    }
266    (rewritten_pages, usages)
267}
268
269fn apply_op(
270    doc: &mut GlossaryAndReferences,
271    op: &Op,
272    ids: &mut CreatedIdLog,
273) -> Result<(), EditError> {
274    let segments = split_path(op.path()).ok_or_else(|| {
275        EditError::new(
276            EditErrorCode::InvalidPath,
277            "paths look like /glossary, /references or /<id>/term",
278        )
279    })?;
280    let segments: Vec<&str> = segments.iter().map(String::as_str).collect();
281    match (op, segments.as_slice()) {
282        (Op::Add { value, .. }, ["glossary"]) => {
283            for object in new_item_objects(
284                value,
285                "a glossary term",
286                "a glossary term is {\"term\":\"…\",\"definition\":\"…\"}",
287                &TERM_FIELDS,
288            )? {
289                let id = ids.new_id();
290                doc.glossary.push(GlossaryTerm {
291                    id,
292                    term: non_empty_string(object.get("term"), "term")?,
293                    definition: non_empty_string(object.get("definition"), "definition")?,
294                });
295            }
296            Ok(())
297        }
298        (Op::Add { value, .. }, ["references"]) => {
299            for object in new_item_objects(
300                value,
301                "a reference",
302                "a reference is {\"citation_key\":\"…\",\"bibtex\":\"@book{…}\"}",
303                &REFERENCE_FIELDS,
304            )? {
305                let id = ids.new_id();
306                doc.references.push(Reference {
307                    id,
308                    citation_key: non_empty_string(object.get("citation_key"), "citation_key")?,
309                    bibtex: non_empty_string(object.get("bibtex"), "bibtex")?,
310                });
311            }
312            Ok(())
313        }
314        (Op::Add { .. }, _) => Err(EditError::new(
315            EditErrorCode::InvalidPath,
316            "add new items to /glossary or /references; the lists are unordered",
317        )),
318        (_, ["glossary" | "references", ..]) => Err(EditError::numeric_index(
319            op.path(),
320            "glossary terms and references",
321        )
322        .with_fix("use /<id> or /<id>/term")),
323        (_, [raw_id, field @ ..]) => {
324            let id = parse_uuid(raw_id, op.path())?;
325            if let Some(position) = doc.glossary.iter().position(|t| t.id == id) {
326                let edit = entry_edit(op, field, &TERM_FIELDS, "a glossary term")?;
327                return edit_entry(&mut doc.glossary, position, edit, |term, field, value| {
328                    match field {
329                        "term" => term.term = value,
330                        "definition" => term.definition = value,
331                        _ => return Err(unknown_entry_field(&TERM_FIELDS)),
332                    }
333                    Ok(())
334                });
335            }
336            if let Some(position) = doc.references.iter().position(|r| r.id == id) {
337                let edit = entry_edit(op, field, &REFERENCE_FIELDS, "a reference")?;
338                return edit_entry(
339                    &mut doc.references,
340                    position,
341                    edit,
342                    |reference, field, value| {
343                        match field {
344                            "citation_key" => reference.citation_key = value,
345                            "bibtex" => reference.bibtex = value,
346                            _ => return Err(unknown_entry_field(&REFERENCE_FIELDS)),
347                        }
348                        Ok(())
349                    },
350                );
351            }
352            Err(EditError::new(
353                EditErrorCode::NotFound,
354                format!("no glossary term or reference {id}"),
355            )
356            .with_fix("get_course_glossary_and_references lists the ids"))
357        }
358        _ => Err(EditError::new(EditErrorCode::InvalidPath, "empty path")),
359    }
360}
361
362/// What a `remove` or `replace` does to one glossary term or reference.
363enum EntryEdit {
364    Remove,
365    /// Field names and their new values.
366    Set(Vec<(&'static str, String)>),
367}
368
369/// The edit `op` makes at `field` (the path after the entry's id) of an entry with `fields`.
370fn entry_edit(
371    op: &Op,
372    field: &[&str],
373    fields: &[&'static str],
374    noun: &str,
375) -> Result<EntryEdit, EditError> {
376    match (op, field) {
377        (Op::Remove { .. }, []) => Ok(EntryEdit::Remove),
378        (Op::Replace { value, .. }, [name]) => match fields.iter().find(|f| *f == name) {
379            Some(field) => Ok(EntryEdit::Set(vec![(
380                *field,
381                non_empty_string(Some(value), field)?,
382            )])),
383            None => Err(unknown_entry_field(fields)),
384        },
385        (Op::Replace { value, .. }, []) => {
386            let object = value.as_object().ok_or_else(|| {
387                EditError::new(
388                    EditErrorCode::InvalidValue,
389                    "replace takes an object of fields",
390                )
391            })?;
392            let allowed: Vec<&str> = fields.iter().copied().chain(["id"]).collect();
393            reject_unknown_keys(object, &allowed, noun)?;
394            let mut set = Vec::new();
395            for name in fields {
396                if let Some(v) = object.get(*name) {
397                    set.push((*name, non_empty_string(Some(v), name)?));
398                }
399            }
400            Ok(EntryEdit::Set(set))
401        }
402        (Op::Remove { .. }, [name]) if fields.contains(name) => Err(EditError::new(
403            EditErrorCode::InvalidValue,
404            format!("{name} can't be empty; remove the whole item with /<id>"),
405        )),
406        _ => Err(EditError::new(
407            EditErrorCode::UnknownField,
408            format!("fields: {}", fields.join(", ")),
409        )),
410    }
411}
412
413/// Applies `edit` to `entries[position]`; `set` writes one named field.
414fn edit_entry<T>(
415    entries: &mut Vec<T>,
416    position: usize,
417    edit: EntryEdit,
418    set: impl Fn(&mut T, &str, String) -> Result<(), EditError>,
419) -> Result<(), EditError> {
420    match edit {
421        EntryEdit::Remove => {
422            entries.remove(position);
423        }
424        EntryEdit::Set(fields) => {
425            if let Some(entry) = entries.get_mut(position) {
426                for (field, value) in fields {
427                    set(entry, field, value)?;
428                }
429            }
430        }
431    }
432    Ok(())
433}
434
435/// A field an entry with `fields` doesn't have.
436fn unknown_entry_field(fields: &[&str]) -> EditError {
437    EditError::new(
438        EditErrorCode::UnknownField,
439        format!("fields: {}", fields.join(", ")),
440    )
441}
442
443/// Checks the terms and references of `doc` that differ from `before`: entries the call left alone
444/// may predate these rules, and mustn't block unrelated edits.
445fn validate(before: &GlossaryAndReferences, doc: &GlossaryAndReferences) -> Result<(), EditError> {
446    let is_touched = |term: &GlossaryTerm| !before.glossary.contains(term);
447    let mut terms: HashMap<String, &GlossaryTerm> = HashMap::new();
448    for term in &doc.glossary {
449        if let Some(other) = terms.insert(term.term.trim().to_lowercase(), term)
450            && (is_touched(term) || is_touched(other))
451        {
452            return Err(EditError::new(
453                EditErrorCode::DuplicateTerm,
454                format!(
455                    "the glossary would have \"{}\" twice ({} and {})",
456                    term.term, other.id, term.id
457                ),
458            ));
459        }
460    }
461    let mut keys: HashMap<&str, Uuid> = HashMap::new();
462    for reference in &doc.references {
463        if let Some(other) = keys.insert(reference.citation_key.as_str(), reference.id) {
464            return Err(EditError::new(
465                EditErrorCode::CitationKey,
466                format!(
467                    "citation key \"{}\" would be used by both {other} and {}",
468                    reference.citation_key, reference.id
469                ),
470            ));
471        }
472        if before.references.contains(reference) {
473            continue;
474        }
475        let entry_key = bibtex_entry_key(&reference.bibtex).map_err(|reason| {
476            EditError::new(
477                EditErrorCode::InvalidBibtex,
478                format!(
479                    "the BibTeX of \"{}\" doesn't parse: {reason}",
480                    reference.citation_key
481                ),
482            )
483        })?;
484        if entry_key != reference.citation_key {
485            return Err(EditError::new(
486                EditErrorCode::CitationKey,
487                format!(
488                    "citation_key \"{}\" differs from the BibTeX entry key \"{entry_key}\"",
489                    reference.citation_key
490                ),
491            )
492            .with_fix("change both together in one call"));
493        }
494    }
495    Ok(())
496}
497
498static CITE: LazyLock<Regex> = LazyLock::new(|| {
499    Regex::new(r"\\cite((?:\[[^\]]*\]){0,2})\{(.*?)\}")
500        .expect("a constant pattern the unit tests compile")
501});
502
503/// The renderer hands `[latex]…[/latex]` to KaTeX before it looks for citations, so a `\cite` in
504/// there is no citation.
505static LATEX: LazyLock<Regex> = LazyLock::new(|| {
506    Regex::new(r"\[latex\].*?\[/latex\]").expect("a constant pattern the unit tests compile")
507});
508
509/// The keys of the citations the renderer shows. Rich text stores keys HTML-escaped.
510fn citation_keys(text: &str) -> Vec<String> {
511    let outside_latex = LATEX.replace_all(text, "");
512    CITE.captures_iter(&outside_latex)
513        .map(|c| decode_html_entities(&c[2]).into_owned())
514        .collect()
515}
516
517fn rewrite_citations(text: &str, renames: &[(String, String)]) -> String {
518    let rewrite = |segment: &str| {
519        CITE.replace_all(segment, |caps: &regex::Captures| {
520            let key = decode_html_entities(&caps[2]);
521            match renames.iter().find(|(old, _)| *old == key) {
522                Some((_, new)) => format!("\\cite{}{{{}}}", &caps[1], escape_html_text(new)),
523                None => caps[0].to_string(),
524            }
525        })
526        .into_owned()
527    };
528    let mut rewritten = String::with_capacity(text.len());
529    let mut rest_start = 0;
530    for latex in LATEX.find_iter(text) {
531        rewritten.push_str(&rewrite(&text[rest_start..latex.start()]));
532        rewritten.push_str(latex.as_str());
533        rest_start = latex.end();
534    }
535    rewritten.push_str(&rewrite(&text[rest_start..]));
536    rewritten
537}
538
539/// Whole-word and case-sensitive, as the renderer highlights glossary terms.
540fn uses_term(text: &str, term: &str) -> bool {
541    !term.is_empty() && whole_word_matches(text, term).next().is_some()
542}
543
544#[cfg(test)]
545mod tests {
546    use pretty_assertions::assert_eq;
547    use serde_json::{Value, json};
548
549    use super::*;
550    use crate::engine::op::PatchTarget;
551    use crate::engine::{PageDocument, PageType};
552    use crate::ids::SequentialIds;
553    use crate::test_fixtures::{document_refs, id, p, page, para};
554
555    fn sample() -> GlossaryAndReferences {
556        GlossaryAndReferences {
557            glossary: vec![GlossaryTerm {
558                id: id("g1"),
559                term: "muuttuja".into(),
560                definition: "A named value".into(),
561            }],
562            references: vec![Reference {
563                id: id("r1"),
564                citation_key: "knuth1997".into(),
565                bibtex: "@book{knuth1997, title = {The Art}, year = {1997}}".into(),
566            }],
567        }
568    }
569
570    /// Cases main-frontend's renderer tests run too (tests/rendererTextCases.test.ts).
571    #[test]
572    fn citations_and_terms_are_found_as_the_renderer_finds_them() {
573        let cases: Value =
574            serde_json::from_str(include_str!("renderer_text_cases.json")).expect("valid JSON");
575        for case in cases["citations"].as_array().expect("citation cases") {
576            let text = case["text"].as_str().expect("a text");
577            assert_eq!(json!(citation_keys(text)), case["keys"], "{text}");
578        }
579        assert_term_cases(&cases["terms"], "is_used");
580        assert_term_cases(&cases["term_divergences"], "mcp");
581    }
582
583    fn assert_term_cases(cases: &Value, expected_field: &str) {
584        for case in cases.as_array().expect("term cases") {
585            let text = case["text"].as_str().expect("a text");
586            let term = case["term"].as_str().expect("a term");
587            assert_eq!(
588                uses_term(text, term),
589                case[expected_field],
590                "{term} in {text}"
591            );
592        }
593    }
594
595    #[test]
596    fn renaming_skips_latex_and_keeps_keys_escaped() {
597        let renames = [("a&b".to_string(), "c&d".to_string())];
598        assert_eq!(
599            rewrite_citations(
600                r"[latex]\cite{a&amp;b}[/latex] \cite[p. 1]{a&amp;b}",
601                &renames
602            ),
603            r"[latex]\cite{a&amp;b}[/latex] \cite[p. 1]{c&amp;d}"
604        );
605    }
606
607    fn pages() -> Vec<PageDocument> {
608        vec![
609            page(
610                "pg1",
611                PageType::CoursePage,
612                vec![para(
613                    "p1",
614                    "As shown \\cite[p. 3]{knuth1997}, a muuttuja holds a value.",
615                )],
616            ),
617            page(
618                "pg2",
619                PageType::CoursePage,
620                vec![para("p2", "Nothing here.")],
621            ),
622        ]
623    }
624
625    fn run(ops: Value) -> Result<GlossaryReferencesResult, EditError> {
626        let ops = crate::test_fixtures::ops(ops, PatchTarget::GlossaryAndReferences)?;
627        let ids = SequentialIds::default();
628        apply(&sample(), &ops, &ids).map(|edit| edit.with_page_effects(&document_refs(&pages())))
629    }
630
631    #[test]
632    fn several_items_are_added_from_a_list() {
633        let result = run(json!([
634            {"op":"add","path":"/glossary","value":[{"term":"a","definition":"A"},{"term":"b","definition":"B"}]},
635            {"op":"add","path":"/references","value":[{"citation_key":"x1","bibtex":"@misc{x1, title={X}}"}]},
636        ]))
637        .unwrap();
638        assert_eq!(result.document.glossary.len(), 3);
639        assert_eq!(result.document.references.len(), 2);
640    }
641
642    #[test]
643    fn terms_are_whole_words_even_with_symbols() {
644        assert!(uses_term("C++ is a language", "C++"));
645        assert!(uses_term("Learn .NET today", ".NET"));
646        assert!(!uses_term("Learn .NETwork", ".NET"));
647        assert!(uses_term("a muuttuja.", "muuttuja"));
648        assert!(!uses_term("muuttujat", "muuttuja"));
649    }
650
651    #[test]
652    fn parenthesized_entries_may_hold_parentheses() {
653        assert_eq!(
654            bibtex_entry_key(r#"@misc(k, note = "see (x) here")"#).unwrap(),
655            "k"
656        );
657        assert_eq!(bibtex_entry_key("@misc(k, note = {a (b) c})").unwrap(), "k");
658        assert!(bibtex_entry_key("@misc(k, note = {x}) @misc(j, t={y})").is_err());
659    }
660
661    #[test]
662    fn citation_renames_leave_exercise_content_alone() {
663        let ops = crate::test_fixtures::ops(
664            json!([{"op":"replace","path":format!("{}/citation_key", p("r1")),"value":"knuth98"}]),
665            PatchTarget::GlossaryAndReferences,
666        )
667        .unwrap();
668        let ids = SequentialIds::default();
669        let pages = vec![page(
670            "pg1",
671            PageType::CoursePage,
672            vec![
673                para("p1", "\\cite{knuth1997}"),
674                crate::test_fixtures::block(
675                    "ex",
676                    blocks::EXERCISE_BLOCK,
677                    json!({"id": id("e1").to_string()}),
678                    vec![para("inside", "\\cite{knuth1997}")],
679                ),
680            ],
681        )];
682        let mut renamed = sample();
683        renamed.references[0].bibtex = "@book{knuth98, title = {The Art}}".into();
684        let result = apply(&renamed, &ops, &ids)
685            .unwrap()
686            .with_page_effects(&document_refs(&pages));
687        let blocks = &result.rewritten_pages[0].blocks;
688        assert_eq!(blocks[0].attributes["content"], json!("\\cite{knuth98}"));
689        assert_eq!(
690            blocks[1].inner_blocks[0].attributes["content"],
691            json!("\\cite{knuth1997}")
692        );
693    }
694
695    #[test]
696    fn add_replace_remove_terms() {
697        let result = run(json!([
698            {"op":"add","path":"/glossary","value":{"term":"silmukka","definition":"A loop"}},
699            {"op":"replace","path":format!("{}/definition", p("g1")),"value":"A named slot"},
700        ]))
701        .unwrap();
702        assert_eq!(result.document.glossary.len(), 2);
703        assert_eq!(result.document.glossary[0].definition, "A named slot");
704        assert!(result.usages.is_empty());
705    }
706
707    #[test]
708    fn renaming_or_removing_a_term_reports_pages() {
709        let result = run(json!([{"op":"remove","path":p("g1")}])).unwrap();
710        assert_eq!(
711            result.usages,
712            vec![Usage {
713                document: DocumentKey::Page(id("pg1")),
714                kind: UsageKind::GlossaryTerm,
715                value: "muuttuja".into()
716            }]
717        );
718        assert!(result.rewritten_pages.is_empty());
719    }
720
721    #[test]
722    fn citation_key_rename_rewrites_citations() {
723        let result = run(json!([
724            {"op":"replace","path":p("r1"),"value":{"citation_key":"knuth97","bibtex":"@book{knuth97, title = {The Art}}"}},
725        ]))
726        .unwrap();
727        assert_eq!(result.rewritten_pages.len(), 1);
728        assert_eq!(
729            result.rewritten_pages[0].blocks[0].attributes["content"],
730            json!("As shown \\cite[p. 3]{knuth97}, a muuttuja holds a value.")
731        );
732    }
733
734    #[test]
735    fn key_must_match_bibtex_and_be_unique() {
736        let mismatch = run(
737            json!([{"op":"replace","path":format!("{}/citation_key", p("r1")),"value":"other"}]),
738        )
739        .unwrap_err();
740        assert_eq!(mismatch.code, EditErrorCode::CitationKey);
741        let duplicate = run(json!([{"op":"add","path":"/references","value":{"citation_key":"knuth1997","bibtex":"@misc{knuth1997, title={x}}"}}])).unwrap_err();
742        assert_eq!(duplicate.code, EditErrorCode::CitationKey);
743        let broken = run(json!([{"op":"add","path":"/references","value":{"citation_key":"x","bibtex":"@misc{x, title={unclosed}"}}])).unwrap_err();
744        assert_eq!(broken.code, EditErrorCode::InvalidBibtex);
745    }
746
747    #[test]
748    fn removing_a_reference_keeps_citations_and_reports() {
749        let result = run(json!([{"op":"remove","path":p("r1")}])).unwrap();
750        assert!(result.rewritten_pages.is_empty());
751        assert_eq!(result.usages[0].kind, UsageKind::CitationKey);
752    }
753
754    #[test]
755    fn basic_bibtex_parser() {
756        assert_eq!(
757            bibtex_entry_key("@article{a1, title={X {nested}}}").unwrap(),
758            "a1"
759        );
760        assert!(bibtex_entry_key("@article{a1, x={1}} @book{b, y={2}}").is_err());
761        assert!(bibtex_entry_key("not bibtex").is_err());
762    }
763}