Skip to main content

ucm_engine/
section.rs

1//! Section management utilities for UCM documents.
2//!
3//! This module provides utilities for working with document sections,
4//! including clearing content, integrating blocks, and finding sections by path.
5//!
6//! The module supports undo operations by preserving deleted content
7//! in `DeletedContent` structures that can be used for restoration.
8
9use serde::{Deserialize, Serialize};
10use std::collections::{HashMap, VecDeque};
11use ucm_core::{Block, BlockId, Content, Document};
12
13use crate::error::{Error, Result};
14
15/// Represents deleted content that can be restored.
16///
17/// When blocks are deleted or sections are rewritten, this structure
18/// preserves the original content and structure for potential restoration.
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct DeletedContent {
21    /// The blocks that were deleted, keyed by their original IDs
22    pub blocks: HashMap<BlockId, Block>,
23    /// The structure (parent -> children) of deleted blocks
24    pub structure: HashMap<BlockId, Vec<BlockId>>,
25    /// The parent block ID where this content was attached
26    pub parent_id: BlockId,
27    /// Timestamp when the deletion occurred
28    pub deleted_at: chrono::DateTime<chrono::Utc>,
29}
30
31impl DeletedContent {
32    /// Create a new DeletedContent structure
33    pub fn new(parent_id: BlockId) -> Self {
34        Self {
35            blocks: HashMap::new(),
36            structure: HashMap::new(),
37            parent_id,
38            deleted_at: chrono::Utc::now(),
39        }
40    }
41
42    /// Check if there is any deleted content
43    pub fn is_empty(&self) -> bool {
44        self.blocks.is_empty()
45    }
46
47    /// Get the number of deleted blocks
48    pub fn block_count(&self) -> usize {
49        self.blocks.len()
50    }
51
52    /// Get all block IDs in the deleted content
53    pub fn block_ids(&self) -> Vec<BlockId> {
54        self.blocks.keys().copied().collect()
55    }
56}
57
58/// Result of a section clear operation with undo support
59#[derive(Debug, Clone)]
60pub struct ClearResult {
61    /// IDs of blocks that were removed
62    pub removed_ids: Vec<BlockId>,
63    /// The deleted content for potential restoration
64    pub deleted_content: DeletedContent,
65}
66
67/// Clear all children of a section, preparing it for new content.
68///
69/// This removes all child blocks from the section, recursively deleting
70/// the entire subtree rooted at the section.
71///
72/// # Arguments
73/// * `doc` - The document to modify
74/// * `section_id` - The block ID of the section to clear
75///
76/// # Returns
77/// * `Ok(Vec<BlockId>)` - List of removed block IDs
78/// * `Err(Error)` - If the section doesn't exist
79pub fn clear_section_content(doc: &mut Document, section_id: &BlockId) -> Result<Vec<BlockId>> {
80    let result = clear_section_content_with_undo(doc, section_id)?;
81    Ok(result.removed_ids)
82}
83
84/// Clear all children of a section with undo support.
85///
86/// This is like `clear_section_content` but preserves the deleted content
87/// for potential restoration using `restore_deleted_content`.
88///
89/// # Arguments
90/// * `doc` - The document to modify
91/// * `section_id` - The block ID of the section to clear
92///
93/// # Returns
94/// * `Ok(ClearResult)` - Contains removed IDs and preserved content
95/// * `Err(Error)` - If the section doesn't exist
96pub fn clear_section_content_with_undo(
97    doc: &mut Document,
98    section_id: &BlockId,
99) -> Result<ClearResult> {
100    // Verify section exists
101    if !doc.blocks.contains_key(section_id) {
102        return Err(Error::BlockNotFound(section_id.to_string()));
103    }
104
105    let mut deleted = DeletedContent::new(*section_id);
106    let mut to_remove = Vec::new();
107    let mut queue = VecDeque::new();
108
109    // Get immediate children and store structure
110    if let Some(children) = doc.structure.get(section_id) {
111        deleted.structure.insert(*section_id, children.clone());
112        for child in children.clone() {
113            queue.push_back(child);
114        }
115    }
116
117    // BFS to collect all descendants and preserve them
118    while let Some(block_id) = queue.pop_front() {
119        to_remove.push(block_id);
120
121        // Preserve block content
122        if let Some(block) = doc.blocks.get(&block_id) {
123            deleted.blocks.insert(block_id, block.clone());
124        }
125
126        // Preserve and traverse structure
127        if let Some(children) = doc.structure.get(&block_id) {
128            deleted.structure.insert(block_id, children.clone());
129            for child in children.clone() {
130                queue.push_back(child);
131            }
132        }
133    }
134
135    // Remove blocks from document
136    for block_id in &to_remove {
137        doc.blocks.remove(block_id);
138        doc.structure.remove(block_id);
139    }
140
141    // Clear section's children list
142    if let Some(children) = doc.structure.get_mut(section_id) {
143        children.clear();
144    }
145
146    Ok(ClearResult {
147        removed_ids: to_remove,
148        deleted_content: deleted,
149    })
150}
151
152/// Restore previously deleted content to a document.
153///
154/// This restores blocks that were deleted by `clear_section_content_with_undo`
155/// back to their original parent section.
156///
157/// # Arguments
158/// * `doc` - The document to restore to
159/// * `deleted` - The deleted content to restore
160///
161/// # Returns
162/// * `Ok(Vec<BlockId>)` - List of restored block IDs
163/// * `Err(Error)` - If the parent section doesn't exist
164pub fn restore_deleted_content(
165    doc: &mut Document,
166    deleted: &DeletedContent,
167) -> Result<Vec<BlockId>> {
168    // Verify parent exists
169    if !doc.blocks.contains_key(&deleted.parent_id) {
170        return Err(Error::BlockNotFound(deleted.parent_id.to_string()));
171    }
172
173    // Remove current content under the parent section
174    if let Some(existing_children) = doc.structure.get(&deleted.parent_id).cloned() {
175        for child in existing_children {
176            remove_subtree(doc, &child);
177        }
178        if let Some(children) = doc.structure.get_mut(&deleted.parent_id) {
179            children.clear();
180        }
181    }
182
183    let mut restored = Vec::new();
184
185    // Restore all blocks
186    for (block_id, block) in &deleted.blocks {
187        doc.blocks.insert(*block_id, block.clone());
188        restored.push(*block_id);
189    }
190
191    // Restore structure for deleted blocks
192    for (block_id, children) in &deleted.structure {
193        if *block_id != deleted.parent_id {
194            doc.structure.insert(*block_id, children.clone());
195        }
196    }
197
198    // Restore children of parent section
199    if let Some(parent_children) = deleted.structure.get(&deleted.parent_id) {
200        if let Some(children) = doc.structure.get_mut(&deleted.parent_id) {
201            children.extend(parent_children.clone());
202        } else {
203            doc.structure
204                .insert(deleted.parent_id, parent_children.clone());
205        }
206    }
207
208    Ok(restored)
209}
210
211fn remove_subtree(doc: &mut Document, block_id: &BlockId) {
212    if let Some(children) = doc.structure.get(block_id).cloned() {
213        for child in children {
214            remove_subtree(doc, &child);
215        }
216    }
217
218    if let Some(parent) = doc.parent(block_id).cloned() {
219        if let Some(children) = doc.structure.get_mut(&parent) {
220            children.retain(|c| c != block_id);
221        }
222    }
223
224    doc.blocks.remove(block_id);
225    doc.structure.remove(block_id);
226}
227
228/// Integrate blocks from a source document into a target section.
229///
230/// This takes all non-root blocks from the source document and adds them
231/// as children of the target section, preserving their relative hierarchy.
232///
233/// # Arguments
234/// * `doc` - The target document to modify
235/// * `target_section` - The section to add blocks to
236/// * `source_doc` - The source document containing blocks to integrate
237/// * `base_heading_level` - Optional base level for heading adjustment
238///
239/// # Returns
240/// * `Ok(Vec<BlockId>)` - List of added block IDs
241/// * `Err(Error)` - If the target section doesn't exist
242pub fn integrate_section_blocks(
243    doc: &mut Document,
244    target_section: &BlockId,
245    source_doc: &Document,
246    base_heading_level: Option<usize>,
247) -> Result<Vec<BlockId>> {
248    // Verify target section exists
249    if !doc.blocks.contains_key(target_section) {
250        return Err(Error::BlockNotFound(target_section.to_string()));
251    }
252
253    let mut added_blocks = Vec::new();
254
255    // Get root children from source document
256    let root_children = source_doc
257        .structure
258        .get(&source_doc.root)
259        .cloned()
260        .unwrap_or_default();
261
262    // Process each root child and its subtree
263    for child_id in root_children {
264        let integrated = integrate_subtree(
265            doc,
266            target_section,
267            source_doc,
268            &child_id,
269            base_heading_level,
270            0,
271        )?;
272        added_blocks.extend(integrated);
273    }
274
275    Ok(added_blocks)
276}
277
278/// Recursively integrate a subtree from source to target document.
279fn integrate_subtree(
280    doc: &mut Document,
281    parent_id: &BlockId,
282    source_doc: &Document,
283    source_block_id: &BlockId,
284    base_heading_level: Option<usize>,
285    depth: usize,
286) -> Result<Vec<BlockId>> {
287    let mut added_blocks = Vec::new();
288
289    // Get the source block
290    let source_block = source_doc
291        .get_block(source_block_id)
292        .ok_or_else(|| Error::BlockNotFound(source_block_id.to_string()))?;
293
294    // Clone and potentially adjust heading level
295    let mut new_block = source_block.clone();
296
297    if let Some(base_level) = base_heading_level {
298        adjust_heading_level(&mut new_block, base_level, depth);
299    }
300
301    // Regenerate ID for the new block to avoid conflicts
302    let new_id = regenerate_block_id(&new_block);
303    new_block.id = new_id;
304
305    // Add block to target document
306    doc.blocks.insert(new_id, new_block);
307    added_blocks.push(new_id);
308
309    // Add to parent's children
310    let parent_children = doc.structure.entry(*parent_id).or_default();
311    parent_children.push(new_id);
312
313    // Initialize structure for new block
314    doc.structure.entry(new_id).or_default();
315
316    // Process children recursively
317    if let Some(children) = source_doc.structure.get(source_block_id) {
318        for child_id in children.clone() {
319            let child_added = integrate_subtree(
320                doc,
321                &new_id,
322                source_doc,
323                &child_id,
324                base_heading_level,
325                depth + 1,
326            )?;
327            added_blocks.extend(child_added);
328        }
329    }
330
331    Ok(added_blocks)
332}
333
334/// Adjust heading level based on base level and depth.
335fn adjust_heading_level(block: &mut Block, base_level: usize, _depth: usize) {
336    if let Some(ref mut role) = block.metadata.semantic_role {
337        let role_str = role.category.as_str();
338
339        // Check if this is a heading
340        if let Some(level_str) = role_str.strip_prefix("heading") {
341            if let Ok(current_level) = level_str.parse::<usize>() {
342                // Adjust level: new_level = base_level + current_level - 1
343                let new_level = (base_level + current_level - 1).clamp(1, 6);
344
345                // Update the semantic role
346                if let Some(new_role) =
347                    ucm_core::metadata::SemanticRole::parse(&format!("heading{}", new_level))
348                {
349                    *role = new_role;
350                }
351            }
352        }
353    }
354}
355
356/// Regenerate block ID to avoid conflicts.
357fn regenerate_block_id(block: &Block) -> BlockId {
358    use chrono::Utc;
359
360    // Create a unique ID based on content and timestamp
361    let timestamp = Utc::now().timestamp_nanos_opt().unwrap_or(0) as u128;
362
363    let content_hash = ucm_core::id::compute_content_hash(&block.content);
364
365    // Combine timestamp and content hash for uniqueness
366    let mut id_bytes = [0u8; 12];
367    id_bytes[0..8].copy_from_slice(&timestamp.to_le_bytes()[0..8]);
368    id_bytes[8..12].copy_from_slice(&content_hash.as_bytes()[0..4]);
369
370    BlockId::from_bytes(id_bytes)
371}
372
373/// Find a section by path (e.g., "Section 1 > Subsection 2").
374///
375/// The path uses " > " as a separator between heading names.
376///
377/// # Arguments
378/// * `doc` - The document to search
379/// * `path` - The path to the section (e.g., "Introduction > Getting Started")
380///
381/// # Returns
382/// * `Some(BlockId)` - The block ID of the found section
383/// * `None` - If the path doesn't match any section
384pub fn find_section_by_path(doc: &Document, path: &str) -> Option<BlockId> {
385    let parts: Vec<&str> = path.split(" > ").map(|s| s.trim()).collect();
386
387    if parts.is_empty() {
388        return None;
389    }
390
391    let mut current_id = doc.root;
392
393    for part in parts {
394        let children = doc.structure.get(&current_id)?;
395
396        let found = children.iter().find(|child_id| {
397            if let Some(block) = doc.get_block(child_id) {
398                // Check if this is a heading with matching text
399                let is_heading = block
400                    .metadata
401                    .semantic_role
402                    .as_ref()
403                    .map(|r| r.category.as_str().starts_with("heading"))
404                    .unwrap_or(false);
405
406                if is_heading {
407                    // Extract text content
408                    let text = match &block.content {
409                        Content::Text(t) => t.text.trim(),
410                        _ => return false,
411                    };
412                    return text == part;
413                }
414            }
415            false
416        });
417
418        current_id = *found?;
419    }
420
421    if current_id == doc.root {
422        None
423    } else {
424        Some(current_id)
425    }
426}
427
428/// Get the depth of a section in the document hierarchy.
429///
430/// # Arguments
431/// * `doc` - The document to search
432/// * `section_id` - The section to find the depth of
433///
434/// # Returns
435/// * `Some(usize)` - The depth (0 for root children)
436/// * `None` - If the section doesn't exist
437pub fn get_section_depth(doc: &Document, section_id: &BlockId) -> Option<usize> {
438    if *section_id == doc.root {
439        return Some(0);
440    }
441
442    let mut depth = 0;
443    let mut current = *section_id;
444
445    while let Some(parent) = doc.parent(&current) {
446        depth += 1;
447        if *parent == doc.root {
448            return Some(depth);
449        }
450        current = *parent;
451    }
452
453    None
454}
455
456/// Get all sections (heading blocks) in the document.
457///
458/// # Arguments
459/// * `doc` - The document to search
460///
461/// # Returns
462/// * `Vec<(BlockId, usize)>` - List of (section_id, heading_level) tuples
463pub fn get_all_sections(doc: &Document) -> Vec<(BlockId, usize)> {
464    let mut sections = Vec::new();
465
466    for (block_id, block) in &doc.blocks {
467        if let Some(ref role) = block.metadata.semantic_role {
468            if let Some(level_str) = role.category.as_str().strip_prefix("heading") {
469                if let Ok(level) = level_str.parse::<usize>() {
470                    sections.push((*block_id, level));
471                }
472            }
473        }
474    }
475
476    sections
477}
478
479#[cfg(test)]
480mod tests {
481    use super::*;
482    use ucm_core::{Block, Content, Document};
483
484    fn create_test_document() -> Document {
485        let mut doc = Document::create();
486        let root = doc.root;
487
488        // Create heading structure: H1 > H2 > paragraph
489        let h1 = Block::new(Content::text("Introduction"), Some("heading1"));
490        let h1_id = doc.add_block(h1, &root).unwrap();
491
492        let h2 = Block::new(Content::text("Getting Started"), Some("heading2"));
493        let h2_id = doc.add_block(h2, &h1_id).unwrap();
494
495        let para = Block::new(Content::text("Some content here"), Some("paragraph"));
496        doc.add_block(para, &h2_id).unwrap();
497
498        doc
499    }
500
501    #[test]
502    fn test_clear_section_content() {
503        let mut doc = create_test_document();
504
505        // Find the H1 section
506        let h1_id = find_section_by_path(&doc, "Introduction").unwrap();
507
508        // Clear the section
509        let removed = clear_section_content(&mut doc, &h1_id).unwrap();
510
511        // Should have removed H2 and paragraph
512        assert_eq!(removed.len(), 2);
513
514        // H1 should have no children now
515        let children = doc.structure.get(&h1_id).unwrap();
516        assert!(children.is_empty());
517    }
518
519    #[test]
520    fn test_find_section_by_path() {
521        let doc = create_test_document();
522
523        // Find single level
524        let h1_id = find_section_by_path(&doc, "Introduction");
525        assert!(h1_id.is_some());
526
527        // Find nested path
528        let h2_id = find_section_by_path(&doc, "Introduction > Getting Started");
529        assert!(h2_id.is_some());
530
531        // Non-existent path
532        let missing = find_section_by_path(&doc, "Missing Section");
533        assert!(missing.is_none());
534    }
535
536    #[test]
537    fn test_get_all_sections() {
538        let doc = create_test_document();
539
540        let sections = get_all_sections(&doc);
541
542        // Should have H1 and H2
543        assert_eq!(sections.len(), 2);
544
545        // Check levels
546        let levels: Vec<usize> = sections.iter().map(|(_, l)| *l).collect();
547        assert!(levels.contains(&1));
548        assert!(levels.contains(&2));
549    }
550
551    #[test]
552    fn test_get_section_depth() {
553        let doc = create_test_document();
554
555        let h1_id = find_section_by_path(&doc, "Introduction").unwrap();
556        let h2_id = find_section_by_path(&doc, "Introduction > Getting Started").unwrap();
557
558        assert_eq!(get_section_depth(&doc, &h1_id), Some(1));
559        assert_eq!(get_section_depth(&doc, &h2_id), Some(2));
560    }
561
562    #[test]
563    fn test_clear_with_undo_and_restore() {
564        let mut doc = create_test_document();
565        let original_count = doc.block_count();
566
567        // Find the H1 section
568        let h1_id = find_section_by_path(&doc, "Introduction").unwrap();
569
570        // Clear with undo support
571        let result = clear_section_content_with_undo(&mut doc, &h1_id).unwrap();
572
573        // Should have removed H2 and paragraph
574        assert_eq!(result.removed_ids.len(), 2);
575        assert_eq!(result.deleted_content.block_count(), 2);
576
577        // Document should have fewer blocks
578        assert!(doc.block_count() < original_count);
579
580        // Restore the deleted content
581        let restored = restore_deleted_content(&mut doc, &result.deleted_content).unwrap();
582
583        // Should have restored all blocks
584        assert_eq!(restored.len(), 2);
585        assert_eq!(doc.block_count(), original_count);
586
587        // H1 should have children again
588        let children = doc.structure.get(&h1_id).unwrap();
589        assert!(!children.is_empty());
590    }
591}