Skip to main content

okf_core/
refactor.rs

1//! Knowledge refactoring, concept relocation, deletion, splitting, and merging.
2//!
3//! Refactoring a concept graph requires maintaining link integrity across the
4//! entire bundle:
5//!
6//! - [`move_concept`]: Moves/renames a concept, rewrites all incoming backlinks
7//!   across the bundle, and rebases outgoing relative links and frontmatter paths.
8//! - [`remove_concept`]: Safely deletes a concept, checking for inbound links
9//!   and optionally redirecting or unlinking them.
10//! - [`split_concept`]: Extracts a section/heading into a new concept, moves
11//!   relevant footnote citations/sources, and links to the new concept.
12//! - [`merge_concepts`]: Consolidates two concepts, merging bodies, sources,
13//!   and verification events, while redirecting incoming backlinks.
14
15use crate::bundle::Bundle;
16use crate::concept_id::ConceptId;
17use crate::date::Date;
18use crate::document::Document;
19use crate::frontmatter::Frontmatter;
20use crate::index::regenerate_indexes;
21use crate::links::{self, Link, LinkKind};
22use crate::provenance::Source;
23use crate::scaffold::{current_iso_timestamp, default_author};
24use crate::yaml::Value;
25use std::collections::HashSet;
26use std::fmt;
27use std::fmt::Write as _;
28use std::fs;
29use std::io;
30use std::path::PathBuf;
31
32pub use crate::log::append_log_entry;
33pub use crate::markdown::{
34    LinkRewriteAction, heading_slug, matches_heading, rewrite_markdown_links,
35};
36/// Error returned when a refactoring operation fails.
37#[derive(Clone, Debug, PartialEq, Eq)]
38pub enum RefactorError {
39    /// The specified source concept was not found in the bundle.
40    ConceptNotFound(ConceptId),
41    /// The destination concept already exists in the bundle.
42    ConceptAlreadyExists(ConceptId),
43    /// The source and target concepts are the same.
44    SameSourceAndTarget(ConceptId),
45    /// The concept cannot be deleted because incoming links point to it.
46    HasInboundLinks {
47        /// Target concept being removed.
48        target: ConceptId,
49        /// Number of incoming links.
50        inbound_count: usize,
51        /// Concept IDs linking to the target.
52        inbound_concepts: Vec<ConceptId>,
53    },
54    /// The requested section heading was not found in the concept body.
55    SectionNotFound {
56        /// The concept searched.
57        concept: ConceptId,
58        /// The section name searched for.
59        section: String,
60    },
61    /// Invalid concept ID.
62    InvalidConceptId(String),
63    /// Filesystem I/O error.
64    Io(String),
65}
66
67impl fmt::Display for RefactorError {
68    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
69        match self {
70            Self::ConceptNotFound(id) => write!(f, "concept '{id}' not found in bundle"),
71            Self::ConceptAlreadyExists(id) => {
72                write!(
73                    f,
74                    "destination concept '{id}' already exists (use --force to overwrite)"
75                )
76            }
77            Self::SameSourceAndTarget(id) => {
78                write!(f, "source and target concepts are identical: '{id}'")
79            }
80            Self::HasInboundLinks {
81                target,
82                inbound_count,
83                inbound_concepts,
84            } => {
85                let joined = inbound_concepts
86                    .iter()
87                    .map(ToString::to_string)
88                    .collect::<Vec<_>>()
89                    .join(", ");
90                write!(
91                    f,
92                    "cannot remove concept '{target}' because {inbound_count} other concept(s) link to it: [{joined}]. Use --redirect-to <target> to re-route links, --unlink to remove links, or --force to delete anyway"
93                )
94            }
95            Self::SectionNotFound { concept, section } => {
96                write!(f, "section '{section}' not found in concept '{concept}'")
97            }
98            Self::InvalidConceptId(msg) => write!(f, "invalid concept ID: {msg}"),
99            Self::Io(msg) => write!(f, "I/O error: {msg}"),
100        }
101    }
102}
103
104impl std::error::Error for RefactorError {}
105
106impl From<io::Error> for RefactorError {
107    fn from(err: io::Error) -> Self {
108        Self::Io(err.to_string())
109    }
110}
111
112/// Options for moving or renaming a concept.
113#[derive(Clone, Debug, PartialEq, Eq)]
114#[allow(clippy::struct_excessive_bools)]
115pub struct MoveOptions {
116    /// If true, simulate changes without writing to disk.
117    pub dry_run: bool,
118    /// Overwrite destination file if it exists.
119    pub force: bool,
120    /// Regenerate index.md listings after move.
121    pub update_index: bool,
122    /// Record the move in log.md.
123    pub update_log: bool,
124    /// Author attribution for the log entry.
125    pub author: Option<String>,
126}
127
128impl Default for MoveOptions {
129    fn default() -> Self {
130        Self {
131            dry_run: false,
132            force: false,
133            update_index: true,
134            update_log: true,
135            author: None,
136        }
137    }
138}
139
140/// Summary report of a move/rename operation.
141#[derive(Clone, Debug, PartialEq, Eq)]
142pub struct MoveReport {
143    /// Original concept ID.
144    pub source: ConceptId,
145    /// New concept ID.
146    pub target: ConceptId,
147    /// Original file path.
148    pub source_path: PathBuf,
149    /// New file path.
150    pub target_path: PathBuf,
151    /// Number of incoming links rewritten across the bundle.
152    pub rewritten_incoming_links: usize,
153    /// Number of outgoing relative links rebased in the moved concept.
154    pub rebased_outgoing_links: usize,
155    /// Number of frontmatter path fields rebased.
156    pub rebased_frontmatter_paths: usize,
157    /// List of all modified or created file paths.
158    pub affected_files: Vec<PathBuf>,
159    /// Whether this was a dry run.
160    pub dry_run: bool,
161}
162
163impl fmt::Display for MoveReport {
164    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
165        let prefix = if self.dry_run {
166            "[dry-run] would rename"
167        } else {
168            "renamed"
169        };
170        writeln!(f, "{prefix} concept {} -> {}", self.source, self.target)?;
171        writeln!(
172            f,
173            "  rewrote {} incoming link(s)",
174            self.rewritten_incoming_links
175        )?;
176        writeln!(
177            f,
178            "  rebased {} outgoing link(s)",
179            self.rebased_outgoing_links
180        )?;
181        if self.rebased_frontmatter_paths > 0 {
182            writeln!(
183                f,
184                "  rebased {} frontmatter path(s)",
185                self.rebased_frontmatter_paths
186            )?;
187        }
188        write!(f, "  affected {} file(s)", self.affected_files.len())
189    }
190}
191
192/// Options for removing a concept.
193#[derive(Clone, Debug, PartialEq, Eq)]
194#[allow(clippy::struct_excessive_bools)]
195pub struct RemoveOptions {
196    /// If true, simulate changes without writing to disk.
197    pub dry_run: bool,
198    /// Force deletion even if inbound links exist.
199    pub force: bool,
200    /// Re-route all inbound links to this concept instead.
201    pub redirect_to: Option<ConceptId>,
202    /// Convert inbound links to plain text (`[Text](dest)` -> `Text`).
203    pub unlink: bool,
204    /// Regenerate index.md listings after removal.
205    pub update_index: bool,
206    /// Record the deletion in log.md.
207    pub update_log: bool,
208    /// Author attribution for the log entry.
209    pub author: Option<String>,
210}
211
212impl Default for RemoveOptions {
213    fn default() -> Self {
214        Self {
215            dry_run: false,
216            force: false,
217            redirect_to: None,
218            unlink: false,
219            update_index: true,
220            update_log: true,
221            author: None,
222        }
223    }
224}
225
226/// Summary report of a removal operation.
227#[derive(Clone, Debug, PartialEq, Eq)]
228pub struct RemoveReport {
229    /// The concept ID that was removed.
230    pub target: ConceptId,
231    /// The file path that was removed.
232    pub removed_path: PathBuf,
233    /// Replacement concept ID if links were redirected.
234    pub redirected_to: Option<ConceptId>,
235    /// Number of links rewritten to a redirect target.
236    pub redirected_count: usize,
237    /// Number of links unlinked to plain text.
238    pub unlinked_count: usize,
239    /// List of all modified or removed file paths.
240    pub affected_files: Vec<PathBuf>,
241    /// Whether this was a dry run.
242    pub dry_run: bool,
243}
244
245impl fmt::Display for RemoveReport {
246    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
247        let prefix = if self.dry_run {
248            "[dry-run] would remove"
249        } else {
250            "removed"
251        };
252        if let Some(r) = &self.redirected_to {
253            writeln!(
254                f,
255                "{prefix} concept {} (redirected {} link(s) to {r})",
256                self.target, self.redirected_count
257            )?;
258        } else if self.unlinked_count > 0 {
259            writeln!(
260                f,
261                "{prefix} concept {} (unlinked {} link(s))",
262                self.target, self.unlinked_count
263            )?;
264        } else {
265            writeln!(f, "{prefix} concept {}", self.target)?;
266        }
267        write!(f, "  affected {} file(s)", self.affected_files.len())
268    }
269}
270
271/// Options for splitting a concept section into a new concept.
272#[derive(Clone, Debug, PartialEq, Eq)]
273#[allow(clippy::struct_excessive_bools)]
274pub struct SplitOptions {
275    /// The heading/section title to extract (e.g. "Pricing Model" or "## Pricing Model").
276    pub section: String,
277    /// Optional title for the new concept (defaults to section heading text).
278    pub title: Option<String>,
279    /// Optional concept type for the new concept (defaults to "Concept").
280    pub type_: Option<String>,
281    /// Optional custom text for the replacement link in the source document.
282    pub link_text: Option<String>,
283    /// Overwrite destination file if it exists.
284    pub force: bool,
285    /// If true, simulate changes without writing to disk.
286    pub dry_run: bool,
287    /// Regenerate index.md listings after split.
288    pub update_index: bool,
289    /// Record the split in log.md.
290    pub update_log: bool,
291    /// Author attribution for generated frontmatter and log entry.
292    pub author: Option<String>,
293}
294
295impl Default for SplitOptions {
296    fn default() -> Self {
297        Self {
298            section: String::new(),
299            title: None,
300            type_: None,
301            link_text: None,
302            force: false,
303            dry_run: false,
304            update_index: true,
305            update_log: true,
306            author: None,
307        }
308    }
309}
310
311impl SplitOptions {
312    /// Creates split options for the given section name.
313    #[must_use]
314    pub const fn new(section: String) -> Self {
315        Self {
316            section,
317            title: None,
318            type_: None,
319            link_text: None,
320            force: false,
321            dry_run: false,
322            update_index: true,
323            update_log: true,
324            author: None,
325        }
326    }
327}
328
329/// Summary report of a split operation.
330#[derive(Clone, Debug, PartialEq, Eq)]
331pub struct SplitReport {
332    /// Source concept ID.
333    pub source: ConceptId,
334    /// Newly created target concept ID.
335    pub target: ConceptId,
336    /// Name of the extracted section.
337    pub section: String,
338    /// Title of the newly created concept.
339    pub target_title: String,
340    /// File path of the new concept document.
341    pub target_path: PathBuf,
342    /// Number of lines extracted.
343    pub extracted_lines_count: usize,
344    /// Number of sources/footnotes moved or copied.
345    pub moved_sources_count: usize,
346    /// List of all modified or created file paths.
347    pub affected_files: Vec<PathBuf>,
348    /// Whether this was a dry run.
349    pub dry_run: bool,
350}
351
352impl fmt::Display for SplitReport {
353    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
354        let prefix = if self.dry_run {
355            "[dry-run] would extract"
356        } else {
357            "extracted"
358        };
359        writeln!(
360            f,
361            "{prefix} section '{}' from {} -> {}",
362            self.section, self.source, self.target
363        )?;
364        writeln!(f, "  extracted {} line(s)", self.extracted_lines_count)?;
365        writeln!(f, "  moved {} source/footnote(s)", self.moved_sources_count)?;
366        write!(f, "  created {}", self.target_path.display())
367    }
368}
369
370/// Options for merging one concept into another.
371#[derive(Clone, Debug, PartialEq, Eq)]
372#[allow(clippy::struct_excessive_bools)]
373pub struct MergeOptions {
374    /// Optional heading under which to append source content in target.
375    pub heading: Option<String>,
376    /// Force merge even if non-fatal warnings exist.
377    pub force: bool,
378    /// If true, simulate changes without writing to disk.
379    pub dry_run: bool,
380    /// Regenerate index.md listings after merge.
381    pub update_index: bool,
382    /// Record the merge in log.md.
383    pub update_log: bool,
384    /// Author attribution for the log entry.
385    pub author: Option<String>,
386}
387
388impl Default for MergeOptions {
389    fn default() -> Self {
390        Self {
391            heading: None,
392            force: false,
393            dry_run: false,
394            update_index: true,
395            update_log: true,
396            author: None,
397        }
398    }
399}
400
401/// Summary report of a merge operation.
402#[derive(Clone, Debug, PartialEq, Eq)]
403pub struct MergeReport {
404    /// Source concept that was merged and removed.
405    pub source: ConceptId,
406    /// Target concept that received the merged content.
407    pub target: ConceptId,
408    /// Path of the removed source file.
409    pub removed_path: PathBuf,
410    /// Path of the updated target file.
411    pub updated_path: PathBuf,
412    /// Number of incoming links rewritten from source to target.
413    pub rewritten_links_count: usize,
414    /// Number of sources merged into target frontmatter.
415    pub merged_sources_count: usize,
416    /// List of all modified or removed file paths.
417    pub affected_files: Vec<PathBuf>,
418    /// Whether this was a dry run.
419    pub dry_run: bool,
420}
421
422impl fmt::Display for MergeReport {
423    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
424        let prefix = if self.dry_run {
425            "[dry-run] would merge"
426        } else {
427            "merged"
428        };
429        writeln!(f, "{prefix} concept {} -> {}", self.source, self.target)?;
430        writeln!(
431            f,
432            "  rewrote {} incoming link(s)",
433            self.rewritten_links_count
434        )?;
435        writeln!(f, "  merged {} source(s)", self.merged_sources_count)?;
436        writeln!(f, "  removed {}", self.removed_path.display())?;
437        write!(f, "  updated {}", self.updated_path.display())
438    }
439}
440
441/// Options for renaming a section heading within a concept.
442#[derive(Clone, Debug, PartialEq, Eq)]
443pub struct RenameSectionOptions {
444    /// If true, simulate changes without writing to disk.
445    pub dry_run: bool,
446    /// Record the section rename in log.md.
447    pub update_log: bool,
448    /// Author attribution for the log entry.
449    pub author: Option<String>,
450}
451
452impl Default for RenameSectionOptions {
453    fn default() -> Self {
454        Self {
455            dry_run: false,
456            update_log: true,
457            author: None,
458        }
459    }
460}
461
462/// Summary report of a section rename operation.
463#[derive(Clone, Debug, PartialEq, Eq)]
464pub struct RenameSectionReport {
465    /// Concept ID containing the section.
466    pub concept: ConceptId,
467    /// Original section name / query.
468    pub old_section: String,
469    /// New section name.
470    pub new_section: String,
471    /// Old anchor slug.
472    pub old_slug: String,
473    /// New anchor slug.
474    pub new_slug: String,
475    /// Number of in-document anchors updated.
476    pub internal_links_updated: usize,
477    /// Number of external backlinks updated across the bundle.
478    pub external_links_updated: usize,
479    /// Affected files.
480    pub affected_files: Vec<PathBuf>,
481    /// Whether this was a dry run.
482    pub dry_run: bool,
483}
484
485impl fmt::Display for RenameSectionReport {
486    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
487        let prefix = if self.dry_run {
488            "[dry-run] would rename"
489        } else {
490            "renamed"
491        };
492        writeln!(
493            f,
494            "{prefix} section '{}' -> '{}' in {}",
495            self.old_section, self.new_section, self.concept
496        )?;
497        writeln!(
498            f,
499            "  updated {} internal link(s)",
500            self.internal_links_updated
501        )?;
502        writeln!(
503            f,
504            "  updated {} external backlink(s)",
505            self.external_links_updated
506        )?;
507        write!(f, "  affected {} file(s)", self.affected_files.len())
508    }
509}
510
511/// Computes the relative markdown link path from `from_concept` to `to_concept`.
512///
513/// Example:
514/// - `from: auth/tokens/jwt`, `to: auth/tokens/refresh` -> `"refresh.md"`
515/// - `from: auth/tokens/jwt`, `to: users/profile` -> `"../../users/profile.md"`
516/// - `from: overview`, `to: tables/users` -> `"tables/users.md"`
517/// - `from: tables/users`, `to: overview` -> `"../overview.md"`
518#[must_use]
519pub fn compute_relative_path(from_concept: &ConceptId, to_concept: &ConceptId) -> String {
520    let from_dir: Vec<String> = from_concept
521        .parent()
522        .map(|p| p.segments().to_vec())
523        .unwrap_or_default();
524    let to_segments = to_concept.segments();
525    let to_dir = &to_segments[..to_segments.len().saturating_sub(1)];
526    let to_name = to_segments.last().map_or("", String::as_str);
527
528    // Find common directory prefix
529    let mut common_len = 0;
530    while common_len < from_dir.len()
531        && common_len < to_dir.len()
532        && from_dir[common_len] == to_dir[common_len]
533    {
534        common_len += 1;
535    }
536
537    let steps_up = from_dir.len() - common_len;
538    let mut parts: Vec<&str> = Vec::new();
539    parts.extend(std::iter::repeat_n("..", steps_up));
540    for seg in &to_dir[common_len..] {
541        parts.push(seg);
542    }
543    let file_part = format!("{to_name}.md");
544    parts.push(&file_part);
545    parts.join("/")
546}
547
548/// Rebases a relative file/resource path from `old_dir` to `new_dir`.
549///
550/// If `rel_path` is external or absolute (`/`), it is returned as-is.
551#[must_use]
552pub fn rebase_relative_path(old_dir: &[String], new_dir: &[String], rel_path: &str) -> String {
553    let trimmed = rel_path.trim();
554    if trimmed.starts_with('/') || Link::classify(trimmed) == LinkKind::External {
555        return trimmed.to_string();
556    }
557
558    // Split anchor if present
559    let (path_part, anchor_part) = trimmed
560        .find('#')
561        .map_or((trimmed, ""), |idx| (&trimmed[..idx], &trimmed[idx..]));
562
563    if path_part.is_empty() {
564        return trimmed.to_string();
565    }
566
567    // Normalize old_dir + path_part into bundle-relative segments
568    let mut resolved = old_dir.to_vec();
569    for seg in path_part.split('/') {
570        match seg {
571            "" | "." => {}
572            ".." => {
573                resolved.pop();
574            }
575            other => resolved.push(other.to_string()),
576        }
577    }
578
579    let resolved_dir = if resolved.is_empty() {
580        &[][..]
581    } else {
582        &resolved[..resolved.len() - 1]
583    };
584    let filename = resolved.last().map_or("", String::as_str);
585
586    // Compute relative path from new_dir to resolved segments
587    let mut common_len = 0;
588    while common_len < new_dir.len()
589        && common_len < resolved_dir.len()
590        && new_dir[common_len] == resolved_dir[common_len]
591    {
592        common_len += 1;
593    }
594
595    let steps_up = new_dir.len() - common_len;
596    let mut parts: Vec<&str> = Vec::new();
597    parts.extend(std::iter::repeat_n("..", steps_up));
598    for seg in &resolved_dir[common_len..] {
599        parts.push(seg);
600    }
601    if !filename.is_empty() {
602        parts.push(filename);
603    }
604
605    let rebased = if parts.is_empty() {
606        ".".to_string()
607    } else {
608        parts.join("/")
609    };
610    format!("{rebased}{anchor_part}")
611}
612
613/// Renames a section heading within a concept and updates all internal and external anchor links.
614///
615/// # Errors
616///
617/// Returns [`RefactorError::ConceptNotFound`] if `concept_id` is not in the bundle,
618/// [`RefactorError::SectionNotFound`] if the section is not found in the body, or
619/// [`RefactorError::Io`] on filesystem error.
620#[allow(clippy::too_many_lines)]
621pub fn rename_section(
622    bundle: &Bundle,
623    concept_id: &ConceptId,
624    old_section: &str,
625    new_section: &str,
626    options: &RenameSectionOptions,
627) -> Result<RenameSectionReport, RefactorError> {
628    let concept = bundle
629        .get(concept_id)
630        .ok_or_else(|| RefactorError::ConceptNotFound(concept_id.clone()))?;
631
632    let clean_old = old_section.trim().trim_start_matches('#').trim();
633    let clean_new = new_section.trim().trim_start_matches('#').trim();
634    let old_slug = heading_slug(clean_old);
635    let new_slug = heading_slug(clean_new);
636
637    let mut doc = concept.document.clone();
638    let mut found_heading = false;
639    let mut matched_title = String::new();
640    let mut new_lines = Vec::new();
641
642    let mut fence: Option<char> = None;
643
644    for line in doc.body.lines() {
645        let trimmed = line.trim();
646        let trimmed_start = line.trim_start();
647
648        if let Some(f) = fence {
649            if trimmed_start.starts_with(&f.to_string().repeat(3)) {
650                fence = None;
651            }
652            new_lines.push(line.to_string());
653            continue;
654        }
655        if trimmed_start.starts_with("```") {
656            fence = Some('`');
657            new_lines.push(line.to_string());
658            continue;
659        }
660        if trimmed_start.starts_with("~~~") {
661            fence = Some('~');
662            new_lines.push(line.to_string());
663            continue;
664        }
665
666        if !found_heading && trimmed.starts_with('#') {
667            let hashes = trimmed.chars().take_while(|c| *c == '#').count();
668            let title = trimmed[hashes..].trim();
669            if title.eq_ignore_ascii_case(clean_old)
670                || heading_slug(title) == old_slug
671                || title
672                    .replace(['-', '_'], " ")
673                    .eq_ignore_ascii_case(&clean_old.replace(['-', '_'], " "))
674            {
675                found_heading = true;
676                matched_title = title.to_string();
677                let hash_prefix = &trimmed[..hashes];
678                new_lines.push(format!("{hash_prefix} {clean_new}"));
679                continue;
680            }
681        }
682        new_lines.push(line.to_string());
683    }
684
685    if !found_heading {
686        return Err(RefactorError::SectionNotFound {
687            concept: concept_id.clone(),
688            section: old_section.to_string(),
689        });
690    }
691
692    let updated_body_text = if doc.body.ends_with('\n') {
693        format!("{}\n", new_lines.join("\n"))
694    } else {
695        new_lines.join("\n")
696    };
697
698    let matched_slug = heading_slug(&matched_title);
699
700    // 1. Rewrite in-document anchors inside the same concept
701    let (rewritten_doc_body, internal_count) =
702        rewrite_markdown_links(&updated_body_text, |link, _| {
703            if link.kind == LinkKind::Anchor {
704                let anchor_text = link.target.trim_start_matches('#');
705                if anchor_text == old_slug
706                    || anchor_text == matched_slug
707                    || heading_slug(anchor_text) == old_slug
708                    || heading_slug(anchor_text) == matched_slug
709                {
710                    return LinkRewriteAction::Rewrite(format!("#{new_slug}"));
711                }
712            }
713            LinkRewriteAction::Keep
714        });
715    doc.body = rewritten_doc_body;
716
717    let concept_path = concept_id.to_path(bundle.root());
718    let mut affected_files = vec![concept_path.clone()];
719    let mut external_count = 0;
720    let mut updated_other_docs: Vec<(PathBuf, Document)> = Vec::new();
721
722    // 2. Rewrite backlinks with #anchor pointing to this concept across the bundle
723    for other_concept in bundle.concepts() {
724        if other_concept.id == *concept_id {
725            continue;
726        }
727
728        let mut other_doc = other_concept.document.clone();
729        let other_id = &other_concept.id;
730
731        let (new_other_body, count) = rewrite_markdown_links(&other_doc.body, |link, _| {
732            if let Some(resolved_id) = link.resolve(other_id)
733                && resolved_id == *concept_id
734                && let Some(anchor_idx) = link.target.find('#')
735            {
736                let anchor_text = &link.target[anchor_idx + 1..];
737                if anchor_text == old_slug
738                    || anchor_text == matched_slug
739                    || heading_slug(anchor_text) == old_slug
740                    || heading_slug(anchor_text) == matched_slug
741                {
742                    let path_part = &link.target[..anchor_idx];
743                    return LinkRewriteAction::Rewrite(format!("{path_part}#{new_slug}"));
744                }
745            }
746            LinkRewriteAction::Keep
747        });
748
749        if count > 0 {
750            other_doc.body = new_other_body;
751            external_count += count;
752            let path = other_id.to_path(bundle.root());
753            affected_files.push(path.clone());
754            updated_other_docs.push((path, other_doc));
755        }
756    }
757
758    if !options.dry_run {
759        fs::write(&concept_path, doc.serialize())?;
760        for (path, other_d) in updated_other_docs {
761            fs::write(&path, other_d.serialize())?;
762        }
763
764        if options.update_log {
765            let today = Date::today_utc().unwrap_or(Date {
766                year: 2026,
767                month: 8,
768                day: 24,
769            });
770            let author_suffix = options
771                .author
772                .as_ref()
773                .map_or(String::new(), |a| format!(" (by {a})"));
774            let log_msg = format!(
775                "Renamed section `{clean_old}` to `{clean_new}` in concept `{concept_id}` (updated {internal_count} internal link(s), {external_count} external backlink(s)){author_suffix}."
776            );
777            let _ = append_log_entry(bundle.root(), today, "Update", &log_msg);
778        }
779    }
780
781    Ok(RenameSectionReport {
782        concept: concept_id.clone(),
783        old_section: clean_old.to_string(),
784        new_section: clean_new.to_string(),
785        old_slug,
786        new_slug,
787        internal_links_updated: internal_count,
788        external_links_updated: external_count,
789        affected_files,
790        dry_run: options.dry_run,
791    })
792}
793
794/// Moves or renames a concept, rewriting all incoming links and rebasing outgoing links.
795///
796/// # Errors
797///
798/// Returns [`RefactorError::SameSourceAndTarget`] if `source` equals `target`,
799/// [`RefactorError::ConceptNotFound`] if `source` does not exist,
800/// [`RefactorError::ConceptAlreadyExists`] if `target` exists and `force` is false, or
801/// [`RefactorError::Io`] on filesystem error.
802#[allow(clippy::too_many_lines)]
803pub fn move_concept(
804    bundle: &Bundle,
805    source: &ConceptId,
806    target: &ConceptId,
807    options: &MoveOptions,
808) -> Result<MoveReport, RefactorError> {
809    if source == target {
810        return Err(RefactorError::SameSourceAndTarget(source.clone()));
811    }
812
813    let source_concept = bundle
814        .get(source)
815        .ok_or_else(|| RefactorError::ConceptNotFound(source.clone()))?;
816
817    let target_path = target.to_path(bundle.root());
818    let source_path = source.to_path(bundle.root());
819
820    if !options.force && bundle.get(target).is_some() {
821        return Err(RefactorError::ConceptAlreadyExists(target.clone()));
822    }
823
824    let source_dir: Vec<String> = source
825        .parent()
826        .map(|p| p.segments().to_vec())
827        .unwrap_or_default();
828    let target_dir: Vec<String> = target
829        .parent()
830        .map(|p| p.segments().to_vec())
831        .unwrap_or_default();
832
833    // 1. Rebase outgoing links inside the moved document
834    let mut moved_doc = source_concept.document.clone();
835    let (rebased_body, rebased_outgoing_count) =
836        rewrite_markdown_links(&moved_doc.body, |link, _| {
837            match link.kind {
838                LinkKind::Relative => {
839                    // Try resolving to a concept id
840                    link.resolve(source).map_or_else(
841                        || {
842                            // Relative file path (non-concept)
843                            let new_rel =
844                                rebase_relative_path(&source_dir, &target_dir, &link.target);
845                            LinkRewriteAction::Rewrite(new_rel)
846                        },
847                        |dest_concept| {
848                            let new_rel = compute_relative_path(target, &dest_concept);
849                            let anchor =
850                                link.target.find('#').map_or("", |idx| &link.target[idx..]);
851                            LinkRewriteAction::Rewrite(format!("{new_rel}{anchor}"))
852                        },
853                    )
854                }
855                _ => LinkRewriteAction::Keep,
856            }
857        });
858    moved_doc.body = rebased_body;
859
860    // 2. Rebase relative frontmatter paths in the moved document
861    let mut rebased_fm_count = 0;
862    rebase_frontmatter_paths(
863        &mut moved_doc.frontmatter,
864        &source_dir,
865        &target_dir,
866        &mut rebased_fm_count,
867    );
868
869    let mut affected_files = Vec::new();
870    let mut rewritten_incoming_total = 0;
871
872    // 3. Rewrite incoming links in all other documents in the bundle
873    let mut updated_other_docs: Vec<(PathBuf, Document)> = Vec::new();
874
875    for other_concept in bundle.concepts() {
876        if other_concept.id == *source {
877            continue;
878        }
879
880        let mut other_doc = other_concept.document.clone();
881        let other_id = &other_concept.id;
882        let mut modified = false;
883
884        let (new_body, rewritten_count) = rewrite_markdown_links(&other_doc.body, |link, _| {
885            if let Some(resolved_id) = link.resolve(other_id)
886                && resolved_id == *source
887            {
888                let anchor = link.target.find('#').map_or("", |idx| &link.target[idx..]);
889                match link.kind {
890                    LinkKind::Absolute => {
891                        LinkRewriteAction::Rewrite(format!("/{target}.md{anchor}"))
892                    }
893                    LinkKind::Relative => {
894                        let new_rel = compute_relative_path(other_id, target);
895                        LinkRewriteAction::Rewrite(format!("{new_rel}{anchor}"))
896                    }
897                    _ => LinkRewriteAction::Keep,
898                }
899            } else {
900                LinkRewriteAction::Keep
901            }
902        });
903
904        if rewritten_count > 0 {
905            other_doc.body = new_body;
906            rewritten_incoming_total += rewritten_count;
907            modified = true;
908        }
909
910        // Check frontmatter sources and path references in other documents
911        if rewrite_frontmatter_concept_references(
912            &mut other_doc.frontmatter,
913            other_id,
914            source,
915            target,
916        ) {
917            modified = true;
918        }
919
920        if modified {
921            let path = other_id.to_path(bundle.root());
922            affected_files.push(path.clone());
923            updated_other_docs.push((path, other_doc));
924        }
925    }
926
927    affected_files.push(source_path.clone());
928    affected_files.push(target_path.clone());
929
930    if !options.dry_run {
931        // Create destination directory if needed
932        if let Some(parent) = target_path.parent() {
933            fs::create_dir_all(parent)?;
934        }
935
936        // Write moved document to new location
937        fs::write(&target_path, moved_doc.serialize())?;
938
939        // Remove source document
940        if source_path.exists() && source_path != target_path {
941            fs::remove_file(&source_path)?;
942        }
943
944        // Write updated other documents
945        for (path, doc) in updated_other_docs {
946            fs::write(&path, doc.serialize())?;
947        }
948
949        // Regenerate indexes
950        if options.update_index {
951            let _ = regenerate_indexes(bundle.root());
952        }
953
954        // Update log.md
955        if options.update_log {
956            let today = Date::today_utc().unwrap_or(Date {
957                year: 2026,
958                month: 8,
959                day: 24,
960            });
961            let author_suffix = options
962                .author
963                .as_ref()
964                .map_or(String::new(), |a| format!(" (by {a})"));
965            let log_msg = format!(
966                "Renamed concept `{source}` to `{target}` (rewrote {rewritten_incoming_total} incoming links, rebased {rebased_outgoing_count} outgoing links){author_suffix}."
967            );
968            let _ = append_log_entry(bundle.root(), today, "Update", &log_msg);
969        }
970    }
971
972    Ok(MoveReport {
973        source: source.clone(),
974        target: target.clone(),
975        source_path,
976        target_path,
977        rewritten_incoming_links: rewritten_incoming_total,
978        rebased_outgoing_links: rebased_outgoing_count,
979        rebased_frontmatter_paths: rebased_fm_count,
980        affected_files,
981        dry_run: options.dry_run,
982    })
983}
984
985/// Safely removes a concept from the bundle.
986///
987/// # Errors
988///
989/// Returns [`RefactorError::ConceptNotFound`] if `target` is not in the bundle,
990/// [`RefactorError::HasInboundLinks`] if other concepts link to `target` and `force`/`redirect_to`/`unlink`
991/// were not given, or [`RefactorError::Io`] on filesystem error.
992#[allow(clippy::too_many_lines)]
993pub fn remove_concept(
994    bundle: &Bundle,
995    target: &ConceptId,
996    options: &RemoveOptions,
997) -> Result<RemoveReport, RefactorError> {
998    if bundle.get(target).is_none() {
999        return Err(RefactorError::ConceptNotFound(target.clone()));
1000    }
1001
1002    let inbound = bundle.backlinks(target);
1003    let has_inbound = !inbound.is_empty();
1004
1005    if has_inbound && !options.force && options.redirect_to.is_none() && !options.unlink {
1006        return Err(RefactorError::HasInboundLinks {
1007            target: target.clone(),
1008            inbound_count: inbound.len(),
1009            inbound_concepts: inbound.to_vec(),
1010        });
1011    }
1012
1013    let target_path = target.to_path(bundle.root());
1014    let mut affected_files = vec![target_path.clone()];
1015    let mut redirected_count = 0;
1016    let mut unlinked_count = 0;
1017    let mut updated_other_docs: Vec<(PathBuf, Document)> = Vec::new();
1018
1019    if options.redirect_to.is_some() || options.unlink {
1020        for other_concept in bundle.concepts() {
1021            if other_concept.id == *target {
1022                continue;
1023            }
1024
1025            let mut other_doc = other_concept.document.clone();
1026            let other_id = &other_concept.id;
1027            let mut modified = false;
1028
1029            let (new_body, count) = rewrite_markdown_links(&other_doc.body, |link, _| {
1030                if let Some(resolved_id) = link.resolve(other_id)
1031                    && resolved_id == *target
1032                {
1033                    options.redirect_to.as_ref().map_or_else(
1034                        || {
1035                            if options.unlink {
1036                                LinkRewriteAction::Unlink
1037                            } else {
1038                                LinkRewriteAction::Keep
1039                            }
1040                        },
1041                        |redirect_id| {
1042                            let anchor =
1043                                link.target.find('#').map_or("", |idx| &link.target[idx..]);
1044                            match link.kind {
1045                                LinkKind::Absolute => {
1046                                    LinkRewriteAction::Rewrite(format!("/{redirect_id}.md{anchor}"))
1047                                }
1048                                LinkKind::Relative => {
1049                                    let new_rel = compute_relative_path(other_id, redirect_id);
1050                                    LinkRewriteAction::Rewrite(format!("{new_rel}{anchor}"))
1051                                }
1052                                _ => LinkRewriteAction::Keep,
1053                            }
1054                        },
1055                    )
1056                } else {
1057                    LinkRewriteAction::Keep
1058                }
1059            });
1060
1061            if count > 0 {
1062                if options.redirect_to.is_some() {
1063                    redirected_count += count;
1064                } else {
1065                    unlinked_count += count;
1066                }
1067                other_doc.body = new_body;
1068                modified = true;
1069            }
1070
1071            // Also check frontmatter sources
1072            if let Some(redirect_id) = &options.redirect_to
1073                && rewrite_frontmatter_concept_references(
1074                    &mut other_doc.frontmatter,
1075                    other_id,
1076                    target,
1077                    redirect_id,
1078                )
1079            {
1080                modified = true;
1081            }
1082
1083            if modified {
1084                let path = other_id.to_path(bundle.root());
1085                affected_files.push(path.clone());
1086                updated_other_docs.push((path, other_doc));
1087            }
1088        }
1089    }
1090
1091    if !options.dry_run {
1092        // Delete target file
1093        if target_path.exists() {
1094            fs::remove_file(&target_path)?;
1095        }
1096
1097        // Write updated docs
1098        for (path, doc) in updated_other_docs {
1099            fs::write(&path, doc.serialize())?;
1100        }
1101
1102        // Regenerate indexes
1103        if options.update_index {
1104            let _ = regenerate_indexes(bundle.root());
1105        }
1106
1107        // Update log.md
1108        if options.update_log {
1109            let today = Date::today_utc().unwrap_or(Date {
1110                year: 2026,
1111                month: 8,
1112                day: 24,
1113            });
1114            let log_msg = options.redirect_to.as_ref().map_or_else(
1115                || {
1116                    if options.unlink {
1117                        format!("Removed concept `{target}` (unlinked {unlinked_count} inbound links).")
1118                    } else {
1119                        format!("Removed concept `{target}`.")
1120                    }
1121                },
1122                |redirect_id| {
1123                    format!("Removed concept `{target}` (redirected {redirected_count} inbound links to `{redirect_id}`).")
1124                },
1125            );
1126            let _ = append_log_entry(bundle.root(), today, "Update", &log_msg);
1127        }
1128    }
1129
1130    Ok(RemoveReport {
1131        target: target.clone(),
1132        removed_path: target_path,
1133        redirected_to: options.redirect_to.clone(),
1134        redirected_count,
1135        unlinked_count,
1136        affected_files,
1137        dry_run: options.dry_run,
1138    })
1139}
1140
1141/// Splits a section from an existing concept into a new concept.
1142///
1143/// # Errors
1144///
1145/// Returns [`RefactorError::SameSourceAndTarget`] if `source` equals `target`,
1146/// [`RefactorError::ConceptNotFound`] if `source` is not found,
1147/// [`RefactorError::ConceptAlreadyExists`] if `target` exists and `force` is false,
1148/// [`RefactorError::SectionNotFound`] if the section is not found in `source`, or
1149/// [`RefactorError::Io`] on filesystem error.
1150#[allow(clippy::too_many_lines)]
1151pub fn split_concept(
1152    bundle: &Bundle,
1153    source: &ConceptId,
1154    target: &ConceptId,
1155    options: &SplitOptions,
1156) -> Result<SplitReport, RefactorError> {
1157    if source == target {
1158        return Err(RefactorError::SameSourceAndTarget(source.clone()));
1159    }
1160
1161    let source_concept = bundle
1162        .get(source)
1163        .ok_or_else(|| RefactorError::ConceptNotFound(source.clone()))?;
1164
1165    if !options.force && bundle.get(target).is_some() {
1166        return Err(RefactorError::ConceptAlreadyExists(target.clone()));
1167    }
1168
1169    let target_path = target.to_path(bundle.root());
1170    let source_path = source.to_path(bundle.root());
1171
1172    let (extracted_heading, extracted_lines, new_source_body) = extract_section_from_body(
1173        &source_concept.document.body,
1174        &options.section,
1175        source,
1176        target,
1177        options.link_text.as_deref().or(options.title.as_deref()),
1178    )
1179    .ok_or_else(|| RefactorError::SectionNotFound {
1180        concept: source.clone(),
1181        section: options.section.clone(),
1182    })?;
1183
1184    let target_title = options
1185        .title
1186        .clone()
1187        .unwrap_or_else(|| extracted_heading.clone());
1188
1189    // Identify footnotes in extracted lines vs remaining source lines
1190    let extracted_text = extracted_lines.join("\n");
1191    let extracted_refs: HashSet<String> = crate::footnotes::extract_refs(&extracted_text)
1192        .into_iter()
1193        .map(|r| r.label)
1194        .collect();
1195
1196    let remaining_refs: HashSet<String> = crate::footnotes::extract_refs(&new_source_body)
1197        .into_iter()
1198        .map(|r| r.label)
1199        .collect();
1200
1201    let all_defs = source_concept.document.footnote_definitions();
1202    let mut target_defs = Vec::new();
1203
1204    for def in all_defs {
1205        let in_extracted = extracted_refs.contains(&def.label);
1206        if in_extracted {
1207            target_defs.push(def.clone());
1208        }
1209    }
1210
1211    // Build new target document
1212    let mut target_fm = Frontmatter::new();
1213    target_fm.set(
1214        "type",
1215        Value::String(
1216            options
1217                .type_
1218                .clone()
1219                .unwrap_or_else(|| "Concept".to_string()),
1220        ),
1221    );
1222    target_fm.set("title", Value::String(target_title.clone()));
1223    let target_desc = format!("Extracted details for {target_title}.");
1224    target_fm.set("description", Value::String(target_desc));
1225    target_fm.set(
1226        "status",
1227        Value::String(source_concept.document.frontmatter.status().to_string()),
1228    );
1229
1230    let author = options.author.clone().unwrap_or_else(default_author);
1231    let mut gen_map = crate::yaml::Mapping::new();
1232    gen_map.insert("by", Value::String(author));
1233    gen_map.insert("at", Value::String(current_iso_timestamp()));
1234    target_fm.set("generated", Value::Mapping(gen_map));
1235
1236    // Copy relevant sources to target frontmatter
1237    let all_sources = source_concept.document.frontmatter.sources();
1238    let mut target_sources = Vec::new();
1239    let mut kept_sources = Vec::new();
1240
1241    for src in all_sources {
1242        let src_id = src.id.as_deref().unwrap_or("");
1243        let in_extracted = extracted_refs.contains(src_id);
1244        let in_remaining = remaining_refs.contains(src_id);
1245        if in_extracted {
1246            target_sources.push(src.clone());
1247        }
1248        if in_remaining {
1249            kept_sources.push(src);
1250        }
1251    }
1252
1253    if !target_sources.is_empty() {
1254        let seq = target_sources.iter().map(Source::to_yaml_value).collect();
1255        target_fm.set("sources", Value::Sequence(seq));
1256    }
1257
1258    let mut target_body = format!("# {target_title}\n\n{}\n", extracted_text.trim());
1259    if !target_defs.is_empty() {
1260        target_body.push('\n');
1261        for def in &target_defs {
1262            let _ = writeln!(target_body, "[^{}]: {}", def.label, def.text);
1263        }
1264    }
1265
1266    let target_doc = Document::new(target_fm, target_body);
1267
1268    // Update source document
1269    let mut new_source_doc = source_concept.document.clone();
1270    new_source_doc.body = new_source_body;
1271    if kept_sources.is_empty() {
1272        new_source_doc.frontmatter.remove("sources");
1273    } else {
1274        let seq = kept_sources.iter().map(Source::to_yaml_value).collect();
1275        new_source_doc
1276            .frontmatter
1277            .set("sources", Value::Sequence(seq));
1278    }
1279
1280    let affected_files = vec![source_path.clone(), target_path.clone()];
1281
1282    if !options.dry_run {
1283        if let Some(parent) = target_path.parent() {
1284            fs::create_dir_all(parent)?;
1285        }
1286        fs::write(&target_path, target_doc.serialize())?;
1287        fs::write(&source_path, new_source_doc.serialize())?;
1288
1289        if options.update_index {
1290            let _ = regenerate_indexes(bundle.root());
1291        }
1292
1293        if options.update_log {
1294            let today = Date::today_utc().unwrap_or(Date {
1295                year: 2026,
1296                month: 8,
1297                day: 24,
1298            });
1299            let author_suffix = options
1300                .author
1301                .as_ref()
1302                .map_or(String::new(), |a| format!(" (by {a})"));
1303            let log_msg = format!(
1304                "Extracted section `{}` from `{source}` into new concept `{target}`{author_suffix}.",
1305                options.section
1306            );
1307            let _ = append_log_entry(bundle.root(), today, "Creation", &log_msg);
1308        }
1309    }
1310
1311    Ok(SplitReport {
1312        source: source.clone(),
1313        target: target.clone(),
1314        section: options.section.clone(),
1315        target_title,
1316        target_path,
1317        extracted_lines_count: extracted_lines.len(),
1318        moved_sources_count: target_sources.len(),
1319        affected_files,
1320        dry_run: options.dry_run,
1321    })
1322}
1323
1324/// Merges `source` concept into `target` concept and deletes `source`.
1325///
1326/// # Errors
1327///
1328/// Returns [`RefactorError::SameSourceAndTarget`] if `source` equals `target`,
1329/// [`RefactorError::ConceptNotFound`] if either `source` or `target` is not found, or
1330/// [`RefactorError::Io`] on filesystem error.
1331#[allow(clippy::too_many_lines)]
1332pub fn merge_concepts(
1333    bundle: &Bundle,
1334    source: &ConceptId,
1335    target: &ConceptId,
1336    options: &MergeOptions,
1337) -> Result<MergeReport, RefactorError> {
1338    if source == target {
1339        return Err(RefactorError::SameSourceAndTarget(source.clone()));
1340    }
1341
1342    let source_concept = bundle
1343        .get(source)
1344        .ok_or_else(|| RefactorError::ConceptNotFound(source.clone()))?;
1345    let target_concept = bundle
1346        .get(target)
1347        .ok_or_else(|| RefactorError::ConceptNotFound(target.clone()))?;
1348
1349    let source_path = source.to_path(bundle.root());
1350    let target_path = target.to_path(bundle.root());
1351
1352    let source_dir: Vec<String> = source
1353        .parent()
1354        .map(|p| p.segments().to_vec())
1355        .unwrap_or_default();
1356    let target_dir: Vec<String> = target
1357        .parent()
1358        .map(|p| p.segments().to_vec())
1359        .unwrap_or_default();
1360
1361    // 1. Rebase outgoing links in source body to target directory
1362    let (rebased_source_body, _) =
1363        rewrite_markdown_links(&source_concept.document.body, |link, _| match link.kind {
1364            LinkKind::Relative => link.resolve(source).map_or_else(
1365                || {
1366                    let new_rel = rebase_relative_path(&source_dir, &target_dir, &link.target);
1367                    LinkRewriteAction::Rewrite(new_rel)
1368                },
1369                |dest_concept| {
1370                    if dest_concept == *target {
1371                        LinkRewriteAction::Keep
1372                    } else {
1373                        let new_rel = compute_relative_path(target, &dest_concept);
1374                        let anchor = link.target.find('#').map_or("", |idx| &link.target[idx..]);
1375                        LinkRewriteAction::Rewrite(format!("{new_rel}{anchor}"))
1376                    }
1377                },
1378            ),
1379            _ => LinkRewriteAction::Keep,
1380        });
1381
1382    // 2. Merge sources and resolve ID collisions
1383    let mut target_doc = target_concept.document.clone();
1384    let mut target_sources = target_doc.frontmatter.sources();
1385    let target_defs = target_doc.footnote_definitions();
1386    let mut existing_ids: HashSet<String> = target_sources
1387        .iter()
1388        .filter_map(|s| s.id.clone())
1389        .chain(target_defs.iter().map(|d| d.label.clone()))
1390        .collect();
1391
1392    let mut merged_sources_count = 0;
1393    let mut source_body_final = rebased_source_body;
1394
1395    for mut src in source_concept.document.frontmatter.sources() {
1396        if let Some(orig_id) = src.id.clone() {
1397            if existing_ids.contains(&orig_id) {
1398                // Check if identical source already exists
1399                let is_identical = target_sources
1400                    .iter()
1401                    .any(|s| s.id.as_deref() == Some(&orig_id) && s.resource == src.resource);
1402                if !is_identical {
1403                    // Remap id
1404                    let new_id = format!("{}_{orig_id}", source.name());
1405                    source_body_final = source_body_final
1406                        .replace(&format!("[^{orig_id}]"), &format!("[^{new_id}]"));
1407                    source_body_final = source_body_final
1408                        .replace(&format!("[^{orig_id}]:"), &format!("[^{new_id}]:"));
1409                    src.id = Some(new_id.clone());
1410                    existing_ids.insert(new_id);
1411                    target_sources.push(src);
1412                    merged_sources_count += 1;
1413                }
1414            } else {
1415                existing_ids.insert(orig_id);
1416                target_sources.push(src);
1417                merged_sources_count += 1;
1418            }
1419        } else {
1420            target_sources.push(src);
1421            merged_sources_count += 1;
1422        }
1423    }
1424
1425    if !target_sources.is_empty() {
1426        let seq = target_sources.iter().map(Source::to_yaml_value).collect();
1427        target_doc.frontmatter.set("sources", Value::Sequence(seq));
1428    }
1429
1430    // 3. Merge verified events
1431    let mut target_verified = target_doc.frontmatter.verified();
1432    for v in source_concept.document.frontmatter.verified() {
1433        if !target_verified.contains(&v) {
1434            target_verified.push(v);
1435        }
1436    }
1437    if !target_verified.is_empty() {
1438        let seq = target_verified
1439            .iter()
1440            .map(|v| {
1441                let mut map = crate::yaml::Mapping::new();
1442                if let Some(by) = &v.by {
1443                    map.insert("by", Value::String(by.as_str().to_string()));
1444                }
1445                if let Some(at) = &v.at {
1446                    map.insert("at", Value::String(at.raw.clone()));
1447                }
1448                Value::Mapping(map)
1449            })
1450            .collect();
1451        target_doc.frontmatter.set("verified", Value::Sequence(seq));
1452    }
1453
1454    // 4. Append body under heading
1455    let heading = options.heading.clone().unwrap_or_else(|| {
1456        let source_title = source_concept
1457            .document
1458            .frontmatter
1459            .title()
1460            .map_or_else(|| source.name().to_string(), std::borrow::Cow::into_owned);
1461        format!("## {source_title}")
1462    });
1463
1464    let body_to_append = {
1465        let trimmed = source_body_final.trim();
1466        trimmed.strip_prefix('#').map_or(trimmed, |rest| {
1467            let first_line = rest.lines().next().unwrap_or("");
1468            if first_line.starts_with('#') {
1469                trimmed
1470            } else {
1471                rest[first_line.len()..].trim_start()
1472            }
1473        })
1474    };
1475
1476    target_doc.body = format!(
1477        "{}\n\n{heading}\n\n{body_to_append}\n",
1478        target_doc.body.trim_end()
1479    );
1480
1481    let heading_anchor = heading_slug(&heading);
1482    let (new_target_body, target_rewritten) =
1483        rewrite_markdown_links(&target_doc.body, |link, _| {
1484            if let Some(resolved_id) = link.resolve(target)
1485                && resolved_id == *source
1486            {
1487                if heading_anchor.is_empty() {
1488                    LinkRewriteAction::Unlink
1489                } else {
1490                    LinkRewriteAction::Rewrite(format!("#{heading_anchor}"))
1491                }
1492            } else {
1493                LinkRewriteAction::Keep
1494            }
1495        });
1496    if target_rewritten > 0 {
1497        target_doc.body = new_target_body;
1498    }
1499
1500    // 5. Rewrite incoming backlinks across the bundle
1501    let mut affected_files = vec![source_path.clone(), target_path.clone()];
1502    let mut updated_other_docs: Vec<(PathBuf, Document)> = Vec::new();
1503    let mut rewritten_links_total = 0;
1504
1505    for other_concept in bundle.concepts() {
1506        if other_concept.id == *source || other_concept.id == *target {
1507            continue;
1508        }
1509
1510        let mut other_doc = other_concept.document.clone();
1511        let other_id = &other_concept.id;
1512        let mut modified = false;
1513
1514        let (new_body, count) = rewrite_markdown_links(&other_doc.body, |link, _| {
1515            if let Some(resolved_id) = link.resolve(other_id)
1516                && resolved_id == *source
1517            {
1518                let anchor = link.target.find('#').map_or("", |idx| &link.target[idx..]);
1519                match link.kind {
1520                    LinkKind::Absolute => {
1521                        LinkRewriteAction::Rewrite(format!("/{target}.md{anchor}"))
1522                    }
1523                    LinkKind::Relative => {
1524                        let new_rel = compute_relative_path(other_id, target);
1525                        LinkRewriteAction::Rewrite(format!("{new_rel}{anchor}"))
1526                    }
1527                    _ => LinkRewriteAction::Keep,
1528                }
1529            } else {
1530                LinkRewriteAction::Keep
1531            }
1532        });
1533
1534        if count > 0 {
1535            other_doc.body = new_body;
1536            rewritten_links_total += count;
1537            modified = true;
1538        }
1539
1540        if rewrite_frontmatter_concept_references(
1541            &mut other_doc.frontmatter,
1542            other_id,
1543            source,
1544            target,
1545        ) {
1546            modified = true;
1547        }
1548
1549        if modified {
1550            let path = other_id.to_path(bundle.root());
1551            affected_files.push(path.clone());
1552            updated_other_docs.push((path, other_doc));
1553        }
1554    }
1555
1556    if !options.dry_run {
1557        // Write updated target
1558        fs::write(&target_path, target_doc.serialize())?;
1559
1560        // Remove source file
1561        if source_path.exists() {
1562            fs::remove_file(&source_path)?;
1563        }
1564
1565        // Write updated other docs
1566        for (path, doc) in updated_other_docs {
1567            fs::write(&path, doc.serialize())?;
1568        }
1569
1570        // Regenerate indexes
1571        if options.update_index {
1572            let _ = regenerate_indexes(bundle.root());
1573        }
1574
1575        // Update log.md
1576        if options.update_log {
1577            let today = Date::today_utc().unwrap_or(Date {
1578                year: 2026,
1579                month: 8,
1580                day: 24,
1581            });
1582            let author_suffix = options
1583                .author
1584                .as_ref()
1585                .map_or(String::new(), |a| format!(" (by {a})"));
1586            let log_msg = format!(
1587                "Merged concept `{source}` into `{target}` (rewrote {rewritten_links_total} inbound links){author_suffix}."
1588            );
1589            let _ = append_log_entry(bundle.root(), today, "Update", &log_msg);
1590        }
1591    }
1592
1593    Ok(MergeReport {
1594        source: source.clone(),
1595        target: target.clone(),
1596        removed_path: source_path,
1597        updated_path: target_path,
1598        rewritten_links_count: rewritten_links_total,
1599        merged_sources_count,
1600        affected_files,
1601        dry_run: options.dry_run,
1602    })
1603}
1604
1605fn rebase_frontmatter_paths(
1606    fm: &mut Frontmatter,
1607    old_dir: &[String],
1608    new_dir: &[String],
1609    count: &mut usize,
1610) {
1611    if let Some(Value::String(s)) = fm.get("computation")
1612        && !s.starts_with('/')
1613        && links::Link::classify(s) == LinkKind::Relative
1614    {
1615        fm.set(
1616            "computation",
1617            Value::String(rebase_relative_path(old_dir, new_dir, s)),
1618        );
1619        *count += 1;
1620    }
1621
1622    if let Some(Value::Mapping(map)) = fm.get_mut("executor")
1623        && let Some(Value::String(res)) = map.get("resource")
1624        && !res.starts_with('/')
1625        && links::Link::classify(res) == LinkKind::Relative
1626    {
1627        map.insert(
1628            "resource",
1629            Value::String(rebase_relative_path(old_dir, new_dir, res)),
1630        );
1631        *count += 1;
1632    }
1633
1634    if let Some(Value::Mapping(map)) = fm.get_mut("attester")
1635        && let Some(Value::String(res)) = map.get("resource")
1636        && !res.starts_with('/')
1637        && links::Link::classify(res) == LinkKind::Relative
1638    {
1639        map.insert(
1640            "resource",
1641            Value::String(rebase_relative_path(old_dir, new_dir, res)),
1642        );
1643        *count += 1;
1644    }
1645
1646    if let Some(Value::Sequence(sources)) = fm.get_mut("sources") {
1647        for src_val in sources {
1648            if let Value::Mapping(src_map) = src_val
1649                && let Some(Value::String(res)) = src_map.get("resource")
1650                && !res.starts_with('/')
1651                && links::Link::classify(res) == LinkKind::Relative
1652            {
1653                src_map.insert(
1654                    "resource",
1655                    Value::String(rebase_relative_path(old_dir, new_dir, res)),
1656                );
1657                *count += 1;
1658            }
1659        }
1660    }
1661}
1662
1663fn rewrite_frontmatter_concept_references(
1664    fm: &mut Frontmatter,
1665    from_id: &ConceptId,
1666    old_target: &ConceptId,
1667    new_target: &ConceptId,
1668) -> bool {
1669    let mut modified = false;
1670
1671    if let Some(Value::Sequence(sources)) = fm.get_mut("sources") {
1672        for src_val in sources {
1673            if let Value::Mapping(src_map) = src_val
1674                && let Some(Value::String(res)) = src_map.get("resource")
1675            {
1676                let link = Link {
1677                    text: String::new(),
1678                    kind: Link::classify(res),
1679                    target: res.clone(),
1680                };
1681                if let Some(resolved) = link.resolve(from_id)
1682                    && resolved == *old_target
1683                {
1684                    let new_res = match link.kind {
1685                        LinkKind::Absolute => format!("/{new_target}.md"),
1686                        LinkKind::Relative => compute_relative_path(from_id, new_target),
1687                        _ => continue,
1688                    };
1689                    src_map.insert("resource", Value::String(new_res));
1690                    modified = true;
1691                }
1692            }
1693        }
1694    }
1695
1696    modified
1697}
1698
1699/// Extracts a section matching `section_query` from body lines and generates replacement.
1700fn extract_section_from_body(
1701    body: &str,
1702    section_query: &str,
1703    source: &ConceptId,
1704    target: &ConceptId,
1705    custom_link_text: Option<&str>,
1706) -> Option<(String, Vec<String>, String)> {
1707    let clean_query = section_query.trim().trim_start_matches('#').trim();
1708    let query_slug = heading_slug(clean_query);
1709    let lines: Vec<&str> = body.lines().collect();
1710
1711    let mut match_idx = None;
1712    let mut match_level = 1;
1713    let mut match_title = String::new();
1714    let mut fence: Option<char> = None;
1715
1716    for (i, line) in lines.iter().enumerate() {
1717        let trimmed_start = line.trim_start();
1718        if let Some(f) = fence {
1719            if trimmed_start.starts_with(&f.to_string().repeat(3)) {
1720                fence = None;
1721            }
1722            continue;
1723        }
1724        if trimmed_start.starts_with("```") {
1725            fence = Some('`');
1726            continue;
1727        }
1728        if trimmed_start.starts_with("~~~") {
1729            fence = Some('~');
1730            continue;
1731        }
1732
1733        let trimmed = line.trim();
1734        if trimmed.starts_with('#') {
1735            let hashes = trimmed.chars().take_while(|c| *c == '#').count();
1736            let title = trimmed[hashes..].trim();
1737            if title.eq_ignore_ascii_case(clean_query)
1738                || heading_slug(title) == query_slug
1739                || title
1740                    .replace(['-', '_'], " ")
1741                    .eq_ignore_ascii_case(&clean_query.replace(['-', '_'], " "))
1742            {
1743                match_idx = Some(i);
1744                match_level = hashes;
1745                match_title = title.to_string();
1746                break;
1747            }
1748        }
1749    }
1750
1751    let start_idx = match_idx?;
1752    let mut end_idx = lines.len();
1753    fence = None;
1754
1755    for (i, line) in lines.iter().enumerate().skip(start_idx + 1) {
1756        let trimmed_start = line.trim_start();
1757        if let Some(f) = fence {
1758            if trimmed_start.starts_with(&f.to_string().repeat(3)) {
1759                fence = None;
1760            }
1761            continue;
1762        }
1763        if trimmed_start.starts_with("```") {
1764            fence = Some('`');
1765            continue;
1766        }
1767        if trimmed_start.starts_with("~~~") {
1768            fence = Some('~');
1769            continue;
1770        }
1771
1772        let trimmed = line.trim();
1773        if trimmed.starts_with('#') {
1774            let hashes = trimmed.chars().take_while(|c| *c == '#').count();
1775            if hashes <= match_level {
1776                end_idx = i;
1777                break;
1778            }
1779        }
1780    }
1781
1782    let heading_line = lines[start_idx];
1783    let extracted_content: Vec<String> = lines[start_idx + 1..end_idx]
1784        .iter()
1785        .map(ToString::to_string)
1786        .collect();
1787
1788    let rel_link = compute_relative_path(source, target);
1789    let lt = custom_link_text.unwrap_or(&match_title);
1790    let replacement = format!("{heading_line}\n\nSee [{lt}]({rel_link}).\n");
1791
1792    let mut remaining_lines: Vec<String> = Vec::new();
1793    for line in &lines[..start_idx] {
1794        remaining_lines.push(line.to_string());
1795    }
1796    remaining_lines.push(replacement);
1797    for line in &lines[end_idx..] {
1798        remaining_lines.push(line.to_string());
1799    }
1800
1801    let mut new_body = remaining_lines.join("\n");
1802    if body.ends_with('\n') {
1803        new_body.push('\n');
1804    }
1805
1806    Some((match_title, extracted_content, new_body))
1807}
1808
1809#[cfg(test)]
1810mod tests {
1811    use super::*;
1812
1813    #[test]
1814    fn test_compute_relative_path() {
1815        let from = ConceptId::parse("auth/tokens/jwt").unwrap();
1816        let to_same = ConceptId::parse("auth/tokens/refresh").unwrap();
1817        assert_eq!(compute_relative_path(&from, &to_same), "refresh.md");
1818
1819        let to_up = ConceptId::parse("auth/user").unwrap();
1820        assert_eq!(compute_relative_path(&from, &to_up), "../user.md");
1821
1822        let to_deep = ConceptId::parse("billing/invoicing/pdf").unwrap();
1823        assert_eq!(
1824            compute_relative_path(&from, &to_deep),
1825            "../../billing/invoicing/pdf.md"
1826        );
1827
1828        let root = ConceptId::parse("overview").unwrap();
1829        assert_eq!(compute_relative_path(&root, &from), "auth/tokens/jwt.md");
1830        assert_eq!(compute_relative_path(&from, &root), "../../overview.md");
1831    }
1832
1833    #[test]
1834    fn test_rebase_relative_path() {
1835        let old_dir = vec!["auth".to_string(), "tokens".to_string()];
1836        let new_dir = vec!["security".to_string()];
1837
1838        let rebased = rebase_relative_path(&old_dir, &new_dir, "../scripts/calc.py");
1839        assert_eq!(rebased, "../auth/scripts/calc.py");
1840
1841        let rebased_anchor = rebase_relative_path(&old_dir, &new_dir, "../user.md#profile");
1842        assert_eq!(rebased_anchor, "../auth/user.md#profile");
1843    }
1844
1845    #[test]
1846    fn test_rewrite_markdown_links() {
1847        let body = "\
1848# Title
1849
1850See [User Guide](../guides/user.md) and [Profile](/users/profile.md#info).
1851Also `[Code Link](../not/a/link.md)` should not change.
1852
1853```python
1854# [Python Link](../ignored.md)
1855pass
1856```
1857";
1858
1859        let (rewritten, count) = rewrite_markdown_links(body, |link, _| {
1860            if link.target.starts_with("../guides/user.md") {
1861                LinkRewriteAction::Rewrite("../../docs/user.md".to_string())
1862            } else if link.target.starts_with("/users/profile.md") {
1863                LinkRewriteAction::Unlink
1864            } else {
1865                LinkRewriteAction::Keep
1866            }
1867        });
1868
1869        assert_eq!(count, 2);
1870        assert!(rewritten.contains("[User Guide](../../docs/user.md)"));
1871        assert!(rewritten.contains("and Profile."));
1872        assert!(rewritten.contains("`[Code Link](../not/a/link.md)`"));
1873        assert!(rewritten.contains("# [Python Link](../ignored.md)"));
1874    }
1875}