gobby-wiki 0.8.0

Gobby wiki CLI shell
use chrono::{NaiveDate, Utc};
use gobby_core::config::AiRouting;

use crate::explainer::{ExplainerGenerator, ExplainerPrompt};
use crate::support::scope::resolve_selection_context;
use crate::support::time::collect_timestamp;
use crate::{CommandOutcome, RecapOptions, ScopeSelection, WikiError, recap};

use super::lanes::{ai_notice_label, resolve_explainer_transport, routing_label};

const COMMAND: &str = "gwiki recap";

pub(crate) fn execute(
    selection: ScopeSelection,
    options: RecapOptions,
    ai: AiRouting,
) -> Result<CommandOutcome, WikiError> {
    let context = resolve_selection_context(&selection)?;
    let vault_root = context.scope.root().to_path_buf();
    let timestamp = collect_timestamp()?;
    let date = resolve_date(options.date.as_deref())?;

    // Recap is one bounded completion by design — never the Lane B tool loop —
    // so it survives local models that cannot drive an agent loop.
    let transport = resolve_explainer_transport(ai, COMMAND);
    let route_label = transport.route_label();
    let fallback = transport.fallback();
    let notice = transport.notice_kind();
    let mut generate = |prompt: &ExplainerPrompt| transport.generate(prompt);
    let generator: Option<ExplainerGenerator<'_>> = if transport.is_active() {
        Some(&mut generate)
    } else {
        None
    };

    let report = recap::run(
        &vault_root,
        context.output_scope.clone(),
        date,
        generator,
        &timestamp,
    )?;

    let mut payload = serde_json::to_value(&report).map_err(|error| WikiError::Json {
        action: "serialize recap report",
        path: None,
        source: error,
    })?;
    if let Some(object) = payload.as_object_mut() {
        object.insert(
            "ai".to_string(),
            serde_json::json!({
                "requested_mode": routing_label(ai),
                "lane": "one_shot",
                "route": route_label,
                "fallback": fallback,
                "notice": notice.map(ai_notice_label),
            }),
        );
    }
    let text = recap::render_text(&report);
    Ok(super::scoped_outcome(
        "recap",
        &context.output_scope,
        payload,
        text,
    ))
}

/// Parse `--date` or default to today (UTC).
fn resolve_date(requested: Option<&str>) -> Result<NaiveDate, WikiError> {
    match requested {
        None => Ok(Utc::now().date_naive()),
        Some(value) => {
            NaiveDate::parse_from_str(value, "%Y-%m-%d").map_err(|_| WikiError::Config {
                detail: format!("invalid --date `{value}`: expected YYYY-MM-DD"),
            })
        }
    }
}

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

    #[test]
    fn resolve_date_parses_iso_dates_and_rejects_garbage() {
        let parsed = resolve_date(Some("2026-07-04")).expect("valid date");
        assert_eq!(parsed.format("%Y-%m-%d").to_string(), "2026-07-04");
        assert!(resolve_date(Some("07/04/2026")).is_err());
        assert!(resolve_date(Some("2026-13-01")).is_err());
        assert!(resolve_date(None).is_ok());
    }
}