Skip to main content

repo/
repository_context.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Context annotation helpers for attaching metadata to file and state targets.
3
4use std::{
5    collections::BTreeMap,
6    path::{Path, PathBuf},
7};
8
9use objects::{
10    object::{
11        Annotation, AnnotationScope, Blob, ContentHash, ContextBlob, ContextTarget, EntryType,
12        State, Tree, TreeEntry,
13    },
14    store::ObjectStore,
15};
16
17use super::{HeddleError, Repository, Result};
18
19#[derive(Clone, Debug, PartialEq, Eq)]
20pub struct ContextEntry {
21    pub target: ContextTarget,
22    pub blob: ContextBlob,
23}
24
25impl Repository {
26    /// Get the context blob for a target from the given state's context tree.
27    pub fn get_context_blob(
28        &self,
29        context_root: &ContentHash,
30        target: &ContextTarget,
31    ) -> Result<Option<ContextBlob>> {
32        let Some(blob_hash) = self.lookup_context_leaf_for_target(context_root, target)? else {
33            return Ok(None);
34        };
35        let Some(blob) = self.store.get_blob(&blob_hash)? else {
36            return Ok(None);
37        };
38        ContextBlob::decode(blob.content())
39            .map(Some)
40            .map_err(|e| HeddleError::InvalidObject(format!("invalid context blob: {e}")))
41    }
42
43    /// Store a context blob at a target, returning the new context tree root hash.
44    ///
45    /// If `context_root` is None, creates a new context tree from scratch.
46    pub fn set_context_blob(
47        &self,
48        context_root: Option<&ContentHash>,
49        target: &ContextTarget,
50        blob: &ContextBlob,
51    ) -> Result<ContentHash> {
52        let bytes = blob
53            .encode()
54            .map_err(|e| HeddleError::InvalidObject(format!("encode context: {e}")))?;
55        let blob_hash = self.store.put_blob(&Blob::new(bytes))?;
56
57        let current_tree = match context_root {
58            Some(root) => self.require_tree(root)?,
59            None => Tree::new(),
60        };
61
62        let mut root_hash =
63            self.insert_leaf_at_path(&current_tree, &target.storage_path(), blob_hash)?;
64
65        if let (Some(existing_root), Some(legacy_path)) =
66            (context_root, target.legacy_storage_path())
67            && legacy_path != target.storage_path()
68            && self
69                .lookup_context_leaf(existing_root, &legacy_path)?
70                .is_some()
71        {
72            root_hash = self
73                .remove_leaf_at_path(&root_hash, &legacy_path)?
74                .unwrap_or(root_hash);
75        }
76
77        Ok(root_hash)
78    }
79
80    /// Remove context at a target (optionally filtered by scope).
81    ///
82    /// Returns the new context tree root, or None if the tree is now empty.
83    pub fn remove_context_at_target(
84        &self,
85        context_root: &ContentHash,
86        target: &ContextTarget,
87        scope: Option<&AnnotationScope>,
88    ) -> Result<Option<ContentHash>> {
89        if let Some(scope) = scope {
90            if let Some(mut blob) = self.get_context_blob(context_root, target)? {
91                blob.annotations.retain(|a| !a.scope.matches(scope));
92                if blob.annotations.is_empty() {
93                    return self.remove_context_target(context_root, target);
94                }
95                let new_root = self.set_context_blob(Some(context_root), target, &blob)?;
96                return Ok(Some(new_root));
97            }
98            return Ok(Some(*context_root));
99        }
100
101        self.remove_context_target(context_root, target)
102    }
103
104    pub fn remove_context_target(
105        &self,
106        context_root: &ContentHash,
107        target: &ContextTarget,
108    ) -> Result<Option<ContentHash>> {
109        let mut current = self.remove_leaf_at_path(context_root, &target.storage_path())?;
110        if current.is_none()
111            && let Some(legacy_path) = target.legacy_storage_path()
112        {
113            current = self.remove_leaf_at_path(context_root, &legacy_path)?;
114        }
115        Ok(current)
116    }
117
118    /// List all context entries in the tree, optionally filtered by file prefix.
119    pub fn list_context_entries(
120        &self,
121        context_root: &ContentHash,
122        prefix: Option<&Path>,
123    ) -> Result<Vec<ContextEntry>> {
124        let tree = match self.store.get_tree(context_root)? {
125            Some(t) => t,
126            None => return Ok(Vec::new()),
127        };
128        let mut results = BTreeMap::new();
129        self.walk_context_tree(&tree, &PathBuf::new(), prefix, &mut results)?;
130        Ok(results
131            .into_iter()
132            .map(|(_, (target, blob))| ContextEntry { target, blob })
133            .collect())
134    }
135
136    pub fn find_annotation(
137        &self,
138        context_root: &ContentHash,
139        annotation_id: &str,
140    ) -> Result<Option<(ContextTarget, ContextBlob, usize)>> {
141        for entry in self.list_context_entries(context_root, None)? {
142            if let Some(index) = entry
143                .blob
144                .annotations
145                .iter()
146                .position(|annotation| annotation.annotation_id == annotation_id)
147            {
148                return Ok(Some((entry.target, entry.blob, index)));
149            }
150        }
151        Ok(None)
152    }
153
154    // --- private helpers ---
155
156    fn lookup_context_leaf_for_target(
157        &self,
158        root: &ContentHash,
159        target: &ContextTarget,
160    ) -> Result<Option<ContentHash>> {
161        let storage_path = target.storage_path();
162        if let Some(hash) = self.lookup_context_leaf(root, &storage_path)? {
163            return Ok(Some(hash));
164        }
165        if let Some(legacy_path) = target.legacy_storage_path() {
166            return self.lookup_context_leaf(root, &legacy_path);
167        }
168        Ok(None)
169    }
170
171    fn lookup_context_leaf(&self, root: &ContentHash, path: &Path) -> Result<Option<ContentHash>> {
172        let Some((name, rest)) = split_path(path) else {
173            return Ok(None);
174        };
175        let Some(tree) = self.store.get_tree(root)? else {
176            return Ok(None);
177        };
178        let Some(entry) = tree.get(name) else {
179            return Ok(None);
180        };
181        if rest.as_os_str().is_empty() {
182            return Ok(entry.is_blob().then_some(entry.hash));
183        }
184        if !entry.is_tree() {
185            return Ok(None);
186        }
187        self.lookup_context_leaf(&entry.hash, rest)
188    }
189
190    fn insert_leaf_at_path(
191        &self,
192        tree: &Tree,
193        path: &Path,
194        blob_hash: ContentHash,
195    ) -> Result<ContentHash> {
196        let Some((name, rest)) = split_path(path) else {
197            return Err(HeddleError::InvalidObject("empty path".to_string()));
198        };
199
200        let mut new_tree = tree.clone();
201
202        if rest.as_os_str().is_empty() {
203            new_tree.insert(TreeEntry::file(name, blob_hash, false)?);
204        } else {
205            let subtree = tree
206                .get(name)
207                .filter(|e| e.is_tree())
208                .and_then(|e| self.store.get_tree(&e.hash).ok().flatten())
209                .unwrap_or_default();
210
211            let sub_hash = self.insert_leaf_at_path(&subtree, rest, blob_hash)?;
212            new_tree.insert(TreeEntry::directory(name, sub_hash)?);
213        }
214
215        self.store.put_tree(&new_tree)
216    }
217
218    fn remove_leaf_at_path(&self, root: &ContentHash, path: &Path) -> Result<Option<ContentHash>> {
219        let Some(tree) = self.store.get_tree(root)? else {
220            return Ok(None);
221        };
222        let Some((name, rest)) = split_path(path) else {
223            return Ok(None);
224        };
225
226        let mut new_tree = tree.clone();
227
228        if rest.as_os_str().is_empty() {
229            new_tree.remove(name);
230        } else {
231            let Some(entry) = tree.get(name) else {
232                return Ok(Some(*root));
233            };
234            if !entry.is_tree() {
235                return Ok(Some(*root));
236            }
237            match self.remove_leaf_at_path(&entry.hash, rest)? {
238                Some(sub_hash) => {
239                    new_tree.insert(TreeEntry::directory(name, sub_hash)?);
240                }
241                None => {
242                    new_tree.remove(name);
243                }
244            }
245        }
246
247        if new_tree.is_empty() {
248            Ok(None)
249        } else {
250            Ok(Some(self.store.put_tree(&new_tree)?))
251        }
252    }
253
254    fn walk_context_tree(
255        &self,
256        tree: &Tree,
257        current_path: &Path,
258        prefix: Option<&Path>,
259        results: &mut BTreeMap<String, (ContextTarget, ContextBlob)>,
260    ) -> Result<()> {
261        for entry in tree.entries() {
262            let entry_path = current_path.join(&entry.name);
263            match entry.entry_type {
264                EntryType::Tree => {
265                    if let Some(prefix) = prefix
266                        && !prefix.starts_with(&entry_path)
267                        && !entry_path.starts_with(prefix)
268                        && !entry_path.starts_with("__files")
269                        && !entry_path.starts_with("__states")
270                    {
271                        continue;
272                    }
273                    if let Some(subtree) = self.store.get_tree(&entry.hash)? {
274                        self.walk_context_tree(&subtree, &entry_path, prefix, results)?;
275                    }
276                }
277                EntryType::Blob => {
278                    let Some(target) = ContextTarget::from_storage_path(&entry_path) else {
279                        continue;
280                    };
281                    if let Some(prefix) = prefix
282                        && let Some(path) = target.path()
283                        && !Path::new(path).starts_with(prefix)
284                    {
285                        continue;
286                    }
287                    if let Some(blob) = self.store.get_blob(&entry.hash)?
288                        && let Ok(context) = ContextBlob::decode(blob.content())
289                    {
290                        results.insert(context_entry_key(&target), (target, context));
291                    }
292                }
293                EntryType::Symlink => {}
294            }
295        }
296        Ok(())
297    }
298
299    /// Carry a single parent state's context tree forward onto a new
300    /// snapshot. Because context trees are content-addressed, this is a
301    /// pointer copy: the new state's `context` field gets the same
302    /// `ContentHash` as the parent. Annotations attached upstream remain
303    /// active at the new state, and the existing on-demand staleness check
304    /// (which compares the stored `source_hash` against the current bytes
305    /// at the anchor) naturally reports drift caused by the new tree.
306    ///
307    /// Returns `None` when the parent has no context tree.
308    pub fn inherit_parent_context(parent: &State) -> Option<ContentHash> {
309        parent.context
310    }
311
312    /// Build a unioned context tree across multiple parent states for a
313    /// merge snapshot. Annotations from every parent appear in the result;
314    /// when the same `annotation_id` is present on more than one parent the
315    /// revision with the latest `created_at` wins (with a stable tiebreak
316    /// on revision_id so the merge stays deterministic).
317    ///
318    /// Targets that only exist on one side propagate unchanged (single-blob
319    /// pointer copy via the existing tree). Targets present on both sides
320    /// are merged blob-by-blob: annotations are deduped by id, the per-id
321    /// revisions are picked by latest-`created_at`, and the resulting blob
322    /// is rewritten via `set_context_blob`.
323    ///
324    /// Returns `None` when none of the parents has any context.
325    pub fn union_parent_contexts(&self, parents: &[&State]) -> Result<Option<ContentHash>> {
326        // Fast paths: nothing or single-parent.
327        let mut roots: Vec<ContentHash> = parents.iter().filter_map(|p| p.context).collect();
328        if roots.is_empty() {
329            return Ok(None);
330        }
331        if roots.len() == 1 {
332            return Ok(Some(roots.pop().expect("len == 1")));
333        }
334        if roots.iter().all(|r| *r == roots[0]) {
335            // All parents pointed at the same context tree; pointer copy.
336            return Ok(Some(roots[0]));
337        }
338
339        // Walk every parent and merge by `context_entry_key`. Each entry's
340        // blob gets unioned into a running map; ties are broken by the
341        // revision-comparator below.
342        let mut merged: BTreeMap<String, (ContextTarget, ContextBlob)> = BTreeMap::new();
343        for parent_root in &roots {
344            for entry in self.list_context_entries(parent_root, None)? {
345                let key = context_entry_key(&entry.target);
346                match merged.remove(&key) {
347                    None => {
348                        merged.insert(key, (entry.target, entry.blob));
349                    }
350                    Some((target, existing)) => {
351                        let merged_blob = merge_context_blobs(existing, entry.blob);
352                        merged.insert(key, (target, merged_blob));
353                    }
354                }
355            }
356        }
357
358        if merged.is_empty() {
359            return Ok(None);
360        }
361
362        // Rebuild the tree from scratch by writing each blob.
363        let mut root: Option<ContentHash> = None;
364        for (_, (target, blob)) in merged {
365            if blob.annotations.is_empty() {
366                continue;
367            }
368            let new_root = self.set_context_blob(root.as_ref(), &target, &blob)?;
369            root = Some(new_root);
370        }
371
372        Ok(root)
373    }
374}
375
376/// Merge two `ContextBlob`s by unioning their annotations on
377/// `annotation_id`. When an id appears in both, the annotation with the
378/// later current-revision `created_at` wins; ties are broken by
379/// `revision_id` to keep the result deterministic. The resulting blob
380/// preserves `format_version` from `left`.
381fn merge_context_blobs(left: ContextBlob, right: ContextBlob) -> ContextBlob {
382    let format_version = left.format_version;
383    let mut by_id: BTreeMap<String, Annotation> = BTreeMap::new();
384    for annotation in left.annotations.into_iter().chain(right.annotations) {
385        match by_id.remove(&annotation.annotation_id) {
386            None => {
387                by_id.insert(annotation.annotation_id.clone(), annotation);
388            }
389            Some(existing) => {
390                let winner = pick_newer_annotation(existing, annotation);
391                by_id.insert(winner.annotation_id.clone(), winner);
392            }
393        }
394    }
395    ContextBlob {
396        format_version,
397        annotations: by_id.into_values().collect(),
398    }
399}
400
401fn pick_newer_annotation(a: Annotation, b: Annotation) -> Annotation {
402    let ts_a = a
403        .current_revision()
404        .map(|r| r.created_at)
405        .unwrap_or(i64::MIN);
406    let ts_b = b
407        .current_revision()
408        .map(|r| r.created_at)
409        .unwrap_or(i64::MIN);
410    if ts_a > ts_b {
411        a
412    } else if ts_b > ts_a {
413        b
414    } else {
415        // Deterministic tiebreak on the revision_id of each side's current
416        // revision (lexicographic — revision_ids are stable strings).
417        let rev_a = a
418            .current_revision()
419            .map(|r| r.revision_id.as_str())
420            .unwrap_or("");
421        let rev_b = b
422            .current_revision()
423            .map(|r| r.revision_id.as_str())
424            .unwrap_or("");
425        if rev_a >= rev_b { a } else { b }
426    }
427}
428
429fn context_entry_key(target: &ContextTarget) -> String {
430    match target {
431        ContextTarget::File { path } => format!("file:{path}"),
432        ContextTarget::State { change_id } => format!("state:{}", change_id.to_string_full()),
433    }
434}
435
436fn split_path(path: &Path) -> Option<(&str, &Path)> {
437    let mut components = path.components();
438    let first = components.next()?;
439    let std::path::Component::Normal(name) = first else {
440        return None;
441    };
442    Some((name.to_str()?, components.as_path()))
443}
444
445#[cfg(test)]
446mod tests {
447    use objects::object::{Annotation, AnnotationKind, ChangeId};
448    use tempfile::TempDir;
449
450    use super::{Repository, *};
451
452    fn setup() -> (TempDir, Repository) {
453        let dir = TempDir::new().unwrap();
454        let repo = Repository::init_default(dir.path()).unwrap();
455        (dir, repo)
456    }
457
458    fn make_annotation(scope: AnnotationScope, content: &str) -> Annotation {
459        Annotation::new(
460            scope,
461            AnnotationKind::Rationale,
462            content.to_string(),
463            vec![],
464            "test@example.com".to_string(),
465            1700000000,
466            None,
467            None,
468        )
469    }
470
471    #[test]
472    fn get_and_set_context_blob_for_file_target() {
473        let (_dir, repo) = setup();
474        let target = ContextTarget::file("src/main.rs").unwrap();
475        let blob = ContextBlob::new(vec![make_annotation(AnnotationScope::File, "Entry point")]);
476
477        let root = repo.set_context_blob(None, &target, &blob).unwrap();
478        let retrieved = repo.get_context_blob(&root, &target).unwrap().unwrap();
479
480        assert_eq!(retrieved, blob);
481    }
482
483    #[test]
484    fn supports_state_targets() {
485        let (_dir, repo) = setup();
486        let target = ContextTarget::state(ChangeId::generate());
487        let blob = ContextBlob::new(vec![make_annotation(AnnotationScope::File, "review note")]);
488
489        let root = repo.set_context_blob(None, &target, &blob).unwrap();
490        let retrieved = repo.get_context_blob(&root, &target).unwrap().unwrap();
491        assert_eq!(retrieved, blob);
492    }
493
494    #[test]
495    fn remove_context_blob_by_scope() {
496        let (_dir, repo) = setup();
497        let target = ContextTarget::file("src/lib.rs").unwrap();
498        let blob = ContextBlob::new(vec![
499            make_annotation(AnnotationScope::File, "file-level"),
500            make_annotation(AnnotationScope::Lines(1, 10), "range-level"),
501        ]);
502
503        let root = repo.set_context_blob(None, &target, &blob).unwrap();
504        let new_root = repo
505            .remove_context_at_target(&root, &target, Some(&AnnotationScope::Lines(1, 10)))
506            .unwrap()
507            .unwrap();
508        let remaining = repo.get_context_blob(&new_root, &target).unwrap().unwrap();
509
510        assert_eq!(remaining.annotations.len(), 1);
511        assert_eq!(
512            remaining
513                .annotations
514                .first()
515                .unwrap()
516                .current_revision()
517                .unwrap()
518                .content,
519            "file-level"
520        );
521    }
522
523    #[test]
524    fn list_context_entries_filters_by_prefix() {
525        let (_dir, repo) = setup();
526        let target1 = ContextTarget::file("src/main.rs").unwrap();
527        let target2 = ContextTarget::file("src/lib.rs").unwrap();
528        let target3 = ContextTarget::file("tests/test.rs").unwrap();
529        let blob1 = ContextBlob::new(vec![make_annotation(AnnotationScope::File, "first")]);
530        let blob2 = ContextBlob::new(vec![make_annotation(AnnotationScope::File, "second")]);
531        let blob3 = ContextBlob::new(vec![make_annotation(AnnotationScope::File, "third")]);
532
533        let root1 = repo.set_context_blob(None, &target1, &blob1).unwrap();
534        let root2 = repo
535            .set_context_blob(Some(&root1), &target2, &blob2)
536            .unwrap();
537        let root3 = repo
538            .set_context_blob(Some(&root2), &target3, &blob3)
539            .unwrap();
540
541        let all = repo.list_context_entries(&root3, None).unwrap();
542        assert_eq!(all.len(), 3);
543
544        let src_only = repo
545            .list_context_entries(&root3, Some(Path::new("src")))
546            .unwrap();
547        assert_eq!(src_only.len(), 2);
548
549        let exact_root_file = repo
550            .list_context_entries(&root3, Some(Path::new("tests/test.rs")))
551            .unwrap();
552        assert_eq!(exact_root_file.len(), 1);
553    }
554
555    #[test]
556    fn find_annotation_returns_target_and_index() {
557        let (_dir, repo) = setup();
558        let target = ContextTarget::file("src/main.rs").unwrap();
559        let blob = ContextBlob::new(vec![make_annotation(AnnotationScope::File, "first")]);
560        let annotation_id = blob.annotations[0].annotation_id.clone();
561        let root = repo.set_context_blob(None, &target, &blob).unwrap();
562
563        let found = repo
564            .find_annotation(&root, &annotation_id)
565            .unwrap()
566            .unwrap();
567        assert_eq!(found.0, target);
568        assert_eq!(found.2, 0);
569    }
570
571    /// Build a synthetic State whose `context` field points at a freshly
572    /// rooted context tree containing the given annotations on a single
573    /// file target. The state's `tree` is left at its default — the helpers
574    /// under test never inspect it.
575    fn state_with_context(repo: &Repository, path: &str, anns: Vec<Annotation>) -> State {
576        let target = ContextTarget::file(path).unwrap();
577        let blob = ContextBlob::new(anns);
578        let root = repo.set_context_blob(None, &target, &blob).unwrap();
579        let mut state = State::new_snapshot(
580            ContentHash::compute(b""),
581            vec![],
582            objects::object::Attribution::human(objects::object::Principal::new(
583                "test",
584                "test@example.com",
585            )),
586        );
587        state = state.with_context(root);
588        state
589    }
590
591    fn ann_with_id(id: &str, content: &str, created_at: i64) -> Annotation {
592        let mut a = Annotation::new(
593            AnnotationScope::File,
594            AnnotationKind::Rationale,
595            content.to_string(),
596            vec![],
597            "test@example.com".to_string(),
598            created_at,
599            None,
600            None,
601        );
602        a.annotation_id = id.to_string();
603        a
604    }
605
606    #[test]
607    fn inherit_parent_context_passes_through_pointer() {
608        let (_dir, repo) = setup();
609        let parent = state_with_context(
610            &repo,
611            "src/lib.rs",
612            vec![make_annotation(AnnotationScope::File, "first")],
613        );
614        let inherited = Repository::inherit_parent_context(&parent);
615        assert_eq!(inherited, parent.context);
616    }
617
618    #[test]
619    fn inherit_parent_context_yields_none_when_parent_has_none() {
620        let parent = State::new_snapshot(
621            ContentHash::compute(b""),
622            vec![],
623            objects::object::Attribution::human(objects::object::Principal::new(
624                "test",
625                "test@example.com",
626            )),
627        );
628        assert_eq!(Repository::inherit_parent_context(&parent), None);
629    }
630
631    #[test]
632    fn union_parent_contexts_returns_none_for_empty_parents() {
633        let (_dir, repo) = setup();
634        let p = State::new_snapshot(
635            ContentHash::compute(b""),
636            vec![],
637            objects::object::Attribution::human(objects::object::Principal::new(
638                "test",
639                "test@example.com",
640            )),
641        );
642        let merged = repo.union_parent_contexts(&[&p, &p]).unwrap();
643        assert_eq!(merged, None);
644    }
645
646    #[test]
647    fn union_parent_contexts_pointer_copies_when_one_side_has_context() {
648        let (_dir, repo) = setup();
649        let parent_with = state_with_context(
650            &repo,
651            "src/lib.rs",
652            vec![make_annotation(AnnotationScope::File, "first")],
653        );
654        let parent_without = State::new_snapshot(
655            ContentHash::compute(b""),
656            vec![],
657            objects::object::Attribution::human(objects::object::Principal::new(
658                "test",
659                "test@example.com",
660            )),
661        );
662        let merged = repo
663            .union_parent_contexts(&[&parent_with, &parent_without])
664            .unwrap();
665        assert_eq!(merged, parent_with.context);
666    }
667
668    #[test]
669    fn union_parent_contexts_carries_disjoint_annotations() {
670        let (_dir, repo) = setup();
671        let left = state_with_context(
672            &repo,
673            "src/lib.rs",
674            vec![ann_with_id("ann-a", "left side", 1)],
675        );
676        let right = state_with_context(
677            &repo,
678            "src/main.rs",
679            vec![ann_with_id("ann-b", "right side", 1)],
680        );
681        let merged = repo
682            .union_parent_contexts(&[&left, &right])
683            .unwrap()
684            .expect("merged context root");
685        let entries = repo.list_context_entries(&merged, None).unwrap();
686        assert_eq!(entries.len(), 2);
687        let mut ids: Vec<String> = entries
688            .iter()
689            .flat_map(|e| e.blob.annotations.iter().map(|a| a.annotation_id.clone()))
690            .collect();
691        ids.sort();
692        assert_eq!(ids, vec!["ann-a".to_string(), "ann-b".to_string()]);
693    }
694
695    #[test]
696    fn union_parent_contexts_dedupes_same_id_with_newest_revision_wins() {
697        let (_dir, repo) = setup();
698        let older = ann_with_id("ann-shared", "older content", 1);
699        let newer = ann_with_id("ann-shared", "newer content", 9);
700        let left = state_with_context(&repo, "src/lib.rs", vec![older]);
701        let right = state_with_context(&repo, "src/lib.rs", vec![newer]);
702        let merged = repo
703            .union_parent_contexts(&[&left, &right])
704            .unwrap()
705            .expect("merged context root");
706        let entries = repo.list_context_entries(&merged, None).unwrap();
707        assert_eq!(entries.len(), 1);
708        let blob = &entries[0].blob;
709        assert_eq!(blob.annotations.len(), 1);
710        let revision = blob.annotations[0]
711            .current_revision()
712            .expect("annotation has a revision");
713        assert_eq!(revision.content, "newer content");
714    }
715}