xbp 10.39.0

XBP is a zero-config build pack that can also interact with proxies, kafka, sockets, synthetic monitors.
//! Optional OpenRouter enrichment for terse TODO → issue titles/bodies.
//!
//! **Opt-in only.** Never runs unless `todos.openrouter_enrich: true` or
//! `xbp todos sync --enrich` is set. Failures soft-fall back to the raw marker text.

use super::scan::TodoHit;
use crate::config::{resolve_openrouter_api_key, resolve_openrouter_commit_model};
use crate::openrouter::{complete_prompt_with_options, CompletionOptions, DEFAULT_MODEL};
use serde::Deserialize;
use serde_json::Value as JsonValue;

pub(crate) const DEFAULT_TODO_ENRICH_SYSTEM_PROMPT: &str = r#"You enrich a terse source-code TODO/FIXME marker into a clear engineering issue.

Return strict JSON only. No markdown fences, no commentary.

Schema:
{
  "title": "concise imperative title under 90 chars, no trailing period",
  "problem": "1-3 short paragraphs: what is wrong or incomplete, grounded only in the provided marker + context",
  "suggested_work": ["optional concrete step", "optional step"],
  "risks": ["optional risk if ignored"]
}

Rules:
- Do not invent files, APIs, or behaviors not implied by the marker or context.
- Keep title actionable and specific (not "Fix TODO" or "Improve code").
- Prefer the marker's own wording when it is already clear.
- suggested_work and risks may be empty arrays.
- Write for engineers; plain language; no marketing fluff.
"#;

#[derive(Debug, Clone)]
pub struct EnrichedIssueText {
    pub title: String,
    /// Markdown section to inject after the Marker block (before Context footer bits).
    pub enrichment_markdown: String,
    pub used_openrouter: bool,
}

#[derive(Debug, Deserialize)]
struct EnrichmentPayload {
    #[serde(default)]
    title: Option<String>,
    #[serde(default)]
    problem: Option<String>,
    #[serde(default)]
    suggested_work: Option<Vec<String>>,
    #[serde(default)]
    risks: Option<Vec<String>>,
}

/// Enrich a TODO hit. Returns `None` when enrichment is skipped or fails.
pub async fn enrich_todo_issue(
    hit: &TodoHit,
    location_label: &str,
    model_override: Option<&str>,
) -> Option<EnrichedIssueText> {
    let api_key = resolve_openrouter_api_key()?;
    let model = model_override
        .map(str::trim)
        .filter(|s| !s.is_empty())
        .map(str::to_string)
        .unwrap_or_else(|| {
            let m = resolve_openrouter_commit_model();
            if m.trim().is_empty() {
                DEFAULT_MODEL.to_string()
            } else {
                m
            }
        });

    let prompt = build_enrich_user_prompt(hit, location_label);
    let raw = complete_prompt_with_options(
        &api_key,
        &model,
        Some(DEFAULT_TODO_ENRICH_SYSTEM_PROMPT),
        &prompt,
        Some("xbp-todos-enrich"),
        CompletionOptions::fast_json(700),
    )
    .await?;

    let payload = parse_enrichment_payload(&raw)?;
    let title = payload
        .title
        .map(|t| t.trim().to_string())
        .filter(|t| !t.is_empty())
        .unwrap_or_else(|| default_title(hit));

    let mut enrichment = String::new();
    if let Some(problem) = payload
        .problem
        .map(|p| p.trim().to_string())
        .filter(|p| !p.is_empty())
    {
        enrichment.push_str("## Problem\n\n");
        enrichment.push_str(&problem);
        enrichment.push_str("\n\n");
    }
    if let Some(steps) = payload.suggested_work {
        let steps: Vec<_> = steps
            .into_iter()
            .map(|s| s.trim().to_string())
            .filter(|s| !s.is_empty())
            .collect();
        if !steps.is_empty() {
            enrichment.push_str("## Suggested work\n\n");
            for step in steps {
                enrichment.push_str(&format!("- {step}\n"));
            }
            enrichment.push('\n');
        }
    }
    if let Some(risks) = payload.risks {
        let risks: Vec<_> = risks
            .into_iter()
            .map(|s| s.trim().to_string())
            .filter(|s| !s.is_empty())
            .collect();
        if !risks.is_empty() {
            enrichment.push_str("## Risks if ignored\n\n");
            for risk in risks {
                enrichment.push_str(&format!("- {risk}\n"));
            }
            enrichment.push('\n');
        }
    }
    if enrichment.trim().is_empty() {
        return None;
    }

    Some(EnrichedIssueText {
        title: truncate_title(&title, 90),
        enrichment_markdown: enrichment,
        used_openrouter: true,
    })
}

fn default_title(hit: &TodoHit) -> String {
    format!("[{}] {}", hit.kind, truncate_title(&hit.text, 80))
}

fn build_enrich_user_prompt(hit: &TodoHit, location_label: &str) -> String {
    let mut prompt = format!(
        "Marker kind: {}\nLocation: {}\nMarker text: {}\n",
        hit.kind, location_label, hit.text
    );
    if let Some(ctx) = &hit.context {
        // Cap context so small TODOs stay cheap.
        let capped = if ctx.len() > 6000 {
            format!("{}", &ctx[..6000])
        } else {
            ctx.clone()
        };
        prompt.push_str("\nSource context (line-numbered excerpt):\n```\n");
        prompt.push_str(&capped);
        prompt.push_str("\n```\n");
    }
    prompt
}

fn parse_enrichment_payload(raw: &str) -> Option<EnrichmentPayload> {
    let trimmed = raw.trim();
    // Strip accidental fences.
    let trimmed = trimmed
        .strip_prefix("```json")
        .or_else(|| trimmed.strip_prefix("```"))
        .map(|s| s.trim_end_matches('`').trim())
        .unwrap_or(trimmed);

    if let Ok(payload) = serde_json::from_str::<EnrichmentPayload>(trimmed) {
        return Some(payload);
    }
    // Some models wrap the object.
    if let Ok(value) = serde_json::from_str::<JsonValue>(trimmed) {
        if let Some(obj) = value.as_object() {
            if let Ok(payload) = serde_json::from_value::<EnrichmentPayload>(JsonValue::Object(obj.clone())) {
                return Some(payload);
            }
        }
    }
    None
}

fn truncate_title(title: &str, max: usize) -> String {
    let title = title.trim().trim_end_matches('.');
    let mut out: String = title.chars().take(max).collect();
    if title.chars().count() > max {
        out.push('');
    }
    out
}

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

    #[test]
    fn parses_enrichment_json() {
        let raw = r#"{
          "title": "Migrate user_permission_scopes to grants",
          "problem": "The table name drifted from the intended grants model.",
          "suggested_work": ["Add migration", "Update queries"],
          "risks": ["Permission checks stay inconsistent"]
        }"#;
        let payload = parse_enrichment_payload(raw).expect("parse");
        assert!(payload.title.unwrap().contains("Migrate"));
        assert_eq!(payload.suggested_work.unwrap().len(), 2);
    }
}