lazyspec 0.8.0

A little TUI & CLI for project documentation.
Documentation
use crate::cli::json::doc_to_json;
use crate::engine::config::{Config, StoreBackend};
use crate::engine::document::{split_frontmatter, DocMeta, DocType};
use crate::engine::fs_ops;
use crate::engine::gh::GhCli;
use crate::engine::git_ref::GitCli;
use crate::engine::git_ref_store::GitRefStore;
use crate::engine::issue_cache::IssueCache;
use crate::engine::issue_map::IssueMap;
use crate::engine::reservation;
use crate::engine::store::{Filter, Store};
use crate::engine::store_dispatch::{DocumentStore, GithubIssuesStore};
use anyhow::{anyhow, bail, Result};
use std::fs;
use std::path::{Path, PathBuf};

pub fn run(
    root: &Path,
    config: &Config,
    store: &Store,
    doc_type: &str,
    title: &str,
    author: &str,
    on_progress: impl Fn(reservation::ReservationProgress),
) -> Result<PathBuf> {
    run_with_body(
        root,
        config,
        store,
        doc_type,
        title,
        author,
        None,
        on_progress,
    )
}

#[allow(clippy::too_many_arguments)]
pub fn run_with_body(
    root: &Path,
    config: &Config,
    store: &Store,
    doc_type: &str,
    title: &str,
    author: &str,
    body: Option<&str>,
    on_progress: impl Fn(reservation::ReservationProgress),
) -> Result<PathBuf> {
    let type_def = config.type_by_name(doc_type).ok_or_else(|| {
        anyhow!(
            "unknown doc type: '{}'. valid types: {}",
            doc_type,
            config
                .documents
                .types
                .iter()
                .map(|t| t.name.as_str())
                .collect::<Vec<_>>()
                .join(", ")
        )
    })?;

    if type_def.singleton {
        let existing: Vec<_> = store.list(&Filter {
            doc_type: Some(DocType::new(doc_type)),
            ..Default::default()
        });
        if let Some(doc) = existing.first() {
            bail!("{} already exists at {}", doc_type, doc.path.display());
        }
    }

    if type_def.store == StoreBackend::GithubIssues {
        let gh_config = config.documents.github.as_ref().ok_or_else(|| {
            anyhow!(
                "type '{}' uses github-issues store but no [github] config found",
                doc_type
            )
        })?;
        let repo = gh_config.repo.as_ref().ok_or_else(|| {
            anyhow!(
                "type '{}' uses github-issues store but no github.repo configured",
                doc_type
            )
        })?;
        let mut store = GithubIssuesStore {
            client: GhCli::new(),
            root: root.to_path_buf(),
            repo: repo.clone(),
            config: config.clone(),
            issue_map: IssueMap::load(root)?,
            issue_cache: IssueCache::new(root),
        };
        let created = store.create(type_def, title, author, body.unwrap_or(""))?;
        return Ok(root.join(&created.path));
    }

    if type_def.store == StoreBackend::GitRef {
        let reserved_number = if let Some(coord) = &config.coordination {
            let cache_dir = root.join(".lazyspec/cache").join(&type_def.name);
            std::fs::create_dir_all(&cache_dir)?;
            Some(reservation::reserve_next(
                root,
                &coord.remote,
                &type_def.prefix,
                coord.max_push_retries,
                &cache_dir,
                &on_progress,
            )?)
        } else {
            None
        };
        let mut store = GitRefStore {
            git: GitCli,
            root: root.to_path_buf(),
            config: config.clone(),
            reserved_number,
        };
        let created = store.create(type_def, title, author, body.unwrap_or(""))?;
        return Ok(root.join(&created.path));
    }

    let path = fs_ops::create_document(
        root,
        config,
        doc_type,
        &type_def.dir,
        &type_def.prefix,
        title,
        author,
        &type_def.numbering,
        type_def.subdirectory,
        on_progress,
    )?;

    if let Some(body_text) = body {
        let content = fs::read_to_string(&path)?;
        let (yaml, _) = split_frontmatter(&content)?;
        let new_content = format!("---\n{}\n---\n\n{}\n", yaml.trim(), body_text);
        fs::write(&path, new_content)?;
    }

    Ok(path)
}

pub fn run_json(
    root: &Path,
    config: &Config,
    store: &Store,
    doc_type: &str,
    title: &str,
    author: &str,
    on_progress: impl Fn(reservation::ReservationProgress),
) -> Result<String> {
    run_json_with_body(
        root,
        config,
        store,
        doc_type,
        title,
        author,
        None,
        on_progress,
    )
}

#[allow(clippy::too_many_arguments)]
pub fn run_json_with_body(
    root: &Path,
    config: &Config,
    store: &Store,
    doc_type: &str,
    title: &str,
    author: &str,
    body: Option<&str>,
    on_progress: impl Fn(reservation::ReservationProgress),
) -> Result<String> {
    let path = run_with_body(
        root,
        config,
        store,
        doc_type,
        title,
        author,
        body,
        on_progress,
    )?;
    let relative = path.strip_prefix(root).unwrap_or(&path).to_path_buf();

    let content = fs::read_to_string(&path)?;
    let mut meta = DocMeta::parse(&content)?;
    meta.path = relative;

    let json = doc_to_json(&meta);
    Ok(serde_json::to_string_pretty(&json)?)
}