1use 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#[derive(Clone, Debug, PartialEq, Eq)]
38pub enum RefactorError {
39 ConceptNotFound(ConceptId),
41 ConceptAlreadyExists(ConceptId),
43 SameSourceAndTarget(ConceptId),
45 HasInboundLinks {
47 target: ConceptId,
49 inbound_count: usize,
51 inbound_concepts: Vec<ConceptId>,
53 },
54 SectionNotFound {
56 concept: ConceptId,
58 section: String,
60 },
61 InvalidConceptId(String),
63 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#[derive(Clone, Debug, PartialEq, Eq)]
114#[allow(clippy::struct_excessive_bools)]
115pub struct MoveOptions {
116 pub dry_run: bool,
118 pub force: bool,
120 pub update_index: bool,
122 pub update_log: bool,
124 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#[derive(Clone, Debug, PartialEq, Eq)]
142pub struct MoveReport {
143 pub source: ConceptId,
145 pub target: ConceptId,
147 pub source_path: PathBuf,
149 pub target_path: PathBuf,
151 pub rewritten_incoming_links: usize,
153 pub rebased_outgoing_links: usize,
155 pub rebased_frontmatter_paths: usize,
157 pub affected_files: Vec<PathBuf>,
159 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#[derive(Clone, Debug, PartialEq, Eq)]
194#[allow(clippy::struct_excessive_bools)]
195pub struct RemoveOptions {
196 pub dry_run: bool,
198 pub force: bool,
200 pub redirect_to: Option<ConceptId>,
202 pub unlink: bool,
204 pub update_index: bool,
206 pub update_log: bool,
208 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#[derive(Clone, Debug, PartialEq, Eq)]
228pub struct RemoveReport {
229 pub target: ConceptId,
231 pub removed_path: PathBuf,
233 pub redirected_to: Option<ConceptId>,
235 pub redirected_count: usize,
237 pub unlinked_count: usize,
239 pub affected_files: Vec<PathBuf>,
241 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#[derive(Clone, Debug, PartialEq, Eq)]
273#[allow(clippy::struct_excessive_bools)]
274pub struct SplitOptions {
275 pub section: String,
277 pub title: Option<String>,
279 pub type_: Option<String>,
281 pub link_text: Option<String>,
283 pub force: bool,
285 pub dry_run: bool,
287 pub update_index: bool,
289 pub update_log: bool,
291 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 #[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#[derive(Clone, Debug, PartialEq, Eq)]
331pub struct SplitReport {
332 pub source: ConceptId,
334 pub target: ConceptId,
336 pub section: String,
338 pub target_title: String,
340 pub target_path: PathBuf,
342 pub extracted_lines_count: usize,
344 pub moved_sources_count: usize,
346 pub affected_files: Vec<PathBuf>,
348 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#[derive(Clone, Debug, PartialEq, Eq)]
372#[allow(clippy::struct_excessive_bools)]
373pub struct MergeOptions {
374 pub heading: Option<String>,
376 pub force: bool,
378 pub dry_run: bool,
380 pub update_index: bool,
382 pub update_log: bool,
384 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#[derive(Clone, Debug, PartialEq, Eq)]
403pub struct MergeReport {
404 pub source: ConceptId,
406 pub target: ConceptId,
408 pub removed_path: PathBuf,
410 pub updated_path: PathBuf,
412 pub rewritten_links_count: usize,
414 pub merged_sources_count: usize,
416 pub affected_files: Vec<PathBuf>,
418 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#[derive(Clone, Debug, PartialEq, Eq)]
443pub struct RenameSectionOptions {
444 pub dry_run: bool,
446 pub update_log: bool,
448 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#[derive(Clone, Debug, PartialEq, Eq)]
464pub struct RenameSectionReport {
465 pub concept: ConceptId,
467 pub old_section: String,
469 pub new_section: String,
471 pub old_slug: String,
473 pub new_slug: String,
475 pub internal_links_updated: usize,
477 pub external_links_updated: usize,
479 pub affected_files: Vec<PathBuf>,
481 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#[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 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#[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 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 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 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#[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 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 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#[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 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 link.resolve(source).map_or_else(
841 || {
842 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 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 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 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 if let Some(parent) = target_path.parent() {
933 fs::create_dir_all(parent)?;
934 }
935
936 fs::write(&target_path, moved_doc.serialize())?;
938
939 if source_path.exists() && source_path != target_path {
941 fs::remove_file(&source_path)?;
942 }
943
944 for (path, doc) in updated_other_docs {
946 fs::write(&path, doc.serialize())?;
947 }
948
949 if options.update_index {
951 let _ = regenerate_indexes(bundle.root());
952 }
953
954 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#[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 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 if target_path.exists() {
1094 fs::remove_file(&target_path)?;
1095 }
1096
1097 for (path, doc) in updated_other_docs {
1099 fs::write(&path, doc.serialize())?;
1100 }
1101
1102 if options.update_index {
1104 let _ = regenerate_indexes(bundle.root());
1105 }
1106
1107 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#[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 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 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 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 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#[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 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 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 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 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 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 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 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 fs::write(&target_path, target_doc.serialize())?;
1538
1539 if source_path.exists() {
1541 fs::remove_file(&source_path)?;
1542 }
1543
1544 for (path, doc) in updated_other_docs {
1546 fs::write(&path, doc.serialize())?;
1547 }
1548
1549 if options.update_index {
1551 let _ = regenerate_indexes(bundle.root());
1552 }
1553
1554 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
1678fn 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}