Implement planning mode
This commit is contained in:
396
crates/g3-planner/src/git.rs
Normal file
396
crates/g3-planner/src/git.rs
Normal file
@@ -0,0 +1,396 @@
|
||||
//! Git operations for planning mode
|
||||
//!
|
||||
//! This module provides git functionality for the planner:
|
||||
//! - Repository detection
|
||||
//! - Branch information
|
||||
//! - Dirty file detection
|
||||
//! - Staging and committing
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
/// Files and directories to exclude from staging
|
||||
const EXCLUDE_PATTERNS: &[&str] = &[
|
||||
"target/",
|
||||
"node_modules/",
|
||||
"__pycache__/",
|
||||
".venv/",
|
||||
"*.log",
|
||||
"*.tmp",
|
||||
"*.bak",
|
||||
".DS_Store",
|
||||
"Thumbs.db",
|
||||
"*.pyc",
|
||||
"tmp/",
|
||||
"temp/",
|
||||
".pytest_cache/",
|
||||
".mypy_cache/",
|
||||
".ruff_cache/",
|
||||
"*.swp",
|
||||
"*.swo",
|
||||
"*~",
|
||||
];
|
||||
|
||||
/// Check if the given path is within a git repository
|
||||
pub fn check_git_repo(codepath: &Path) -> Result<bool> {
|
||||
let output = Command::new("git")
|
||||
.args(["rev-parse", "--git-dir"])
|
||||
.current_dir(codepath)
|
||||
.output()
|
||||
.context("Failed to execute git command")?;
|
||||
|
||||
Ok(output.status.success())
|
||||
}
|
||||
|
||||
/// Get the root directory of the git repository
|
||||
pub fn get_repo_root(codepath: &Path) -> Result<String> {
|
||||
let output = Command::new("git")
|
||||
.args(["rev-parse", "--show-toplevel"])
|
||||
.current_dir(codepath)
|
||||
.output()
|
||||
.context("Failed to get git repo root")?;
|
||||
|
||||
if !output.status.success() {
|
||||
anyhow::bail!("Not in a git repository");
|
||||
}
|
||||
|
||||
let root = String::from_utf8(output.stdout)
|
||||
.context("Invalid UTF-8 in git output")?
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
Ok(root)
|
||||
}
|
||||
|
||||
/// Get the current git branch name
|
||||
pub fn get_current_branch(codepath: &Path) -> Result<String> {
|
||||
let output = Command::new("git")
|
||||
.args(["branch", "--show-current"])
|
||||
.current_dir(codepath)
|
||||
.output()
|
||||
.context("Failed to get current git branch")?;
|
||||
|
||||
if !output.status.success() {
|
||||
// Might be in detached HEAD state
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
anyhow::bail!("Failed to get branch name: {}", stderr);
|
||||
}
|
||||
|
||||
let branch = String::from_utf8(output.stdout)
|
||||
.context("Invalid UTF-8 in git output")?
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
if branch.is_empty() {
|
||||
// Detached HEAD state - get short SHA instead
|
||||
let sha_output = Command::new("git")
|
||||
.args(["rev-parse", "--short", "HEAD"])
|
||||
.current_dir(codepath)
|
||||
.output()
|
||||
.context("Failed to get HEAD SHA")?;
|
||||
|
||||
let sha = String::from_utf8(sha_output.stdout)
|
||||
.context("Invalid UTF-8 in git output")?
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
Ok(format!("(detached HEAD at {})", sha))
|
||||
} else {
|
||||
Ok(branch)
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the current HEAD SHA
|
||||
pub fn get_head_sha(codepath: &Path) -> Result<String> {
|
||||
let output = Command::new("git")
|
||||
.args(["rev-parse", "HEAD"])
|
||||
.current_dir(codepath)
|
||||
.output()
|
||||
.context("Failed to get HEAD SHA")?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
anyhow::bail!("Failed to get HEAD SHA: {}", stderr);
|
||||
}
|
||||
|
||||
let sha = String::from_utf8(output.stdout)
|
||||
.context("Invalid UTF-8 in git output")?
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
Ok(sha)
|
||||
}
|
||||
|
||||
/// Information about dirty/untracked files
|
||||
#[derive(Debug, Default)]
|
||||
pub struct DirtyFiles {
|
||||
pub modified: Vec<String>,
|
||||
pub untracked: Vec<String>,
|
||||
pub staged: Vec<String>,
|
||||
}
|
||||
|
||||
impl DirtyFiles {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.modified.is_empty() && self.untracked.is_empty() && self.staged.is_empty()
|
||||
}
|
||||
|
||||
pub fn to_display_string(&self) -> String {
|
||||
let mut lines = Vec::new();
|
||||
|
||||
if !self.staged.is_empty() {
|
||||
lines.push("Staged:".to_string());
|
||||
for f in &self.staged {
|
||||
lines.push(format!(" {}", f));
|
||||
}
|
||||
}
|
||||
|
||||
if !self.modified.is_empty() {
|
||||
lines.push("Modified:".to_string());
|
||||
for f in &self.modified {
|
||||
lines.push(format!(" {}", f));
|
||||
}
|
||||
}
|
||||
|
||||
if !self.untracked.is_empty() {
|
||||
lines.push("Untracked:".to_string());
|
||||
for f in &self.untracked {
|
||||
lines.push(format!(" {}", f));
|
||||
}
|
||||
}
|
||||
|
||||
lines.join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
/// Check for untracked, uncommitted, or dirty files
|
||||
/// Optionally ignores files matching a given path pattern
|
||||
pub fn check_dirty_files(codepath: &Path, ignore_pattern: Option<&str>) -> Result<DirtyFiles> {
|
||||
let output = Command::new("git")
|
||||
.args(["status", "--porcelain"])
|
||||
.current_dir(codepath)
|
||||
.output()
|
||||
.context("Failed to check git status")?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
anyhow::bail!("Failed to check git status: {}", stderr);
|
||||
}
|
||||
|
||||
let status_output = String::from_utf8(output.stdout)
|
||||
.context("Invalid UTF-8 in git output")?;
|
||||
|
||||
let mut result = DirtyFiles::default();
|
||||
|
||||
for line in status_output.lines() {
|
||||
if line.len() < 3 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let status = &line[0..2];
|
||||
let file = line[3..].trim();
|
||||
|
||||
// Check if this file should be ignored
|
||||
if let Some(pattern) = ignore_pattern {
|
||||
if file.contains(pattern) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
match status {
|
||||
"??" => result.untracked.push(file.to_string()),
|
||||
" M" | "MM" | "AM" => result.modified.push(file.to_string()),
|
||||
"M " | "A " | "D " | "R " => result.staged.push(file.to_string()),
|
||||
_ => {
|
||||
// Other statuses (deleted, renamed, etc.)
|
||||
if status.starts_with(' ') {
|
||||
result.modified.push(file.to_string());
|
||||
} else {
|
||||
result.staged.push(file.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Check if a file should be excluded from staging based on patterns
|
||||
fn should_exclude(path: &str) -> bool {
|
||||
for pattern in EXCLUDE_PATTERNS {
|
||||
if pattern.ends_with('/') {
|
||||
// Directory pattern
|
||||
let dir_name = pattern.trim_end_matches('/');
|
||||
if path.contains(&format!("/{}/", dir_name)) || path.starts_with(&format!("{}/", dir_name)) {
|
||||
return true;
|
||||
}
|
||||
} else if pattern.starts_with('*') {
|
||||
// Wildcard pattern
|
||||
let suffix = pattern.trim_start_matches('*');
|
||||
if path.ends_with(suffix) {
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
// Exact match
|
||||
if path == *pattern || path.ends_with(&format!("/{}", pattern)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Stage files for commit, excluding temporary/artifact files
|
||||
/// Stages all files in the specified directory plus any modified/new code files
|
||||
pub fn stage_files(codepath: &Path, plan_dir: &Path) -> Result<StagingResult> {
|
||||
let mut result = StagingResult::default();
|
||||
|
||||
// First, stage all files in the g3-plan directory
|
||||
let plan_dir_str = plan_dir.to_string_lossy();
|
||||
let add_plan_output = Command::new("git")
|
||||
.args(["add", &plan_dir_str])
|
||||
.current_dir(codepath)
|
||||
.output()
|
||||
.context("Failed to stage g3-plan directory")?;
|
||||
|
||||
if !add_plan_output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&add_plan_output.stderr);
|
||||
// Don't fail if directory doesn't exist yet
|
||||
if !stderr.contains("did not match any files") {
|
||||
anyhow::bail!("Failed to stage g3-plan directory: {}", stderr);
|
||||
}
|
||||
}
|
||||
|
||||
// Get list of all changed files
|
||||
let status_output = Command::new("git")
|
||||
.args(["status", "--porcelain"])
|
||||
.current_dir(codepath)
|
||||
.output()
|
||||
.context("Failed to get git status")?;
|
||||
|
||||
let status_str = String::from_utf8(status_output.stdout)
|
||||
.context("Invalid UTF-8 in git output")?;
|
||||
|
||||
// Stage files that aren't excluded
|
||||
for line in status_str.lines() {
|
||||
if line.len() < 3 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let status = &line[0..2];
|
||||
let file = line[3..].trim();
|
||||
|
||||
// Skip already staged files
|
||||
if !status.starts_with(' ') && status != "??" {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if this file should be excluded
|
||||
if should_exclude(file) {
|
||||
result.excluded.push(file.to_string());
|
||||
continue;
|
||||
}
|
||||
|
||||
// Stage the file
|
||||
let add_output = Command::new("git")
|
||||
.args(["add", file])
|
||||
.current_dir(codepath)
|
||||
.output()
|
||||
.context(format!("Failed to stage file: {}", file))?;
|
||||
|
||||
if add_output.status.success() {
|
||||
result.staged.push(file.to_string());
|
||||
} else {
|
||||
result.failed.push(file.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Result of staging operation
|
||||
#[derive(Debug, Default)]
|
||||
pub struct StagingResult {
|
||||
pub staged: Vec<String>,
|
||||
pub excluded: Vec<String>,
|
||||
pub failed: Vec<String>,
|
||||
}
|
||||
|
||||
/// Make a git commit with the given summary and description
|
||||
pub fn commit(codepath: &Path, summary: &str, description: &str) -> Result<String> {
|
||||
// Combine summary and description into full commit message
|
||||
let full_message = if description.is_empty() {
|
||||
summary.to_string()
|
||||
} else {
|
||||
format!("{}\n\n{}", summary, description)
|
||||
};
|
||||
|
||||
let output = Command::new("git")
|
||||
.args(["commit", "-m", &full_message])
|
||||
.current_dir(codepath)
|
||||
.output()
|
||||
.context("Failed to make git commit")?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
anyhow::bail!("Git commit failed: {}", stderr);
|
||||
}
|
||||
|
||||
// Get the commit SHA
|
||||
get_head_sha(codepath)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_should_exclude_target() {
|
||||
assert!(should_exclude("target/debug/something"));
|
||||
assert!(should_exclude("some/path/target/release/bin"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_exclude_node_modules() {
|
||||
assert!(should_exclude("node_modules/package/index.js"));
|
||||
assert!(should_exclude("frontend/node_modules/react/index.js"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_exclude_log_files() {
|
||||
assert!(should_exclude("app.log"));
|
||||
assert!(should_exclude("logs/debug.log"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_exclude_temp_files() {
|
||||
assert!(should_exclude("file.tmp"));
|
||||
assert!(should_exclude("file.bak"));
|
||||
assert!(should_exclude("file.swp"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_not_exclude_normal_files() {
|
||||
assert!(!should_exclude("src/main.rs"));
|
||||
assert!(!should_exclude("Cargo.toml"));
|
||||
assert!(!should_exclude("README.md"));
|
||||
assert!(!should_exclude("package.json"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dirty_files_display() {
|
||||
let dirty = DirtyFiles {
|
||||
modified: vec!["src/main.rs".to_string()],
|
||||
untracked: vec!["new_file.txt".to_string()],
|
||||
staged: vec!["Cargo.toml".to_string()],
|
||||
};
|
||||
|
||||
let display = dirty.to_display_string();
|
||||
assert!(display.contains("Modified:"));
|
||||
assert!(display.contains("src/main.rs"));
|
||||
assert!(display.contains("Untracked:"));
|
||||
assert!(display.contains("new_file.txt"));
|
||||
assert!(display.contains("Staged:"));
|
||||
assert!(display.contains("Cargo.toml"));
|
||||
}
|
||||
}
|
||||
234
crates/g3-planner/src/history.rs
Normal file
234
crates/g3-planner/src/history.rs
Normal file
@@ -0,0 +1,234 @@
|
||||
//! Planner history management
|
||||
//!
|
||||
//! This module manages the planner_history.txt file which serves as:
|
||||
//! - An audit log of planning steps
|
||||
//! - A comprehensive reference of historic requirements and implementations
|
||||
//! - A file that requires merging/resolution if updated on separate git branches
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use chrono::Local;
|
||||
use std::fs::{self, OpenOptions};
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::prompts;
|
||||
|
||||
/// Format a timestamp for planner_history.txt entries
|
||||
/// Format: YYYY-MM-DD HH:MM:SS (ISO 8601 for readability)
|
||||
pub fn format_timestamp() -> String {
|
||||
Local::now().format("%Y-%m-%d %H:%M:%S").to_string()
|
||||
}
|
||||
|
||||
/// Format a timestamp for filenames
|
||||
/// Format: YYYY-MM-DD_HH-MM-SS (filesystem-safe)
|
||||
pub fn format_timestamp_for_filename() -> String {
|
||||
Local::now().format("%Y-%m-%d_%H-%M-%S").to_string()
|
||||
}
|
||||
|
||||
/// Ensure the planner_history.txt file exists, creating it if necessary
|
||||
pub fn ensure_history_file(plan_dir: &Path) -> Result<()> {
|
||||
let history_path = plan_dir.join("planner_history.txt");
|
||||
|
||||
if !history_path.exists() {
|
||||
fs::write(&history_path, "")
|
||||
.context("Failed to create planner_history.txt")?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Append an entry to planner_history.txt
|
||||
fn append_entry(plan_dir: &Path, entry: &str) -> Result<()> {
|
||||
let history_path = plan_dir.join("planner_history.txt");
|
||||
|
||||
let mut file = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&history_path)
|
||||
.context("Failed to open planner_history.txt for appending")?;
|
||||
|
||||
writeln!(file, "{}", entry)
|
||||
.context("Failed to write to planner_history.txt")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write a "REFINING REQUIREMENTS" entry
|
||||
pub fn write_refining_requirements(plan_dir: &Path) -> Result<()> {
|
||||
let timestamp = format_timestamp();
|
||||
let entry = "{timestamp} - REFINING REQUIREMENTS (new_requirements.md)"
|
||||
.replace("{timestamp}", ×tamp);
|
||||
append_entry(plan_dir, &entry)
|
||||
}
|
||||
|
||||
/// Write a "GIT HEAD" entry with the current SHA
|
||||
pub fn write_git_head(plan_dir: &Path, sha: &str) -> Result<()> {
|
||||
let timestamp = format_timestamp();
|
||||
let entry = "{timestamp} - GIT HEAD ({sha})"
|
||||
.replace("{timestamp}", ×tamp)
|
||||
.replace("{sha}", sha);
|
||||
append_entry(plan_dir, &entry)
|
||||
}
|
||||
|
||||
/// Write a "START IMPLEMENTING" entry with a summary block
|
||||
pub fn write_start_implementing(plan_dir: &Path, summary: &str) -> Result<()> {
|
||||
let timestamp = format_timestamp();
|
||||
let entry = "{timestamp} - START IMPLEMENTING (current_requirements.md)"
|
||||
.replace("{timestamp}", ×tamp);
|
||||
|
||||
// Format the summary with proper indentation
|
||||
let indented_summary = summary
|
||||
.lines()
|
||||
.map(|line| format!(" {}", line))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
let summary_block = "<<\n{summary}\n>>"
|
||||
.replace("{summary}", &indented_summary);
|
||||
|
||||
append_entry(plan_dir, &entry)?;
|
||||
append_entry(plan_dir, &summary_block)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write an "ATTEMPTING RECOVERY" entry
|
||||
pub fn write_attempting_recovery(plan_dir: &Path) -> Result<()> {
|
||||
let timestamp = format_timestamp();
|
||||
let entry = "{timestamp} ATTEMPTING RECOVERY"
|
||||
.replace("{timestamp}", ×tamp);
|
||||
append_entry(plan_dir, &entry)
|
||||
}
|
||||
|
||||
/// Write a "USER SKIPPED RECOVERY" entry
|
||||
pub fn write_skipped_recovery(plan_dir: &Path) -> Result<()> {
|
||||
let timestamp = format_timestamp();
|
||||
let entry = "{timestamp} USER SKIPPED RECOVERY"
|
||||
.replace("{timestamp}", ×tamp);
|
||||
append_entry(plan_dir, &entry)
|
||||
}
|
||||
|
||||
/// Write a "COMPLETED REQUIREMENTS" entry
|
||||
pub fn write_completed_requirements(
|
||||
plan_dir: &Path,
|
||||
requirements_file: &str,
|
||||
todo_file: &str,
|
||||
) -> Result<()> {
|
||||
let timestamp = format_timestamp();
|
||||
let entry = "{timestamp} - COMPLETED REQUIREMENTS ({requirements_file}, {todo_file})"
|
||||
.replace("{timestamp}", ×tamp)
|
||||
.replace("{requirements_file}", requirements_file)
|
||||
.replace("{todo_file}", todo_file);
|
||||
append_entry(plan_dir, &entry)
|
||||
}
|
||||
|
||||
/// Write a "GIT COMMIT" entry
|
||||
pub fn write_git_commit(plan_dir: &Path, message: &str) -> Result<()> {
|
||||
let timestamp = format_timestamp();
|
||||
// Truncate message if too long for a single line
|
||||
let truncated_message = if message.len() > 72 {
|
||||
format!("{}...", &message[..69])
|
||||
} else {
|
||||
message.to_string()
|
||||
};
|
||||
let entry = "{timestamp} - GIT COMMIT ({message})"
|
||||
.replace("{timestamp}", ×tamp)
|
||||
.replace("{message}", &truncated_message);
|
||||
append_entry(plan_dir, &entry)
|
||||
}
|
||||
|
||||
/// Generate the completed requirements filename
|
||||
pub fn completed_requirements_filename() -> String {
|
||||
format!("completed_requirements_{}.md", format_timestamp_for_filename())
|
||||
}
|
||||
|
||||
/// Generate the completed todo filename
|
||||
pub fn completed_todo_filename() -> String {
|
||||
format!("completed_todo_{}.md", format_timestamp_for_filename())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn test_format_timestamp() {
|
||||
let ts = format_timestamp();
|
||||
// Should be in format YYYY-MM-DD HH:MM:SS
|
||||
assert_eq!(ts.len(), 19);
|
||||
assert_eq!(&ts[4..5], "-");
|
||||
assert_eq!(&ts[7..8], "-");
|
||||
assert_eq!(&ts[10..11], " ");
|
||||
assert_eq!(&ts[13..14], ":");
|
||||
assert_eq!(&ts[16..17], ":");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_timestamp_for_filename() {
|
||||
let ts = format_timestamp_for_filename();
|
||||
// Should be in format YYYY-MM-DD_HH-MM-SS
|
||||
assert_eq!(ts.len(), 19);
|
||||
assert_eq!(&ts[4..5], "-");
|
||||
assert_eq!(&ts[7..8], "-");
|
||||
assert_eq!(&ts[10..11], "_");
|
||||
assert_eq!(&ts[13..14], "-");
|
||||
assert_eq!(&ts[16..17], "-");
|
||||
// Should not contain colons (filesystem-safe)
|
||||
assert!(!ts.contains(':'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ensure_history_file() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let plan_dir = temp_dir.path();
|
||||
|
||||
let history_path = plan_dir.join("planner_history.txt");
|
||||
assert!(!history_path.exists());
|
||||
|
||||
ensure_history_file(plan_dir).unwrap();
|
||||
|
||||
assert!(history_path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_write_entries() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let plan_dir = temp_dir.path();
|
||||
|
||||
ensure_history_file(plan_dir).unwrap();
|
||||
|
||||
write_refining_requirements(plan_dir).unwrap();
|
||||
write_git_head(plan_dir, "abc123def456").unwrap();
|
||||
write_start_implementing(plan_dir, "Test summary line 1\nTest summary line 2").unwrap();
|
||||
write_attempting_recovery(plan_dir).unwrap();
|
||||
write_completed_requirements(plan_dir, "completed_requirements_2025-01-01_12-00-00.md", "completed_todo_2025-01-01_12-00-00.md").unwrap();
|
||||
write_git_commit(plan_dir, "Add feature X").unwrap();
|
||||
|
||||
let history_path = plan_dir.join("planner_history.txt");
|
||||
let content = fs::read_to_string(history_path).unwrap();
|
||||
|
||||
assert!(content.contains("REFINING REQUIREMENTS"));
|
||||
assert!(content.contains("GIT HEAD (abc123def456)"));
|
||||
assert!(content.contains("START IMPLEMENTING"));
|
||||
assert!(content.contains("Test summary line 1"));
|
||||
assert!(content.contains("ATTEMPTING RECOVERY"));
|
||||
assert!(content.contains("COMPLETED REQUIREMENTS"));
|
||||
assert!(content.contains("GIT COMMIT"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_completed_filenames() {
|
||||
let req_file = completed_requirements_filename();
|
||||
let todo_file = completed_todo_filename();
|
||||
|
||||
assert!(req_file.starts_with("completed_requirements_"));
|
||||
assert!(req_file.ends_with(".md"));
|
||||
assert!(todo_file.starts_with("completed_todo_"));
|
||||
assert!(todo_file.ends_with(".md"));
|
||||
|
||||
// Should not contain colons
|
||||
assert!(!req_file.contains(':'));
|
||||
assert!(!todo_file.contains(':'));
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,24 @@
|
||||
//! g3-planner: Fast-discovery planner for G3 AI coding agent
|
||||
//! g3-planner: Planning mode and fast-discovery planner for G3 AI coding agent
|
||||
//!
|
||||
//! This crate provides functionality to generate initial discovery tool calls
|
||||
//! that are injected into the conversation before the first LLM turn.
|
||||
//! This crate provides:
|
||||
//! - Planning mode state machine and orchestration
|
||||
//! - Requirements refinement workflow
|
||||
//! - Git integration for planning commits
|
||||
//! - Planner history management
|
||||
//! - Fast-discovery functionality for codebase exploration
|
||||
|
||||
mod code_explore;
|
||||
pub mod git;
|
||||
pub mod history;
|
||||
pub mod llm;
|
||||
pub mod planner;
|
||||
pub mod prompts;
|
||||
pub mod state;
|
||||
|
||||
pub use code_explore::explore_codebase;
|
||||
pub use planner::{expand_codepath, PlannerConfig, PlannerResult};
|
||||
pub use state::{PlannerState, RecoveryInfo};
|
||||
pub use planner::run_planning_mode;
|
||||
|
||||
use anyhow::Result;
|
||||
use chrono::Local;
|
||||
|
||||
321
crates/g3-planner/src/llm.rs
Normal file
321
crates/g3-planner/src/llm.rs
Normal file
@@ -0,0 +1,321 @@
|
||||
//! LLM integration for planning mode
|
||||
//!
|
||||
//! This module provides LLM-based functionality for:
|
||||
//! - Requirements refinement
|
||||
//! - Generating requirements summaries
|
||||
//! - Generating git commit messages
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use g3_config::Config;
|
||||
use g3_core::project::Project;
|
||||
use g3_core::Agent;
|
||||
use g3_providers::{CompletionRequest, LLMProvider, Message, MessageRole};
|
||||
|
||||
use crate::prompts;
|
||||
|
||||
/// Create an LLM provider for the planner based on config
|
||||
pub async fn create_planner_provider(
|
||||
config_path: Option<&str>,
|
||||
) -> Result<Box<dyn LLMProvider>> {
|
||||
// Load configuration
|
||||
let config = Config::load(config_path)
|
||||
.context("Failed to load configuration")?;
|
||||
|
||||
// Get planner provider reference (or default)
|
||||
let provider_ref = config.get_planner_provider();
|
||||
|
||||
// If no explicit planner provider, notify user about fallback
|
||||
if config.providers.planner.is_none() {
|
||||
let msg = "Note: No 'planner' provider specified in config. Using default_provider '{provider}' for planning mode."
|
||||
.replace("{provider}", provider_ref);
|
||||
println!("ℹ️ {}", msg);
|
||||
}
|
||||
|
||||
// Parse the provider reference
|
||||
let (provider_type, config_name) = Config::parse_provider_reference(provider_ref)?;
|
||||
|
||||
// Create the appropriate provider
|
||||
match provider_type.as_str() {
|
||||
"anthropic" => {
|
||||
let anthropic_config = config
|
||||
.get_anthropic_config(&config_name)
|
||||
.ok_or_else(|| anyhow!("Anthropic config '{}' not found", config_name))?;
|
||||
|
||||
let provider = g3_providers::AnthropicProvider::new_with_name(
|
||||
format!("anthropic.{}", config_name),
|
||||
anthropic_config.api_key.clone(),
|
||||
Some(anthropic_config.model.clone()),
|
||||
anthropic_config.max_tokens,
|
||||
anthropic_config.temperature,
|
||||
anthropic_config.cache_config.clone(),
|
||||
anthropic_config.enable_1m_context,
|
||||
anthropic_config.thinking_budget_tokens,
|
||||
)?;
|
||||
Ok(Box::new(provider))
|
||||
}
|
||||
"openai" => {
|
||||
let openai_config = config
|
||||
.get_openai_config(&config_name)
|
||||
.ok_or_else(|| anyhow!("OpenAI config '{}' not found", config_name))?;
|
||||
|
||||
let provider = g3_providers::OpenAIProvider::new_with_name(
|
||||
format!("openai.{}", config_name),
|
||||
openai_config.api_key.clone(),
|
||||
Some(openai_config.model.clone()),
|
||||
openai_config.base_url.clone(),
|
||||
openai_config.max_tokens,
|
||||
openai_config.temperature,
|
||||
)?;
|
||||
Ok(Box::new(provider))
|
||||
}
|
||||
"databricks" => {
|
||||
let databricks_config = config
|
||||
.get_databricks_config(&config_name)
|
||||
.ok_or_else(|| anyhow!("Databricks config '{}' not found", config_name))?;
|
||||
|
||||
let provider = if let Some(token) = &databricks_config.token {
|
||||
g3_providers::DatabricksProvider::from_token_with_name(
|
||||
format!("databricks.{}", config_name),
|
||||
databricks_config.host.clone(),
|
||||
token.clone(),
|
||||
databricks_config.model.clone(),
|
||||
databricks_config.max_tokens,
|
||||
databricks_config.temperature,
|
||||
)?
|
||||
} else {
|
||||
g3_providers::DatabricksProvider::from_oauth_with_name(
|
||||
format!("databricks.{}", config_name),
|
||||
databricks_config.host.clone(),
|
||||
databricks_config.model.clone(),
|
||||
databricks_config.max_tokens,
|
||||
databricks_config.temperature,
|
||||
)
|
||||
.await?
|
||||
};
|
||||
Ok(Box::new(provider))
|
||||
}
|
||||
_ => {
|
||||
Err(anyhow!(
|
||||
"Unsupported provider type '{}' for planner. Supported: anthropic, openai, databricks",
|
||||
provider_type
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate a summary of requirements for planner_history.txt
|
||||
///
|
||||
/// Uses the planner LLM to generate a concise summary of the requirements.
|
||||
/// The summary is at most 5 lines, each at most 120 characters.
|
||||
pub async fn generate_requirements_summary(
|
||||
provider: &dyn LLMProvider,
|
||||
requirements: &str,
|
||||
) -> Result<String> {
|
||||
let prompt = prompts::GENERATE_REQUIREMENTS_SUMMARY_PROMPT
|
||||
.replace("{requirements}", requirements);
|
||||
|
||||
let messages = vec![Message::new(MessageRole::User, prompt)];
|
||||
|
||||
let request = CompletionRequest {
|
||||
messages,
|
||||
max_tokens: Some(500), // Summary should be short
|
||||
temperature: Some(0.3), // Low temperature for consistent output
|
||||
stream: false,
|
||||
tools: None,
|
||||
};
|
||||
|
||||
let response = provider
|
||||
.complete(request)
|
||||
.await
|
||||
.context("Failed to generate requirements summary")?;
|
||||
|
||||
// Clean up the response - ensure max 5 lines, each max 120 chars
|
||||
let summary = response
|
||||
.content
|
||||
.lines()
|
||||
.take(5)
|
||||
.map(|line| {
|
||||
if line.len() > 120 {
|
||||
format!("{}...", &line[..117])
|
||||
} else {
|
||||
line.to_string()
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
Ok(summary)
|
||||
}
|
||||
|
||||
/// Generate a git commit message based on the requirements
|
||||
///
|
||||
/// Uses the planner LLM to generate a commit summary and description.
|
||||
/// Returns (summary, description) tuple.
|
||||
pub async fn generate_commit_message(
|
||||
provider: &dyn LLMProvider,
|
||||
requirements: &str,
|
||||
requirements_file: &str,
|
||||
todo_file: &str,
|
||||
) -> Result<(String, String)> {
|
||||
let prompt = prompts::GENERATE_COMMIT_MESSAGE_PROMPT
|
||||
.replace("{requirements}", requirements)
|
||||
.replace("{requirements_file}", requirements_file)
|
||||
.replace("{todo_file}", todo_file);
|
||||
|
||||
let messages = vec![Message::new(MessageRole::User, prompt)];
|
||||
|
||||
let request = CompletionRequest {
|
||||
messages,
|
||||
max_tokens: Some(1000),
|
||||
temperature: Some(0.3),
|
||||
stream: false,
|
||||
tools: None,
|
||||
};
|
||||
|
||||
let response = provider
|
||||
.complete(request)
|
||||
.await
|
||||
.context("Failed to generate commit message")?;
|
||||
|
||||
// Parse the response using the existing parse_commit_message function
|
||||
Ok(crate::planner::parse_commit_message(&response.content))
|
||||
}
|
||||
|
||||
/// A simple UiWriter implementation for planner output
|
||||
#[derive(Clone)]
|
||||
pub struct PlannerUiWriter;
|
||||
|
||||
impl g3_core::ui_writer::UiWriter for PlannerUiWriter {
|
||||
fn print(&self, message: &str) {
|
||||
println!("{}", message);
|
||||
}
|
||||
|
||||
fn println(&self, message: &str) {
|
||||
println!("{}", message);
|
||||
}
|
||||
|
||||
fn print_inline(&self, message: &str) {
|
||||
print!("{}", message);
|
||||
}
|
||||
|
||||
fn print_system_prompt(&self, _prompt: &str) {}
|
||||
|
||||
fn print_context_status(&self, message: &str) {
|
||||
println!("📊 {}", message);
|
||||
}
|
||||
|
||||
fn print_context_thinning(&self, message: &str) {
|
||||
println!("🗜️ {}", message);
|
||||
}
|
||||
|
||||
fn print_tool_header(&self, tool_name: &str) {
|
||||
println!("🔧 {}", tool_name);
|
||||
}
|
||||
|
||||
fn print_tool_arg(&self, _key: &str, _value: &str) {}
|
||||
fn print_tool_output_header(&self) {}
|
||||
fn update_tool_output_line(&self, _line: &str) {}
|
||||
fn print_tool_output_line(&self, _line: &str) {}
|
||||
fn print_tool_output_summary(&self, _hidden_count: usize) {}
|
||||
fn print_tool_timing(&self, _duration_str: &str) {}
|
||||
fn print_agent_prompt(&self) {}
|
||||
fn print_agent_response(&self, _content: &str) {}
|
||||
fn notify_sse_received(&self) {}
|
||||
|
||||
fn flush(&self) {
|
||||
use std::io::Write;
|
||||
std::io::stdout().flush().ok();
|
||||
}
|
||||
|
||||
fn prompt_user_yes_no(&self, _message: &str) -> bool {
|
||||
true // Default to yes for automated planner
|
||||
}
|
||||
|
||||
fn prompt_user_choice(&self, _message: &str, _options: &[&str]) -> usize {
|
||||
0 // Default to first option
|
||||
}
|
||||
|
||||
fn print_final_output(&self, summary: &str) {
|
||||
println!("\n📝 Final Output:\n{}", summary);
|
||||
}
|
||||
}
|
||||
|
||||
/// Call LLM to refine requirements using a full Agent with tool execution
|
||||
pub async fn call_refinement_llm_with_tools(
|
||||
config: &Config,
|
||||
codepath: &str,
|
||||
) -> Result<String> {
|
||||
// Build system message with codepath context
|
||||
let system_prompt = prompts::REFINE_REQUIREMENTS_SYSTEM_PROMPT
|
||||
.replace("<codepath>", codepath);
|
||||
|
||||
// Build user message
|
||||
let user_message = build_refinement_user_message(codepath);
|
||||
|
||||
// Create agent with planner config
|
||||
let planner_config = config.for_planner()?;
|
||||
let ui_writer = PlannerUiWriter;
|
||||
|
||||
// Create project pointing to codepath as workspace
|
||||
let workspace = std::path::PathBuf::from(codepath);
|
||||
let project = Project::new(workspace.clone());
|
||||
project.ensure_workspace_exists()?;
|
||||
project.enter_workspace()?;
|
||||
|
||||
// Create agent - not autonomous mode, just regular agent with tools
|
||||
let mut agent = Agent::new_with_readme_and_quiet(
|
||||
planner_config,
|
||||
ui_writer,
|
||||
Some(system_prompt),
|
||||
false, // not quiet
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Execute the refinement task
|
||||
// The agent will have access to tools and execute them
|
||||
let task = user_message;
|
||||
|
||||
let result = agent
|
||||
.execute_task_with_timing(&task, None, false, false, false, true, None)
|
||||
.await
|
||||
.context("Failed to call refinement LLM")?;
|
||||
|
||||
println!("📝 Refinement complete");
|
||||
|
||||
Ok(result.response)
|
||||
}
|
||||
|
||||
/// Build the user message for requirements refinement
|
||||
///
|
||||
/// This message instructs the LLM to read the codebase and refine requirements.
|
||||
pub fn build_refinement_user_message(codepath: &str) -> String {
|
||||
format!(
|
||||
r#"Please refine the requirements for the codebase at: {codepath}
|
||||
|
||||
Before making suggestions, please:
|
||||
1. Read the codebase structure using shell commands like `ls`, `find`, or `tree`
|
||||
2. Read `{codepath}/g3-plan/planner_history.txt` to understand past planning activities
|
||||
3. Read any `{codepath}/g3-plan/completed_requirements_*.md` files to see what was implemented before
|
||||
4. Read `{codepath}/g3-plan/new_requirements.md` which contains the requirements to refine
|
||||
|
||||
After understanding the context, update the `{codepath}/g3-plan/new_requirements.md` file by prepending
|
||||
your refined requirements under the heading `{{{{CURRENT REQUIREMENTS}}}}`.
|
||||
|
||||
Use final_output when you are done to indicate completion."#,
|
||||
codepath = codepath
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_build_refinement_user_message() {
|
||||
let msg = build_refinement_user_message("/test/project");
|
||||
assert!(msg.contains("/test/project"));
|
||||
assert!(msg.contains("planner_history.txt"));
|
||||
assert!(msg.contains("new_requirements.md"));
|
||||
assert!(msg.contains("{{CURRENT REQUIREMENTS}}"));
|
||||
}
|
||||
}
|
||||
989
crates/g3-planner/src/planner.rs
Normal file
989
crates/g3-planner/src/planner.rs
Normal file
@@ -0,0 +1,989 @@
|
||||
//! 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::prompts;
|
||||
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<String>,
|
||||
}
|
||||
|
||||
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<PathBuf> {
|
||||
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<PathBuf> {
|
||||
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<String> {
|
||||
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<PlannerState> {
|
||||
// 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<PlannerState> {
|
||||
// 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<bool> {
|
||||
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<ApprovalChoice> {
|
||||
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<String> {
|
||||
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<Option<String>> {
|
||||
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<CompletionChoice> {
|
||||
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(());
|
||||
}
|
||||
|
||||
// Make commit
|
||||
print_msg("📝 Making git commit...");
|
||||
let _commit_sha = git::commit(&config.codepath, summary, description)?;
|
||||
print_msg("✅ Commit successful");
|
||||
|
||||
// Log commit to history
|
||||
history::write_git_commit(&config.plan_dir(), summary)?;
|
||||
|
||||
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<String> = 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;
|
||||
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;
|
||||
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;
|
||||
print_msg(&format!("📝 Coach feedback: {} chars", coach_feedback.len()));
|
||||
}
|
||||
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<String>,
|
||||
no_git: bool,
|
||||
config_path: Option<&str>,
|
||||
) -> anyhow::Result<()> {
|
||||
print_msg("\n🎯 G3 Planning Mode");
|
||||
print_msg("==================\n");
|
||||
|
||||
// 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()));
|
||||
|
||||
// Get codepath from argument or prompt user
|
||||
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());
|
||||
}
|
||||
|
||||
// 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();
|
||||
|
||||
// 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,
|
||||
).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());
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,11 @@
|
||||
//! Prompts used for discovery phase
|
||||
//! Prompts used for planning mode and discovery phase
|
||||
//!
|
||||
//! This module contains all LLM prompts used in the planner crate.
|
||||
//! All prompts are defined as constants to ensure consistency and maintainability.
|
||||
|
||||
// =============================================================================
|
||||
// DISCOVERY PHASE PROMPTS (existing)
|
||||
// =============================================================================
|
||||
|
||||
/// System prompt for discovery mode - instructs the LLM to analyze codebase and generate exploration commands
|
||||
pub const DISCOVERY_SYSTEM_PROMPT: &str = r#"You are an expert code analyst. Your task is to analyze a codebase structure and generate shell commands to explore it further.
|
||||
@@ -35,3 +42,101 @@ Your output MUST include:
|
||||
- Mark the beginning and end of the commands with "```".
|
||||
|
||||
DO NOT ADD ANY COMMENTS OR OTHER EXPLANATION IN THE COMMANDS SECTION, JUST INCLUDE THE SHELL COMMANDS."#;
|
||||
|
||||
// =============================================================================
|
||||
// PLANNING MODE PROMPTS
|
||||
// =============================================================================
|
||||
|
||||
/// System prompt for requirements refinement phase
|
||||
pub const REFINE_REQUIREMENTS_SYSTEM_PROMPT: &str = r#"You're an experienced software engineering architect. Please help me to ideate and refine
|
||||
REQUIREMENTS for an implementation (or changes to the existing implementation), at the specified codepath.
|
||||
The requirements will later be used by an LLM.
|
||||
|
||||
IMPORTANT: Before suggesting changes, you MUST:
|
||||
1. Read and understand the existing codebase at the specified codepath using read_file, shell commands, and code_search
|
||||
2. Read the `<codepath>/g3-plan/` directory to understand past requirements and implementation history
|
||||
- Pay particular attention to `planner_history.txt` which contains a chronological record of all planning activities
|
||||
- Review any `completed_requirements_*.md` files to understand what has been implemented before
|
||||
3. Use this context to ensure your suggestions are consistent with the existing codebase architecture
|
||||
|
||||
I wish to have a compact specification, and DO NOT ATTEMPT TO IMPLEMENT OR BUILD ANYTHING.
|
||||
At this point ONLY suggest improvements to the requirements. Do not implement anything.
|
||||
DO NOT DO A RE-WRITE, UNLESS THE USER EXPLICITLY ASKS FOR THAT.
|
||||
If you think the requirements are totally incoherent and unusable, write constructive feedback on
|
||||
why that is, and suggest (very briefly) that you could rewrite it if explicitly asked to do so.
|
||||
If the requirements are usable, make some edits/changes/additions as you deem necessary, and
|
||||
PREPEND them under the heading `{{CURRENT REQUIREMENTS}}` to the `<codepath>/g3-plan/new_requirements.md` file.
|
||||
|
||||
The codepath will be provided in the user message."#;
|
||||
|
||||
/// System prompt for generating requirements summary for planner_history.txt
|
||||
pub const GENERATE_REQUIREMENTS_SUMMARY_PROMPT: &str = r#"Generate a short summary of the following requirements.
|
||||
Take care that the most important elements of the requirements are reflected.
|
||||
Do not go into deep detail. Make the summary at most 5 lines long.
|
||||
Each line should be at most 120 characters long.
|
||||
Output ONLY the summary text, no headers or formatting.
|
||||
|
||||
Requirements:
|
||||
{requirements}"#;
|
||||
|
||||
/// System prompt for generating git commit message
|
||||
pub const GENERATE_COMMIT_MESSAGE_PROMPT: &str = r#"Generate a git commit message for the following implementation.
|
||||
|
||||
REQUIREMENTS THAT WERE IMPLEMENTED:
|
||||
{requirements}
|
||||
|
||||
COMPLETED FILES:
|
||||
- Requirements: {requirements_file}
|
||||
- Todo: {todo_file}
|
||||
|
||||
Generate a commit message with:
|
||||
1. A summary line (max 72 characters, imperative mood, e.g., "Add planning mode with...")
|
||||
2. A blank line
|
||||
3. A description (max 10 lines, each max 72 characters, wrapped properly)
|
||||
|
||||
The description should:
|
||||
- Describe the implementation concisely
|
||||
- Include only the most important and salient details
|
||||
- Mention the completed_requirements and completed_todo filenames
|
||||
|
||||
Output format:
|
||||
{{COMMIT_SUMMARY}}
|
||||
<summary line here>
|
||||
{{COMMIT_DESCRIPTION}}
|
||||
<description here>"#;
|
||||
|
||||
// =============================================================================
|
||||
// CONFIG ERROR MESSAGES
|
||||
// =============================================================================
|
||||
|
||||
/// Error message for old config format
|
||||
pub const OLD_CONFIG_FORMAT_ERROR: &str = r#"Your configuration file uses an old format that is no longer supported.
|
||||
|
||||
Please update your configuration to use the new provider format:
|
||||
|
||||
```toml
|
||||
[providers]
|
||||
default_provider = "anthropic.default" # Format: "<provider_type>.<config_name>"
|
||||
planner = "anthropic.planner" # Optional: specific provider for planner
|
||||
coach = "anthropic.default" # Optional: specific provider for coach
|
||||
player = "openai.player" # Optional: specific provider for player
|
||||
|
||||
# Named configs per provider type
|
||||
[providers.anthropic.default]
|
||||
api_key = "your-api-key"
|
||||
model = "claude-sonnet-4-5"
|
||||
max_tokens = 64000
|
||||
|
||||
[providers.anthropic.planner]
|
||||
api_key = "your-api-key"
|
||||
model = "claude-opus-4-5"
|
||||
thinking_budget_tokens = 16000
|
||||
|
||||
[providers.openai.player]
|
||||
api_key = "your-api-key"
|
||||
model = "gpt-5"
|
||||
```
|
||||
|
||||
Each mode (planner, coach, player) can specify a full path like "<provider_type>.<config_name>".
|
||||
If not specified, they fall back to `default_provider`."#;
|
||||
|
||||
|
||||
289
crates/g3-planner/src/state.rs
Normal file
289
crates/g3-planner/src/state.rs
Normal file
@@ -0,0 +1,289 @@
|
||||
//! Planner state machine
|
||||
//!
|
||||
//! This module defines the state machine for the planning mode:
|
||||
//!
|
||||
//! ```text
|
||||
//! +------------- RECOVERY (Resume) ---------------------+
|
||||
//! | |
|
||||
//! | +---------- RECOVERY (Mark Complete) ----+ |
|
||||
//! | | | |
|
||||
//! ^ ^ v v
|
||||
//! STARTUP -> PROMPT FOR NEW REQUIREMENTS -> REFINE REQUIREMENTS -> IMPLEMENT REQUIREMENTS -> IMPLEMENTATION COMPLETE +
|
||||
//! ^ v
|
||||
//! | |
|
||||
//! +---------------------------------------------------------------------------------------------------------+
|
||||
//! ```
|
||||
|
||||
use std::path::Path;
|
||||
use chrono::{DateTime, Local};
|
||||
|
||||
/// The state of the planning mode
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum PlannerState {
|
||||
/// Initial startup state
|
||||
Startup,
|
||||
/// Recovery needed - found incomplete previous run
|
||||
Recovery(RecoveryInfo),
|
||||
/// Prompting user for new requirements
|
||||
PromptForRequirements,
|
||||
/// Refining requirements with LLM
|
||||
RefineRequirements,
|
||||
/// Implementing requirements (coach/player loop)
|
||||
ImplementRequirements,
|
||||
/// Implementation completed successfully
|
||||
ImplementationComplete,
|
||||
/// User quit the application
|
||||
Quit,
|
||||
}
|
||||
|
||||
/// Information about a recovery situation
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RecoveryInfo {
|
||||
/// Whether current_requirements.md exists
|
||||
pub has_current_requirements: bool,
|
||||
/// Timestamp of current_requirements.md if it exists
|
||||
pub requirements_modified: Option<String>,
|
||||
/// Whether todo.g3.md exists
|
||||
pub has_todo: bool,
|
||||
/// Contents of todo.g3.md if it exists
|
||||
pub todo_contents: Option<String>,
|
||||
}
|
||||
|
||||
impl RecoveryInfo {
|
||||
/// Create recovery info by checking file existence
|
||||
pub fn detect(plan_dir: &Path) -> Option<Self> {
|
||||
let current_req_path = plan_dir.join("current_requirements.md");
|
||||
let todo_path = plan_dir.join("todo.g3.md");
|
||||
|
||||
let has_current_requirements = current_req_path.exists();
|
||||
let has_todo = todo_path.exists();
|
||||
|
||||
// If neither file exists, no recovery needed
|
||||
if !has_current_requirements && !has_todo {
|
||||
return None;
|
||||
}
|
||||
|
||||
let requirements_modified = if has_current_requirements {
|
||||
get_file_modified_time(¤t_req_path)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let todo_contents = if has_todo {
|
||||
std::fs::read_to_string(&todo_path).ok()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Some(RecoveryInfo {
|
||||
has_current_requirements,
|
||||
requirements_modified,
|
||||
has_todo,
|
||||
todo_contents,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the modified time of a file as a formatted string
|
||||
fn get_file_modified_time(path: &Path) -> Option<String> {
|
||||
let metadata = std::fs::metadata(path).ok()?;
|
||||
let modified = metadata.modified().ok()?;
|
||||
let datetime: DateTime<Local> = modified.into();
|
||||
Some(datetime.format("%Y-%m-%d %H:%M:%S").to_string())
|
||||
}
|
||||
|
||||
/// User's choice when presented with recovery options
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RecoveryChoice {
|
||||
/// Resume the previous implementation
|
||||
Resume,
|
||||
/// Mark as complete and proceed to new requirements
|
||||
MarkComplete,
|
||||
/// Quit and investigate manually
|
||||
Quit,
|
||||
}
|
||||
|
||||
impl RecoveryChoice {
|
||||
/// Parse user input into a recovery choice
|
||||
pub fn from_input(input: &str) -> Option<Self> {
|
||||
let input = input.trim().to_lowercase();
|
||||
match input.as_str() {
|
||||
"y" | "yes" => Some(RecoveryChoice::Resume),
|
||||
"n" | "no" => Some(RecoveryChoice::MarkComplete),
|
||||
"q" | "quit" => Some(RecoveryChoice::Quit),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// User's choice when asked to approve requirements
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ApprovalChoice {
|
||||
/// Approve and proceed to implementation
|
||||
Approve,
|
||||
/// Continue refining
|
||||
Refine,
|
||||
/// Quit the application
|
||||
Quit,
|
||||
}
|
||||
|
||||
impl ApprovalChoice {
|
||||
/// Parse user input into an approval choice
|
||||
pub fn from_input(input: &str) -> Option<Self> {
|
||||
let input = input.trim().to_lowercase();
|
||||
match input.as_str() {
|
||||
"y" | "yes" => Some(ApprovalChoice::Approve),
|
||||
"n" | "no" => Some(ApprovalChoice::Refine),
|
||||
"q" | "quit" => Some(ApprovalChoice::Quit),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// User's choice when asked if implementation is complete
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CompletionChoice {
|
||||
/// Yes, implementation is complete
|
||||
Complete,
|
||||
/// No, continue with coach/player loop
|
||||
Continue,
|
||||
/// Quit the application
|
||||
Quit,
|
||||
}
|
||||
|
||||
impl CompletionChoice {
|
||||
/// Parse user input into a completion choice
|
||||
pub fn from_input(input: &str) -> Option<Self> {
|
||||
let input = input.trim().to_lowercase();
|
||||
match input.as_str() {
|
||||
"y" | "yes" | "" => Some(CompletionChoice::Complete),
|
||||
"n" | "no" => Some(CompletionChoice::Continue),
|
||||
"q" | "quit" => Some(CompletionChoice::Quit),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// User's choice when asked to confirm git branch
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum BranchConfirmChoice {
|
||||
/// Yes, correct branch
|
||||
Confirm,
|
||||
/// No, wrong branch - quit
|
||||
Quit,
|
||||
}
|
||||
|
||||
impl BranchConfirmChoice {
|
||||
/// Parse user input into a branch confirmation choice
|
||||
pub fn from_input(input: &str) -> Option<Self> {
|
||||
let input = input.trim().to_lowercase();
|
||||
match input.as_str() {
|
||||
"y" | "yes" | "" => Some(BranchConfirmChoice::Confirm),
|
||||
"n" | "no" | "q" | "quit" => Some(BranchConfirmChoice::Quit),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// User's choice when warned about dirty files
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DirtyFilesChoice {
|
||||
/// Proceed anyway
|
||||
Proceed,
|
||||
/// Quit and handle manually
|
||||
Quit,
|
||||
}
|
||||
|
||||
impl DirtyFilesChoice {
|
||||
/// Parse user input into a dirty files choice
|
||||
pub fn from_input(input: &str) -> Option<Self> {
|
||||
let input = input.trim().to_lowercase();
|
||||
match input.as_str() {
|
||||
"y" | "yes" | "" => Some(DirtyFilesChoice::Proceed),
|
||||
"n" | "no" | "q" | "quit" => Some(DirtyFilesChoice::Quit),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn test_recovery_info_no_files() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let result = RecoveryInfo::detect(temp_dir.path());
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_recovery_info_with_current_requirements() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let req_path = temp_dir.path().join("current_requirements.md");
|
||||
std::fs::write(&req_path, "test requirements").unwrap();
|
||||
|
||||
let result = RecoveryInfo::detect(temp_dir.path());
|
||||
assert!(result.is_some());
|
||||
let info = result.unwrap();
|
||||
assert!(info.has_current_requirements);
|
||||
assert!(info.requirements_modified.is_some());
|
||||
assert!(!info.has_todo);
|
||||
assert!(info.todo_contents.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_recovery_info_with_todo() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let todo_path = temp_dir.path().join("todo.g3.md");
|
||||
std::fs::write(&todo_path, "- [ ] Test task").unwrap();
|
||||
|
||||
let result = RecoveryInfo::detect(temp_dir.path());
|
||||
assert!(result.is_some());
|
||||
let info = result.unwrap();
|
||||
assert!(!info.has_current_requirements);
|
||||
assert!(info.has_todo);
|
||||
assert_eq!(info.todo_contents, Some("- [ ] Test task".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_recovery_choice_parsing() {
|
||||
assert_eq!(RecoveryChoice::from_input("y"), Some(RecoveryChoice::Resume));
|
||||
assert_eq!(RecoveryChoice::from_input("YES"), Some(RecoveryChoice::Resume));
|
||||
assert_eq!(RecoveryChoice::from_input("n"), Some(RecoveryChoice::MarkComplete));
|
||||
assert_eq!(RecoveryChoice::from_input("No"), Some(RecoveryChoice::MarkComplete));
|
||||
assert_eq!(RecoveryChoice::from_input("q"), Some(RecoveryChoice::Quit));
|
||||
assert_eq!(RecoveryChoice::from_input("quit"), Some(RecoveryChoice::Quit));
|
||||
assert_eq!(RecoveryChoice::from_input("invalid"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_approval_choice_parsing() {
|
||||
assert_eq!(ApprovalChoice::from_input("yes"), Some(ApprovalChoice::Approve));
|
||||
assert_eq!(ApprovalChoice::from_input("no"), Some(ApprovalChoice::Refine));
|
||||
assert_eq!(ApprovalChoice::from_input("quit"), Some(ApprovalChoice::Quit));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_completion_choice_parsing() {
|
||||
assert_eq!(CompletionChoice::from_input("y"), Some(CompletionChoice::Complete));
|
||||
assert_eq!(CompletionChoice::from_input(""), Some(CompletionChoice::Complete)); // Default
|
||||
assert_eq!(CompletionChoice::from_input("n"), Some(CompletionChoice::Continue));
|
||||
assert_eq!(CompletionChoice::from_input("quit"), Some(CompletionChoice::Quit));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_branch_confirm_parsing() {
|
||||
assert_eq!(BranchConfirmChoice::from_input("y"), Some(BranchConfirmChoice::Confirm));
|
||||
assert_eq!(BranchConfirmChoice::from_input(""), Some(BranchConfirmChoice::Confirm)); // Default
|
||||
assert_eq!(BranchConfirmChoice::from_input("n"), Some(BranchConfirmChoice::Quit));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dirty_files_choice_parsing() {
|
||||
assert_eq!(DirtyFilesChoice::from_input("y"), Some(DirtyFilesChoice::Proceed));
|
||||
assert_eq!(DirtyFilesChoice::from_input(""), Some(DirtyFilesChoice::Proceed)); // Default
|
||||
assert_eq!(DirtyFilesChoice::from_input("n"), Some(DirtyFilesChoice::Quit));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user