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,
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>>,
}
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 {
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();
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);
}
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);
}
}