1use 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#[derive(Debug, Clone, PartialEq, Serialize)]
28pub struct GlossaryAndReferences {
29 pub glossary: Vec<GlossaryTerm>,
30 pub references: Vec<Reference>,
31}
32
33#[derive(Debug, Clone, PartialEq, Serialize)]
35pub struct GlossaryTerm {
36 pub id: Uuid,
37 pub term: String,
38 pub definition: String,
39}
40
41#[derive(Debug, Clone, PartialEq, Serialize)]
43pub struct Reference {
44 pub id: Uuid,
45 pub citation_key: String,
46 pub bibtex: String,
48}
49
50fn 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 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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
105#[serde(rename_all = "snake_case")]
106pub enum UsageKind {
107 GlossaryTerm,
108 CitationKey,
109}
110
111#[derive(Debug, Clone, PartialEq)]
113pub struct GlossaryReferencesResult {
114 pub document: GlossaryAndReferences,
115 pub created: Vec<CreatedIds>,
116 pub rewritten_pages: Vec<Document>,
118 pub usages: Vec<Usage>,
119}
120
121pub(crate) const TERM_FIELDS: [&str; 2] = ["term", "definition"];
123pub(crate) const REFERENCE_FIELDS: [&str; 2] = ["citation_key", "bibtex"];
125
126#[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 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 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
162pub 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
215fn 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
362enum EntryEdit {
364 Remove,
365 Set(Vec<(&'static str, String)>),
367}
368
369fn 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
413fn 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
435fn unknown_entry_field(fields: &[&str]) -> EditError {
437 EditError::new(
438 EditErrorCode::UnknownField,
439 format!("fields: {}", fields.join(", ")),
440 )
441}
442
443fn 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
503static LATEX: LazyLock<Regex> = LazyLock::new(|| {
506 Regex::new(r"\[latex\].*?\[/latex\]").expect("a constant pattern the unit tests compile")
507});
508
509fn 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: ®ex::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
539fn 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 #[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&b}[/latex] \cite[p. 1]{a&b}",
601 &renames
602 ),
603 r"[latex]\cite{a&b}[/latex] \cite[p. 1]{c&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}