lazyspec 0.8.0

A little TUI & CLI for project documentation.
Documentation
use std::collections::HashMap;
use std::path::Path;

use crate::engine::config::{Config, NumberingStrategy};
use crate::engine::document::{compose_frontmatter, split_frontmatter};
use crate::engine::fs::FileSystem;
use crate::engine::store::{extract_id_from_name, Store};
use crate::engine::template::{next_number, next_sqids_id};

use super::renumber::cascade_references;
use super::ConflictFixResult;

pub(super) fn collect_conflict_fixes(
    root: &Path,
    store: &Store,
    config: &Config,
    dry_run: bool,
    fs: &dyn FileSystem,
) -> Vec<ConflictFixResult> {
    let mut id_groups: HashMap<String, Vec<&crate::engine::document::DocMeta>> = HashMap::new();

    for doc in store.all_docs() {
        if doc.virtual_doc {
            continue;
        }
        let filename = doc.path.file_name().and_then(|f| f.to_str()).unwrap_or("");
        let name = if filename == "index.md" {
            doc.path
                .parent()
                .and_then(|p| p.file_name())
                .and_then(|f| f.to_str())
                .unwrap_or("")
        } else {
            doc.path.file_stem().and_then(|f| f.to_str()).unwrap_or("")
        };
        let id = extract_id_from_name(name);
        id_groups.entry(id).or_default().push(doc);
    }

    let mut results = Vec::new();

    for (id, mut docs) in id_groups {
        if docs.len() < 2 {
            continue;
        }

        docs.sort_by(|a, b| {
            let date_cmp = a.date.cmp(&b.date);
            if date_cmp != std::cmp::Ordering::Equal {
                return date_cmp;
            }
            let mtime_a = std::fs::metadata(root.join(&a.path))
                .and_then(|m| m.modified())
                .ok();
            let mtime_b = std::fs::metadata(root.join(&b.path))
                .and_then(|m| m.modified())
                .ok();
            mtime_a.cmp(&mtime_b)
        });

        for loser in &docs[1..] {
            if let Some(mut fix) = renumber_doc(root, loser, &id, config, dry_run, fs) {
                let refs = cascade_references(root, store, &fix.old_id, &fix.new_id, dry_run, fs);
                fix.references_updated = refs;
                results.push(fix);
            }
        }
    }

    results
}

fn renumber_doc(
    root: &Path,
    doc: &crate::engine::document::DocMeta,
    old_id: &str,
    config: &Config,
    dry_run: bool,
    fs: &dyn FileSystem,
) -> Option<ConflictFixResult> {
    let doc_type_prefix = old_id.split('-').next().unwrap_or("");

    let type_def = config
        .documents
        .types
        .iter()
        .find(|t| t.prefix.eq_ignore_ascii_case(doc_type_prefix))?;
    let type_dir = root.join(&type_def.dir);

    let new_id = match type_def.numbering {
        NumberingStrategy::Sqids => {
            let sqids_config = config.documents.sqids.as_ref()?;
            let sqid = next_sqids_id(&type_dir, &type_def.prefix, sqids_config).ok()?;
            type_def.make_id(&sqid)
        }
        NumberingStrategy::Incremental => {
            let new_num = next_number(&type_dir, &type_def.prefix);
            type_def.make_id(format_args!("{:03}", new_num))
        }
        NumberingStrategy::Reserved => {
            return None;
        }
    };

    let filename = doc.path.file_name().and_then(|f| f.to_str()).unwrap_or("");
    let is_subfolder = filename == "index.md";

    let old_path_str = doc.path.display().to_string();

    if is_subfolder {
        let parent_rel = doc.path.parent()?;
        let parent_name = parent_rel.file_name().and_then(|f| f.to_str())?;
        let new_dir_name = parent_name.replacen(old_id, &new_id, 1);
        let new_parent_rel = parent_rel.with_file_name(&new_dir_name);
        let new_path_str = new_parent_rel.join("index.md").display().to_string();

        let old_abs = root.join(parent_rel);
        let new_abs = root.join(&new_parent_rel);

        if !dry_run {
            fs.rename(&old_abs, &new_abs).ok()?;
            update_title_in_file(&new_abs.join("index.md"), old_id, &new_id, fs);
        }

        Some(ConflictFixResult {
            old_path: old_path_str,
            new_path: new_path_str,
            old_id: old_id.to_string(),
            new_id,
            references_updated: vec![],
            written: !dry_run,
        })
    } else {
        let stem = doc.path.file_stem().and_then(|f| f.to_str())?;
        let new_stem = stem.replacen(old_id, &new_id, 1);
        let new_filename = format!("{}.md", new_stem);
        let new_rel = doc.path.with_file_name(&new_filename);
        let new_path_str = new_rel.display().to_string();

        let old_abs = root.join(&doc.path);
        let new_abs = root.join(&new_rel);

        if !dry_run {
            fs.rename(&old_abs, &new_abs).ok()?;
            update_title_in_file(&new_abs, old_id, &new_id, fs);
        }

        Some(ConflictFixResult {
            old_path: old_path_str,
            new_path: new_path_str,
            old_id: old_id.to_string(),
            new_id,
            references_updated: vec![],
            written: !dry_run,
        })
    }
}

fn update_title_in_file(path: &Path, old_id: &str, new_id: &str, fs: &dyn FileSystem) {
    let content = match fs.read_to_string(path) {
        Ok(c) => c,
        Err(_) => return,
    };

    let (yaml_str, body) = match split_frontmatter(&content) {
        Ok((y, b)) => (y, b),
        Err(_) => return,
    };

    if !yaml_str.contains(old_id) {
        return;
    }

    let new_yaml = yaml_str.replace(old_id, new_id);
    let output = compose_frontmatter(&new_yaml, &body);
    let _ = fs.write(path, &output);
}