use crate::bundle::Bundle;
use crate::concept_id::ConceptId;
use crate::date::Date;
use crate::document::Document;
use crate::frontmatter::Frontmatter;
use crate::index::regenerate_indexes;
use crate::links::{self, Link, LinkKind};
use crate::provenance::Source;
use crate::scaffold::{current_iso_timestamp, default_author};
use crate::yaml::Value;
use std::collections::HashSet;
use std::fmt;
use std::fmt::Write as _;
use std::fs;
use std::io;
use std::path::PathBuf;
pub use crate::log::append_log_entry;
pub use crate::markdown::{
LinkRewriteAction, heading_slug, matches_heading, rewrite_markdown_links,
};
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum RefactorError {
ConceptNotFound(ConceptId),
ConceptAlreadyExists(ConceptId),
SameSourceAndTarget(ConceptId),
HasInboundLinks {
target: ConceptId,
inbound_count: usize,
inbound_concepts: Vec<ConceptId>,
},
SectionNotFound {
concept: ConceptId,
section: String,
},
InvalidConceptId(String),
Io(String),
}
impl fmt::Display for RefactorError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::ConceptNotFound(id) => write!(f, "concept '{id}' not found in bundle"),
Self::ConceptAlreadyExists(id) => {
write!(
f,
"destination concept '{id}' already exists (use --force to overwrite)"
)
}
Self::SameSourceAndTarget(id) => {
write!(f, "source and target concepts are identical: '{id}'")
}
Self::HasInboundLinks {
target,
inbound_count,
inbound_concepts,
} => {
let joined = inbound_concepts
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(", ");
write!(
f,
"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"
)
}
Self::SectionNotFound { concept, section } => {
write!(f, "section '{section}' not found in concept '{concept}'")
}
Self::InvalidConceptId(msg) => write!(f, "invalid concept ID: {msg}"),
Self::Io(msg) => write!(f, "I/O error: {msg}"),
}
}
}
impl std::error::Error for RefactorError {}
impl From<io::Error> for RefactorError {
fn from(err: io::Error) -> Self {
Self::Io(err.to_string())
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[allow(clippy::struct_excessive_bools)]
pub struct MoveOptions {
pub dry_run: bool,
pub force: bool,
pub update_index: bool,
pub update_log: bool,
pub author: Option<String>,
}
impl Default for MoveOptions {
fn default() -> Self {
Self {
dry_run: false,
force: false,
update_index: true,
update_log: true,
author: None,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct MoveReport {
pub source: ConceptId,
pub target: ConceptId,
pub source_path: PathBuf,
pub target_path: PathBuf,
pub rewritten_incoming_links: usize,
pub rebased_outgoing_links: usize,
pub rebased_frontmatter_paths: usize,
pub affected_files: Vec<PathBuf>,
pub dry_run: bool,
}
impl fmt::Display for MoveReport {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let prefix = if self.dry_run {
"[dry-run] would rename"
} else {
"renamed"
};
writeln!(f, "{prefix} concept {} -> {}", self.source, self.target)?;
writeln!(
f,
" rewrote {} incoming link(s)",
self.rewritten_incoming_links
)?;
writeln!(
f,
" rebased {} outgoing link(s)",
self.rebased_outgoing_links
)?;
if self.rebased_frontmatter_paths > 0 {
writeln!(
f,
" rebased {} frontmatter path(s)",
self.rebased_frontmatter_paths
)?;
}
write!(f, " affected {} file(s)", self.affected_files.len())
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[allow(clippy::struct_excessive_bools)]
pub struct RemoveOptions {
pub dry_run: bool,
pub force: bool,
pub redirect_to: Option<ConceptId>,
pub unlink: bool,
pub update_index: bool,
pub update_log: bool,
pub author: Option<String>,
}
impl Default for RemoveOptions {
fn default() -> Self {
Self {
dry_run: false,
force: false,
redirect_to: None,
unlink: false,
update_index: true,
update_log: true,
author: None,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RemoveReport {
pub target: ConceptId,
pub removed_path: PathBuf,
pub redirected_to: Option<ConceptId>,
pub redirected_count: usize,
pub unlinked_count: usize,
pub affected_files: Vec<PathBuf>,
pub dry_run: bool,
}
impl fmt::Display for RemoveReport {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let prefix = if self.dry_run {
"[dry-run] would remove"
} else {
"removed"
};
if let Some(r) = &self.redirected_to {
writeln!(
f,
"{prefix} concept {} (redirected {} link(s) to {r})",
self.target, self.redirected_count
)?;
} else if self.unlinked_count > 0 {
writeln!(
f,
"{prefix} concept {} (unlinked {} link(s))",
self.target, self.unlinked_count
)?;
} else {
writeln!(f, "{prefix} concept {}", self.target)?;
}
write!(f, " affected {} file(s)", self.affected_files.len())
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[allow(clippy::struct_excessive_bools)]
pub struct SplitOptions {
pub section: String,
pub title: Option<String>,
pub type_: Option<String>,
pub link_text: Option<String>,
pub force: bool,
pub dry_run: bool,
pub update_index: bool,
pub update_log: bool,
pub author: Option<String>,
}
impl Default for SplitOptions {
fn default() -> Self {
Self {
section: String::new(),
title: None,
type_: None,
link_text: None,
force: false,
dry_run: false,
update_index: true,
update_log: true,
author: None,
}
}
}
impl SplitOptions {
#[must_use]
pub const fn new(section: String) -> Self {
Self {
section,
title: None,
type_: None,
link_text: None,
force: false,
dry_run: false,
update_index: true,
update_log: true,
author: None,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SplitReport {
pub source: ConceptId,
pub target: ConceptId,
pub section: String,
pub target_title: String,
pub target_path: PathBuf,
pub extracted_lines_count: usize,
pub moved_sources_count: usize,
pub affected_files: Vec<PathBuf>,
pub dry_run: bool,
}
impl fmt::Display for SplitReport {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let prefix = if self.dry_run {
"[dry-run] would extract"
} else {
"extracted"
};
writeln!(
f,
"{prefix} section '{}' from {} -> {}",
self.section, self.source, self.target
)?;
writeln!(f, " extracted {} line(s)", self.extracted_lines_count)?;
writeln!(f, " moved {} source/footnote(s)", self.moved_sources_count)?;
write!(f, " created {}", self.target_path.display())
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[allow(clippy::struct_excessive_bools)]
pub struct MergeOptions {
pub heading: Option<String>,
pub force: bool,
pub dry_run: bool,
pub update_index: bool,
pub update_log: bool,
pub author: Option<String>,
}
impl Default for MergeOptions {
fn default() -> Self {
Self {
heading: None,
force: false,
dry_run: false,
update_index: true,
update_log: true,
author: None,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct MergeReport {
pub source: ConceptId,
pub target: ConceptId,
pub removed_path: PathBuf,
pub updated_path: PathBuf,
pub rewritten_links_count: usize,
pub merged_sources_count: usize,
pub affected_files: Vec<PathBuf>,
pub dry_run: bool,
}
impl fmt::Display for MergeReport {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let prefix = if self.dry_run {
"[dry-run] would merge"
} else {
"merged"
};
writeln!(f, "{prefix} concept {} -> {}", self.source, self.target)?;
writeln!(
f,
" rewrote {} incoming link(s)",
self.rewritten_links_count
)?;
writeln!(f, " merged {} source(s)", self.merged_sources_count)?;
writeln!(f, " removed {}", self.removed_path.display())?;
write!(f, " updated {}", self.updated_path.display())
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RenameSectionOptions {
pub dry_run: bool,
pub update_log: bool,
pub author: Option<String>,
}
impl Default for RenameSectionOptions {
fn default() -> Self {
Self {
dry_run: false,
update_log: true,
author: None,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RenameSectionReport {
pub concept: ConceptId,
pub old_section: String,
pub new_section: String,
pub old_slug: String,
pub new_slug: String,
pub internal_links_updated: usize,
pub external_links_updated: usize,
pub affected_files: Vec<PathBuf>,
pub dry_run: bool,
}
impl fmt::Display for RenameSectionReport {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let prefix = if self.dry_run {
"[dry-run] would rename"
} else {
"renamed"
};
writeln!(
f,
"{prefix} section '{}' -> '{}' in {}",
self.old_section, self.new_section, self.concept
)?;
writeln!(
f,
" updated {} internal link(s)",
self.internal_links_updated
)?;
writeln!(
f,
" updated {} external backlink(s)",
self.external_links_updated
)?;
write!(f, " affected {} file(s)", self.affected_files.len())
}
}
#[must_use]
pub fn compute_relative_path(from_concept: &ConceptId, to_concept: &ConceptId) -> String {
let from_dir: Vec<String> = from_concept
.parent()
.map(|p| p.segments().to_vec())
.unwrap_or_default();
let to_segments = to_concept.segments();
let to_dir = &to_segments[..to_segments.len().saturating_sub(1)];
let to_name = to_segments.last().map_or("", String::as_str);
let mut common_len = 0;
while common_len < from_dir.len()
&& common_len < to_dir.len()
&& from_dir[common_len] == to_dir[common_len]
{
common_len += 1;
}
let steps_up = from_dir.len() - common_len;
let mut parts: Vec<&str> = Vec::new();
parts.extend(std::iter::repeat_n("..", steps_up));
for seg in &to_dir[common_len..] {
parts.push(seg);
}
let file_part = format!("{to_name}.md");
parts.push(&file_part);
parts.join("/")
}
#[must_use]
pub fn rebase_relative_path(old_dir: &[String], new_dir: &[String], rel_path: &str) -> String {
let trimmed = rel_path.trim();
if trimmed.starts_with('/') || Link::classify(trimmed) == LinkKind::External {
return trimmed.to_string();
}
let (path_part, anchor_part) = trimmed
.find('#')
.map_or((trimmed, ""), |idx| (&trimmed[..idx], &trimmed[idx..]));
if path_part.is_empty() {
return trimmed.to_string();
}
let mut resolved = old_dir.to_vec();
for seg in path_part.split('/') {
match seg {
"" | "." => {}
".." => {
resolved.pop();
}
other => resolved.push(other.to_string()),
}
}
let resolved_dir = if resolved.is_empty() {
&[][..]
} else {
&resolved[..resolved.len() - 1]
};
let filename = resolved.last().map_or("", String::as_str);
let mut common_len = 0;
while common_len < new_dir.len()
&& common_len < resolved_dir.len()
&& new_dir[common_len] == resolved_dir[common_len]
{
common_len += 1;
}
let steps_up = new_dir.len() - common_len;
let mut parts: Vec<&str> = Vec::new();
parts.extend(std::iter::repeat_n("..", steps_up));
for seg in &resolved_dir[common_len..] {
parts.push(seg);
}
if !filename.is_empty() {
parts.push(filename);
}
let rebased = if parts.is_empty() {
".".to_string()
} else {
parts.join("/")
};
format!("{rebased}{anchor_part}")
}
#[allow(clippy::too_many_lines)]
pub fn rename_section(
bundle: &Bundle,
concept_id: &ConceptId,
old_section: &str,
new_section: &str,
options: &RenameSectionOptions,
) -> Result<RenameSectionReport, RefactorError> {
let concept = bundle
.get(concept_id)
.ok_or_else(|| RefactorError::ConceptNotFound(concept_id.clone()))?;
let clean_old = old_section.trim().trim_start_matches('#').trim();
let clean_new = new_section.trim().trim_start_matches('#').trim();
let old_slug = heading_slug(clean_old);
let new_slug = heading_slug(clean_new);
let mut doc = concept.document.clone();
let mut found_heading = false;
let mut matched_title = String::new();
let mut new_lines = Vec::new();
let mut fence: Option<char> = None;
for line in doc.body.lines() {
let trimmed = line.trim();
let trimmed_start = line.trim_start();
if let Some(f) = fence {
if trimmed_start.starts_with(&f.to_string().repeat(3)) {
fence = None;
}
new_lines.push(line.to_string());
continue;
}
if trimmed_start.starts_with("```") {
fence = Some('`');
new_lines.push(line.to_string());
continue;
}
if trimmed_start.starts_with("~~~") {
fence = Some('~');
new_lines.push(line.to_string());
continue;
}
if !found_heading && trimmed.starts_with('#') {
let hashes = trimmed.chars().take_while(|c| *c == '#').count();
let title = trimmed[hashes..].trim();
if title.eq_ignore_ascii_case(clean_old)
|| heading_slug(title) == old_slug
|| title
.replace(['-', '_'], " ")
.eq_ignore_ascii_case(&clean_old.replace(['-', '_'], " "))
{
found_heading = true;
matched_title = title.to_string();
let hash_prefix = &trimmed[..hashes];
new_lines.push(format!("{hash_prefix} {clean_new}"));
continue;
}
}
new_lines.push(line.to_string());
}
if !found_heading {
return Err(RefactorError::SectionNotFound {
concept: concept_id.clone(),
section: old_section.to_string(),
});
}
let updated_body_text = if doc.body.ends_with('\n') {
format!("{}\n", new_lines.join("\n"))
} else {
new_lines.join("\n")
};
let matched_slug = heading_slug(&matched_title);
let (rewritten_doc_body, internal_count) =
rewrite_markdown_links(&updated_body_text, |link, _| {
if link.kind == LinkKind::Anchor {
let anchor_text = link.target.trim_start_matches('#');
if anchor_text == old_slug
|| anchor_text == matched_slug
|| heading_slug(anchor_text) == old_slug
|| heading_slug(anchor_text) == matched_slug
{
return LinkRewriteAction::Rewrite(format!("#{new_slug}"));
}
}
LinkRewriteAction::Keep
});
doc.body = rewritten_doc_body;
let concept_path = concept_id.to_path(bundle.root());
let mut affected_files = vec![concept_path.clone()];
let mut external_count = 0;
let mut updated_other_docs: Vec<(PathBuf, Document)> = Vec::new();
for other_concept in bundle.concepts() {
if other_concept.id == *concept_id {
continue;
}
let mut other_doc = other_concept.document.clone();
let other_id = &other_concept.id;
let (new_other_body, count) = rewrite_markdown_links(&other_doc.body, |link, _| {
if let Some(resolved_id) = link.resolve(other_id)
&& resolved_id == *concept_id
&& let Some(anchor_idx) = link.target.find('#')
{
let anchor_text = &link.target[anchor_idx + 1..];
if anchor_text == old_slug
|| anchor_text == matched_slug
|| heading_slug(anchor_text) == old_slug
|| heading_slug(anchor_text) == matched_slug
{
let path_part = &link.target[..anchor_idx];
return LinkRewriteAction::Rewrite(format!("{path_part}#{new_slug}"));
}
}
LinkRewriteAction::Keep
});
if count > 0 {
other_doc.body = new_other_body;
external_count += count;
let path = other_id.to_path(bundle.root());
affected_files.push(path.clone());
updated_other_docs.push((path, other_doc));
}
}
if !options.dry_run {
fs::write(&concept_path, doc.serialize())?;
for (path, other_d) in updated_other_docs {
fs::write(&path, other_d.serialize())?;
}
if options.update_log {
let today = Date::today_utc().unwrap_or(Date {
year: 2026,
month: 8,
day: 24,
});
let author_suffix = options
.author
.as_ref()
.map_or(String::new(), |a| format!(" (by {a})"));
let log_msg = format!(
"Renamed section `{clean_old}` to `{clean_new}` in concept `{concept_id}` (updated {internal_count} internal link(s), {external_count} external backlink(s)){author_suffix}."
);
let _ = append_log_entry(bundle.root(), today, "Update", &log_msg);
}
}
Ok(RenameSectionReport {
concept: concept_id.clone(),
old_section: clean_old.to_string(),
new_section: clean_new.to_string(),
old_slug,
new_slug,
internal_links_updated: internal_count,
external_links_updated: external_count,
affected_files,
dry_run: options.dry_run,
})
}
#[allow(clippy::too_many_lines)]
pub fn move_concept(
bundle: &Bundle,
source: &ConceptId,
target: &ConceptId,
options: &MoveOptions,
) -> Result<MoveReport, RefactorError> {
if source == target {
return Err(RefactorError::SameSourceAndTarget(source.clone()));
}
let source_concept = bundle
.get(source)
.ok_or_else(|| RefactorError::ConceptNotFound(source.clone()))?;
let target_path = target.to_path(bundle.root());
let source_path = source.to_path(bundle.root());
if !options.force && bundle.get(target).is_some() {
return Err(RefactorError::ConceptAlreadyExists(target.clone()));
}
let source_dir: Vec<String> = source
.parent()
.map(|p| p.segments().to_vec())
.unwrap_or_default();
let target_dir: Vec<String> = target
.parent()
.map(|p| p.segments().to_vec())
.unwrap_or_default();
let mut moved_doc = source_concept.document.clone();
let (rebased_body, rebased_outgoing_count) =
rewrite_markdown_links(&moved_doc.body, |link, _| {
match link.kind {
LinkKind::Relative => {
link.resolve(source).map_or_else(
|| {
let new_rel =
rebase_relative_path(&source_dir, &target_dir, &link.target);
LinkRewriteAction::Rewrite(new_rel)
},
|dest_concept| {
let new_rel = compute_relative_path(target, &dest_concept);
let anchor =
link.target.find('#').map_or("", |idx| &link.target[idx..]);
LinkRewriteAction::Rewrite(format!("{new_rel}{anchor}"))
},
)
}
_ => LinkRewriteAction::Keep,
}
});
moved_doc.body = rebased_body;
let mut rebased_fm_count = 0;
rebase_frontmatter_paths(
&mut moved_doc.frontmatter,
&source_dir,
&target_dir,
&mut rebased_fm_count,
);
let mut affected_files = Vec::new();
let mut rewritten_incoming_total = 0;
let mut updated_other_docs: Vec<(PathBuf, Document)> = Vec::new();
for other_concept in bundle.concepts() {
if other_concept.id == *source {
continue;
}
let mut other_doc = other_concept.document.clone();
let other_id = &other_concept.id;
let mut modified = false;
let (new_body, rewritten_count) = rewrite_markdown_links(&other_doc.body, |link, _| {
if let Some(resolved_id) = link.resolve(other_id)
&& resolved_id == *source
{
let anchor = link.target.find('#').map_or("", |idx| &link.target[idx..]);
match link.kind {
LinkKind::Absolute => {
LinkRewriteAction::Rewrite(format!("/{target}.md{anchor}"))
}
LinkKind::Relative => {
let new_rel = compute_relative_path(other_id, target);
LinkRewriteAction::Rewrite(format!("{new_rel}{anchor}"))
}
_ => LinkRewriteAction::Keep,
}
} else {
LinkRewriteAction::Keep
}
});
if rewritten_count > 0 {
other_doc.body = new_body;
rewritten_incoming_total += rewritten_count;
modified = true;
}
if rewrite_frontmatter_concept_references(
&mut other_doc.frontmatter,
other_id,
source,
target,
) {
modified = true;
}
if modified {
let path = other_id.to_path(bundle.root());
affected_files.push(path.clone());
updated_other_docs.push((path, other_doc));
}
}
affected_files.push(source_path.clone());
affected_files.push(target_path.clone());
if !options.dry_run {
if let Some(parent) = target_path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(&target_path, moved_doc.serialize())?;
if source_path.exists() && source_path != target_path {
fs::remove_file(&source_path)?;
}
for (path, doc) in updated_other_docs {
fs::write(&path, doc.serialize())?;
}
if options.update_index {
let _ = regenerate_indexes(bundle.root());
}
if options.update_log {
let today = Date::today_utc().unwrap_or(Date {
year: 2026,
month: 8,
day: 24,
});
let author_suffix = options
.author
.as_ref()
.map_or(String::new(), |a| format!(" (by {a})"));
let log_msg = format!(
"Renamed concept `{source}` to `{target}` (rewrote {rewritten_incoming_total} incoming links, rebased {rebased_outgoing_count} outgoing links){author_suffix}."
);
let _ = append_log_entry(bundle.root(), today, "Update", &log_msg);
}
}
Ok(MoveReport {
source: source.clone(),
target: target.clone(),
source_path,
target_path,
rewritten_incoming_links: rewritten_incoming_total,
rebased_outgoing_links: rebased_outgoing_count,
rebased_frontmatter_paths: rebased_fm_count,
affected_files,
dry_run: options.dry_run,
})
}
#[allow(clippy::too_many_lines)]
pub fn remove_concept(
bundle: &Bundle,
target: &ConceptId,
options: &RemoveOptions,
) -> Result<RemoveReport, RefactorError> {
if bundle.get(target).is_none() {
return Err(RefactorError::ConceptNotFound(target.clone()));
}
let inbound = bundle.backlinks(target);
let has_inbound = !inbound.is_empty();
if has_inbound && !options.force && options.redirect_to.is_none() && !options.unlink {
return Err(RefactorError::HasInboundLinks {
target: target.clone(),
inbound_count: inbound.len(),
inbound_concepts: inbound.to_vec(),
});
}
let target_path = target.to_path(bundle.root());
let mut affected_files = vec![target_path.clone()];
let mut redirected_count = 0;
let mut unlinked_count = 0;
let mut updated_other_docs: Vec<(PathBuf, Document)> = Vec::new();
if options.redirect_to.is_some() || options.unlink {
for other_concept in bundle.concepts() {
if other_concept.id == *target {
continue;
}
let mut other_doc = other_concept.document.clone();
let other_id = &other_concept.id;
let mut modified = false;
let (new_body, count) = rewrite_markdown_links(&other_doc.body, |link, _| {
if let Some(resolved_id) = link.resolve(other_id)
&& resolved_id == *target
{
options.redirect_to.as_ref().map_or_else(
|| {
if options.unlink {
LinkRewriteAction::Unlink
} else {
LinkRewriteAction::Keep
}
},
|redirect_id| {
let anchor =
link.target.find('#').map_or("", |idx| &link.target[idx..]);
match link.kind {
LinkKind::Absolute => {
LinkRewriteAction::Rewrite(format!("/{redirect_id}.md{anchor}"))
}
LinkKind::Relative => {
let new_rel = compute_relative_path(other_id, redirect_id);
LinkRewriteAction::Rewrite(format!("{new_rel}{anchor}"))
}
_ => LinkRewriteAction::Keep,
}
},
)
} else {
LinkRewriteAction::Keep
}
});
if count > 0 {
if options.redirect_to.is_some() {
redirected_count += count;
} else {
unlinked_count += count;
}
other_doc.body = new_body;
modified = true;
}
if let Some(redirect_id) = &options.redirect_to
&& rewrite_frontmatter_concept_references(
&mut other_doc.frontmatter,
other_id,
target,
redirect_id,
)
{
modified = true;
}
if modified {
let path = other_id.to_path(bundle.root());
affected_files.push(path.clone());
updated_other_docs.push((path, other_doc));
}
}
}
if !options.dry_run {
if target_path.exists() {
fs::remove_file(&target_path)?;
}
for (path, doc) in updated_other_docs {
fs::write(&path, doc.serialize())?;
}
if options.update_index {
let _ = regenerate_indexes(bundle.root());
}
if options.update_log {
let today = Date::today_utc().unwrap_or(Date {
year: 2026,
month: 8,
day: 24,
});
let log_msg = options.redirect_to.as_ref().map_or_else(
|| {
if options.unlink {
format!("Removed concept `{target}` (unlinked {unlinked_count} inbound links).")
} else {
format!("Removed concept `{target}`.")
}
},
|redirect_id| {
format!("Removed concept `{target}` (redirected {redirected_count} inbound links to `{redirect_id}`).")
},
);
let _ = append_log_entry(bundle.root(), today, "Update", &log_msg);
}
}
Ok(RemoveReport {
target: target.clone(),
removed_path: target_path,
redirected_to: options.redirect_to.clone(),
redirected_count,
unlinked_count,
affected_files,
dry_run: options.dry_run,
})
}
#[allow(clippy::too_many_lines)]
pub fn split_concept(
bundle: &Bundle,
source: &ConceptId,
target: &ConceptId,
options: &SplitOptions,
) -> Result<SplitReport, RefactorError> {
if source == target {
return Err(RefactorError::SameSourceAndTarget(source.clone()));
}
let source_concept = bundle
.get(source)
.ok_or_else(|| RefactorError::ConceptNotFound(source.clone()))?;
if !options.force && bundle.get(target).is_some() {
return Err(RefactorError::ConceptAlreadyExists(target.clone()));
}
let target_path = target.to_path(bundle.root());
let source_path = source.to_path(bundle.root());
let (extracted_heading, extracted_lines, new_source_body) = extract_section_from_body(
&source_concept.document.body,
&options.section,
source,
target,
options.link_text.as_deref().or(options.title.as_deref()),
)
.ok_or_else(|| RefactorError::SectionNotFound {
concept: source.clone(),
section: options.section.clone(),
})?;
let target_title = options
.title
.clone()
.unwrap_or_else(|| extracted_heading.clone());
let extracted_text = extracted_lines.join("\n");
let extracted_refs: HashSet<String> = crate::footnotes::extract_refs(&extracted_text)
.into_iter()
.map(|r| r.label)
.collect();
let remaining_refs: HashSet<String> = crate::footnotes::extract_refs(&new_source_body)
.into_iter()
.map(|r| r.label)
.collect();
let all_defs = source_concept.document.footnote_definitions();
let mut target_defs = Vec::new();
for def in all_defs {
let in_extracted = extracted_refs.contains(&def.label);
if in_extracted {
target_defs.push(def.clone());
}
}
let mut target_fm = Frontmatter::new();
target_fm.set(
"type",
Value::String(
options
.type_
.clone()
.unwrap_or_else(|| "Concept".to_string()),
),
);
target_fm.set("title", Value::String(target_title.clone()));
target_fm.set(
"status",
Value::String(source_concept.document.frontmatter.status().to_string()),
);
let author = options.author.clone().unwrap_or_else(default_author);
let mut gen_map = crate::yaml::Mapping::new();
gen_map.insert("by", Value::String(author));
gen_map.insert("at", Value::String(current_iso_timestamp()));
target_fm.set("generated", Value::Mapping(gen_map));
let all_sources = source_concept.document.frontmatter.sources();
let mut target_sources = Vec::new();
let mut kept_sources = Vec::new();
for src in all_sources {
let src_id = src.id.as_deref().unwrap_or("");
let in_extracted = extracted_refs.contains(src_id);
let in_remaining = remaining_refs.contains(src_id);
if in_extracted {
target_sources.push(src.clone());
}
if in_remaining {
kept_sources.push(src);
}
}
if !target_sources.is_empty() {
let seq = target_sources.iter().map(Source::to_yaml_value).collect();
target_fm.set("sources", Value::Sequence(seq));
}
let mut target_body = format!("# {target_title}\n\n{}\n", extracted_text.trim());
if !target_defs.is_empty() {
target_body.push('\n');
for def in &target_defs {
let _ = writeln!(target_body, "[^{}]: {}", def.label, def.text);
}
}
let target_doc = Document::new(target_fm, target_body);
let mut new_source_doc = source_concept.document.clone();
new_source_doc.body = new_source_body;
if kept_sources.is_empty() {
new_source_doc.frontmatter.remove("sources");
} else {
let seq = kept_sources.iter().map(Source::to_yaml_value).collect();
new_source_doc
.frontmatter
.set("sources", Value::Sequence(seq));
}
let affected_files = vec![source_path.clone(), target_path.clone()];
if !options.dry_run {
if let Some(parent) = target_path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(&target_path, target_doc.serialize())?;
fs::write(&source_path, new_source_doc.serialize())?;
if options.update_index {
let _ = regenerate_indexes(bundle.root());
}
if options.update_log {
let today = Date::today_utc().unwrap_or(Date {
year: 2026,
month: 8,
day: 24,
});
let author_suffix = options
.author
.as_ref()
.map_or(String::new(), |a| format!(" (by {a})"));
let log_msg = format!(
"Extracted section `{}` from `{source}` into new concept `{target}`{author_suffix}.",
options.section
);
let _ = append_log_entry(bundle.root(), today, "Creation", &log_msg);
}
}
Ok(SplitReport {
source: source.clone(),
target: target.clone(),
section: options.section.clone(),
target_title,
target_path,
extracted_lines_count: extracted_lines.len(),
moved_sources_count: target_sources.len(),
affected_files,
dry_run: options.dry_run,
})
}
#[allow(clippy::too_many_lines)]
pub fn merge_concepts(
bundle: &Bundle,
source: &ConceptId,
target: &ConceptId,
options: &MergeOptions,
) -> Result<MergeReport, RefactorError> {
if source == target {
return Err(RefactorError::SameSourceAndTarget(source.clone()));
}
let source_concept = bundle
.get(source)
.ok_or_else(|| RefactorError::ConceptNotFound(source.clone()))?;
let target_concept = bundle
.get(target)
.ok_or_else(|| RefactorError::ConceptNotFound(target.clone()))?;
let source_path = source.to_path(bundle.root());
let target_path = target.to_path(bundle.root());
let source_dir: Vec<String> = source
.parent()
.map(|p| p.segments().to_vec())
.unwrap_or_default();
let target_dir: Vec<String> = target
.parent()
.map(|p| p.segments().to_vec())
.unwrap_or_default();
let (rebased_source_body, _) =
rewrite_markdown_links(&source_concept.document.body, |link, _| match link.kind {
LinkKind::Relative => link.resolve(source).map_or_else(
|| {
let new_rel = rebase_relative_path(&source_dir, &target_dir, &link.target);
LinkRewriteAction::Rewrite(new_rel)
},
|dest_concept| {
if dest_concept == *target {
LinkRewriteAction::Keep
} else {
let new_rel = compute_relative_path(target, &dest_concept);
let anchor = link.target.find('#').map_or("", |idx| &link.target[idx..]);
LinkRewriteAction::Rewrite(format!("{new_rel}{anchor}"))
}
},
),
_ => LinkRewriteAction::Keep,
});
let mut target_doc = target_concept.document.clone();
let mut target_sources = target_doc.frontmatter.sources();
let target_defs = target_doc.footnote_definitions();
let mut existing_ids: HashSet<String> = target_sources
.iter()
.filter_map(|s| s.id.clone())
.chain(target_defs.iter().map(|d| d.label.clone()))
.collect();
let mut merged_sources_count = 0;
let mut source_body_final = rebased_source_body;
for mut src in source_concept.document.frontmatter.sources() {
if let Some(orig_id) = src.id.clone() {
if existing_ids.contains(&orig_id) {
let is_identical = target_sources
.iter()
.any(|s| s.id.as_deref() == Some(&orig_id) && s.resource == src.resource);
if !is_identical {
let new_id = format!("{}_{orig_id}", source.name());
source_body_final = source_body_final
.replace(&format!("[^{orig_id}]"), &format!("[^{new_id}]"));
source_body_final = source_body_final
.replace(&format!("[^{orig_id}]:"), &format!("[^{new_id}]:"));
src.id = Some(new_id.clone());
existing_ids.insert(new_id);
target_sources.push(src);
merged_sources_count += 1;
}
} else {
existing_ids.insert(orig_id);
target_sources.push(src);
merged_sources_count += 1;
}
} else {
target_sources.push(src);
merged_sources_count += 1;
}
}
if !target_sources.is_empty() {
let seq = target_sources.iter().map(Source::to_yaml_value).collect();
target_doc.frontmatter.set("sources", Value::Sequence(seq));
}
let mut target_verified = target_doc.frontmatter.verified();
for v in source_concept.document.frontmatter.verified() {
if !target_verified.contains(&v) {
target_verified.push(v);
}
}
if !target_verified.is_empty() {
let seq = target_verified
.iter()
.map(|v| {
let mut map = crate::yaml::Mapping::new();
if let Some(by) = &v.by {
map.insert("by", Value::String(by.as_str().to_string()));
}
if let Some(at) = &v.at {
map.insert("at", Value::String(at.raw.clone()));
}
Value::Mapping(map)
})
.collect();
target_doc.frontmatter.set("verified", Value::Sequence(seq));
}
let heading = options.heading.clone().unwrap_or_else(|| {
let source_title = source_concept
.document
.frontmatter
.title()
.map_or_else(|| source.name().to_string(), std::borrow::Cow::into_owned);
format!("## {source_title}")
});
let body_to_append = {
let trimmed = source_body_final.trim();
trimmed.strip_prefix('#').map_or(trimmed, |rest| {
let first_line = rest.lines().next().unwrap_or("");
if first_line.starts_with('#') {
trimmed
} else {
rest[first_line.len()..].trim_start()
}
})
};
target_doc.body = format!(
"{}\n\n{heading}\n\n{body_to_append}\n",
target_doc.body.trim_end()
);
let mut affected_files = vec![source_path.clone(), target_path.clone()];
let mut updated_other_docs: Vec<(PathBuf, Document)> = Vec::new();
let mut rewritten_links_total = 0;
for other_concept in bundle.concepts() {
if other_concept.id == *source || other_concept.id == *target {
continue;
}
let mut other_doc = other_concept.document.clone();
let other_id = &other_concept.id;
let mut modified = false;
let (new_body, count) = rewrite_markdown_links(&other_doc.body, |link, _| {
if let Some(resolved_id) = link.resolve(other_id)
&& resolved_id == *source
{
let anchor = link.target.find('#').map_or("", |idx| &link.target[idx..]);
match link.kind {
LinkKind::Absolute => {
LinkRewriteAction::Rewrite(format!("/{target}.md{anchor}"))
}
LinkKind::Relative => {
let new_rel = compute_relative_path(other_id, target);
LinkRewriteAction::Rewrite(format!("{new_rel}{anchor}"))
}
_ => LinkRewriteAction::Keep,
}
} else {
LinkRewriteAction::Keep
}
});
if count > 0 {
other_doc.body = new_body;
rewritten_links_total += count;
modified = true;
}
if rewrite_frontmatter_concept_references(
&mut other_doc.frontmatter,
other_id,
source,
target,
) {
modified = true;
}
if modified {
let path = other_id.to_path(bundle.root());
affected_files.push(path.clone());
updated_other_docs.push((path, other_doc));
}
}
if !options.dry_run {
fs::write(&target_path, target_doc.serialize())?;
if source_path.exists() {
fs::remove_file(&source_path)?;
}
for (path, doc) in updated_other_docs {
fs::write(&path, doc.serialize())?;
}
if options.update_index {
let _ = regenerate_indexes(bundle.root());
}
if options.update_log {
let today = Date::today_utc().unwrap_or(Date {
year: 2026,
month: 8,
day: 24,
});
let author_suffix = options
.author
.as_ref()
.map_or(String::new(), |a| format!(" (by {a})"));
let log_msg = format!(
"Merged concept `{source}` into `{target}` (rewrote {rewritten_links_total} inbound links){author_suffix}."
);
let _ = append_log_entry(bundle.root(), today, "Update", &log_msg);
}
}
Ok(MergeReport {
source: source.clone(),
target: target.clone(),
removed_path: source_path,
updated_path: target_path,
rewritten_links_count: rewritten_links_total,
merged_sources_count,
affected_files,
dry_run: options.dry_run,
})
}
fn rebase_frontmatter_paths(
fm: &mut Frontmatter,
old_dir: &[String],
new_dir: &[String],
count: &mut usize,
) {
if let Some(Value::String(s)) = fm.get("computation")
&& !s.starts_with('/')
&& links::Link::classify(s) == LinkKind::Relative
{
fm.set(
"computation",
Value::String(rebase_relative_path(old_dir, new_dir, s)),
);
*count += 1;
}
if let Some(Value::Mapping(map)) = fm.get_mut("executor")
&& let Some(Value::String(res)) = map.get("resource")
&& !res.starts_with('/')
&& links::Link::classify(res) == LinkKind::Relative
{
map.insert(
"resource",
Value::String(rebase_relative_path(old_dir, new_dir, res)),
);
*count += 1;
}
if let Some(Value::Mapping(map)) = fm.get_mut("attester")
&& let Some(Value::String(res)) = map.get("resource")
&& !res.starts_with('/')
&& links::Link::classify(res) == LinkKind::Relative
{
map.insert(
"resource",
Value::String(rebase_relative_path(old_dir, new_dir, res)),
);
*count += 1;
}
if let Some(Value::Sequence(sources)) = fm.get_mut("sources") {
for src_val in sources {
if let Value::Mapping(src_map) = src_val
&& let Some(Value::String(res)) = src_map.get("resource")
&& !res.starts_with('/')
&& links::Link::classify(res) == LinkKind::Relative
{
src_map.insert(
"resource",
Value::String(rebase_relative_path(old_dir, new_dir, res)),
);
*count += 1;
}
}
}
}
fn rewrite_frontmatter_concept_references(
fm: &mut Frontmatter,
from_id: &ConceptId,
old_target: &ConceptId,
new_target: &ConceptId,
) -> bool {
let mut modified = false;
if let Some(Value::Sequence(sources)) = fm.get_mut("sources") {
for src_val in sources {
if let Value::Mapping(src_map) = src_val
&& let Some(Value::String(res)) = src_map.get("resource")
{
let link = Link {
text: String::new(),
kind: Link::classify(res),
target: res.clone(),
};
if let Some(resolved) = link.resolve(from_id)
&& resolved == *old_target
{
let new_res = match link.kind {
LinkKind::Absolute => format!("/{new_target}.md"),
LinkKind::Relative => compute_relative_path(from_id, new_target),
_ => continue,
};
src_map.insert("resource", Value::String(new_res));
modified = true;
}
}
}
}
modified
}
fn extract_section_from_body(
body: &str,
section_query: &str,
source: &ConceptId,
target: &ConceptId,
custom_link_text: Option<&str>,
) -> Option<(String, Vec<String>, String)> {
let clean_query = section_query.trim().trim_start_matches('#').trim();
let query_slug = heading_slug(clean_query);
let lines: Vec<&str> = body.lines().collect();
let mut match_idx = None;
let mut match_level = 1;
let mut match_title = String::new();
let mut fence: Option<char> = None;
for (i, line) in lines.iter().enumerate() {
let trimmed_start = line.trim_start();
if let Some(f) = fence {
if trimmed_start.starts_with(&f.to_string().repeat(3)) {
fence = None;
}
continue;
}
if trimmed_start.starts_with("```") {
fence = Some('`');
continue;
}
if trimmed_start.starts_with("~~~") {
fence = Some('~');
continue;
}
let trimmed = line.trim();
if trimmed.starts_with('#') {
let hashes = trimmed.chars().take_while(|c| *c == '#').count();
let title = trimmed[hashes..].trim();
if title.eq_ignore_ascii_case(clean_query)
|| heading_slug(title) == query_slug
|| title
.replace(['-', '_'], " ")
.eq_ignore_ascii_case(&clean_query.replace(['-', '_'], " "))
{
match_idx = Some(i);
match_level = hashes;
match_title = title.to_string();
break;
}
}
}
let start_idx = match_idx?;
let mut end_idx = lines.len();
fence = None;
for (i, line) in lines.iter().enumerate().skip(start_idx + 1) {
let trimmed_start = line.trim_start();
if let Some(f) = fence {
if trimmed_start.starts_with(&f.to_string().repeat(3)) {
fence = None;
}
continue;
}
if trimmed_start.starts_with("```") {
fence = Some('`');
continue;
}
if trimmed_start.starts_with("~~~") {
fence = Some('~');
continue;
}
let trimmed = line.trim();
if trimmed.starts_with('#') {
let hashes = trimmed.chars().take_while(|c| *c == '#').count();
if hashes <= match_level {
end_idx = i;
break;
}
}
}
let heading_line = lines[start_idx];
let extracted_content: Vec<String> = lines[start_idx + 1..end_idx]
.iter()
.map(ToString::to_string)
.collect();
let rel_link = compute_relative_path(source, target);
let lt = custom_link_text.unwrap_or(&match_title);
let replacement = format!("{heading_line}\n\nSee [{lt}]({rel_link}).\n");
let mut remaining_lines: Vec<String> = Vec::new();
for line in &lines[..start_idx] {
remaining_lines.push(line.to_string());
}
remaining_lines.push(replacement);
for line in &lines[end_idx..] {
remaining_lines.push(line.to_string());
}
let mut new_body = remaining_lines.join("\n");
if body.ends_with('\n') {
new_body.push('\n');
}
Some((match_title, extracted_content, new_body))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_compute_relative_path() {
let from = ConceptId::parse("auth/tokens/jwt").unwrap();
let to_same = ConceptId::parse("auth/tokens/refresh").unwrap();
assert_eq!(compute_relative_path(&from, &to_same), "refresh.md");
let to_up = ConceptId::parse("auth/user").unwrap();
assert_eq!(compute_relative_path(&from, &to_up), "../user.md");
let to_deep = ConceptId::parse("billing/invoicing/pdf").unwrap();
assert_eq!(
compute_relative_path(&from, &to_deep),
"../../billing/invoicing/pdf.md"
);
let root = ConceptId::parse("overview").unwrap();
assert_eq!(compute_relative_path(&root, &from), "auth/tokens/jwt.md");
assert_eq!(compute_relative_path(&from, &root), "../../overview.md");
}
#[test]
fn test_rebase_relative_path() {
let old_dir = vec!["auth".to_string(), "tokens".to_string()];
let new_dir = vec!["security".to_string()];
let rebased = rebase_relative_path(&old_dir, &new_dir, "../scripts/calc.py");
assert_eq!(rebased, "../auth/scripts/calc.py");
let rebased_anchor = rebase_relative_path(&old_dir, &new_dir, "../user.md#profile");
assert_eq!(rebased_anchor, "../auth/user.md#profile");
}
#[test]
fn test_rewrite_markdown_links() {
let body = "\
# Title
See [User Guide](../guides/user.md) and [Profile](/users/profile.md#info).
Also `[Code Link](../not/a/link.md)` should not change.
```python
# [Python Link](../ignored.md)
pass
```
";
let (rewritten, count) = rewrite_markdown_links(body, |link, _| {
if link.target.starts_with("../guides/user.md") {
LinkRewriteAction::Rewrite("../../docs/user.md".to_string())
} else if link.target.starts_with("/users/profile.md") {
LinkRewriteAction::Unlink
} else {
LinkRewriteAction::Keep
}
});
assert_eq!(count, 2);
assert!(rewritten.contains("[User Guide](../../docs/user.md)"));
assert!(rewritten.contains("and Profile."));
assert!(rewritten.contains("`[Code Link](../not/a/link.md)`"));
assert!(rewritten.contains("# [Python Link](../ignored.md)"));
}
}