use super::{EpisodeMatcher, EpisodeMatchingError, SinglePromptGenerator};
use crate::metadata_retrieval::{Episode, TVSeries};
use crate::speech_to_text::Transcript;
use serde::Deserialize;
use std::io::Write;
use std::process::{Command, Stdio};
#[derive(Debug, Deserialize)]
struct ClaudeResponse {
season: usize,
episode: usize,
}
pub(crate) struct ClaudeCodeMatcher<G: SinglePromptGenerator> {
generator: G,
}
impl<G: SinglePromptGenerator> ClaudeCodeMatcher<G> {
pub fn new(generator: G) -> Self {
Self { generator }
}
fn is_claude_installed() -> bool {
Command::new("claude")
.arg("--version")
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|status| status.success())
.unwrap_or(false)
}
fn call_claude(prompt: &str) -> Result<String, EpisodeMatchingError> {
if !Self::is_claude_installed() {
return Err(EpisodeMatchingError::ServiceError(
"Claude CLI not found. Please install it first.".to_string(),
));
}
let mut child = Command::new("claude")
.arg("-p")
.arg("--output-format")
.arg("text")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| {
EpisodeMatchingError::ServiceError(format!("Failed to spawn claude CLI: {}", e))
})?;
if let Some(mut stdin) = child.stdin.take() {
stdin.write_all(prompt.as_bytes()).map_err(|e| {
EpisodeMatchingError::ServiceError(format!(
"Failed to write to claude stdin: {}",
e
))
})?;
}
let output = child.wait_with_output().map_err(|e| {
EpisodeMatchingError::ServiceError(format!("Failed to read claude output: {}", e))
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(EpisodeMatchingError::ServiceError(format!(
"Claude CLI failed with exit code {:?}: {}",
output.status.code(),
stderr
)));
}
String::from_utf8(output.stdout.clone()).map_err(|e| {
let lossy_response = String::from_utf8_lossy(&output.stdout);
EpisodeMatchingError::ParseError {
reason: format!("Invalid UTF-8 in claude response: {}", e),
response: lossy_response.to_string(),
}
})
}
fn extract_json_block(response: &str) -> Result<String, EpisodeMatchingError> {
let start_marker = "```json";
let end_marker = "```";
if let Some(start_pos) = response.find(start_marker) {
let json_start = start_pos + start_marker.len();
let remaining = &response[json_start..];
if let Some(end_pos) = remaining.find(end_marker) {
let json_str = remaining[..end_pos].trim();
return Ok(json_str.to_string());
}
}
Err(EpisodeMatchingError::ParseError {
reason: "No JSON code block found in response".to_string(),
response: response.to_string(),
})
}
fn find_episode(
series: &TVSeries,
season_num: usize,
episode_num: usize,
response: &str,
) -> Result<Episode, EpisodeMatchingError> {
for season in &series.seasons {
if season.season_number == season_num {
for episode in &season.episodes {
if episode.episode_number == episode_num {
return Ok(episode.clone());
}
}
}
}
Err(EpisodeMatchingError::NoMatchFound {
response: response.to_string(),
})
}
}
impl<G: SinglePromptGenerator> EpisodeMatcher for ClaudeCodeMatcher<G> {
fn match_episode(
&self,
transcript: &Transcript,
series: &TVSeries,
) -> Result<Episode, EpisodeMatchingError> {
let prompt = self.generator.generate_single_prompt(transcript, series);
let response = Self::call_claude(&prompt)?;
let json_str = Self::extract_json_block(&response)?;
let claude_response: ClaudeResponse =
serde_json::from_str(&json_str).map_err(|e| EpisodeMatchingError::ParseError {
reason: format!("Failed to parse JSON response: {}", e),
response: response.clone(),
})?;
Self::find_episode(
series,
claude_response.season,
claude_response.episode,
&response,
)
}
}