use crate::cli;
use crate::commands::auto_compact_if_needed;
use crate::docs;
use crate::format::*;
use crate::prompt::*;
pub use crate::commands_refactor::{
handle_extract, handle_move, handle_refactor, handle_rename, rename_in_project,
};
use std::sync::RwLock;
use yoagent::agent::Agent;
use yoagent::*;
#[derive(Debug, Clone, PartialEq)]
pub enum TodoStatus {
Pending,
InProgress,
Done,
}
impl std::fmt::Display for TodoStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
TodoStatus::Pending => write!(f, "[ ]"),
TodoStatus::InProgress => write!(f, "[~]"),
TodoStatus::Done => write!(f, "[✓]"),
}
}
}
#[derive(Debug, Clone)]
pub struct TodoItem {
pub id: usize,
pub description: String,
pub status: TodoStatus,
}
static TODO_LIST: RwLock<Vec<TodoItem>> = RwLock::new(Vec::new());
static TODO_NEXT_ID: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(1);
pub fn todo_add(description: &str) -> usize {
let id = TODO_NEXT_ID.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
let item = TodoItem {
id,
description: description.to_string(),
status: TodoStatus::Pending,
};
TODO_LIST.write().unwrap().push(item);
id
}
pub fn todo_update(id: usize, status: TodoStatus) -> Result<(), String> {
let mut list = TODO_LIST.write().unwrap();
match list.iter_mut().find(|item| item.id == id) {
Some(item) => {
item.status = status;
Ok(())
}
None => Err(format!("No todo item with ID {id}")),
}
}
pub fn todo_list() -> Vec<TodoItem> {
TODO_LIST.read().unwrap().clone()
}
pub fn todo_clear() {
TODO_LIST.write().unwrap().clear();
TODO_NEXT_ID.store(1, std::sync::atomic::Ordering::SeqCst);
}
pub fn todo_remove(id: usize) -> Result<TodoItem, String> {
let mut list = TODO_LIST.write().unwrap();
let pos = list
.iter()
.position(|item| item.id == id)
.ok_or_else(|| format!("No todo item with ID {id}"))?;
Ok(list.remove(pos))
}
pub fn format_todo_list(items: &[TodoItem]) -> String {
if items.is_empty() {
return " No tasks. Use /todo add <description> to add one.".to_string();
}
let mut out = String::new();
for item in items {
out.push_str(&format!(
" {} #{} {}\n",
item.status, item.id, item.description
));
}
if out.ends_with('\n') {
out.truncate(out.len() - 1);
}
out
}
pub fn handle_todo(input: &str) -> String {
let arg = input.strip_prefix("/todo").unwrap_or("").trim();
if arg.is_empty() {
let items = todo_list();
return format_todo_list(&items);
}
if arg == "clear" {
todo_clear();
return format!("{GREEN} ✓ Cleared all tasks{RESET}");
}
if let Some(desc) = arg.strip_prefix("add ") {
let desc = desc.trim();
if desc.is_empty() {
return " Usage: /todo add <description>".to_string();
}
let id = todo_add(desc);
return format!("{GREEN} ✓ Added task #{id}: {desc}{RESET}");
}
if arg == "add" {
return " Usage: /todo add <description>".to_string();
}
if let Some(id_str) = arg.strip_prefix("done ") {
let id_str = id_str.trim();
match id_str.parse::<usize>() {
Ok(id) => match todo_update(id, TodoStatus::Done) {
Ok(()) => return format!("{GREEN} ✓ Marked #{id} as done{RESET}"),
Err(e) => return format!("{RED} {e}{RESET}"),
},
Err(_) => return format!("{RED} Invalid ID: {id_str}{RESET}"),
}
}
if let Some(id_str) = arg.strip_prefix("wip ") {
let id_str = id_str.trim();
match id_str.parse::<usize>() {
Ok(id) => match todo_update(id, TodoStatus::InProgress) {
Ok(()) => return format!("{GREEN} ✓ Marked #{id} as in-progress{RESET}"),
Err(e) => return format!("{RED} {e}{RESET}"),
},
Err(_) => return format!("{RED} Invalid ID: {id_str}{RESET}"),
}
}
if let Some(id_str) = arg.strip_prefix("remove ") {
let id_str = id_str.trim();
match id_str.parse::<usize>() {
Ok(id) => match todo_remove(id) {
Ok(item) => {
return format!("{GREEN} ✓ Removed #{id}: {}{RESET}", item.description)
}
Err(e) => return format!("{RED} {e}{RESET}"),
},
Err(_) => return format!("{RED} Invalid ID: {id_str}{RESET}"),
}
}
" Usage:\n\
\x20 /todo Show all tasks\n\
\x20 /todo add <description> Add a new task\n\
\x20 /todo done <id> Mark task as done\n\
\x20 /todo wip <id> Mark as in-progress\n\
\x20 /todo remove <id> Remove a task\n\
\x20 /todo clear Clear all tasks"
.to_string()
}
const CONTEXT_SUBCOMMANDS: &[&str] = &["system"];
pub fn context_subcommands() -> &'static [&'static str] {
CONTEXT_SUBCOMMANDS
}
pub fn handle_context(input: &str, system_prompt: &str) {
let args = input.strip_prefix("/context").unwrap_or("").trim();
if args.starts_with("system") {
show_system_prompt_sections(system_prompt);
} else {
show_project_context_files();
}
}
fn show_project_context_files() {
let files = cli::list_project_context_files();
if files.is_empty() {
println!("{DIM} No project context files found.");
println!(" Create a YOYO.md to give yoyo project context.");
println!(" Also supports: CLAUDE.md (compatibility alias), .yoyo/instructions.md");
println!(" Run /init to create a starter YOYO.md.{RESET}\n");
} else {
println!("{DIM} Project context files:");
for (name, lines) in &files {
let word = crate::format::pluralize(*lines, "line", "lines");
println!(" {name} ({lines} {word})");
}
println!("{RESET}");
}
}
#[derive(Debug, Clone)]
pub struct PromptSection {
pub name: String,
pub header_level: usize,
pub lines: Vec<String>,
}
pub fn parse_prompt_sections(prompt: &str) -> Vec<PromptSection> {
let mut sections: Vec<PromptSection> = Vec::new();
let mut current_name = "(preamble)".to_string();
let mut current_level = 0usize;
let mut current_lines: Vec<String> = Vec::new();
for line in prompt.lines() {
if let Some(rest) = line.strip_prefix("# ") {
if !current_lines.is_empty() || current_name != "(preamble)" {
sections.push(PromptSection {
name: current_name,
header_level: current_level,
lines: current_lines,
});
}
current_name = rest.trim().to_string();
current_level = 1;
current_lines = Vec::new();
} else if let Some(rest) = line.strip_prefix("## ") {
if !current_lines.is_empty() || current_name != "(preamble)" {
sections.push(PromptSection {
name: current_name,
header_level: current_level,
lines: current_lines,
});
}
current_name = rest.trim().to_string();
current_level = 2;
current_lines = Vec::new();
} else {
current_lines.push(line.to_string());
}
}
if !current_lines.is_empty() || current_name != "(preamble)" {
sections.push(PromptSection {
name: current_name,
header_level: current_level,
lines: current_lines,
});
}
sections
}
pub fn estimate_tokens(text: &str) -> usize {
text.len().div_ceil(4)
}
fn show_system_prompt_sections(prompt: &str) {
if prompt.is_empty() {
println!("{DIM} System prompt is empty.{RESET}\n");
return;
}
let sections = parse_prompt_sections(prompt);
let total_lines: usize = sections.iter().map(|s| s.lines.len() + 1).sum(); let total_tokens = estimate_tokens(prompt);
println!("{BOLD} System prompt sections:{RESET}");
println!();
for section in §ions {
let section_text = section.lines.join("\n");
let tokens = estimate_tokens(&format!("{}\n{}", section.name, section_text));
let line_count = section.lines.len();
let prefix = if section.header_level <= 1 { "#" } else { "##" };
let word = crate::format::pluralize(line_count, "line", "lines");
println!(
"{BOLD} {prefix} {}{RESET} {DIM}({line_count} {word}, ~{tokens} tokens){RESET}",
section.name
);
let preview_lines: Vec<&String> = section
.lines
.iter()
.filter(|l| !l.trim().is_empty())
.take(3)
.collect();
for line in &preview_lines {
let display = crate::format::truncate_with_ellipsis(line, 80);
println!("{DIM} {display}{RESET}");
}
if section
.lines
.iter()
.filter(|l| !l.trim().is_empty())
.count()
> 3
{
println!("{DIM} ...{RESET}");
}
println!();
}
println!("{DIM} Total: {total_lines} lines, ~{total_tokens} tokens (estimated){RESET}\n");
}
pub fn scan_important_files(dir: &std::path::Path) -> Vec<String> {
let candidates = [
"README.md",
"README",
"readme.md",
"LICENSE",
"LICENSE.md",
"CHANGELOG.md",
"CONTRIBUTING.md",
".gitignore",
".editorconfig",
"Cargo.toml",
"Cargo.lock",
"rust-toolchain.toml",
"package.json",
"package-lock.json",
"tsconfig.json",
".eslintrc.json",
".eslintrc.js",
".prettierrc",
"pyproject.toml",
"setup.py",
"setup.cfg",
"requirements.txt",
"Pipfile",
"tox.ini",
"go.mod",
"go.sum",
"Makefile",
"Dockerfile",
"docker-compose.yml",
"docker-compose.yaml",
".dockerignore",
".github/workflows",
".gitlab-ci.yml",
".circleci/config.yml",
".travis.yml",
"Jenkinsfile",
];
candidates
.iter()
.filter(|f| dir.join(f).exists())
.map(|f| f.to_string())
.collect()
}
pub fn scan_important_dirs(dir: &std::path::Path) -> Vec<String> {
let candidates = [
"src",
"lib",
"tests",
"test",
"docs",
"doc",
"examples",
"benches",
"scripts",
".github",
".vscode",
"config",
"public",
"static",
"assets",
"migrations",
];
candidates
.iter()
.filter(|d| dir.join(d).is_dir())
.map(|d| d.to_string())
.collect()
}
pub fn build_commands_for_project(project_type: &ProjectType) -> Vec<(&'static str, &'static str)> {
match project_type {
ProjectType::Rust => vec![
("Build", "cargo build"),
("Test", "cargo test"),
("Lint", "cargo clippy --all-targets -- -D warnings"),
("Format check", "cargo fmt -- --check"),
("Format", "cargo fmt"),
],
ProjectType::Node => vec![
("Install", "npm install"),
("Test", "npm test"),
("Lint", "npx eslint ."),
],
ProjectType::Python => vec![
("Test", "python -m pytest"),
("Lint", "ruff check ."),
("Type check", "python -m mypy ."),
],
ProjectType::Go => vec![
("Build", "go build ./..."),
("Test", "go test ./..."),
("Vet", "go vet ./..."),
],
ProjectType::Make => vec![("Build", "make"), ("Test", "make test")],
ProjectType::Unknown => vec![],
}
}
fn extract_project_name_from_readme(dir: &std::path::Path) -> Option<String> {
let readme_names = ["README.md", "readme.md", "README"];
for name in &readme_names {
if let Ok(content) = std::fs::read_to_string(dir.join(name)) {
for line in content.lines() {
let trimmed = line.trim();
if let Some(title) = trimmed.strip_prefix("# ") {
let title = title.trim();
if !title.is_empty() {
return Some(title.to_string());
}
}
}
}
}
None
}
fn extract_name_from_cargo_toml(dir: &std::path::Path) -> Option<String> {
let content = std::fs::read_to_string(dir.join("Cargo.toml")).ok()?;
for line in content.lines() {
let trimmed = line.trim();
if let Some(rest) = trimmed.strip_prefix("name") {
let rest = rest.trim();
if let Some(rest) = rest.strip_prefix('=') {
let val = rest.trim().trim_matches('"').trim_matches('\'');
if !val.is_empty() {
return Some(val.to_string());
}
}
}
}
None
}
fn extract_name_from_package_json(dir: &std::path::Path) -> Option<String> {
let content = std::fs::read_to_string(dir.join("package.json")).ok()?;
for line in content.lines() {
let trimmed = line.trim().trim_end_matches(',');
if let Some(rest) = trimmed.strip_prefix("\"name\"") {
let rest = rest.trim();
if let Some(rest) = rest.strip_prefix(':') {
let val = rest.trim().trim_matches('"');
if !val.is_empty() {
return Some(val.to_string());
}
}
}
}
None
}
pub fn detect_project_name(dir: &std::path::Path) -> String {
if let Some(name) = extract_name_from_cargo_toml(dir) {
return name;
}
if let Some(name) = extract_name_from_package_json(dir) {
return name;
}
if let Some(name) = extract_project_name_from_readme(dir) {
return name;
}
dir.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "my-project".to_string())
}
pub fn generate_init_content(dir: &std::path::Path) -> String {
let project_type = detect_project_type(dir);
let project_name = detect_project_name(dir);
let important_files = scan_important_files(dir);
let important_dirs = scan_important_dirs(dir);
let build_commands = build_commands_for_project(&project_type);
let mut content = String::new();
content.push_str("# Project Context\n\n");
content.push_str("<!-- YOYO.md — generated by `yoyo /init`. Edit to customize. -->\n");
content.push_str("<!-- Also works as CLAUDE.md for compatibility with other tools. -->\n\n");
content.push_str("## About This Project\n\n");
content.push_str(&format!("**{project_name}**"));
if project_type != ProjectType::Unknown {
content.push_str(&format!(" — {project_type} project"));
}
content.push_str("\n\n");
content.push_str("<!-- Add a description of what this project does. -->\n\n");
content.push_str("## Build & Test\n\n");
if build_commands.is_empty() {
content.push_str("<!-- Add build, test, and run commands for this project. -->\n\n");
} else {
content.push_str("```bash\n");
for (label, cmd) in &build_commands {
content.push_str(&format!("{cmd:<50} # {label}\n"));
}
content.push_str("```\n\n");
}
content.push_str("## Coding Conventions\n\n");
content.push_str(
"<!-- List any coding standards, naming conventions, or patterns to follow. -->\n\n",
);
content.push_str("## Important Files\n\n");
if important_files.is_empty() && important_dirs.is_empty() {
content.push_str("<!-- List key files and directories the agent should know about. -->\n");
} else {
if !important_dirs.is_empty() {
content.push_str("Key directories:\n");
for d in &important_dirs {
content.push_str(&format!("- `{d}/`\n"));
}
content.push('\n');
}
if !important_files.is_empty() {
content.push_str("Key files:\n");
for f in &important_files {
content.push_str(&format!("- `{f}`\n"));
}
content.push('\n');
}
}
content
}
pub fn handle_init() {
let path = "YOYO.md";
if std::path::Path::new(path).exists() {
println!("{DIM} {path} already exists — not overwriting.{RESET}\n");
} else if std::path::Path::new("CLAUDE.md").exists() {
println!("{DIM} CLAUDE.md already exists — yoyo reads it as a compatibility alias.");
println!(" Rename it to YOYO.md when you're ready: mv CLAUDE.md YOYO.md{RESET}\n");
} else {
let cwd = std::env::current_dir().unwrap_or_default();
let project_type = detect_project_type(&cwd);
println!("{DIM} Scanning project...{RESET}");
if project_type != ProjectType::Unknown {
println!("{DIM} Detected: {project_type}{RESET}");
}
let content = generate_init_content(&cwd);
match std::fs::write(path, &content) {
Ok(_) => {
let line_count = content.lines().count();
let word = crate::format::pluralize(line_count, "line", "lines");
println!("{GREEN} ✓ Created {path} ({line_count} {word}) — edit it to add project context.{RESET}");
println!("{DIM} Tip: Use /remember to save project-specific notes that persist across sessions.{RESET}\n");
}
Err(e) => eprintln!("{RED} error creating {path}: {e}{RESET}\n"),
}
}
}
pub fn handle_docs(input: &str) {
if input == "/docs" {
println!("{DIM} usage: /docs <crate> [item]");
println!(" Look up docs.rs documentation for a Rust crate.");
println!(" Examples: /docs serde, /docs tokio task{RESET}\n");
return;
}
let args = input.trim_start_matches("/docs ").trim();
if args.is_empty() {
println!("{DIM} usage: /docs <crate> [item]{RESET}\n");
return;
}
let parts: Vec<&str> = args.splitn(2, char::is_whitespace).collect();
let crate_name = parts[0].trim();
let item_name = parts.get(1).map(|s| s.trim()).unwrap_or("");
let (found, summary) = if item_name.is_empty() {
docs::fetch_docs_summary(crate_name)
} else {
docs::fetch_docs_item(crate_name, item_name)
};
if found {
let label = if item_name.is_empty() {
crate_name.to_string()
} else {
format!("{crate_name}::{item_name}")
};
println!("{GREEN} ✓ {label}{RESET}");
println!("{DIM}{summary}{RESET}\n");
} else {
println!("{RED} ✗ {summary}{RESET}\n");
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum ProjectType {
Rust,
Node,
Python,
Go,
Make,
Unknown,
}
impl std::fmt::Display for ProjectType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ProjectType::Rust => write!(f, "Rust (Cargo)"),
ProjectType::Node => write!(f, "Node.js (npm)"),
ProjectType::Python => write!(f, "Python"),
ProjectType::Go => write!(f, "Go"),
ProjectType::Make => write!(f, "Makefile"),
ProjectType::Unknown => write!(f, "Unknown"),
}
}
}
pub fn detect_project_type(dir: &std::path::Path) -> ProjectType {
if dir.join("Cargo.toml").exists() {
ProjectType::Rust
} else if dir.join("package.json").exists() {
ProjectType::Node
} else if dir.join("pyproject.toml").exists()
|| dir.join("setup.py").exists()
|| dir.join("setup.cfg").exists()
{
ProjectType::Python
} else if dir.join("go.mod").exists() {
ProjectType::Go
} else if dir.join("Makefile").exists() || dir.join("makefile").exists() {
ProjectType::Make
} else {
ProjectType::Unknown
}
}
pub fn parse_plan_task(input: &str) -> Option<String> {
let task = input.strip_prefix("/plan").unwrap_or("").trim().to_string();
if task.is_empty() {
None
} else {
Some(task)
}
}
pub fn build_plan_prompt(task: &str) -> String {
format!(
r#"Create a detailed step-by-step plan for the following task. Do NOT execute any tools — this is planning only.
## Task
{task}
## Instructions
Analyze the task and produce a structured plan covering:
1. **Files to examine** — which existing files need to be read to understand the current state
2. **Files to modify** — which files will be created or changed, and what changes
3. **Step-by-step approach** — ordered list of concrete implementation steps
4. **Tests to write** — what tests should be added or updated
5. **Potential risks** — what could go wrong, edge cases, backwards compatibility concerns
6. **Verification** — how to confirm the changes work correctly
Be specific: mention file paths, function names, and concrete code changes where possible.
Keep the plan actionable — someone (or you, in the next step) should be able to execute it directly."#
)
}
pub async fn handle_plan(
input: &str,
agent: &mut Agent,
session_total: &mut Usage,
model: &str,
) -> Option<String> {
let task = match parse_plan_task(input) {
Some(t) => t,
None => {
println!("{DIM} usage: /plan <task description>{RESET}");
println!("{DIM} Creates a step-by-step plan without executing any tools.{RESET}");
println!("{DIM} Review the plan, then say \"go ahead\" to execute it.{RESET}\n");
return None;
}
};
println!("{DIM} 📋 Planning: {task}{RESET}\n");
let plan_prompt = build_plan_prompt(&task);
run_prompt(agent, &plan_prompt, session_total, model).await;
auto_compact_if_needed(agent);
println!(
"\n{DIM} 💡 Review the plan above. Say \"go ahead\" to execute it, or refine it.{RESET}\n"
);
Some(plan_prompt)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::commands::KNOWN_COMMANDS;
use crate::help::help_text;
use serial_test::serial;
use std::fs;
use tempfile::TempDir;
#[test]
fn detect_project_type_rust() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"x\"").unwrap();
assert_eq!(detect_project_type(dir.path()), ProjectType::Rust);
}
#[test]
fn detect_project_type_node() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("package.json"), "{}").unwrap();
assert_eq!(detect_project_type(dir.path()), ProjectType::Node);
}
#[test]
fn detect_project_type_python_pyproject() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("pyproject.toml"), "[tool]").unwrap();
assert_eq!(detect_project_type(dir.path()), ProjectType::Python);
}
#[test]
fn detect_project_type_python_setup_py() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("setup.py"), "").unwrap();
assert_eq!(detect_project_type(dir.path()), ProjectType::Python);
}
#[test]
fn detect_project_type_python_setup_cfg() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("setup.cfg"), "").unwrap();
assert_eq!(detect_project_type(dir.path()), ProjectType::Python);
}
#[test]
fn detect_project_type_go() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("go.mod"), "module example").unwrap();
assert_eq!(detect_project_type(dir.path()), ProjectType::Go);
}
#[test]
fn detect_project_type_make() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("Makefile"), "all:").unwrap();
assert_eq!(detect_project_type(dir.path()), ProjectType::Make);
}
#[test]
fn detect_project_type_make_lowercase() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("makefile"), "all:").unwrap();
assert_eq!(detect_project_type(dir.path()), ProjectType::Make);
}
#[test]
fn detect_project_type_unknown_empty_dir() {
let dir = TempDir::new().unwrap();
assert_eq!(detect_project_type(dir.path()), ProjectType::Unknown);
}
#[test]
fn detect_project_type_priority_rust_over_make() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("Cargo.toml"), "[package]").unwrap();
fs::write(dir.path().join("Makefile"), "all:").unwrap();
assert_eq!(detect_project_type(dir.path()), ProjectType::Rust);
}
#[test]
fn project_type_display() {
assert_eq!(format!("{}", ProjectType::Rust), "Rust (Cargo)");
assert_eq!(format!("{}", ProjectType::Node), "Node.js (npm)");
assert_eq!(format!("{}", ProjectType::Python), "Python");
assert_eq!(format!("{}", ProjectType::Go), "Go");
assert_eq!(format!("{}", ProjectType::Make), "Makefile");
assert_eq!(format!("{}", ProjectType::Unknown), "Unknown");
}
#[test]
fn scan_important_files_finds_known_files() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("README.md"), "# Hello").unwrap();
fs::write(dir.path().join("Cargo.toml"), "[package]").unwrap();
fs::write(dir.path().join(".gitignore"), "target/").unwrap();
let found = scan_important_files(dir.path());
assert!(found.contains(&"README.md".to_string()));
assert!(found.contains(&"Cargo.toml".to_string()));
assert!(found.contains(&".gitignore".to_string()));
}
#[test]
fn scan_important_files_empty_dir() {
let dir = TempDir::new().unwrap();
let found = scan_important_files(dir.path());
assert!(found.is_empty());
}
#[test]
fn scan_important_files_ignores_unknown() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("random.txt"), "stuff").unwrap();
let found = scan_important_files(dir.path());
assert!(found.is_empty());
}
#[test]
fn scan_important_dirs_finds_known_dirs() {
let dir = TempDir::new().unwrap();
fs::create_dir(dir.path().join("src")).unwrap();
fs::create_dir(dir.path().join("tests")).unwrap();
fs::create_dir(dir.path().join("docs")).unwrap();
let found = scan_important_dirs(dir.path());
assert!(found.contains(&"src".to_string()));
assert!(found.contains(&"tests".to_string()));
assert!(found.contains(&"docs".to_string()));
}
#[test]
fn scan_important_dirs_empty_dir() {
let dir = TempDir::new().unwrap();
let found = scan_important_dirs(dir.path());
assert!(found.is_empty());
}
#[test]
fn scan_important_dirs_ignores_files() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("src"), "not a dir").unwrap();
let found = scan_important_dirs(dir.path());
assert!(!found.contains(&"src".to_string()));
}
#[test]
fn detect_project_name_from_cargo_toml() {
let dir = TempDir::new().unwrap();
fs::write(
dir.path().join("Cargo.toml"),
"[package]\nname = \"my-crate\"",
)
.unwrap();
assert_eq!(detect_project_name(dir.path()), "my-crate");
}
#[test]
fn detect_project_name_from_package_json() {
let dir = TempDir::new().unwrap();
fs::write(
dir.path().join("package.json"),
"{\n \"name\": \"my-app\",\n \"version\": \"1.0.0\"\n}",
)
.unwrap();
assert_eq!(detect_project_name(dir.path()), "my-app");
}
#[test]
fn detect_project_name_from_readme() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("README.md"), "# Cool Project\n\nSome text").unwrap();
assert_eq!(detect_project_name(dir.path()), "Cool Project");
}
#[test]
fn detect_project_name_cargo_over_readme() {
let dir = TempDir::new().unwrap();
fs::write(
dir.path().join("Cargo.toml"),
"[package]\nname = \"cargo-name\"",
)
.unwrap();
fs::write(dir.path().join("README.md"), "# README Title").unwrap();
assert_eq!(detect_project_name(dir.path()), "cargo-name");
}
#[test]
fn detect_project_name_fallback_to_dir_name() {
let dir = TempDir::new().unwrap();
let name = detect_project_name(dir.path());
assert!(!name.is_empty());
}
#[test]
fn extract_readme_skips_blank_lines() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("README.md"), "\n\n \n# Title After Blanks").unwrap();
assert_eq!(detect_project_name(dir.path()), "Title After Blanks");
}
#[test]
fn extract_readme_empty_title_skipped() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("README.md"), "# \n# Real Title").unwrap();
assert_eq!(detect_project_name(dir.path()), "Real Title");
}
#[test]
fn cargo_toml_name_with_single_quotes() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("Cargo.toml"), "[package]\nname = 'quoted'").unwrap();
assert_eq!(detect_project_name(dir.path()), "quoted");
}
#[test]
fn cargo_toml_name_with_spaces_around_equals() {
let dir = TempDir::new().unwrap();
fs::write(
dir.path().join("Cargo.toml"),
"[package]\nname = \"spaced\"",
)
.unwrap();
assert_eq!(detect_project_name(dir.path()), "spaced");
}
#[test]
fn build_commands_rust() {
let cmds = build_commands_for_project(&ProjectType::Rust);
assert!(!cmds.is_empty());
assert!(cmds.iter().any(|(label, _)| *label == "Build"));
assert!(cmds.iter().any(|(label, _)| *label == "Test"));
}
#[test]
fn build_commands_unknown_empty() {
let cmds = build_commands_for_project(&ProjectType::Unknown);
assert!(cmds.is_empty());
}
#[test]
fn build_commands_node() {
let cmds = build_commands_for_project(&ProjectType::Node);
assert!(cmds.iter().any(|(_, cmd)| *cmd == "npm install"));
}
#[test]
fn build_commands_python() {
let cmds = build_commands_for_project(&ProjectType::Python);
assert!(cmds.iter().any(|(_, cmd)| *cmd == "python -m pytest"));
}
#[test]
fn build_commands_go() {
let cmds = build_commands_for_project(&ProjectType::Go);
assert!(cmds.iter().any(|(_, cmd)| *cmd == "go build ./..."));
}
#[test]
fn generate_init_content_rust_project() {
let dir = TempDir::new().unwrap();
fs::write(
dir.path().join("Cargo.toml"),
"[package]\nname = \"test-proj\"",
)
.unwrap();
fs::create_dir(dir.path().join("src")).unwrap();
fs::write(dir.path().join("src/main.rs"), "fn main() {}").unwrap();
let content = generate_init_content(dir.path());
assert!(content.contains("# Project Context"));
assert!(content.contains("test-proj"));
assert!(content.contains("Rust (Cargo)"));
assert!(content.contains("cargo build"));
assert!(content.contains("cargo test"));
}
#[test]
fn generate_init_content_unknown_project() {
let dir = TempDir::new().unwrap();
let content = generate_init_content(dir.path());
assert!(content.contains("# Project Context"));
assert!(!content.contains("Rust"));
assert!(!content.contains("Node"));
assert!(content.contains("Add build, test, and run commands"));
}
#[test]
fn generate_init_content_includes_dirs_and_files() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("README.md"), "# My Project").unwrap();
fs::create_dir(dir.path().join("src")).unwrap();
let content = generate_init_content(dir.path());
assert!(content.contains("`src/`"));
assert!(content.contains("`README.md`"));
}
#[test]
fn parse_plan_task_with_description() {
let result = parse_plan_task("/plan add error handling to the parser");
assert_eq!(result, Some("add error handling to the parser".to_string()));
}
#[test]
fn parse_plan_task_empty() {
let result = parse_plan_task("/plan");
assert!(result.is_none(), "Empty /plan should return None");
}
#[test]
fn parse_plan_task_whitespace_only() {
let result = parse_plan_task("/plan ");
assert!(result.is_none(), "Whitespace-only /plan should return None");
}
#[test]
fn parse_plan_task_preserves_full_description() {
let result = parse_plan_task("/plan refactor main.rs into smaller modules with tests");
assert_eq!(
result,
Some("refactor main.rs into smaller modules with tests".to_string())
);
}
#[test]
fn build_plan_prompt_contains_task() {
let prompt = build_plan_prompt("add a /plan command");
assert!(
prompt.contains("add a /plan command"),
"Plan prompt should contain the task"
);
}
#[test]
fn build_plan_prompt_contains_no_tools_instruction() {
let prompt = build_plan_prompt("something");
assert!(
prompt.contains("Do NOT execute any tools"),
"Plan prompt should instruct not to use tools"
);
}
#[test]
fn build_plan_prompt_contains_structure_sections() {
let prompt = build_plan_prompt("add feature X");
assert!(
prompt.contains("Files to examine"),
"Should mention files to examine"
);
assert!(
prompt.contains("Files to modify"),
"Should mention files to modify"
);
assert!(
prompt.contains("Step-by-step"),
"Should mention step-by-step approach"
);
assert!(prompt.contains("Tests to write"), "Should mention tests");
assert!(prompt.contains("Potential risks"), "Should mention risks");
assert!(
prompt.contains("Verification"),
"Should mention verification"
);
}
#[test]
#[serial]
fn test_todo_add_returns_incrementing_ids() {
todo_clear();
let id1 = todo_add("first task");
let id2 = todo_add("second task");
assert!(id2 > id1, "IDs should increment: {id1} < {id2}");
let items = todo_list();
assert_eq!(items.len(), 2);
assert_eq!(items[0].description, "first task");
assert_eq!(items[1].description, "second task");
}
#[test]
#[serial]
fn test_todo_update_status() {
todo_clear();
let id = todo_add("update me");
assert_eq!(todo_list()[0].status, TodoStatus::Pending);
todo_update(id, TodoStatus::InProgress).unwrap();
assert_eq!(todo_list()[0].status, TodoStatus::InProgress);
todo_update(id, TodoStatus::Done).unwrap();
assert_eq!(todo_list()[0].status, TodoStatus::Done);
}
#[test]
#[serial]
fn test_todo_update_invalid_id() {
todo_clear();
let result = todo_update(99999, TodoStatus::Done);
assert!(result.is_err());
assert!(result.unwrap_err().contains("99999"));
}
#[test]
#[serial]
fn test_todo_remove() {
todo_clear();
let id = todo_add("remove me");
assert_eq!(todo_list().len(), 1);
let removed = todo_remove(id).unwrap();
assert_eq!(removed.description, "remove me");
assert!(todo_list().is_empty());
}
#[test]
#[serial]
fn test_todo_remove_invalid_id() {
todo_clear();
let result = todo_remove(99998);
assert!(result.is_err());
assert!(result.unwrap_err().contains("99998"));
}
#[test]
#[serial]
fn test_todo_clear() {
todo_clear();
todo_add("one");
todo_add("two");
assert_eq!(todo_list().len(), 2);
todo_clear();
assert!(todo_list().is_empty());
}
#[test]
#[serial]
fn test_todo_list_empty() {
todo_clear();
assert!(todo_list().is_empty());
}
#[test]
#[serial]
fn test_format_todo_list() {
todo_clear();
let id1 = todo_add("pending task");
let id2 = todo_add("wip task");
let id3 = todo_add("done task");
todo_update(id2, TodoStatus::InProgress).unwrap();
todo_update(id3, TodoStatus::Done).unwrap();
let items = todo_list();
let formatted = format_todo_list(&items);
assert!(formatted.contains("[ ]"), "Should contain pending checkbox");
assert!(
formatted.contains("[~]"),
"Should contain in-progress checkbox"
);
assert!(formatted.contains("[✓]"), "Should contain done checkbox");
assert!(formatted.contains(&format!("#{id1}")));
assert!(formatted.contains("pending task"));
assert!(formatted.contains("wip task"));
assert!(formatted.contains("done task"));
}
#[test]
fn test_format_todo_list_empty() {
let formatted = format_todo_list(&[]);
assert!(formatted.contains("No tasks"));
}
#[test]
#[serial]
fn test_handle_todo_add() {
todo_clear();
let result = handle_todo("/todo add write tests");
assert!(result.contains("Added task"));
assert!(result.contains("write tests"));
assert_eq!(todo_list().len(), 1);
}
#[test]
#[serial]
fn test_handle_todo_show_empty() {
todo_clear();
let result = handle_todo("/todo");
assert!(result.contains("No tasks"));
}
#[test]
#[serial]
fn test_handle_todo_done() {
todo_clear();
let id = todo_add("finish me");
let result = handle_todo(&format!("/todo done {id}"));
assert!(result.contains("done"));
assert_eq!(todo_list()[0].status, TodoStatus::Done);
}
#[test]
#[serial]
fn test_handle_todo_wip() {
todo_clear();
let id = todo_add("start me");
let result = handle_todo(&format!("/todo wip {id}"));
assert!(result.contains("in-progress"));
assert_eq!(todo_list()[0].status, TodoStatus::InProgress);
}
#[test]
#[serial]
fn test_handle_todo_remove_via_command() {
todo_clear();
let id = todo_add("delete me");
let result = handle_todo(&format!("/todo remove {id}"));
assert!(result.contains("Removed"));
assert!(todo_list().is_empty());
}
#[test]
#[serial]
fn test_handle_todo_clear_via_command() {
todo_clear();
todo_add("one");
todo_add("two");
let result = handle_todo("/todo clear");
assert!(result.contains("Cleared"));
assert!(todo_list().is_empty());
}
#[test]
fn test_handle_todo_unknown_subcommand() {
let result = handle_todo("/todo badcmd");
assert!(result.contains("Usage"));
}
#[test]
#[serial]
fn test_handle_todo_add_empty_description() {
let result = handle_todo("/todo add");
assert!(result.contains("Usage"));
let result2 = handle_todo("/todo add ");
assert!(result2.contains("Usage"));
}
#[test]
fn test_todo_in_known_commands() {
assert!(
KNOWN_COMMANDS.contains(&"/todo"),
"/todo should be in KNOWN_COMMANDS"
);
}
#[test]
fn test_todo_help_exists() {
let help = crate::help::command_help("todo");
assert!(help.is_some(), "todo should have help text");
let text = help.unwrap();
assert!(text.contains("/todo add"));
assert!(text.contains("/todo done"));
assert!(text.contains("/todo clear"));
}
#[test]
fn test_todo_in_help_text() {
let text = help_text();
assert!(text.contains("/todo"), "/todo should appear in help text");
}
#[test]
fn test_context_system_sections() {
let prompt = "# System Instructions\nYou are helpful.\nBe concise.\n\n\
## Tools\nYou have bash.\nYou have read_file.\nYou have write_file.\n\n\
# Project Context\nThis is a Rust project.\n";
let sections = parse_prompt_sections(prompt);
assert_eq!(sections.len(), 3);
assert_eq!(sections[0].name, "System Instructions");
assert_eq!(sections[0].header_level, 1);
assert!(sections[0].lines.iter().any(|l| l.contains("helpful")));
assert_eq!(sections[1].name, "Tools");
assert_eq!(sections[1].header_level, 2);
assert!(sections[1].lines.iter().any(|l| l.contains("bash")));
assert_eq!(sections[2].name, "Project Context");
assert_eq!(sections[2].header_level, 1);
assert!(sections[2].lines.iter().any(|l| l.contains("Rust")));
}
#[test]
fn test_context_system_empty_prompt() {
let sections = parse_prompt_sections("");
assert!(sections.is_empty());
}
#[test]
fn test_context_system_no_headers() {
let prompt = "Just some plain text\nwith multiple lines.\n";
let sections = parse_prompt_sections(prompt);
assert_eq!(sections.len(), 1);
assert_eq!(sections[0].name, "(preamble)");
assert_eq!(sections[0].header_level, 0);
assert_eq!(sections[0].lines.len(), 2);
}
#[test]
fn test_context_system_preamble_before_header() {
let prompt = "Some preamble text.\n# First Section\nContent here.\n";
let sections = parse_prompt_sections(prompt);
assert_eq!(sections.len(), 2);
assert_eq!(sections[0].name, "(preamble)");
assert_eq!(sections[1].name, "First Section");
}
#[test]
fn test_context_system_consecutive_headers() {
let prompt = "# One\n# Two\nContent for two.\n";
let sections = parse_prompt_sections(prompt);
assert_eq!(sections.len(), 2);
assert_eq!(sections[0].name, "One");
assert!(sections[0].lines.is_empty());
assert_eq!(sections[1].name, "Two");
assert!(!sections[1].lines.is_empty());
}
#[test]
fn test_estimate_tokens() {
assert_eq!(estimate_tokens(""), 0);
assert_eq!(estimate_tokens("abcd"), 1);
assert_eq!(estimate_tokens("abcdefgh"), 2);
let text = "a".repeat(400);
assert_eq!(estimate_tokens(&text), 100);
}
#[test]
fn test_context_default_behavior() {
handle_context("/context", "");
}
#[test]
fn test_context_system_subcommand() {
handle_context("/context system", "# Test\nHello world.\n");
}
#[test]
fn test_context_subcommands_list() {
let subs = context_subcommands();
assert!(subs.contains(&"system"));
}
}