llmwiki-tooling 0.3.0

CLI for managing LLM-wikis with Obsidian-style wikilinks.
Documentation
use std::ops::Range;
use std::path::{Component, Path, PathBuf};

use crate::markdown_document::MarkdownLinkDestination;

type Edits = Vec<(Range<usize>, String)>;

pub(crate) fn rebase_relative_links(
    links: &[MarkdownLinkDestination],
    old_path: &Path,
    new_path: &Path,
) -> Edits {
    let old_parent = old_path.parent().unwrap_or_else(|| Path::new(""));
    let new_parent = new_path.parent().unwrap_or_else(|| Path::new(""));
    markdown_link_edits(links, |dest| {
        let (path_part, suffix) = split_suffix(dest)?;
        if !is_relative_md_path(path_part) {
            return None;
        }
        let target = normalize_path(old_parent.join(path_part));
        let target = if target == old_path {
            new_path.to_path_buf()
        } else {
            target
        };
        Some(markdown_url(&relative_path(new_parent, &target), suffix))
    })
}

pub(crate) fn retarget_relative_links(
    links: &[MarkdownLinkDestination],
    source_path: &Path,
    old_path: &Path,
    new_path: &Path,
) -> Edits {
    let source_parent = source_path.parent().unwrap_or_else(|| Path::new(""));
    markdown_link_edits(links, |dest| {
        let (path_part, suffix) = split_suffix(dest)?;
        if !is_relative_md_path(path_part) {
            return None;
        }
        let target = normalize_path(source_parent.join(path_part));
        (target == old_path).then(|| markdown_url(&relative_path(source_parent, new_path), suffix))
    })
}

fn markdown_link_edits<F>(links: &[MarkdownLinkDestination], mut replacement: F) -> Edits
where
    F: FnMut(&str) -> Option<String>,
{
    let mut edits = Vec::new();
    for link in links {
        let Some(new_dest) = replacement(&link.destination) else {
            continue;
        };
        if new_dest != link.destination {
            edits.push((link.byte_range.clone(), new_dest));
        }
    }
    edits
}

fn split_suffix(dest: &str) -> Option<(&str, &str)> {
    let (path, suffix) = dest.split_once('#').unwrap_or((dest, ""));
    (!path.is_empty()).then_some((path, suffix))
}

fn is_relative_md_path(path: &str) -> bool {
    !path.starts_with('/')
        && !path.contains("://")
        && !path.starts_with("mailto:")
        && Path::new(path).extension().is_some_and(|ext| ext == "md")
}

fn markdown_url(path: &Path, suffix: &str) -> String {
    let mut out = path.to_string_lossy().replace('\\', "/");
    if !suffix.is_empty() {
        out.push('#');
        out.push_str(suffix);
    }
    out
}

pub(crate) fn normalize_path(path: PathBuf) -> PathBuf {
    let mut out = PathBuf::new();
    for component in path.components() {
        match component {
            Component::CurDir => {}
            Component::ParentDir => {
                out.pop();
            }
            other => out.push(other.as_os_str()),
        }
    }
    out
}

fn relative_path(from_dir: &Path, target: &Path) -> PathBuf {
    let from = normalize_path(from_dir.to_path_buf());
    let target = normalize_path(target.to_path_buf());
    let from_components: Vec<_> = from.components().collect();
    let target_components: Vec<_> = target.components().collect();
    let common = from_components
        .iter()
        .zip(&target_components)
        .take_while(|(a, b)| a == b)
        .count();

    let mut out = PathBuf::new();
    for component in &from_components[common..] {
        if matches!(component, Component::Normal(_)) {
            out.push("..");
        }
    }
    for component in &target_components[common..] {
        if let Component::Normal(part) = component {
            out.push(part);
        }
    }
    out
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::markdown_document::MarkdownDocument;
    use crate::splice;

    fn document(source: &str) -> MarkdownDocument {
        MarkdownDocument::new(source.to_owned())
    }

    #[test]
    fn retargets_inline_destination_without_touching_title() {
        let source = "See [topic](topics/old.md \"topics/old.md\").\n";
        let document = document(source);
        let edits = retarget_relative_links(
            document.markdown_links(),
            Path::new("/repo/overview.md"),
            Path::new("/repo/topics/old.md"),
            Path::new("/repo/topics/new.md"),
        );

        assert_eq!(
            splice::apply(source, &edits),
            "See [topic](topics/new.md \"topics/old.md\").\n"
        );
    }

    #[test]
    fn retargets_reference_definition_destination() {
        let source = "See [topic][topic].\n\n[topic]: topics/old.md \"title\"\n";
        let document = document(source);
        let edits = retarget_relative_links(
            document.markdown_links(),
            Path::new("/repo/overview.md"),
            Path::new("/repo/topics/old.md"),
            Path::new("/repo/topics/new.md"),
        );

        assert_eq!(
            splice::apply(source, &edits),
            "See [topic][topic].\n\n[topic]: topics/new.md \"title\"\n"
        );
    }
}