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    target_fm.set(
1224        "status",
1225        Value::String(source_concept.document.frontmatter.status().to_string()),
1226    );
1227
1228    let author = options.author.clone().unwrap_or_else(default_author);
1229    let mut gen_map = crate::yaml::Mapping::new();
1230    gen_map.insert("by", Value::String(author));
1231    gen_map.insert("at", Value::String(current_iso_timestamp()));
1232    target_fm.set("generated", Value::Mapping(gen_map));
1233
1234    // Copy relevant sources to target frontmatter
1235    let all_sources = source_concept.document.frontmatter.sources();
1236    let mut target_sources = Vec::new();
1237    let mut kept_sources = Vec::new();
1238
1239    for src in all_sources {
1240        let src_id = src.id.as_deref().unwrap_or("");
1241        let in_extracted = extracted_refs.contains(src_id);
1242        let in_remaining = remaining_refs.contains(src_id);
1243        if in_extracted {
1244            target_sources.push(src.clone());
1245        }
1246        if in_remaining {
1247            kept_sources.push(src);
1248        }
1249    }
1250
1251    if !target_sources.is_empty() {
1252        let seq = target_sources.iter().map(Source::to_yaml_value).collect();
1253        target_fm.set("sources", Value::Sequence(seq));
1254    }
1255
1256    let mut target_body = format!("# {target_title}\n\n{}\n", extracted_text.trim());
1257    if !target_defs.is_empty() {
1258        target_body.push('\n');
1259        for def in &target_defs {
1260            let _ = writeln!(target_body, "[^{}]: {}", def.label, def.text);
1261        }
1262    }
1263
1264    let target_doc = Document::new(target_fm, target_body);
1265
1266    // Update source document
1267    let mut new_source_doc = source_concept.document.clone();
1268    new_source_doc.body = new_source_body;
1269    if kept_sources.is_empty() {
1270        new_source_doc.frontmatter.remove("sources");
1271    } else {
1272        let seq = kept_sources.iter().map(Source::to_yaml_value).collect();
1273        new_source_doc
1274            .frontmatter
1275            .set("sources", Value::Sequence(seq));
1276    }
1277
1278    let affected_files = vec![source_path.clone(), target_path.clone()];
1279
1280    if !options.dry_run {
1281        if let Some(parent) = target_path.parent() {
1282            fs::create_dir_all(parent)?;
1283        }
1284        fs::write(&target_path, target_doc.serialize())?;
1285        fs::write(&source_path, new_source_doc.serialize())?;
1286
1287        if options.update_index {
1288            let _ = regenerate_indexes(bundle.root());
1289        }
1290
1291        if options.update_log {
1292            let today = Date::today_utc().unwrap_or(Date {
1293                year: 2026,
1294                month: 8,
1295                day: 24,
1296            });
1297            let author_suffix = options
1298                .author
1299                .as_ref()
1300                .map_or(String::new(), |a| format!(" (by {a})"));
1301            let log_msg = format!(
1302                "Extracted section `{}` from `{source}` into new concept `{target}`{author_suffix}.",
1303                options.section
1304            );
1305            let _ = append_log_entry(bundle.root(), today, "Creation", &log_msg);
1306        }
1307    }
1308
1309    Ok(SplitReport {
1310        source: source.clone(),
1311        target: target.clone(),
1312        section: options.section.clone(),
1313        target_title,
1314        target_path,
1315        extracted_lines_count: extracted_lines.len(),
1316        moved_sources_count: target_sources.len(),
1317        affected_files,
1318        dry_run: options.dry_run,
1319    })
1320}
1321
1322/// Merges `source` concept into `target` concept and deletes `source`.
1323///
1324/// # Errors
1325///
1326/// Returns [`RefactorError::SameSourceAndTarget`] if `source` equals `target`,
1327/// [`RefactorError::ConceptNotFound`] if either `source` or `target` is not found, or
1328/// [`RefactorError::Io`] on filesystem error.
1329#[allow(clippy::too_many_lines)]
1330pub fn merge_concepts(
1331    bundle: &Bundle,
1332    source: &ConceptId,
1333    target: &ConceptId,
1334    options: &MergeOptions,
1335) -> Result<MergeReport, RefactorError> {
1336    if source == target {
1337        return Err(RefactorError::SameSourceAndTarget(source.clone()));
1338    }
1339
1340    let source_concept = bundle
1341        .get(source)
1342        .ok_or_else(|| RefactorError::ConceptNotFound(source.clone()))?;
1343    let target_concept = bundle
1344        .get(target)
1345        .ok_or_else(|| RefactorError::ConceptNotFound(target.clone()))?;
1346
1347    let source_path = source.to_path(bundle.root());
1348    let target_path = target.to_path(bundle.root());
1349
1350    let source_dir: Vec<String> = source
1351        .parent()
1352        .map(|p| p.segments().to_vec())
1353        .unwrap_or_default();
1354    let target_dir: Vec<String> = target
1355        .parent()
1356        .map(|p| p.segments().to_vec())
1357        .unwrap_or_default();
1358
1359    // 1. Rebase outgoing links in source body to target directory
1360    let (rebased_source_body, _) =
1361        rewrite_markdown_links(&source_concept.document.body, |link, _| match link.kind {
1362            LinkKind::Relative => link.resolve(source).map_or_else(
1363                || {
1364                    let new_rel = rebase_relative_path(&source_dir, &target_dir, &link.target);
1365                    LinkRewriteAction::Rewrite(new_rel)
1366                },
1367                |dest_concept| {
1368                    if dest_concept == *target {
1369                        LinkRewriteAction::Keep
1370                    } else {
1371                        let new_rel = compute_relative_path(target, &dest_concept);
1372                        let anchor = link.target.find('#').map_or("", |idx| &link.target[idx..]);
1373                        LinkRewriteAction::Rewrite(format!("{new_rel}{anchor}"))
1374                    }
1375                },
1376            ),
1377            _ => LinkRewriteAction::Keep,
1378        });
1379
1380    // 2. Merge sources and resolve ID collisions
1381    let mut target_doc = target_concept.document.clone();
1382    let mut target_sources = target_doc.frontmatter.sources();
1383    let target_defs = target_doc.footnote_definitions();
1384    let mut existing_ids: HashSet<String> = target_sources
1385        .iter()
1386        .filter_map(|s| s.id.clone())
1387        .chain(target_defs.iter().map(|d| d.label.clone()))
1388        .collect();
1389
1390    let mut merged_sources_count = 0;
1391    let mut source_body_final = rebased_source_body;
1392
1393    for mut src in source_concept.document.frontmatter.sources() {
1394        if let Some(orig_id) = src.id.clone() {
1395            if existing_ids.contains(&orig_id) {
1396                // Check if identical source already exists
1397                let is_identical = target_sources
1398                    .iter()
1399                    .any(|s| s.id.as_deref() == Some(&orig_id) && s.resource == src.resource);
1400                if !is_identical {
1401                    // Remap id
1402                    let new_id = format!("{}_{orig_id}", source.name());
1403                    source_body_final = source_body_final
1404                        .replace(&format!("[^{orig_id}]"), &format!("[^{new_id}]"));
1405                    source_body_final = source_body_final
1406                        .replace(&format!("[^{orig_id}]:"), &format!("[^{new_id}]:"));
1407                    src.id = Some(new_id.clone());
1408                    existing_ids.insert(new_id);
1409                    target_sources.push(src);
1410                    merged_sources_count += 1;
1411                }
1412            } else {
1413                existing_ids.insert(orig_id);
1414                target_sources.push(src);
1415                merged_sources_count += 1;
1416            }
1417        } else {
1418            target_sources.push(src);
1419            merged_sources_count += 1;
1420        }
1421    }
1422
1423    if !target_sources.is_empty() {
1424        let seq = target_sources.iter().map(Source::to_yaml_value).collect();
1425        target_doc.frontmatter.set("sources", Value::Sequence(seq));
1426    }
1427
1428    // 3. Merge verified events
1429    let mut target_verified = target_doc.frontmatter.verified();
1430    for v in source_concept.document.frontmatter.verified() {
1431        if !target_verified.contains(&v) {
1432            target_verified.push(v);
1433        }
1434    }
1435    if !target_verified.is_empty() {
1436        let seq = target_verified
1437            .iter()
1438            .map(|v| {
1439                let mut map = crate::yaml::Mapping::new();
1440                if let Some(by) = &v.by {
1441                    map.insert("by", Value::String(by.as_str().to_string()));
1442                }
1443                if let Some(at) = &v.at {
1444                    map.insert("at", Value::String(at.raw.clone()));
1445                }
1446                Value::Mapping(map)
1447            })
1448            .collect();
1449        target_doc.frontmatter.set("verified", Value::Sequence(seq));
1450    }
1451
1452    // 4. Append body under heading
1453    let heading = options.heading.clone().unwrap_or_else(|| {
1454        let source_title = source_concept
1455            .document
1456            .frontmatter
1457            .title()
1458            .map_or_else(|| source.name().to_string(), std::borrow::Cow::into_owned);
1459        format!("## {source_title}")
1460    });
1461
1462    let body_to_append = {
1463        let trimmed = source_body_final.trim();
1464        trimmed.strip_prefix('#').map_or(trimmed, |rest| {
1465            let first_line = rest.lines().next().unwrap_or("");
1466            if first_line.starts_with('#') {
1467                trimmed
1468            } else {
1469                rest[first_line.len()..].trim_start()
1470            }
1471        })
1472    };
1473
1474    target_doc.body = format!(
1475        "{}\n\n{heading}\n\n{body_to_append}\n",
1476        target_doc.body.trim_end()
1477    );
1478
1479    // 5. Rewrite incoming backlinks across the bundle
1480    let mut affected_files = vec![source_path.clone(), target_path.clone()];
1481    let mut updated_other_docs: Vec<(PathBuf, Document)> = Vec::new();
1482    let mut rewritten_links_total = 0;
1483
1484    for other_concept in bundle.concepts() {
1485        if other_concept.id == *source || other_concept.id == *target {
1486            continue;
1487        }
1488
1489        let mut other_doc = other_concept.document.clone();
1490        let other_id = &other_concept.id;
1491        let mut modified = false;
1492
1493        let (new_body, count) = rewrite_markdown_links(&other_doc.body, |link, _| {
1494            if let Some(resolved_id) = link.resolve(other_id)
1495                && resolved_id == *source
1496            {
1497                let anchor = link.target.find('#').map_or("", |idx| &link.target[idx..]);
1498                match link.kind {
1499                    LinkKind::Absolute => {
1500                        LinkRewriteAction::Rewrite(format!("/{target}.md{anchor}"))
1501                    }
1502                    LinkKind::Relative => {
1503                        let new_rel = compute_relative_path(other_id, target);
1504                        LinkRewriteAction::Rewrite(format!("{new_rel}{anchor}"))
1505                    }
1506                    _ => LinkRewriteAction::Keep,
1507                }
1508            } else {
1509                LinkRewriteAction::Keep
1510            }
1511        });
1512
1513        if count > 0 {
1514            other_doc.body = new_body;
1515            rewritten_links_total += count;
1516            modified = true;
1517        }
1518
1519        if rewrite_frontmatter_concept_references(
1520            &mut other_doc.frontmatter,
1521            other_id,
1522            source,
1523            target,
1524        ) {
1525            modified = true;
1526        }
1527
1528        if modified {
1529            let path = other_id.to_path(bundle.root());
1530            affected_files.push(path.clone());
1531            updated_other_docs.push((path, other_doc));
1532        }
1533    }
1534
1535    if !options.dry_run {
1536        // Write updated target
1537        fs::write(&target_path, target_doc.serialize())?;
1538
1539        // Remove source file
1540        if source_path.exists() {
1541            fs::remove_file(&source_path)?;
1542        }
1543
1544        // Write updated other docs
1545        for (path, doc) in updated_other_docs {
1546            fs::write(&path, doc.serialize())?;
1547        }
1548
1549        // Regenerate indexes
1550        if options.update_index {
1551            let _ = regenerate_indexes(bundle.root());
1552        }
1553
1554        // Update log.md
1555        if options.update_log {
1556            let today = Date::today_utc().unwrap_or(Date {
1557                year: 2026,
1558                month: 8,
1559                day: 24,
1560            });
1561            let author_suffix = options
1562                .author
1563                .as_ref()
1564                .map_or(String::new(), |a| format!(" (by {a})"));
1565            let log_msg = format!(
1566                "Merged concept `{source}` into `{target}` (rewrote {rewritten_links_total} inbound links){author_suffix}."
1567            );
1568            let _ = append_log_entry(bundle.root(), today, "Update", &log_msg);
1569        }
1570    }
1571
1572    Ok(MergeReport {
1573        source: source.clone(),
1574        target: target.clone(),
1575        removed_path: source_path,
1576        updated_path: target_path,
1577        rewritten_links_count: rewritten_links_total,
1578        merged_sources_count,
1579        affected_files,
1580        dry_run: options.dry_run,
1581    })
1582}
1583
1584fn rebase_frontmatter_paths(
1585    fm: &mut Frontmatter,
1586    old_dir: &[String],
1587    new_dir: &[String],
1588    count: &mut usize,
1589) {
1590    if let Some(Value::String(s)) = fm.get("computation")
1591        && !s.starts_with('/')
1592        && links::Link::classify(s) == LinkKind::Relative
1593    {
1594        fm.set(
1595            "computation",
1596            Value::String(rebase_relative_path(old_dir, new_dir, s)),
1597        );
1598        *count += 1;
1599    }
1600
1601    if let Some(Value::Mapping(map)) = fm.get_mut("executor")
1602        && let Some(Value::String(res)) = map.get("resource")
1603        && !res.starts_with('/')
1604        && links::Link::classify(res) == LinkKind::Relative
1605    {
1606        map.insert(
1607            "resource",
1608            Value::String(rebase_relative_path(old_dir, new_dir, res)),
1609        );
1610        *count += 1;
1611    }
1612
1613    if let Some(Value::Mapping(map)) = fm.get_mut("attester")
1614        && let Some(Value::String(res)) = map.get("resource")
1615        && !res.starts_with('/')
1616        && links::Link::classify(res) == LinkKind::Relative
1617    {
1618        map.insert(
1619            "resource",
1620            Value::String(rebase_relative_path(old_dir, new_dir, res)),
1621        );
1622        *count += 1;
1623    }
1624
1625    if let Some(Value::Sequence(sources)) = fm.get_mut("sources") {
1626        for src_val in sources {
1627            if let Value::Mapping(src_map) = src_val
1628                && let Some(Value::String(res)) = src_map.get("resource")
1629                && !res.starts_with('/')
1630                && links::Link::classify(res) == LinkKind::Relative
1631            {
1632                src_map.insert(
1633                    "resource",
1634                    Value::String(rebase_relative_path(old_dir, new_dir, res)),
1635                );
1636                *count += 1;
1637            }
1638        }
1639    }
1640}
1641
1642fn rewrite_frontmatter_concept_references(
1643    fm: &mut Frontmatter,
1644    from_id: &ConceptId,
1645    old_target: &ConceptId,
1646    new_target: &ConceptId,
1647) -> bool {
1648    let mut modified = false;
1649
1650    if let Some(Value::Sequence(sources)) = fm.get_mut("sources") {
1651        for src_val in sources {
1652            if let Value::Mapping(src_map) = src_val
1653                && let Some(Value::String(res)) = src_map.get("resource")
1654            {
1655                let link = Link {
1656                    text: String::new(),
1657                    kind: Link::classify(res),
1658                    target: res.clone(),
1659                };
1660                if let Some(resolved) = link.resolve(from_id)
1661                    && resolved == *old_target
1662                {
1663                    let new_res = match link.kind {
1664                        LinkKind::Absolute => format!("/{new_target}.md"),
1665                        LinkKind::Relative => compute_relative_path(from_id, new_target),
1666                        _ => continue,
1667                    };
1668                    src_map.insert("resource", Value::String(new_res));
1669                    modified = true;
1670                }
1671            }
1672        }
1673    }
1674
1675    modified
1676}
1677
1678/// Extracts a section matching `section_query` from body lines and generates replacement.
1679fn extract_section_from_body(
1680    body: &str,
1681    section_query: &str,
1682    source: &ConceptId,
1683    target: &ConceptId,
1684    custom_link_text: Option<&str>,
1685) -> Option<(String, Vec<String>, String)> {
1686    let clean_query = section_query.trim().trim_start_matches('#').trim();
1687    let query_slug = heading_slug(clean_query);
1688    let lines: Vec<&str> = body.lines().collect();
1689
1690    let mut match_idx = None;
1691    let mut match_level = 1;
1692    let mut match_title = String::new();
1693    let mut fence: Option<char> = None;
1694
1695    for (i, line) in lines.iter().enumerate() {
1696        let trimmed_start = line.trim_start();
1697        if let Some(f) = fence {
1698            if trimmed_start.starts_with(&f.to_string().repeat(3)) {
1699                fence = None;
1700            }
1701            continue;
1702        }
1703        if trimmed_start.starts_with("```") {
1704            fence = Some('`');
1705            continue;
1706        }
1707        if trimmed_start.starts_with("~~~") {
1708            fence = Some('~');
1709            continue;
1710        }
1711
1712        let trimmed = line.trim();
1713        if trimmed.starts_with('#') {
1714            let hashes = trimmed.chars().take_while(|c| *c == '#').count();
1715            let title = trimmed[hashes..].trim();
1716            if title.eq_ignore_ascii_case(clean_query)
1717                || heading_slug(title) == query_slug
1718                || title
1719                    .replace(['-', '_'], " ")
1720                    .eq_ignore_ascii_case(&clean_query.replace(['-', '_'], " "))
1721            {
1722                match_idx = Some(i);
1723                match_level = hashes;
1724                match_title = title.to_string();
1725                break;
1726            }
1727        }
1728    }
1729
1730    let start_idx = match_idx?;
1731    let mut end_idx = lines.len();
1732    fence = None;
1733
1734    for (i, line) in lines.iter().enumerate().skip(start_idx + 1) {
1735        let trimmed_start = line.trim_start();
1736        if let Some(f) = fence {
1737            if trimmed_start.starts_with(&f.to_string().repeat(3)) {
1738                fence = None;
1739            }
1740            continue;
1741        }
1742        if trimmed_start.starts_with("```") {
1743            fence = Some('`');
1744            continue;
1745        }
1746        if trimmed_start.starts_with("~~~") {
1747            fence = Some('~');
1748            continue;
1749        }
1750
1751        let trimmed = line.trim();
1752        if trimmed.starts_with('#') {
1753            let hashes = trimmed.chars().take_while(|c| *c == '#').count();
1754            if hashes <= match_level {
1755                end_idx = i;
1756                break;
1757            }
1758        }
1759    }
1760
1761    let heading_line = lines[start_idx];
1762    let extracted_content: Vec<String> = lines[start_idx + 1..end_idx]
1763        .iter()
1764        .map(ToString::to_string)
1765        .collect();
1766
1767    let rel_link = compute_relative_path(source, target);
1768    let lt = custom_link_text.unwrap_or(&match_title);
1769    let replacement = format!("{heading_line}\n\nSee [{lt}]({rel_link}).\n");
1770
1771    let mut remaining_lines: Vec<String> = Vec::new();
1772    for line in &lines[..start_idx] {
1773        remaining_lines.push(line.to_string());
1774    }
1775    remaining_lines.push(replacement);
1776    for line in &lines[end_idx..] {
1777        remaining_lines.push(line.to_string());
1778    }
1779
1780    let mut new_body = remaining_lines.join("\n");
1781    if body.ends_with('\n') {
1782        new_body.push('\n');
1783    }
1784
1785    Some((match_title, extracted_content, new_body))
1786}
1787
1788#[cfg(test)]
1789mod tests {
1790    use super::*;
1791
1792    #[test]
1793    fn test_compute_relative_path() {
1794        let from = ConceptId::parse("auth/tokens/jwt").unwrap();
1795        let to_same = ConceptId::parse("auth/tokens/refresh").unwrap();
1796        assert_eq!(compute_relative_path(&from, &to_same), "refresh.md");
1797
1798        let to_up = ConceptId::parse("auth/user").unwrap();
1799        assert_eq!(compute_relative_path(&from, &to_up), "../user.md");
1800
1801        let to_deep = ConceptId::parse("billing/invoicing/pdf").unwrap();
1802        assert_eq!(
1803            compute_relative_path(&from, &to_deep),
1804            "../../billing/invoicing/pdf.md"
1805        );
1806
1807        let root = ConceptId::parse("overview").unwrap();
1808        assert_eq!(compute_relative_path(&root, &from), "auth/tokens/jwt.md");
1809        assert_eq!(compute_relative_path(&from, &root), "../../overview.md");
1810    }
1811
1812    #[test]
1813    fn test_rebase_relative_path() {
1814        let old_dir = vec!["auth".to_string(), "tokens".to_string()];
1815        let new_dir = vec!["security".to_string()];
1816
1817        let rebased = rebase_relative_path(&old_dir, &new_dir, "../scripts/calc.py");
1818        assert_eq!(rebased, "../auth/scripts/calc.py");
1819
1820        let rebased_anchor = rebase_relative_path(&old_dir, &new_dir, "../user.md#profile");
1821        assert_eq!(rebased_anchor, "../auth/user.md#profile");
1822    }
1823
1824    #[test]
1825    fn test_rewrite_markdown_links() {
1826        let body = "\
1827# Title
1828
1829See [User Guide](../guides/user.md) and [Profile](/users/profile.md#info).
1830Also `[Code Link](../not/a/link.md)` should not change.
1831
1832```python
1833# [Python Link](../ignored.md)
1834pass
1835```
1836";
1837
1838        let (rewritten, count) = rewrite_markdown_links(body, |link, _| {
1839            if link.target.starts_with("../guides/user.md") {
1840                LinkRewriteAction::Rewrite("../../docs/user.md".to_string())
1841            } else if link.target.starts_with("/users/profile.md") {
1842                LinkRewriteAction::Unlink
1843            } else {
1844                LinkRewriteAction::Keep
1845            }
1846        });
1847
1848        assert_eq!(count, 2);
1849        assert!(rewritten.contains("[User Guide](../../docs/user.md)"));
1850        assert!(rewritten.contains("and Profile."));
1851        assert!(rewritten.contains("`[Code Link](../not/a/link.md)`"));
1852        assert!(rewritten.contains("# [Python Link](../ignored.md)"));
1853    }
1854}