1use crate::loop_context::LoopContext;
11use chrono::Utc;
12use serde::{Deserialize, Serialize};
13use std::fs::{File, OpenOptions};
14use std::io::Write;
15use std::path::PathBuf;
16
17#[derive(Debug, thiserror::Error)]
19pub enum PlanningSessionError {
20 #[error("I/O error: {0}")]
21 Io(#[from] std::io::Error),
22
23 #[error("Serialization error: {0}")]
24 Serialization(#[from] serde_json::Error),
25
26 #[error("Session not found: {0}")]
27 NotFound(String),
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
32pub enum SessionStatus {
33 Active,
35 WaitingForInput { prompt_id: String },
37 Completed,
39 TimedOut,
41 Failed,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct ConversationEntry {
48 #[serde(rename = "type")]
50 pub entry_type: ConversationType,
51 pub id: String,
53 pub text: String,
55 pub ts: String,
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
61#[serde(rename_all = "snake_case")]
62pub enum ConversationType {
63 UserPrompt,
65 UserResponse,
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct SessionMetadata {
72 pub id: String,
74 pub prompt: String,
76 pub status: SessionStatus,
78 pub created_at: String,
80 pub updated_at: String,
82 pub iterations: usize,
84 pub config: Option<String>,
86}
87
88#[derive(Debug)]
90pub struct PlanningSession {
91 pub metadata: SessionMetadata,
93 pub session_dir: PathBuf,
95 pub conversation_path: PathBuf,
97}
98
99impl PlanningSession {
100 pub fn new(
108 prompt: &str,
109 context: &LoopContext,
110 config: Option<String>,
111 ) -> Result<Self, PlanningSessionError> {
112 let session_id = Self::generate_session_id();
113 let session_dir = context.planning_session_dir(&session_id);
114 let conversation_path = context.planning_conversation_path(&session_id);
115
116 std::fs::create_dir_all(&session_dir)?;
118
119 let artifacts_dir = context.planning_artifacts_dir(&session_id);
121 std::fs::create_dir_all(&artifacts_dir)?;
122
123 let now = Utc::now().to_rfc3339();
124 let metadata = SessionMetadata {
125 id: session_id.clone(),
126 prompt: prompt.to_string(),
127 status: SessionStatus::Active,
128 created_at: now.clone(),
129 updated_at: now,
130 iterations: 0,
131 config,
132 };
133
134 let metadata_path = context.planning_session_metadata_path(&session_id);
136 let metadata_json = serde_json::to_string_pretty(&metadata)?;
137 let mut file = File::create(&metadata_path)?;
138 file.write_all(metadata_json.as_bytes())?;
139
140 File::create(&conversation_path)?;
142
143 Ok(Self {
144 metadata,
145 session_dir,
146 conversation_path,
147 })
148 }
149
150 pub fn load(id: &str, context: &LoopContext) -> Result<Self, PlanningSessionError> {
157 let session_dir = context.planning_session_dir(id);
158 let conversation_path = context.planning_conversation_path(id);
159 let metadata_path = context.planning_session_metadata_path(id);
160
161 if !session_dir.exists() {
162 return Err(PlanningSessionError::NotFound(id.to_string()));
163 }
164
165 let metadata_json = std::fs::read_to_string(&metadata_path)?;
167 let metadata: SessionMetadata = serde_json::from_str(&metadata_json)?;
168
169 Ok(Self {
170 metadata,
171 session_dir,
172 conversation_path,
173 })
174 }
175
176 fn generate_session_id() -> String {
178 let now = Utc::now();
179 let timestamp = now.format("%Y%m%d-%H%M%S").to_string();
180 let nano_suffix = format!("{:x}", now.timestamp_subsec_nanos());
182 let random_suffix = &nano_suffix[nano_suffix.len().saturating_sub(4)..];
183 format!("{}-{}", timestamp, random_suffix)
184 }
185
186 pub fn id(&self) -> &str {
188 &self.metadata.id
189 }
190
191 pub fn set_status(&mut self, status: SessionStatus) -> Result<(), PlanningSessionError> {
193 self.metadata.status = status;
194 self.metadata.updated_at = Utc::now().to_rfc3339();
195 self.save_metadata()
196 }
197
198 pub fn increment_iterations(&mut self) -> Result<(), PlanningSessionError> {
200 self.metadata.iterations += 1;
201 self.metadata.updated_at = Utc::now().to_rfc3339();
202 self.save_metadata()
203 }
204
205 pub fn save_metadata(&self) -> Result<(), PlanningSessionError> {
207 let metadata_path = self.session_dir.join("session.json");
208 let metadata_json = serde_json::to_string_pretty(&self.metadata)?;
209 let mut file = File::create(&metadata_path)?;
210 file.write_all(metadata_json.as_bytes())?;
211 Ok(())
212 }
213
214 pub fn append_prompt(&self, id: &str, text: &str) -> Result<(), PlanningSessionError> {
221 let entry = ConversationEntry {
222 entry_type: ConversationType::UserPrompt,
223 id: id.to_string(),
224 text: text.to_string(),
225 ts: Utc::now().to_rfc3339(),
226 };
227 self.append_entry(&entry)
228 }
229
230 pub fn append_response(&mut self, id: &str, text: &str) -> Result<(), PlanningSessionError> {
237 let entry = ConversationEntry {
238 entry_type: ConversationType::UserResponse,
239 id: id.to_string(),
240 text: text.to_string(),
241 ts: Utc::now().to_rfc3339(),
242 };
243 self.append_entry(&entry)
244 }
245
246 fn append_entry(&self, entry: &ConversationEntry) -> Result<(), PlanningSessionError> {
248 let mut file = OpenOptions::new()
250 .append(true)
251 .create(true)
252 .open(&self.conversation_path)?;
253
254 let json = serde_json::to_string(entry)?;
256 writeln!(file, "{}", json)?;
257
258 Ok(())
259 }
260
261 pub fn find_response(&self, prompt_id: &str) -> Result<Option<String>, PlanningSessionError> {
269 if !self.conversation_path.exists() {
270 return Ok(None);
271 }
272
273 let content = std::fs::read_to_string(&self.conversation_path)?;
274
275 for line in content.lines() {
276 if let Ok(entry) = serde_json::from_str::<ConversationEntry>(line)
277 && entry.entry_type == ConversationType::UserResponse
278 && entry.id == prompt_id
279 {
280 return Ok(Some(entry.text));
281 }
282 }
283
284 Ok(None)
285 }
286
287 pub fn load_conversation(&self) -> Result<Vec<ConversationEntry>, PlanningSessionError> {
289 if !self.conversation_path.exists() {
290 return Ok(Vec::new());
291 }
292
293 let content = std::fs::read_to_string(&self.conversation_path)?;
294 let mut entries = Vec::new();
295
296 for line in content.lines() {
297 if let Ok(entry) = serde_json::from_str::<ConversationEntry>(line) {
298 entries.push(entry);
299 }
300 }
301
302 Ok(entries)
303 }
304}
305
306#[cfg(test)]
307mod tests {
308 use super::*;
309 use tempfile::TempDir;
310
311 fn create_test_context() -> (TempDir, LoopContext) {
312 let temp = TempDir::new().unwrap();
313 let ctx = LoopContext::primary(temp.path().to_path_buf());
314 (temp, ctx)
315 }
316
317 #[test]
318 fn test_generate_session_id() {
319 let id1 = PlanningSession::generate_session_id();
320 let id2 = PlanningSession::generate_session_id();
321
322 assert_ne!(id1, id2);
324
325 assert!(id1.len() > 10);
327 assert!(id1.contains('-'));
328 }
329
330 #[test]
331 fn test_create_new_session() {
332 let (_temp, ctx) = create_test_context();
333 let prompt = "Build a feature for user authentication";
334
335 let session = PlanningSession::new(prompt, &ctx, None).unwrap();
336
337 assert_eq!(session.metadata.prompt, prompt);
338 assert_eq!(session.metadata.status, SessionStatus::Active);
339 assert_eq!(session.metadata.iterations, 0);
340 assert!(session.session_dir.exists());
341 assert!(session.conversation_path.exists());
342 }
343
344 #[test]
345 fn test_load_existing_session() {
346 let (_temp, ctx) = create_test_context();
347 let prompt = "Build OAuth2 login";
348
349 let session_id = PlanningSession::new(prompt, &ctx, None)
351 .unwrap()
352 .id()
353 .to_string();
354
355 let loaded = PlanningSession::load(&session_id, &ctx).unwrap();
357
358 assert_eq!(loaded.metadata.prompt, prompt);
359 assert_eq!(loaded.metadata.id, session_id);
360 }
361
362 #[test]
363 fn test_load_nonexistent_session() {
364 let (_temp, ctx) = create_test_context();
365
366 let result = PlanningSession::load("nonexistent", &ctx);
367 assert!(matches!(result, Err(PlanningSessionError::NotFound(_))));
368 }
369
370 #[test]
371 fn test_append_prompt_and_response() {
372 let (_temp, ctx) = create_test_context();
373 let mut session = PlanningSession::new("Test prompt", &ctx, None).unwrap();
374
375 session
377 .append_prompt("q1", "What is the feature name?")
378 .unwrap();
379
380 session.append_response("q1", "OAuth Login").unwrap();
382
383 let entries = session.load_conversation().unwrap();
385
386 assert_eq!(entries.len(), 2);
387 assert_eq!(entries[0].entry_type, ConversationType::UserPrompt);
388 assert_eq!(entries[0].id, "q1");
389 assert_eq!(entries[0].text, "What is the feature name?");
390
391 assert_eq!(entries[1].entry_type, ConversationType::UserResponse);
392 assert_eq!(entries[1].id, "q1");
393 assert_eq!(entries[1].text, "OAuth Login");
394 }
395
396 #[test]
397 fn test_find_response() {
398 let (_temp, ctx) = create_test_context();
399 let mut session = PlanningSession::new("Test prompt", &ctx, None).unwrap();
400
401 assert!(session.find_response("q1").unwrap().is_none());
403
404 session.append_prompt("q1", "Question?").unwrap();
406 session.append_response("q1", "Answer").unwrap();
407
408 let response = session.find_response("q1").unwrap();
410 assert_eq!(response, Some("Answer".to_string()));
411
412 assert!(session.find_response("q2").unwrap().is_none());
414 }
415
416 #[test]
417 fn test_set_status() {
418 let (_temp, ctx) = create_test_context();
419 let mut session = PlanningSession::new("Test prompt", &ctx, None).unwrap();
420
421 session
422 .set_status(SessionStatus::WaitingForInput {
423 prompt_id: "q1".to_string(),
424 })
425 .unwrap();
426
427 assert!(matches!(
428 session.metadata.status,
429 SessionStatus::WaitingForInput { .. }
430 ));
431
432 let session_id = session.id().to_string();
434 let loaded = PlanningSession::load(&session_id, &ctx).unwrap();
435 assert!(matches!(
436 loaded.metadata.status,
437 SessionStatus::WaitingForInput { .. }
438 ));
439 }
440
441 #[test]
442 fn test_increment_iterations() {
443 let (_temp, ctx) = create_test_context();
444 let mut session = PlanningSession::new("Test prompt", &ctx, None).unwrap();
445
446 assert_eq!(session.metadata.iterations, 0);
447
448 session.increment_iterations().unwrap();
449 assert_eq!(session.metadata.iterations, 1);
450
451 session.increment_iterations().unwrap();
452 assert_eq!(session.metadata.iterations, 2);
453 }
454
455 #[test]
456 fn test_artifacts_directory_created() {
457 let (_temp, ctx) = create_test_context();
458 let session = PlanningSession::new("Test prompt", &ctx, None).unwrap();
459
460 let artifacts_dir = session.session_dir.join("artifacts");
461 assert!(artifacts_dir.exists());
462 assert!(artifacts_dir.is_dir());
463 }
464}