lore_cli/summarize/
mod.rs1pub mod prompt;
15pub mod provider;
16
17use std::env;
18
19use crate::config::Config;
20use crate::storage::models::Message;
21
22pub use provider::{create_provider, SummaryProvider, SummaryProviderKind};
23
24const MAX_CONVERSATION_CHARS: usize = 100_000;
26
27#[derive(Debug, Clone)]
29pub struct SummaryConfig {
30 pub kind: SummaryProviderKind,
32 pub api_key: String,
34 pub model: Option<String>,
36}
37
38pub fn resolve_config() -> Result<SummaryConfig, SummarizeError> {
47 let config = Config::load().map_err(|_| SummarizeError::NotConfigured)?;
48
49 let provider_str = env::var("LORE_SUMMARY_PROVIDER")
51 .ok()
52 .or_else(|| config.summary_provider.clone());
53
54 let provider_str = provider_str
55 .ok_or(SummarizeError::NotConfigured)?
56 .to_lowercase();
57
58 let kind: SummaryProviderKind = provider_str
59 .parse()
60 .map_err(|_| SummarizeError::NotConfigured)?;
61
62 let api_key = env::var("LORE_SUMMARY_API_KEY")
64 .ok()
65 .or_else(|| config.summary_api_key_for_provider(&provider_str));
66
67 let api_key = api_key.ok_or(SummarizeError::NotConfigured)?;
68
69 if api_key.is_empty() {
70 return Err(SummarizeError::NotConfigured);
71 }
72
73 let model = env::var("LORE_SUMMARY_MODEL")
75 .ok()
76 .or_else(|| config.summary_model_for_provider(&provider_str));
77
78 Ok(SummaryConfig {
79 kind,
80 api_key,
81 model,
82 })
83}
84
85pub fn generate_summary(messages: &[Message]) -> Result<String, SummarizeError> {
95 if messages.is_empty() {
96 return Err(SummarizeError::EmptySession);
97 }
98
99 let config = resolve_config()?;
100
101 let conversation = prompt::prepare_conversation(messages, MAX_CONVERSATION_CHARS);
102 if conversation.is_empty() {
103 return Err(SummarizeError::EmptySession);
104 }
105
106 let system = prompt::system_prompt();
107 let provider = create_provider(config.kind, config.api_key, config.model);
108
109 let response = provider.summarize(system, &conversation)?;
110 Ok(normalize_whitespace(&response.content))
111}
112
113fn normalize_whitespace(text: &str) -> String {
120 let trimmed = text.trim();
121 let mut result = String::with_capacity(trimmed.len());
122 let mut consecutive_newlines = 0u32;
123
124 for ch in trimmed.chars() {
125 if ch == '\n' {
126 consecutive_newlines += 1;
127 if consecutive_newlines <= 2 {
128 result.push(ch);
129 }
130 } else {
131 consecutive_newlines = 0;
132 result.push(ch);
133 }
134 }
135
136 result
137}
138
139#[derive(Debug, thiserror::Error)]
141pub enum SummarizeError {
142 #[error(
144 "Summary provider not configured. Set LORE_SUMMARY_PROVIDER and the corresponding API key."
145 )]
146 NotConfigured,
147
148 #[error("Request failed: {0}")]
150 RequestFailed(String),
151
152 #[error("HTTP error ({status}): {body}")]
154 HttpError {
155 status: u16,
157 body: String,
159 },
160
161 #[error("API error ({status}): {message}")]
163 #[allow(dead_code)]
164 ApiError {
165 status: u16,
167 message: String,
169 },
170
171 #[error("Failed to parse response: {0}")]
173 ParseError(String),
174
175 #[error("Session has no content to summarize")]
177 EmptySession,
178}
179
180#[cfg(test)]
181mod tests {
182 use super::*;
183 use crate::storage::models::{MessageContent, MessageRole};
184 use chrono::Utc;
185 use uuid::Uuid;
186
187 #[test]
188 fn test_summarize_error_display_not_configured() {
189 let err = SummarizeError::NotConfigured;
190 assert!(err.to_string().contains("not configured"));
191 }
192
193 #[test]
194 fn test_summarize_error_display_request_failed() {
195 let err = SummarizeError::RequestFailed("connection refused".to_string());
196 assert!(err.to_string().contains("connection refused"));
197 }
198
199 #[test]
200 fn test_summarize_error_display_http_error() {
201 let err = SummarizeError::HttpError {
202 status: 429,
203 body: "rate limited".to_string(),
204 };
205 let msg = err.to_string();
206 assert!(msg.contains("429"));
207 assert!(msg.contains("rate limited"));
208 }
209
210 #[test]
211 fn test_summarize_error_display_api_error() {
212 let err = SummarizeError::ApiError {
213 status: 400,
214 message: "invalid model".to_string(),
215 };
216 let msg = err.to_string();
217 assert!(msg.contains("400"));
218 assert!(msg.contains("invalid model"));
219 }
220
221 #[test]
222 fn test_summarize_error_display_parse_error() {
223 let err = SummarizeError::ParseError("missing field".to_string());
224 assert!(err.to_string().contains("missing field"));
225 }
226
227 #[test]
228 fn test_summarize_error_display_empty_session() {
229 let err = SummarizeError::EmptySession;
230 assert!(err.to_string().contains("no content"));
231 }
232
233 #[test]
234 fn test_generate_summary_empty_messages() {
235 let messages: Vec<Message> = vec![];
236 let result = generate_summary(&messages);
237 assert!(result.is_err());
238 match result.unwrap_err() {
239 SummarizeError::EmptySession => {}
240 other => panic!("Expected EmptySession, got: {other:?}"),
241 }
242 }
243
244 #[test]
245 fn test_generate_summary_tool_only_messages_returns_empty_session() {
246 let messages = vec![Message {
249 id: Uuid::new_v4(),
250 session_id: Uuid::new_v4(),
251 parent_id: None,
252 index: 0,
253 timestamp: Utc::now(),
254 role: MessageRole::Assistant,
255 content: MessageContent::Blocks(vec![crate::storage::models::ContentBlock::ToolUse {
256 id: "tool_1".to_string(),
257 name: "Read".to_string(),
258 input: serde_json::json!({"file_path": "/tmp/test.rs"}),
259 }]),
260 model: None,
261 git_branch: None,
262 cwd: None,
263 }];
264
265 let result = generate_summary(&messages);
266 match result {
269 Err(SummarizeError::EmptySession) => {}
270 Err(SummarizeError::NotConfigured) => {
271 }
273 other => panic!("Expected EmptySession or NotConfigured, got: {other:?}"),
274 }
275 }
276
277 #[test]
278 fn test_summary_config_debug() {
279 let config = SummaryConfig {
280 kind: SummaryProviderKind::Anthropic,
281 api_key: "sk-test".to_string(),
282 model: Some("claude-haiku-4-5-20241022".to_string()),
283 };
284 let debug = format!("{config:?}");
285 assert!(debug.contains("Anthropic"));
286 assert!(debug.contains("sk-test"));
287 }
288
289 #[test]
290 fn test_max_conversation_chars_constant() {
291 assert_eq!(MAX_CONVERSATION_CHARS, 100_000);
292 }
293
294 #[test]
295 fn test_normalize_whitespace_trims_edges() {
296 assert_eq!(normalize_whitespace(" hello "), "hello");
297 assert_eq!(normalize_whitespace("\n\nhello\n\n"), "hello");
298 }
299
300 #[test]
301 fn test_normalize_whitespace_preserves_single_blank_line() {
302 let input = "Overview sentence.\n\n- Bullet one\n- Bullet two";
303 assert_eq!(normalize_whitespace(input), input);
304 }
305
306 #[test]
307 fn test_normalize_whitespace_collapses_triple_newlines() {
308 let input = "Overview.\n\n\n- Bullet one\n\n\n\n- Bullet two";
309 let expected = "Overview.\n\n- Bullet one\n\n- Bullet two";
310 assert_eq!(normalize_whitespace(input), expected);
311 }
312
313 #[test]
314 fn test_normalize_whitespace_empty_string() {
315 assert_eq!(normalize_whitespace(""), "");
316 assert_eq!(normalize_whitespace(" "), "");
317 }
318
319 #[test]
320 fn test_normalize_whitespace_no_change_needed() {
321 let input = "Line one\nLine two\n\nLine three";
322 assert_eq!(normalize_whitespace(input), input);
323 }
324}