//! Main planning mode orchestration //! //! This module contains the main logic for running planning mode, //! including the state machine transitions and user interactions. use anyhow::{Context, Result}; use std::fs; use std::io::{self, Write}; use std::path::{Path, PathBuf}; use crate::git; use crate::history; use crate::llm; use crate::state::{ ApprovalChoice, BranchConfirmChoice, CompletionChoice, DirtyFilesChoice, PlannerState, RecoveryChoice, RecoveryInfo, }; /// Configuration for planning mode #[derive(Debug, Clone)] pub struct PlannerConfig { /// The codepath to work in pub codepath: PathBuf, /// Whether git operations are disabled pub no_git: bool, /// Maximum turns for coach/player loop pub max_turns: usize, /// Whether to run in quiet mode pub quiet: bool, /// Path to config file pub config_path: Option, } impl PlannerConfig { /// Get the g3-plan directory path pub fn plan_dir(&self) -> PathBuf { self.codepath.join("g3-plan") } /// Get the path to new_requirements.md pub fn new_requirements_path(&self) -> PathBuf { self.plan_dir().join("new_requirements.md") } /// Get the path to current_requirements.md pub fn current_requirements_path(&self) -> PathBuf { self.plan_dir().join("current_requirements.md") } /// Get the path to todo.g3.md pub fn todo_path(&self) -> PathBuf { self.plan_dir().join("todo.g3.md") } /// Get the path to planner_history.txt pub fn history_path(&self) -> PathBuf { self.plan_dir().join("planner_history.txt") } } /// Result of running planning mode #[derive(Debug)] pub enum PlannerResult { /// User quit normally Quit, /// Completed a planning cycle Completed, /// Error occurred Error(String), } /// Expand tilde in path to home directory pub fn expand_codepath(path: &str) -> Result { let expanded = shellexpand::tilde(path); let path = PathBuf::from(expanded.as_ref()); // Resolve to absolute path let resolved = if path.is_absolute() { path } else { std::env::current_dir()?.join(path) }; // Canonicalize if path exists, otherwise just return resolved if resolved.exists() { Ok(resolved.canonicalize()?) } else { Ok(resolved) } } /// Prompt user for codepath if not provided pub fn prompt_for_codepath() -> Result { print!("Enter codepath (path to your project): "); io::stdout().flush()?; let mut input = String::new(); io::stdin().read_line(&mut input)?; let input = input.trim(); if input.is_empty() || input == "quit" || input == "q" { anyhow::bail!("User quit during codepath prompt"); } expand_codepath(input) } /// Read a line of user input fn read_line() -> Result { let mut input = String::new(); io::stdin().read_line(&mut input)?; Ok(input.trim().to_string()) } /// Print a message to stdout fn print_msg(msg: &str) { println!("{}", msg); } /// Print a message and flush stdout (for prompts) fn print_prompt(msg: &str) { print!("{}", msg); io::stdout().flush().ok(); } /// Initialize the planning directory structure pub fn initialize_plan_dir(config: &PlannerConfig) -> Result<()> { let plan_dir = config.plan_dir(); // Create plan directory if it doesn't exist if !plan_dir.exists() { fs::create_dir_all(&plan_dir) .context("Failed to create g3-plan directory")?; print_msg(&format!("šŸ“ Created {}", plan_dir.display())); } // Ensure history file exists history::ensure_history_file(&plan_dir)?; Ok(()) } /// Check git repository status (if git is enabled) pub fn check_git_status(config: &PlannerConfig) -> Result<()> { if config.no_git { print_msg("āš ļø Git operations disabled (--no-git flag)"); return Ok(()); } // Check if we're in a git repo if !git::check_git_repo(&config.codepath)? { print_msg("No git repository found for the codepath. Please initialize a git repo and try again."); anyhow::bail!("No git repository found"); } // Get and display current branch let branch = git::get_current_branch(&config.codepath)?; let prompt = "Current git branch: {branch}\nIs this the correct branch to work on? [Y/n]".replace("{branch}", &branch); print_prompt(&format!("{} ", prompt)); let input = read_line()?; match BranchConfirmChoice::from_input(&input) { Some(BranchConfirmChoice::Confirm) => {}, Some(BranchConfirmChoice::Quit) | None => { print_msg("Exiting - please switch to the correct branch and restart."); anyhow::bail!("User declined branch confirmation"); } } // Check for dirty/untracked files (ignore new_requirements.md) let ignore_pattern = "g3-plan/new_requirements.md"; let dirty_files = git::check_dirty_files(&config.codepath, Some(ignore_pattern))?; if !dirty_files.is_empty() { let warning = r#"Warning: There are uncommitted changes in the git repository: {files} This may be expected if resuming from a previous session. Do you want to proceed anyway? [Y/n]"# .replace("{files}", &dirty_files.to_display_string()); print_msg(&warning); print_prompt("[Y/n] "); let input = read_line()?; match DirtyFilesChoice::from_input(&input) { Some(DirtyFilesChoice::Proceed) => {}, Some(DirtyFilesChoice::Quit) | None => { print_msg("Exiting - please commit or stash your changes and restart."); anyhow::bail!("User declined to proceed with dirty files"); } } } Ok(()) } /// Check startup state and determine if recovery is needed pub fn check_startup_state(config: &PlannerConfig) -> PlannerState { let plan_dir = config.plan_dir(); // Check for recovery situation if let Some(recovery_info) = RecoveryInfo::detect(&plan_dir) { return PlannerState::Recovery(recovery_info); } PlannerState::PromptForRequirements } /// Handle recovery situation pub fn handle_recovery(config: &PlannerConfig, info: &RecoveryInfo) -> Result { // Build the recovery prompt let datetime = info.requirements_modified.as_deref().unwrap_or("unknown time"); let todo_info = if let Some(ref contents) = info.todo_contents { "- todo.g3.md contents:\n{contents}".replace("{contents}", contents) } else { String::new() }; let prompt = r#"The last run didn't complete successfully. Found: - current_requirements.md from {datetime} {todo_info} Would you like to resume the previous implementation? [Y] Yes - Attempt to resume [N] No - Mark as complete and proceed to review new_requirements.md [Q] Quit - Exit and investigate manually"# .replace("{datetime}", datetime) .replace("{todo_info}", &todo_info); print_msg(&prompt); print_prompt("Choice: "); loop { let input = read_line()?; match RecoveryChoice::from_input(&input) { Some(RecoveryChoice::Resume) => { // Log recovery attempt history::write_attempting_recovery(&config.plan_dir())?; return Ok(PlannerState::ImplementRequirements); } Some(RecoveryChoice::MarkComplete) => { // Log skipped recovery history::write_skipped_recovery(&config.plan_dir())?; return Ok(PlannerState::ImplementationComplete); } Some(RecoveryChoice::Quit) => { return Ok(PlannerState::Quit); } None => { print_prompt("Invalid choice. Please enter Y, N, or Q: "); } } } } /// Prompt for new requirements pub fn prompt_for_new_requirements(config: &PlannerConfig) -> Result { // Delete existing todo file since we're starting fresh let todo_path = config.todo_path(); if todo_path.exists() { fs::remove_file(&todo_path) .context("Failed to delete old todo.g3.md")?; } // Display prompt let prompt = r#"I will help you refine the current requirements of your project. Please write or edit your requirements in `{codepath}/g3-plan/new_requirements.md`. Hit enter for me to start a review of that file."# .replace("{codepath}", &config.codepath.display().to_string()); print_msg(&prompt); print_prompt("Press Enter when ready: "); let input = read_line()?; if input.to_lowercase() == "quit" || input.to_lowercase() == "q" { return Ok(PlannerState::Quit); } // Check if new_requirements.md exists let new_req_path = config.new_requirements_path(); if !new_req_path.exists() { let error_msg = "File not found: {path}/g3-plan/new_requirements.md" .replace("{path}", &config.codepath.display().to_string()); print_msg(&format!("āŒ {}", error_msg)); print_msg("Please create the file and try again."); return Ok(PlannerState::PromptForRequirements); } // Ensure the file has the ORIGINAL_REQUIREMENTS tag ensure_original_requirements_tag(&new_req_path)?; // Log that we're refining requirements history::write_refining_requirements(&config.plan_dir())?; Ok(PlannerState::RefineRequirements) } /// Ensure the new_requirements.md file has the ORIGINAL_REQUIREMENTS tag fn ensure_original_requirements_tag(path: &Path) -> Result<()> { let content = fs::read_to_string(path) .context("Failed to read new_requirements.md")?; // Check if either tag is already present if content.contains("{{ORIGINAL USER REQUIREMENTS -- THIS SECTION WILL BE IGNORED BY THE IMPLEMENTATION}}") || content.contains("{{CURRENT REQUIREMENTS}}") { return Ok(()); } // Prepend the ORIGINAL_REQUIREMENTS tag let new_content = format!("{}\n\n{}", "{{ORIGINAL USER REQUIREMENTS -- THIS SECTION WILL BE IGNORED BY THE IMPLEMENTATION}}", content); fs::write(path, new_content) .context("Failed to update new_requirements.md with ORIGINAL_REQUIREMENTS tag")?; Ok(()) } /// Check if requirements have CURRENT REQUIREMENTS tag after LLM refinement pub fn check_current_requirements_tag(config: &PlannerConfig) -> Result { let new_req_path = config.new_requirements_path(); let content = fs::read_to_string(&new_req_path) .context("Failed to read new_requirements.md")?; Ok(content.contains("{{CURRENT REQUIREMENTS}}")) } /// Prompt user to approve refined requirements pub fn prompt_for_approval(config: &PlannerConfig) -> Result { let prompt = r#"The LLM has updated `{codepath}/g3-plan/new_requirements.md`. Please review the file. If it's acceptable, type 'yes' to proceed with implementation. Type 'no' to continue refining, or 'quit' to exit."# .replace("{codepath}", &config.codepath.display().to_string()); print_msg(&prompt); print_prompt("Choice: "); loop { let input = read_line()?; match ApprovalChoice::from_input(&input) { Some(choice) => return Ok(choice), None => { print_prompt("Invalid choice. Please enter 'yes', 'no', or 'quit': "); } } } } /// Move new_requirements.md to current_requirements.md pub fn promote_requirements(config: &PlannerConfig) -> Result<()> { let new_req_path = config.new_requirements_path(); let current_req_path = config.current_requirements_path(); fs::rename(&new_req_path, ¤t_req_path) .context("Failed to rename new_requirements.md to current_requirements.md")?; print_msg(&format!( "šŸ“„ Renamed new_requirements.md to current_requirements.md" )); Ok(()) } /// Read current requirements content pub fn read_current_requirements(config: &PlannerConfig) -> Result { let path = config.current_requirements_path(); fs::read_to_string(&path) .context("Failed to read current_requirements.md") } /// Read todo file content pub fn read_todo(config: &PlannerConfig) -> Result> { let path = config.todo_path(); if path.exists() { Ok(Some(fs::read_to_string(&path) .context("Failed to read todo.g3.md")?)) } else { Ok(None) } } /// Check if all todos are complete pub fn check_todos_complete(todo_contents: &str) -> bool { // Check if there are any incomplete items (- [ ]) !todo_contents.contains("- [ ]") } /// Prompt user to confirm implementation completion pub fn prompt_for_completion(config: &PlannerConfig) -> Result { let todo_contents = read_todo(config)?.unwrap_or_else(|| "(no todo file)".to_string()); let prompt = r#"The coach/player loop has completed. Todo file contents: {todo_contents} Do you consider the todos and requirements completed? [Y/n] If not, we'll return to the coach/player loop."# .replace("{todo_contents}", &todo_contents); print_msg(&prompt); print_prompt("Choice: "); loop { let input = read_line()?; match CompletionChoice::from_input(&input) { Some(choice) => return Ok(choice), None => { print_prompt("Invalid choice. Please enter Y, N, or Q: "); } } } } /// Complete the implementation - rename files and prepare for commit pub fn complete_implementation(config: &PlannerConfig) -> Result<(String, String)> { let plan_dir = config.plan_dir(); // Generate timestamped filenames let req_filename = history::completed_requirements_filename(); let todo_filename = history::completed_todo_filename(); // Rename current_requirements.md let current_req = config.current_requirements_path(); let completed_req = plan_dir.join(&req_filename); if current_req.exists() { fs::rename(¤t_req, &completed_req) .context("Failed to rename current_requirements.md")?; print_msg(&format!("šŸ“„ Renamed to {}", req_filename)); } // Rename todo.g3.md let todo_path = config.todo_path(); let completed_todo = plan_dir.join(&todo_filename); if todo_path.exists() { fs::rename(&todo_path, &completed_todo) .context("Failed to rename todo.g3.md")?; print_msg(&format!("šŸ“„ Renamed to {}", todo_filename)); } // Log completion history::write_completed_requirements(&plan_dir, &req_filename, &todo_filename)?; Ok((req_filename, todo_filename)) } /// Stage files and make git commit pub fn stage_and_commit( config: &PlannerConfig, summary: &str, description: &str, ) -> Result<()> { if config.no_git { print_msg("āš ļø Skipping git commit (--no-git flag)"); return Ok(()); } // Stage files print_msg("šŸ“¦ Staging files..."); let staging_result = git::stage_files(&config.codepath, &config.plan_dir())?; if !staging_result.staged.is_empty() { print_msg(&format!(" Staged {} files", staging_result.staged.len())); } if !staging_result.excluded.is_empty() { print_msg(&format!(" Excluded {} files (temporary/artifacts)", staging_result.excluded.len())); } // Show pre-commit message let pre_commit = r#"Ready to make a git commit with the following message: Summary: {summary} Description: {description} Please review the currently staged files (use `git status` in another terminal). Press Enter to continue with the commit, or type 'quit' to exit without committing."# .replace("{summary}", summary) .replace("{description}", description); print_msg(&pre_commit); let input = read_line()?; if input.to_lowercase() == "quit" || input.to_lowercase() == "q" { print_msg("Skipping commit. Files remain staged."); return Ok(()); } // If you're modifying this function, ENSURE that: // - history::write_git_commit() is called BEFORE git::commit() // - No conditional logic can skip the history write if the commit proceeds // - Tests in commit_history_ordering_test.rs continue to pass history::write_git_commit(&config.plan_dir(), summary)?; // Re-stage g3-plan directory to include the GIT COMMIT entry we just wrote // This ensures planner_history.txt changes are included in the commit git::stage_plan_dir(&config.codepath, &config.plan_dir())?; // Make commit print_msg("šŸ“ Making git commit..."); let _commit_sha = git::commit(&config.codepath, summary, description)?; print_msg("āœ… Commit successful"); Ok(()) } /// Parse commit message from LLM response pub fn parse_commit_message(response: &str) -> (String, String) { let mut summary = String::new(); let mut description = String::new(); if let Some(summary_start) = response.find("{{COMMIT_SUMMARY}}") { let after_tag = &response[summary_start + "{{COMMIT_SUMMARY}}".len()..]; if let Some(end) = after_tag.find("{{COMMIT_DESCRIPTION}}") { summary = after_tag[..end].trim().to_string(); } else { summary = after_tag.lines().next().unwrap_or("").trim().to_string(); } } if let Some(desc_start) = response.find("{{COMMIT_DESCRIPTION}}") { let after_tag = &response[desc_start + "{{COMMIT_DESCRIPTION}}".len()..]; description = after_tag.trim().to_string(); } // Ensure summary is max 72 chars if summary.len() > 72 { summary = format!("{}...", &summary[..69]); } // Ensure description lines are max 72 chars let wrapped_desc: Vec = description .lines() .take(10) // Max 10 lines .map(|line| { if line.len() > 72 { format!("{}...", &line[..69]) } else { line.to_string() } }) .collect(); description = wrapped_desc.join("\n"); // Fallback if parsing failed if summary.is_empty() { summary = "Implement requirements".to_string(); } (summary, description) } /// Tools available to the planner agent pub fn get_planner_tools() -> Vec<&'static str> { vec![ "read_file", "write_file", "shell", "code_search", "str_replace", "final_output", ] } /// Tools NOT available to the planner agent pub fn get_excluded_planner_tools() -> Vec<&'static str> { vec![ "todo_write", // Planner should not write todos during refinement ] } /// Run the coach/player implementation loop /// /// This function runs the actual implementation phase using g3-core's Agent /// in a coach/player feedback loop similar to autonomous mode. pub async fn run_coach_player_loop( planner_config: &PlannerConfig, g3_config: &g3_config::Config, requirements_content: &str, ) -> Result<()> { use g3_core::project::Project; use g3_core::Agent; let max_turns = planner_config.max_turns; // Create project with custom requirements path let project = Project::new_autonomous_with_requirements( planner_config.codepath.clone(), requirements_content.to_string(), )?; // Enter the workspace project.ensure_workspace_exists()?; project.enter_workspace()?; print_msg(&format!("šŸ“ Working in: {}", planner_config.codepath.display())); print_msg(&format!("šŸ”„ Max turns: {}", max_turns)); // Set environment variable for custom todo path std::env::set_var("G3_TODO_PATH", planner_config.todo_path().display().to_string()); let mut turn = 1; let mut coach_feedback = String::new(); while turn <= max_turns { print_msg(&format!("\n=== Turn {}/{} ===", turn, max_turns)); // Player phase - implement requirements print_msg("šŸŽÆ Player: Implementing requirements..."); let player_config = g3_config.for_player()?; let ui_writer = llm::PlannerUiWriter::new(); let mut player_agent = Agent::new_autonomous_with_readme_and_quiet( player_config, ui_writer, None, planner_config.quiet, ).await?; let player_prompt = if coach_feedback.is_empty() { format!( "You are G3 in implementation mode. Read and implement the following requirements:\n\n{}\n\nImplement this step by step. Write the todo list to: {}\n\nCreate all necessary files and code.", requirements_content, planner_config.todo_path().display() ) } else { format!( "You are G3 in implementation mode. Address the following coach feedback:\n\n{}\n\nContext requirements:\n{}\n\nFix the issues mentioned above.", coach_feedback, requirements_content ) }; let player_result = player_agent .execute_task_with_timing(&player_prompt, None, false, false, false, true, None) .await; match player_result { Ok(result) => print_msg(&format!("āœ… Player completed: {} chars response", result.response.len())), Err(e) => print_msg(&format!("āš ļø Player error: {}", e)), } // Coach phase - review implementation print_msg("šŸŽ“ Coach: Reviewing implementation..."); let coach_config = g3_config.for_coach()?; let coach_ui_writer = llm::PlannerUiWriter::new(); let mut coach_agent = Agent::new_autonomous_with_readme_and_quiet( coach_config, coach_ui_writer, None, planner_config.quiet, ).await?; let coach_prompt = format!( "You are G3 in coach mode. Review the implementation against these requirements:\n\n{}\n\nCheck:\n1. Are requirements implemented correctly?\n2. Does the code compile?\n3. What's missing?\n\nIf COMPLETE, respond with 'IMPLEMENTATION_APPROVED'.\nOtherwise, provide specific feedback for the player to fix.", requirements_content ); let coach_result = coach_agent .execute_task_with_timing(&coach_prompt, None, false, false, false, true, None) .await; match coach_result { Ok(result) => { if result.response.contains("IMPLEMENTATION_APPROVED") || result.is_approved() { print_msg("āœ… Coach approved implementation!"); return Ok(()); } coach_feedback = result.response; // Display first 25 lines of coach feedback let lines: Vec<&str> = coach_feedback.lines().collect(); let display_lines = if lines.len() > 25 { let mut truncated: Vec<&str> = lines[..25].to_vec(); truncated.push("..."); truncated } else { lines }; print_msg(&format!("šŸ“ Coach feedback ({} chars):", coach_feedback.len())); for line in display_lines { print_msg(&format!(" {}", line)); } } Err(e) => { print_msg(&format!("āš ļø Coach error: {}", e)); coach_feedback = "Please review and fix any issues.".to_string(); } } turn += 1; } print_msg(&format!("ā° Reached max turns ({})", max_turns)); Ok(()) } /// Main entry point for planning mode /// /// This function orchestrates the entire planning workflow: /// 1. Initialize the planning directory /// 2. Check git status (if enabled) /// 3. Detect and handle recovery situations /// 4. Run the refinement and implementation loop pub async fn run_planning_mode( codepath: Option, workspace: Option, no_git: bool, config_path: Option<&str>, ) -> anyhow::Result<()> { print_msg("\nšŸŽÆ G3 Planning Mode"); print_msg("==================\n"); // Get codepath first (needed for setting workspace path early) let codepath = match codepath { Some(path) => { let expanded = expand_codepath(&path)?; print_msg(&format!("šŸ“ Codepath: {}", expanded.display())); expanded } None => { let path = prompt_for_codepath()?; print_msg(&format!("šŸ“ Codepath: {}", path.display())); path } }; // Verify codepath exists if !codepath.exists() { anyhow::bail!("Codepath does not exist: {}", codepath.display()); } // Determine workspace directory (use workspace arg if provided, else use codepath) let workspace_dir = workspace.unwrap_or_else(|| codepath.clone()); print_msg(&format!("šŸ“ Workspace: {}", workspace_dir.display())); // Set G3_WORKSPACE_PATH environment variable EARLY for all logging std::env::set_var("G3_WORKSPACE_PATH", workspace_dir.display().to_string()); // Create logs directory and verify it exists let logs_dir = workspace_dir.join("logs"); if !logs_dir.exists() { fs::create_dir_all(&logs_dir) .context("Failed to create logs directory")?; } print_msg(&format!("šŸ“ Logs directory: {}", logs_dir.display())); // Create the LLM provider for planning print_msg("šŸ”§ Initializing planner provider..."); let provider = match llm::create_planner_provider(config_path).await { Ok(p) => p, Err(e) => { print_msg(&format!("āŒ Failed to initialize provider: {}", e)); print_msg("Please check your configuration file."); anyhow::bail!("Provider initialization failed: {}", e); } }; print_msg(&format!("āœ… Provider initialized: {}", provider.name())); // Create configuration let config = PlannerConfig { codepath: codepath.clone(), no_git, max_turns: 5, // Default, could be made configurable quiet: false, config_path: config_path.map(|s| s.to_string()), }; // Initialize plan directory initialize_plan_dir(&config)?; // Check git status check_git_status(&config)?; // Main planning loop let mut state = check_startup_state(&config); loop { state = match state { PlannerState::Startup => { // Startup state transitions to checking for recovery check_startup_state(&config) } PlannerState::Recovery(info) => { handle_recovery(&config, &info)? } PlannerState::PromptForRequirements => { prompt_for_new_requirements(&config)? } PlannerState::RefineRequirements => { // Call LLM for refinement with full tool execution print_msg("\nšŸ”„ Refinement phase - calling LLM..."); let codepath_str = config.codepath.display().to_string(); let workspace_str = workspace_dir.display().to_string(); // Load config and call LLM with full tool execution capability let g3_config = g3_config::Config::load(config.config_path.as_deref())?; let response = llm::call_refinement_llm_with_tools( &g3_config, &codepath_str, &workspace_str, ).await; match response { Ok(_) => print_msg("āœ… LLM refinement complete."), Err(e) => print_msg(&format!("āš ļø LLM refinement error: {}", e)), } if check_current_requirements_tag(&config)? { match prompt_for_approval(&config)? { ApprovalChoice::Approve => PlannerState::ImplementRequirements, ApprovalChoice::Refine => PlannerState::PromptForRequirements, ApprovalChoice::Quit => PlannerState::Quit, } } else { print_msg(&format!("āŒ {}", "The LLM didn't update the requirements file with {{CURRENT REQUIREMENTS}}. Please restart the app.")); PlannerState::Quit } } PlannerState::ImplementRequirements => { // Promote requirements and run coach/player if config.new_requirements_path().exists() { promote_requirements(&config)?; } // Write git HEAD to history before implementation if !config.no_git { let head_sha = git::get_head_sha(&config.codepath)?; history::write_git_head(&config.plan_dir(), &head_sha)?; print_msg(&format!("šŸ“ Recorded git HEAD: {}", &head_sha[..12.min(head_sha.len())])); } // Read requirements and generate summary let requirements_content = read_current_requirements(&config)?; print_msg("šŸ“ Generating requirements summary..."); let summary = match llm::generate_requirements_summary( provider.as_ref(), &requirements_content, ).await { Ok(s) => s, Err(e) => { print_msg(&format!("āš ļø Summary generation failed: {}", e)); "Requirements implementation in progress".to_string() } }; // Write start implementing entry with summary history::write_start_implementing(&config.plan_dir(), &summary)?; print_msg("šŸ“ Recorded implementation start in history"); // Run the actual coach/player loop print_msg("\nšŸš€ Starting coach/player implementation loop..."); let g3_config = g3_config::Config::load(config.config_path.as_deref())?; let implementation_result = run_coach_player_loop( &config, &g3_config, &requirements_content, ).await; match implementation_result { Ok(_) => print_msg("āœ… Coach/player loop completed"), Err(e) => { print_msg(&format!("āš ļø Implementation error: {}", e)); print_msg("You can try to resume or mark as complete."); } } PlannerState::ImplementationComplete } PlannerState::ImplementationComplete => { // Check completion and commit match prompt_for_completion(&config)? { CompletionChoice::Complete => { let (req_file, todo_file) = complete_implementation(&config)?; // Read requirements for LLM context let requirements_content = if config.plan_dir().join(&req_file).exists() { std::fs::read_to_string(config.plan_dir().join(&req_file)) .unwrap_or_else(|_| "Requirements unavailable".to_string()) } else { "Requirements unavailable".to_string() }; // Generate commit message using LLM print_msg("šŸ“ Generating commit message..."); let (summary, description) = match llm::generate_commit_message( provider.as_ref(), &requirements_content, &req_file, &todo_file, ).await { Ok((s, d)) => (s, d), Err(e) => { print_msg(&format!("āš ļø Commit message generation failed: {}", e)); ("Implement planning requirements".to_string(), format!("Requirements: {}\nTodo: {}", req_file, todo_file)) } }; stage_and_commit(&config, &summary, &description)?; PlannerState::PromptForRequirements } CompletionChoice::Continue => PlannerState::ImplementRequirements, CompletionChoice::Quit => PlannerState::Quit, } } PlannerState::Quit => { print_msg("\nšŸ‘‹ Exiting planning mode."); break; } }; } Ok(()) } #[cfg(test)] mod tests { use super::*; use tempfile::TempDir; #[test] fn test_expand_codepath_tilde() { let result = expand_codepath("~/test/path").unwrap(); assert!(result.to_string_lossy().contains("test/path")); assert!(!result.to_string_lossy().contains('~')); } #[test] fn test_planner_config_paths() { let config = PlannerConfig { codepath: PathBuf::from("/test/project"), no_git: false, max_turns: 5, quiet: false, config_path: None, }; assert_eq!(config.plan_dir(), PathBuf::from("/test/project/g3-plan")); assert_eq!(config.new_requirements_path(), PathBuf::from("/test/project/g3-plan/new_requirements.md")); assert_eq!(config.current_requirements_path(), PathBuf::from("/test/project/g3-plan/current_requirements.md")); assert_eq!(config.todo_path(), PathBuf::from("/test/project/g3-plan/todo.g3.md")); } #[test] fn test_check_todos_complete() { assert!(check_todos_complete("- [x] Task 1\n- [x] Task 2")); assert!(!check_todos_complete("- [x] Task 1\n- [ ] Task 2")); assert!(!check_todos_complete("- [ ] Task 1")); assert!(check_todos_complete("No tasks here")); } #[test] fn test_parse_commit_message() { let response = r#"Some preamble {{COMMIT_SUMMARY}} Add planning mode with state machine {{COMMIT_DESCRIPTION}} Implements the planning workflow including: - Requirements refinement - Git integration - History tracking"#; let (summary, desc) = parse_commit_message(response); assert_eq!(summary, "Add planning mode with state machine"); assert!(desc.contains("Implements the planning workflow")); assert!(desc.contains("Requirements refinement")); } #[test] fn test_parse_commit_message_truncation() { let long_summary = "A".repeat(100); let response = format!("{{{{COMMIT_SUMMARY}}}}\n{}\n{{{{COMMIT_DESCRIPTION}}}}\nDesc", long_summary); let (summary, _) = parse_commit_message(&response); assert!(summary.len() <= 72); assert!(summary.ends_with("...")); } #[test] fn test_ensure_original_requirements_tag() { let temp_dir = TempDir::new().unwrap(); let path = temp_dir.path().join("new_requirements.md"); // Write content without tag fs::write(&path, "Some requirements").unwrap(); ensure_original_requirements_tag(&path).unwrap(); let content = fs::read_to_string(&path).unwrap(); assert!(content.contains("{{ORIGINAL USER REQUIREMENTS -- THIS SECTION WILL BE IGNORED BY THE IMPLEMENTATION}}")); assert!(content.contains("Some requirements")); } #[test] fn test_ensure_original_requirements_tag_already_present() { let temp_dir = TempDir::new().unwrap(); let path = temp_dir.path().join("new_requirements.md"); // Write content with tag already let content_with_tag = format!("{}\n\nSome requirements", "{{ORIGINAL USER REQUIREMENTS -- THIS SECTION WILL BE IGNORED BY THE IMPLEMENTATION}}"); fs::write(&path, &content_with_tag).unwrap(); ensure_original_requirements_tag(&path).unwrap(); let content = fs::read_to_string(&path).unwrap(); // Should not duplicate the tag assert_eq!(content.matches("{{ORIGINAL USER REQUIREMENTS -- THIS SECTION WILL BE IGNORED BY THE IMPLEMENTATION}}").count(), 1); } #[test] fn test_initialize_plan_dir() { let temp_dir = TempDir::new().unwrap(); let config = PlannerConfig { codepath: temp_dir.path().to_path_buf(), no_git: true, max_turns: 5, quiet: false, config_path: None, }; initialize_plan_dir(&config).unwrap(); assert!(config.plan_dir().exists()); assert!(config.history_path().exists()); } }