lazyspec 0.8.0

A little TUI & CLI for project documentation.
Documentation
use crate::engine::config::{Config, StoreBackend};
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::store_dispatch::{DocumentStore, FilesystemStore, GithubIssuesStore};
use anyhow::{anyhow, Result};
use std::fmt;
use std::path::Path;

#[derive(Debug)]
pub enum ProvenanceError {
    Empty,
    Duplicate(String),
}

impl fmt::Display for ProvenanceError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            ProvenanceError::Empty => write!(f, "citation must not be empty"),
            ProvenanceError::Duplicate(s) => write!(f, "citation already present: {}", s),
        }
    }
}

impl std::error::Error for ProvenanceError {}

pub fn validate_citation(citation: &str) -> Result<&str, ProvenanceError> {
    let trimmed = citation.trim();
    if trimmed.is_empty() {
        Err(ProvenanceError::Empty)
    } else {
        Ok(trimmed)
    }
}

pub fn set_provenance(
    root: &Path,
    config: &Config,
    type_name: &str,
    doc_id: &str,
    new_list: &[String],
) -> Result<()> {
    let type_def = config
        .type_by_name(type_name)
        .ok_or_else(|| anyhow!("unknown document type: {}", type_name))?;

    match type_def.store {
        StoreBackend::Filesystem => {
            let mut fs_store = FilesystemStore {
                root: root.to_path_buf(),
                config: config.clone(),
            };
            fs_store.set_provenance(type_def, doc_id, new_list)
        }
        StoreBackend::GithubIssues => {
            let gh_config = config.documents.github.as_ref().ok_or_else(|| {
                anyhow!(
                    "type '{}' uses github-issues store but no [github] config found",
                    type_name
                )
            })?;
            let repo = gh_config.repo.as_ref().ok_or_else(|| {
                anyhow!(
                    "type '{}' uses github-issues store but no github.repo configured",
                    type_name
                )
            })?;
            let mut gh_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),
            };
            gh_store.set_provenance(type_def, doc_id, new_list)
        }
        StoreBackend::GitRef => {
            let mut git_store = GitRefStore {
                git: GitCli,
                root: root.to_path_buf(),
                config: config.clone(),
                reserved_number: None,
            };
            git_store.set_provenance(type_def, doc_id, new_list)
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn validate_citation_rejects_empty() {
        assert!(matches!(validate_citation(""), Err(ProvenanceError::Empty)));
    }

    #[test]
    fn validate_citation_rejects_whitespace_only() {
        assert!(matches!(
            validate_citation("   "),
            Err(ProvenanceError::Empty)
        ));
    }

    #[test]
    fn validate_citation_trims_and_returns() {
        assert_eq!(validate_citation("  X  ").unwrap(), "X");
    }

    #[test]
    fn display_empty() {
        assert!(format!("{}", ProvenanceError::Empty).contains("empty"));
    }

    #[test]
    fn display_duplicate() {
        let s = format!("{}", ProvenanceError::Duplicate("foo".into()));
        assert!(s.contains("foo") && s.contains("already"));
    }
}