pub mod prompt;
pub mod provider;
use std::env;
use crate::config::Config;
use crate::storage::models::Message;
pub use provider::{create_provider, SummaryProvider, SummaryProviderKind};
const MAX_CONVERSATION_CHARS: usize = 100_000;
#[derive(Debug, Clone)]
pub struct SummaryConfig {
pub kind: SummaryProviderKind,
pub api_key: String,
pub model: Option<String>,
}
pub fn resolve_config() -> Result<SummaryConfig, SummarizeError> {
let config = Config::load().map_err(|_| SummarizeError::NotConfigured)?;
let provider_str = env::var("LORE_SUMMARY_PROVIDER")
.ok()
.or_else(|| config.summary_provider.clone());
let provider_str = provider_str
.ok_or(SummarizeError::NotConfigured)?
.to_lowercase();
let kind: SummaryProviderKind = provider_str
.parse()
.map_err(|_| SummarizeError::NotConfigured)?;
let api_key = env::var("LORE_SUMMARY_API_KEY")
.ok()
.or_else(|| config.summary_api_key_for_provider(&provider_str));
let api_key = api_key.ok_or(SummarizeError::NotConfigured)?;
if api_key.is_empty() {
return Err(SummarizeError::NotConfigured);
}
let model = env::var("LORE_SUMMARY_MODEL")
.ok()
.or_else(|| config.summary_model_for_provider(&provider_str));
Ok(SummaryConfig {
kind,
api_key,
model,
})
}
pub fn generate_summary(messages: &[Message]) -> Result<String, SummarizeError> {
if messages.is_empty() {
return Err(SummarizeError::EmptySession);
}
let config = resolve_config()?;
let conversation = prompt::prepare_conversation(messages, MAX_CONVERSATION_CHARS);
if conversation.is_empty() {
return Err(SummarizeError::EmptySession);
}
let system = prompt::system_prompt();
let provider = create_provider(config.kind, config.api_key, config.model);
let response = provider.summarize(system, &conversation)?;
Ok(normalize_whitespace(&response.content))
}
fn normalize_whitespace(text: &str) -> String {
let trimmed = text.trim();
let mut result = String::with_capacity(trimmed.len());
let mut consecutive_newlines = 0u32;
for ch in trimmed.chars() {
if ch == '\n' {
consecutive_newlines += 1;
if consecutive_newlines <= 2 {
result.push(ch);
}
} else {
consecutive_newlines = 0;
result.push(ch);
}
}
result
}
#[derive(Debug, thiserror::Error)]
pub enum SummarizeError {
#[error(
"Summary provider not configured. Set LORE_SUMMARY_PROVIDER and the corresponding API key."
)]
NotConfigured,
#[error("Request failed: {0}")]
RequestFailed(String),
#[error("HTTP error ({status}): {body}")]
HttpError {
status: u16,
body: String,
},
#[error("API error ({status}): {message}")]
#[allow(dead_code)]
ApiError {
status: u16,
message: String,
},
#[error("Failed to parse response: {0}")]
ParseError(String),
#[error("Session has no content to summarize")]
EmptySession,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::storage::models::{MessageContent, MessageRole};
use chrono::Utc;
use uuid::Uuid;
#[test]
fn test_summarize_error_display_not_configured() {
let err = SummarizeError::NotConfigured;
assert!(err.to_string().contains("not configured"));
}
#[test]
fn test_summarize_error_display_request_failed() {
let err = SummarizeError::RequestFailed("connection refused".to_string());
assert!(err.to_string().contains("connection refused"));
}
#[test]
fn test_summarize_error_display_http_error() {
let err = SummarizeError::HttpError {
status: 429,
body: "rate limited".to_string(),
};
let msg = err.to_string();
assert!(msg.contains("429"));
assert!(msg.contains("rate limited"));
}
#[test]
fn test_summarize_error_display_api_error() {
let err = SummarizeError::ApiError {
status: 400,
message: "invalid model".to_string(),
};
let msg = err.to_string();
assert!(msg.contains("400"));
assert!(msg.contains("invalid model"));
}
#[test]
fn test_summarize_error_display_parse_error() {
let err = SummarizeError::ParseError("missing field".to_string());
assert!(err.to_string().contains("missing field"));
}
#[test]
fn test_summarize_error_display_empty_session() {
let err = SummarizeError::EmptySession;
assert!(err.to_string().contains("no content"));
}
#[test]
fn test_generate_summary_empty_messages() {
let messages: Vec<Message> = vec![];
let result = generate_summary(&messages);
assert!(result.is_err());
match result.unwrap_err() {
SummarizeError::EmptySession => {}
other => panic!("Expected EmptySession, got: {other:?}"),
}
}
#[test]
fn test_generate_summary_tool_only_messages_returns_empty_session() {
let messages = vec![Message {
id: Uuid::new_v4(),
session_id: Uuid::new_v4(),
parent_id: None,
index: 0,
timestamp: Utc::now(),
role: MessageRole::Assistant,
content: MessageContent::Blocks(vec![crate::storage::models::ContentBlock::ToolUse {
id: "tool_1".to_string(),
name: "Read".to_string(),
input: serde_json::json!({"file_path": "/tmp/test.rs"}),
}]),
model: None,
git_branch: None,
cwd: None,
}];
let result = generate_summary(&messages);
match result {
Err(SummarizeError::EmptySession) => {}
Err(SummarizeError::NotConfigured) => {
}
other => panic!("Expected EmptySession or NotConfigured, got: {other:?}"),
}
}
#[test]
fn test_summary_config_debug() {
let config = SummaryConfig {
kind: SummaryProviderKind::Anthropic,
api_key: "sk-test".to_string(),
model: Some("claude-haiku-4-5-20241022".to_string()),
};
let debug = format!("{config:?}");
assert!(debug.contains("Anthropic"));
assert!(debug.contains("sk-test"));
}
#[test]
fn test_max_conversation_chars_constant() {
assert_eq!(MAX_CONVERSATION_CHARS, 100_000);
}
#[test]
fn test_normalize_whitespace_trims_edges() {
assert_eq!(normalize_whitespace(" hello "), "hello");
assert_eq!(normalize_whitespace("\n\nhello\n\n"), "hello");
}
#[test]
fn test_normalize_whitespace_preserves_single_blank_line() {
let input = "Overview sentence.\n\n- Bullet one\n- Bullet two";
assert_eq!(normalize_whitespace(input), input);
}
#[test]
fn test_normalize_whitespace_collapses_triple_newlines() {
let input = "Overview.\n\n\n- Bullet one\n\n\n\n- Bullet two";
let expected = "Overview.\n\n- Bullet one\n\n- Bullet two";
assert_eq!(normalize_whitespace(input), expected);
}
#[test]
fn test_normalize_whitespace_empty_string() {
assert_eq!(normalize_whitespace(""), "");
assert_eq!(normalize_whitespace(" "), "");
}
#[test]
fn test_normalize_whitespace_no_change_needed() {
let input = "Line one\nLine two\n\nLine three";
assert_eq!(normalize_whitespace(input), input);
}
}