Skip to main content

cdx_core/extensions/
phantom.rs

1//! Phantom extension for off-page annotation clusters.
2//!
3//! Phantoms are annotation clusters that exist conceptually outside the page flow,
4//! attached to specific content anchors. They enable rich annotation workflows
5//! while keeping the main content clean.
6//!
7//! # Overview
8//!
9//! - **Phantom clusters** attach to content via anchors
10//! - **Phantoms** within clusters have positions, sizes, and content
11//! - **Connections** link phantoms to each other within a cluster
12//! - **Scopes** control visibility (shared, private, or role-based)
13//!
14//! # Example
15//!
16//! ```rust
17//! use cdx_core::extensions::phantom::{PhantomClusters, PhantomCluster, Phantom, PhantomScope};
18//! use cdx_core::anchor::ContentAnchor;
19//!
20//! let anchor = ContentAnchor::range("para-1", 10, 25);
21//! let cluster = PhantomCluster::new("cluster-1", anchor, "Research Notes")
22//!     .with_scope(PhantomScope::Shared);
23//! ```
24
25use std::collections::{HashMap, HashSet};
26
27use chrono::{DateTime, Utc};
28use serde::{Deserialize, Serialize};
29
30use crate::anchor::ContentAnchor;
31use crate::content::Block;
32use crate::extensions::Collaborator;
33use crate::DocumentState;
34
35/// Root structure for phantom clusters stored at `phantoms/clusters.json`.
36#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
37#[serde(rename_all = "camelCase")]
38pub struct PhantomClusters {
39    /// Format version.
40    pub version: String,
41
42    /// All phantom clusters in the document.
43    #[serde(default, skip_serializing_if = "Vec::is_empty")]
44    pub clusters: Vec<PhantomCluster>,
45}
46
47impl PhantomClusters {
48    /// Create a new empty phantom clusters file.
49    #[must_use]
50    pub fn new() -> Self {
51        Self {
52            version: crate::SPEC_VERSION.to_string(),
53            clusters: Vec::new(),
54        }
55    }
56
57    /// Add a cluster.
58    pub fn add_cluster(&mut self, cluster: PhantomCluster) {
59        self.clusters.push(cluster);
60    }
61
62    /// Find a cluster by ID.
63    #[must_use]
64    pub fn find_cluster(&self, id: &str) -> Option<&PhantomCluster> {
65        self.clusters.iter().find(|c| c.id == id)
66    }
67
68    /// Find a cluster by ID mutably.
69    #[must_use]
70    pub fn find_cluster_mut(&mut self, id: &str) -> Option<&mut PhantomCluster> {
71        self.clusters.iter_mut().find(|c| c.id == id)
72    }
73
74    /// Get the number of clusters.
75    #[must_use]
76    pub fn len(&self) -> usize {
77        self.clusters.len()
78    }
79
80    /// Check if there are no clusters.
81    #[must_use]
82    pub fn is_empty(&self) -> bool {
83        self.clusters.is_empty()
84    }
85
86    /// Validate all clusters.
87    ///
88    /// Returns a list of validation errors, if any.
89    #[must_use]
90    pub fn validate(&self, state: DocumentState) -> Vec<String> {
91        let mut errors = Vec::new();
92
93        for cluster in &self.clusters {
94            errors.extend(cluster.validate_connections(state));
95        }
96
97        errors
98    }
99}
100
101impl Default for PhantomClusters {
102    fn default() -> Self {
103        Self::new()
104    }
105}
106
107/// A cluster of phantoms attached to a content anchor.
108#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
109#[serde(rename_all = "camelCase")]
110pub struct PhantomCluster {
111    /// Unique identifier for this cluster.
112    pub id: String,
113
114    /// Content anchor where this cluster attaches.
115    pub anchor: ContentAnchor,
116
117    /// Display label for the cluster.
118    pub label: String,
119
120    /// Visibility scope for this cluster.
121    #[serde(default)]
122    pub scope: PhantomScope,
123
124    /// Author who created this cluster.
125    #[serde(default, skip_serializing_if = "Option::is_none")]
126    pub author: Option<Collaborator>,
127
128    /// When the cluster was created.
129    #[serde(default, skip_serializing_if = "Option::is_none")]
130    pub created: Option<DateTime<Utc>>,
131
132    /// Custom metadata for the cluster.
133    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
134    pub metadata: HashMap<String, serde_json::Value>,
135
136    /// Phantoms within this cluster.
137    #[serde(default, skip_serializing_if = "Vec::is_empty")]
138    pub phantoms: Vec<Phantom>,
139}
140
141impl PhantomCluster {
142    /// Create a new phantom cluster.
143    #[must_use]
144    pub fn new(id: impl Into<String>, anchor: ContentAnchor, label: impl Into<String>) -> Self {
145        Self {
146            id: id.into(),
147            anchor,
148            label: label.into(),
149            scope: PhantomScope::default(),
150            author: None,
151            created: Some(Utc::now()),
152            metadata: HashMap::new(),
153            phantoms: Vec::new(),
154        }
155    }
156
157    /// Set the visibility scope.
158    #[must_use]
159    pub fn with_scope(mut self, scope: PhantomScope) -> Self {
160        self.scope = scope;
161        self
162    }
163
164    /// Set the author.
165    #[must_use]
166    pub fn with_author(mut self, author: Collaborator) -> Self {
167        self.author = Some(author);
168        self
169    }
170
171    /// Add metadata.
172    #[must_use]
173    pub fn with_metadata(mut self, key: impl Into<String>, value: serde_json::Value) -> Self {
174        self.metadata.insert(key.into(), value);
175        self
176    }
177
178    /// Add a phantom to this cluster.
179    pub fn add_phantom(&mut self, phantom: Phantom) {
180        self.phantoms.push(phantom);
181    }
182
183    /// Add a phantom and return self for chaining.
184    #[must_use]
185    pub fn with_phantom(mut self, phantom: Phantom) -> Self {
186        self.phantoms.push(phantom);
187        self
188    }
189
190    /// Find a phantom by ID.
191    #[must_use]
192    pub fn find_phantom(&self, id: &str) -> Option<&Phantom> {
193        self.phantoms.iter().find(|p| p.id == id)
194    }
195
196    /// Find a phantom by ID mutably.
197    #[must_use]
198    pub fn find_phantom_mut(&mut self, id: &str) -> Option<&mut Phantom> {
199        self.phantoms.iter_mut().find(|p| p.id == id)
200    }
201
202    /// Validate connections within this cluster.
203    ///
204    /// Checks:
205    /// - All connection targets exist within this cluster
206    /// - No circular references that would cause infinite loops
207    ///
208    /// Returns errors in Frozen/Published states, warnings in Draft/Review.
209    #[must_use]
210    pub fn validate_connections(&self, state: DocumentState) -> Vec<String> {
211        let mut errors = Vec::new();
212        let phantom_ids: HashSet<_> = self.phantoms.iter().map(|p| p.id.as_str()).collect();
213
214        for phantom in &self.phantoms {
215            for conn in &phantom.connections {
216                // Check target exists in this cluster
217                if !phantom_ids.contains(conn.target.as_str()) {
218                    let msg = format!(
219                        "Phantom '{}' has connection to non-existent target '{}' in cluster '{}'",
220                        phantom.id, conn.target, self.id
221                    );
222                    if state.is_immutable() {
223                        errors.push(format!("ERROR: {msg}"));
224                    } else {
225                        errors.push(format!("WARNING: {msg}"));
226                    }
227                }
228
229                // Check for self-reference
230                if conn.target == phantom.id {
231                    let msg = format!(
232                        "Phantom '{}' has self-referential connection in cluster '{}'",
233                        phantom.id, self.id
234                    );
235                    if state.is_immutable() {
236                        errors.push(format!("ERROR: {msg}"));
237                    } else {
238                        errors.push(format!("WARNING: {msg}"));
239                    }
240                }
241            }
242        }
243
244        // Check for cycles
245        if let Some(cycle) = self.detect_cycle() {
246            let msg = format!(
247                "Circular connection detected in cluster '{}': {}",
248                self.id,
249                cycle.join(" -> ")
250            );
251            if state.is_immutable() {
252                errors.push(format!("ERROR: {msg}"));
253            } else {
254                errors.push(format!("WARNING: {msg}"));
255            }
256        }
257
258        errors
259    }
260
261    /// Detect cycles in the connection graph.
262    /// Returns the cycle path if one exists.
263    fn detect_cycle(&self) -> Option<Vec<String>> {
264        let mut visited = HashSet::new();
265        let mut rec_stack = HashSet::new();
266        let mut path = Vec::new();
267
268        for phantom in &self.phantoms {
269            if !visited.contains(&phantom.id) {
270                if let Some(cycle) =
271                    self.detect_cycle_dfs(&phantom.id, &mut visited, &mut rec_stack, &mut path)
272                {
273                    return Some(cycle);
274                }
275            }
276        }
277
278        None
279    }
280
281    fn detect_cycle_dfs(
282        &self,
283        node: &str,
284        visited: &mut HashSet<String>,
285        rec_stack: &mut HashSet<String>,
286        path: &mut Vec<String>,
287    ) -> Option<Vec<String>> {
288        visited.insert(node.to_string());
289        rec_stack.insert(node.to_string());
290        path.push(node.to_string());
291
292        if let Some(phantom) = self.find_phantom(node) {
293            for conn in &phantom.connections {
294                if !visited.contains(&conn.target) {
295                    if let Some(cycle) =
296                        self.detect_cycle_dfs(&conn.target, visited, rec_stack, path)
297                    {
298                        return Some(cycle);
299                    }
300                } else if rec_stack.contains(&conn.target) {
301                    // Found cycle - return path from target to current
302                    let mut cycle = Vec::new();
303                    let mut found = false;
304                    for p in path.iter() {
305                        if p == &conn.target || found {
306                            found = true;
307                            cycle.push(p.clone());
308                        }
309                    }
310                    cycle.push(conn.target.clone());
311                    return Some(cycle);
312                }
313            }
314        }
315
316        path.pop();
317        rec_stack.remove(node);
318        None
319    }
320}
321
322/// An individual phantom within a cluster.
323#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
324#[serde(rename_all = "camelCase")]
325pub struct Phantom {
326    /// Unique identifier for this phantom.
327    pub id: String,
328
329    /// Position within the cluster's coordinate space.
330    pub position: PhantomPosition,
331
332    /// Optional size of this phantom.
333    #[serde(default, skip_serializing_if = "Option::is_none")]
334    pub size: Option<PhantomSize>,
335
336    /// Content of this phantom.
337    pub content: PhantomContent,
338
339    /// Connections to other phantoms in the same cluster.
340    #[serde(default, skip_serializing_if = "Vec::is_empty")]
341    pub connections: Vec<PhantomConnection>,
342
343    /// When this phantom was created.
344    #[serde(default, skip_serializing_if = "Option::is_none")]
345    pub created: Option<DateTime<Utc>>,
346
347    /// Author who created this phantom.
348    #[serde(default, skip_serializing_if = "Option::is_none")]
349    pub author: Option<Collaborator>,
350}
351
352impl Phantom {
353    /// Create a new phantom.
354    #[must_use]
355    pub fn new(id: impl Into<String>, position: PhantomPosition, content: PhantomContent) -> Self {
356        Self {
357            id: id.into(),
358            position,
359            size: None,
360            content,
361            connections: Vec::new(),
362            created: Some(Utc::now()),
363            author: None,
364        }
365    }
366
367    /// Set the size.
368    #[must_use]
369    pub fn with_size(mut self, size: PhantomSize) -> Self {
370        self.size = Some(size);
371        self
372    }
373
374    /// Set the author.
375    #[must_use]
376    pub fn with_author(mut self, author: Collaborator) -> Self {
377        self.author = Some(author);
378        self
379    }
380
381    /// Add a connection to another phantom.
382    #[must_use]
383    pub fn with_connection(mut self, connection: PhantomConnection) -> Self {
384        self.connections.push(connection);
385        self
386    }
387
388    /// Add a simple connection to another phantom by ID.
389    #[must_use]
390    pub fn connect_to(mut self, target_id: impl Into<String>) -> Self {
391        self.connections.push(PhantomConnection::new(target_id));
392        self
393    }
394}
395
396/// Position of a phantom in the cluster's coordinate space.
397#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
398pub struct PhantomPosition {
399    /// X coordinate (horizontal).
400    pub x: f64,
401    /// Y coordinate (vertical).
402    pub y: f64,
403}
404
405impl PhantomPosition {
406    /// Create a new position.
407    #[must_use]
408    pub fn new(x: f64, y: f64) -> Self {
409        Self { x, y }
410    }
411}
412
413/// Size of a phantom.
414#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
415pub struct PhantomSize {
416    /// Width.
417    pub width: f64,
418    /// Height.
419    pub height: f64,
420}
421
422impl PhantomSize {
423    /// Create a new size.
424    #[must_use]
425    pub fn new(width: f64, height: f64) -> Self {
426        Self { width, height }
427    }
428}
429
430/// Connection between phantoms in the same cluster.
431#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
432#[serde(rename_all = "camelCase")]
433pub struct PhantomConnection {
434    /// ID of the target phantom (must be in the same cluster).
435    pub target: String,
436
437    /// Visual style of the connection line.
438    #[serde(default, skip_serializing_if = "Option::is_none")]
439    pub style: Option<ConnectionStyle>,
440
441    /// Optional label for the connection.
442    #[serde(default, skip_serializing_if = "Option::is_none")]
443    pub label: Option<String>,
444}
445
446impl PhantomConnection {
447    /// Create a new connection to a target phantom.
448    #[must_use]
449    pub fn new(target: impl Into<String>) -> Self {
450        Self {
451            target: target.into(),
452            style: None,
453            label: None,
454        }
455    }
456
457    /// Set the connection style.
458    #[must_use]
459    pub fn with_style(mut self, style: ConnectionStyle) -> Self {
460        self.style = Some(style);
461        self
462    }
463
464    /// Set a label for the connection.
465    #[must_use]
466    pub fn with_label(mut self, label: impl Into<String>) -> Self {
467        self.label = Some(label.into());
468        self
469    }
470}
471
472/// Visual style for phantom connections.
473#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
474#[serde(rename_all = "lowercase")]
475pub enum ConnectionStyle {
476    /// Simple line.
477    #[default]
478    Line,
479    /// Line with arrowhead.
480    Arrow,
481    /// Dashed line.
482    Dashed,
483}
484
485/// Visibility scope for phantom clusters.
486#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
487#[serde(rename_all = "camelCase", tag = "type")]
488pub enum PhantomScope {
489    /// Visible to all readers.
490    #[default]
491    Shared,
492    /// Visible only to the author.
493    Private,
494    /// Visible to users with the specified role.
495    Role {
496        /// The role that can see this cluster.
497        role: String,
498    },
499}
500
501impl PhantomScope {
502    /// Create a role-based scope.
503    #[must_use]
504    pub fn role(role: impl Into<String>) -> Self {
505        Self::Role { role: role.into() }
506    }
507}
508
509/// Content of a phantom, wrapping standard content blocks.
510#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
511#[serde(rename_all = "camelCase")]
512pub struct PhantomContent {
513    /// Content blocks within this phantom.
514    pub blocks: Vec<Block>,
515}
516
517impl PhantomContent {
518    /// Create new phantom content from blocks.
519    #[must_use]
520    pub fn new(blocks: Vec<Block>) -> Self {
521        Self { blocks }
522    }
523
524    /// Create phantom content with a single paragraph.
525    #[must_use]
526    pub fn paragraph(text: impl Into<String>) -> Self {
527        use crate::content::Text;
528        Self {
529            blocks: vec![Block::paragraph(vec![Text::plain(text)])],
530        }
531    }
532
533    /// Check if the content is empty.
534    #[must_use]
535    pub fn is_empty(&self) -> bool {
536        self.blocks.is_empty()
537    }
538}
539
540impl From<Vec<Block>> for PhantomContent {
541    fn from(blocks: Vec<Block>) -> Self {
542        Self::new(blocks)
543    }
544}
545
546#[cfg(test)]
547mod tests {
548    use super::*;
549
550    #[test]
551    fn test_phantom_clusters_new() {
552        let clusters = PhantomClusters::new();
553        assert_eq!(clusters.version, "0.1");
554        assert!(clusters.is_empty());
555    }
556
557    #[test]
558    fn test_phantom_cluster_creation() {
559        let anchor = ContentAnchor::block("para-1");
560        let cluster = PhantomCluster::new("cluster-1", anchor, "Test Cluster");
561
562        assert_eq!(cluster.id, "cluster-1");
563        assert_eq!(cluster.label, "Test Cluster");
564        assert!(matches!(cluster.scope, PhantomScope::Shared));
565    }
566
567    #[test]
568    fn test_phantom_cluster_with_phantoms() {
569        let anchor = ContentAnchor::range("para-1", 10, 25);
570        let content = PhantomContent::paragraph("Note text");
571        let phantom = Phantom::new("phantom-1", PhantomPosition::new(100.0, 50.0), content);
572
573        let cluster =
574            PhantomCluster::new("cluster-1", anchor, "Notes").with_phantom(phantom.clone());
575
576        assert_eq!(cluster.phantoms.len(), 1);
577        assert!(cluster.find_phantom("phantom-1").is_some());
578    }
579
580    #[test]
581    fn test_phantom_connections() {
582        let anchor = ContentAnchor::block("para-1");
583
584        let phantom1 = Phantom::new(
585            "p1",
586            PhantomPosition::new(0.0, 0.0),
587            PhantomContent::paragraph("First"),
588        )
589        .connect_to("p2");
590
591        let phantom2 = Phantom::new(
592            "p2",
593            PhantomPosition::new(100.0, 0.0),
594            PhantomContent::paragraph("Second"),
595        );
596
597        let cluster = PhantomCluster::new("cluster-1", anchor, "Test")
598            .with_phantom(phantom1)
599            .with_phantom(phantom2);
600
601        assert_eq!(cluster.phantoms[0].connections.len(), 1);
602        assert_eq!(cluster.phantoms[0].connections[0].target, "p2");
603    }
604
605    #[test]
606    fn test_connection_validation_valid() {
607        let anchor = ContentAnchor::block("para-1");
608
609        let phantom1 = Phantom::new(
610            "p1",
611            PhantomPosition::new(0.0, 0.0),
612            PhantomContent::paragraph("First"),
613        )
614        .connect_to("p2");
615
616        let phantom2 = Phantom::new(
617            "p2",
618            PhantomPosition::new(100.0, 0.0),
619            PhantomContent::paragraph("Second"),
620        );
621
622        let cluster = PhantomCluster::new("cluster-1", anchor, "Test")
623            .with_phantom(phantom1)
624            .with_phantom(phantom2);
625
626        let errors = cluster.validate_connections(DocumentState::Draft);
627        assert!(errors.is_empty(), "Valid connections: {errors:?}");
628    }
629
630    #[test]
631    fn test_connection_validation_missing_target() {
632        let anchor = ContentAnchor::block("para-1");
633
634        let phantom1 = Phantom::new(
635            "p1",
636            PhantomPosition::new(0.0, 0.0),
637            PhantomContent::paragraph("First"),
638        )
639        .connect_to("nonexistent");
640
641        let cluster = PhantomCluster::new("cluster-1", anchor, "Test").with_phantom(phantom1);
642
643        let errors = cluster.validate_connections(DocumentState::Draft);
644        assert_eq!(errors.len(), 1);
645        assert!(errors[0].starts_with("WARNING:"));
646
647        let errors = cluster.validate_connections(DocumentState::Frozen);
648        assert_eq!(errors.len(), 1);
649        assert!(errors[0].starts_with("ERROR:"));
650    }
651
652    #[test]
653    fn test_connection_validation_self_reference() {
654        let anchor = ContentAnchor::block("para-1");
655
656        let phantom1 = Phantom::new(
657            "p1",
658            PhantomPosition::new(0.0, 0.0),
659            PhantomContent::paragraph("First"),
660        )
661        .connect_to("p1"); // Self-reference
662
663        let cluster = PhantomCluster::new("cluster-1", anchor, "Test").with_phantom(phantom1);
664
665        let errors = cluster.validate_connections(DocumentState::Frozen);
666        assert!(!errors.is_empty());
667        assert!(errors.iter().any(|e| e.contains("self-referential")));
668    }
669
670    #[test]
671    fn test_connection_validation_cycle() {
672        let anchor = ContentAnchor::block("para-1");
673
674        let phantom1 = Phantom::new(
675            "p1",
676            PhantomPosition::new(0.0, 0.0),
677            PhantomContent::paragraph("First"),
678        )
679        .connect_to("p2");
680
681        let phantom2 = Phantom::new(
682            "p2",
683            PhantomPosition::new(100.0, 0.0),
684            PhantomContent::paragraph("Second"),
685        )
686        .connect_to("p3");
687
688        let phantom3 = Phantom::new(
689            "p3",
690            PhantomPosition::new(200.0, 0.0),
691            PhantomContent::paragraph("Third"),
692        )
693        .connect_to("p1"); // Creates cycle: p1 -> p2 -> p3 -> p1
694
695        let cluster = PhantomCluster::new("cluster-1", anchor, "Test")
696            .with_phantom(phantom1)
697            .with_phantom(phantom2)
698            .with_phantom(phantom3);
699
700        let errors = cluster.validate_connections(DocumentState::Frozen);
701        assert!(!errors.is_empty());
702        assert!(errors.iter().any(|e| e.contains("Circular")));
703    }
704
705    #[test]
706    fn test_phantom_scope_variants() {
707        let shared = PhantomScope::Shared;
708        let json = serde_json::to_string(&shared).unwrap();
709        assert!(json.contains("\"type\":\"shared\""));
710
711        let private = PhantomScope::Private;
712        let json = serde_json::to_string(&private).unwrap();
713        assert!(json.contains("\"type\":\"private\""));
714
715        let role = PhantomScope::role("editor");
716        let json = serde_json::to_string(&role).unwrap();
717        assert!(json.contains("\"type\":\"role\""));
718        assert!(json.contains("\"role\":\"editor\""));
719    }
720
721    #[test]
722    fn test_phantom_clusters_serialization() {
723        let anchor = ContentAnchor::block("para-1");
724        let content = PhantomContent::paragraph("Note");
725        let phantom = Phantom::new("p1", PhantomPosition::new(50.0, 50.0), content)
726            .with_size(PhantomSize::new(200.0, 100.0));
727
728        let cluster = PhantomCluster::new("cluster-1", anchor, "Notes")
729            .with_scope(PhantomScope::Private)
730            .with_phantom(phantom);
731
732        let mut clusters = PhantomClusters::new();
733        clusters.add_cluster(cluster);
734
735        let json = serde_json::to_string_pretty(&clusters).unwrap();
736        assert!(json.contains("\"version\": \"0.1\""));
737        assert!(json.contains("\"clusters\":"));
738        assert!(json.contains("\"phantoms\":"));
739
740        // Roundtrip
741        let parsed: PhantomClusters = serde_json::from_str(&json).unwrap();
742        assert_eq!(parsed.clusters.len(), 1);
743        assert_eq!(parsed.clusters[0].phantoms.len(), 1);
744    }
745
746    #[test]
747    fn test_connection_style() {
748        let conn = PhantomConnection::new("target")
749            .with_style(ConnectionStyle::Arrow)
750            .with_label("relates to");
751
752        let json = serde_json::to_string(&conn).unwrap();
753        assert!(json.contains("\"target\":\"target\""));
754        assert!(json.contains("\"style\":\"arrow\""));
755        assert!(json.contains("\"label\":\"relates to\""));
756    }
757}