lazyspec 0.8.0

A little TUI & CLI for project documentation.
Documentation
use crate::cli::style::{error_prefix, warning_prefix};
use crate::engine::config::Config;
use crate::engine::gh::{AuthStatus, GhAuth, GhCli};
use crate::engine::store::Store;
use console::{colors_enabled, Style};

fn success_message() -> String {
    if colors_enabled() {
        format!(
            "{} All documents valid.",
            Style::new().green().bold().apply_to("\u{2713}")
        )
    } else {
        "All documents valid.".to_string()
    }
}

pub fn gh_auth_warnings(gh: &dyn GhAuth) -> Vec<String> {
    match gh.auth_status() {
        Ok(AuthStatus::GhNotInstalled) => {
            vec!["gh CLI is not installed; github-issues types will not sync".to_string()]
        }
        Ok(AuthStatus::NotAuthenticated(msg)) => {
            vec![format!(
                "gh is not authenticated; github-issues types will not sync ({})",
                msg
            )]
        }
        Ok(AuthStatus::Authenticated { .. }) => vec![],
        Err(e) => {
            vec![format!("could not check gh auth status: {}", e)]
        }
    }
}

pub fn run_full(store: &Store, config: &Config, json: bool, warnings: bool) -> i32 {
    let result = store.validate_full(config);

    let gh_warnings = if config.documents.has_github_issues_types() {
        let gh = GhCli::new();
        gh_auth_warnings(&gh)
    } else {
        vec![]
    };

    if json {
        let output = run_json(store, config, &gh_warnings);
        println!("{}", output);
    } else {
        let output = run_human(store, config, warnings, &gh_warnings);
        if output.is_empty() {
            println!("{}", success_message());
        } else {
            eprint!("{}", output);
        }
    }

    if result.errors.is_empty() && store.parse_errors().is_empty() {
        0
    } else {
        2
    }
}

pub fn run_json(store: &Store, config: &Config, extra_warnings: &[String]) -> String {
    let result = store.validate_full(config);
    let errors: Vec<_> = result.errors.iter().map(|e| format!("{}", e)).collect();
    let mut warnings: Vec<_> = result.warnings.iter().map(|w| format!("{}", w)).collect();
    warnings.extend(extra_warnings.iter().cloned());
    let parse_errors: Vec<_> = store
        .parse_errors()
        .iter()
        .map(|pe| serde_json::json!({ "path": pe.path.display().to_string(), "error": pe.error }))
        .collect();
    serde_json::to_string_pretty(&serde_json::json!({
        "errors": errors,
        "warnings": warnings,
        "parse_errors": parse_errors,
    }))
    .unwrap()
}

pub fn run_human(
    store: &Store,
    config: &Config,
    show_warnings: bool,
    extra_warnings: &[String],
) -> String {
    let result = store.validate_full(config);
    let mut output = String::new();

    for pe in store.parse_errors() {
        output.push_str(&format!(
            "  {} parse error in {}: {}\n",
            error_prefix(),
            pe.path.display(),
            pe.error
        ));
    }
    for error in &result.errors {
        output.push_str(&format!("  {} {}\n", error_prefix(), error));
    }
    if show_warnings {
        for warning in &result.warnings {
            output.push_str(&format!("  {} {}\n", warning_prefix(), warning));
        }
        for warning in extra_warnings {
            output.push_str(&format!("  {} {}\n", warning_prefix(), warning));
        }
    }

    output
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::engine::gh::{test_support::MockGhClient, AuthStatus};

    #[test]
    fn gh_auth_warnings_when_not_installed() {
        let gh = MockGhClient::new().with_auth(AuthStatus::GhNotInstalled);
        let warnings = gh_auth_warnings(&gh);
        assert_eq!(warnings.len(), 1);
        assert!(warnings[0].contains("not installed"));
    }

    #[test]
    fn gh_auth_warnings_when_not_authenticated() {
        let gh = MockGhClient::new()
            .with_auth(AuthStatus::NotAuthenticated("token expired".to_string()));
        let warnings = gh_auth_warnings(&gh);
        assert_eq!(warnings.len(), 1);
        assert!(warnings[0].contains("not authenticated"));
        assert!(warnings[0].contains("token expired"));
    }

    #[test]
    fn gh_auth_warnings_when_authenticated() {
        let gh = MockGhClient::new();
        let warnings = gh_auth_warnings(&gh);
        assert!(warnings.is_empty());
    }

    #[test]
    fn run_human_includes_gh_warnings_when_shown() {
        let dir = tempfile::tempdir().unwrap();
        let config = Config::default();
        let store = Store::load(dir.path(), &config).unwrap();
        let extra = vec!["gh CLI is not installed; github-issues types will not sync".to_string()];
        let output = run_human(&store, &config, true, &extra);
        assert!(output.contains("gh CLI is not installed"));
    }

    #[test]
    fn run_human_hides_gh_warnings_when_not_shown() {
        let dir = tempfile::tempdir().unwrap();
        let config = Config::default();
        let store = Store::load(dir.path(), &config).unwrap();
        let extra = vec!["gh CLI is not installed; github-issues types will not sync".to_string()];
        let output = run_human(&store, &config, false, &extra);
        assert!(!output.contains("gh CLI is not installed"));
    }

    #[test]
    fn run_json_includes_gh_warnings() {
        let dir = tempfile::tempdir().unwrap();
        let config = Config::default();
        let store = Store::load(dir.path(), &config).unwrap();
        let extra = vec!["gh not installed warning".to_string()];
        let output = run_json(&store, &config, &extra);
        assert!(output.contains("gh not installed warning"));
    }
}