doc-sync 0.1.2

A CLI to convert rust documentation to markdown files and then back to rust documentation.
use std::borrow::Cow;
use std::error::Error;
use std::path::{Component, PathBuf};

use cli_failure::{bail, failure_raw};
use normalize_path::NormalizePath;
use regex::Regex;
use rustdoc_types::{Crate, Id};
use tracing::{debug, info, warn};
use xshell::Shell;

use crate::consts::*;
use crate::edit_docs;
use crate::from_markdown::module_path::AbsoluteModulePath;

use super::item_path::ItemPath;
use super::module_path::RelativeModulePath;

#[derive(custom_debug_derive::Debug)]
pub struct ItemInfo<'lt> {
    pub id: String,
    #[debug(skip)]
    pub rustdoc_item: &'lt rustdoc_types::Item,
    #[debug(skip)]
    pub new_docs: Vec<String>,
    pub file_path: String,
    pub file_module_path: RelativeModulePath,
    pub item_path: ItemPath,
}

#[wrap_match::wrap_match(log_success = false)]
#[tracing::instrument(skip(sh, json, id_capture))]
pub fn get_item_info<'lt>(
    sh: &Shell,
    json: &'lt Crate,
    id_capture: &Regex,
    input_dir: &PathBuf,
    file: PathBuf,
) -> Result<Option<ItemInfo<'lt>>, Box<dyn Error>> {
    let new_docs = sh.read_file(&file)?;
    let captures = id_capture
        .captures(&new_docs)
        .ok_or_else(|| failure_raw!("No rustdoc ID found"))?;
    if captures.len() <= 1 {
        bail!("No rustdoc ID found");
    } else if captures.len() > 2 {
        warn!("More than one rustdoc ID found, only the first ID will be used");
    }

    let id = Id(captures[1].to_owned());
    debug!(id = id.0);
    let rustdoc_item = json.index.get(&id).ok_or_else(|| {
        failure_raw!(
            "No item found; did you re-run rustdoc in JSON output format since using to-markdown?"
        )
    })?;
    let mut new_docs: Vec<_> = new_docs
        .replace(&format!("{METADATA_COMMENT_PREFIX}{METADATA_ID_PREFIX}{}{METADATA_ID_SUFFIX}{METADATA_COMMENT_SUFFIX}", id.0), "")
        .trim()
        .lines()
        .map(|l| l.to_owned())
        .collect();
    if new_docs
        == rustdoc_item
            .docs
            .to_owned()
            .unwrap_or_default()
            .trim()
            .lines()
            .collect::<Vec<_>>()
    {
        info!("Docs have not been changed");
        return Ok(None);
    }
    edit_docs::from_markdown(&mut new_docs);
    let file_path = rustdoc_item
        .span
        .as_ref()
        .ok_or_else(|| failure_raw!("No span for item"))?
        .filename
        .display()
        .to_string();
    let file_module_path = RelativeModulePath::from_file_path(&file_path);
    let module_path = if let Some(rustdoc_item_summary) = json.paths.get(&id) {
        Cow::Borrowed(&rustdoc_item_summary.path)
    } else {
        let mut module_path = vec![];
        for component in file
            .normalize()
            .strip_prefix(input_dir.normalize())
            .expect("file should always start with input_dir")
            .components()
        {
            if let Component::Normal(component) = component {
                let component = component.to_str().expect("path not utf-8");
                if component.ends_with(".md") {
                    module_path.push(component.split('~').next().unwrap().to_owned());
                    break;
                } else {
                    module_path.push(component.to_owned());
                }
            }
        }
        warn!(module_path = debug(&module_path), "Item seems to be an inner item. This means that we had to estimate the module path based on the file path, so it may be incorrect");
        Cow::Owned(module_path)
    };
    let item_path = ItemPath::new(
        &file_module_path,
        AbsoluteModulePath(&module_path),
        &json,
    )
    .ok_or_else(|| failure_raw!("Couldn't find item for part (see above); did you re-run rustdoc in JSON output format since using to-markdown?"))?;

    Ok(Some(ItemInfo {
        id: id.0,
        rustdoc_item,
        new_docs,
        file_path,
        file_module_path,
        item_path,
    }))
}