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)
}
pub const SKILL_SUBCOMMANDS: &[&str] = &["list", "show", "path"];
pub fn handle_skill(input: &str, skills: &yoagent::skills::SkillSet) {
let sub = input.strip_prefix("/skill").unwrap_or(input).trim();
if sub.is_empty() || sub == "list" {
skill_list(skills);
} else if sub == "path" {
skill_path(skills);
} else if let Some(name) = sub.strip_prefix("show ") {
skill_show(name.trim(), skills);
} else if sub == "show" {
eprintln!("{YELLOW} usage: /skill show <name>{RESET}");
eprintln!("{DIM} try /skill list to see available skills{RESET}\n");
} else {
eprintln!("{RED} unknown subcommand: {sub}{RESET}");
eprintln!("{DIM} try: /skill list, /skill show <name>, /skill path{RESET}\n");
}
}
fn skill_list(skills: &yoagent::skills::SkillSet) {
if skills.is_empty() {
println!("{DIM} no skills loaded{RESET}");
println!("{DIM} use --skills <dir> to load skills from a directory{RESET}\n");
return;
}
println!("{BOLD} Loaded skills ({}):{RESET}\n", skills.len());
let max_name_len = skills
.skills()
.iter()
.map(|s| s.name.len())
.max()
.unwrap_or(0);
for skill in skills.skills() {
let padding = " ".repeat(max_name_len.saturating_sub(skill.name.len()));
println!(
" {GREEN}{}{RESET}{} {DIM}{}{RESET}",
skill.name, padding, skill.description
);
}
println!();
}
fn skill_path(skills: &yoagent::skills::SkillSet) {
if skills.is_empty() {
println!("{DIM} no skills directory configured{RESET}");
println!("{DIM} use --skills <dir> to load skills from a directory{RESET}\n");
return;
}
let mut dirs: Vec<String> = skills
.skills()
.iter()
.filter_map(|s| s.base_dir.parent().map(|p| p.display().to_string()))
.collect();
dirs.sort();
dirs.dedup();
if dirs.len() == 1 {
println!("{DIM} skills directory: {}{RESET}\n", dirs[0]);
} else {
println!("{DIM} skills directories:{RESET}");
for d in &dirs {
println!("{DIM} {d}{RESET}");
}
println!();
}
}
fn skill_show(name: &str, skills: &yoagent::skills::SkillSet) {
let skill = skills.skills().iter().find(|s| s.name == name);
match skill {
Some(s) => {
match std::fs::read_to_string(&s.file_path) {
Ok(content) => {
println!("{BOLD} Skill: {}{RESET}", s.name);
println!("{DIM} path: {}{RESET}\n", s.file_path.display());
for line in content.lines() {
println!(" {line}");
}
println!();
}
Err(e) => {
eprintln!(
"{RED} error reading {}: {e}{RESET}\n",
s.file_path.display()
);
}
}
}
None => {
eprintln!("{RED} skill not found: {name}{RESET}");
if !skills.is_empty() {
let names: Vec<&str> = skills.skills().iter().map(|s| s.name.as_str()).collect();
eprintln!("{DIM} available: {}{RESET}\n", names.join(", "));
} else {
eprintln!("{DIM} no skills loaded — use --skills <dir>{RESET}\n");
}
}
}
}
#[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"));
}
#[test]
fn test_detect_project_type_rust() {
let cwd = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
assert_eq!(detect_project_type(&cwd), ProjectType::Rust);
}
#[test]
fn test_detect_project_type_node() {
let tmp = std::env::temp_dir().join("yoyo_test_node");
let _ = std::fs::create_dir_all(&tmp);
std::fs::write(tmp.join("package.json"), "{}").unwrap();
assert_eq!(detect_project_type(&tmp), ProjectType::Node);
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn test_detect_project_type_python_pyproject() {
let tmp = std::env::temp_dir().join("yoyo_test_python_pyproject");
let _ = std::fs::create_dir_all(&tmp);
std::fs::write(tmp.join("pyproject.toml"), "[project]").unwrap();
assert_eq!(detect_project_type(&tmp), ProjectType::Python);
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn test_detect_project_type_python_setup_py() {
let tmp = std::env::temp_dir().join("yoyo_test_python_setup");
let _ = std::fs::create_dir_all(&tmp);
std::fs::write(tmp.join("setup.py"), "").unwrap();
assert_eq!(detect_project_type(&tmp), ProjectType::Python);
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn test_detect_project_type_go() {
let tmp = std::env::temp_dir().join("yoyo_test_go");
let _ = std::fs::create_dir_all(&tmp);
std::fs::write(tmp.join("go.mod"), "module example.com/test").unwrap();
assert_eq!(detect_project_type(&tmp), ProjectType::Go);
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn test_detect_project_type_makefile() {
let tmp = std::env::temp_dir().join("yoyo_test_make");
let _ = std::fs::create_dir_all(&tmp);
std::fs::write(tmp.join("Makefile"), "test:\n\techo ok").unwrap();
assert_eq!(detect_project_type(&tmp), ProjectType::Make);
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn test_detect_project_type_unknown() {
let tmp = std::env::temp_dir().join("yoyo_test_unknown");
let _ = std::fs::create_dir_all(&tmp);
assert_eq!(detect_project_type(&tmp), ProjectType::Unknown);
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn test_detect_project_type_priority_rust_over_makefile() {
let tmp = std::env::temp_dir().join("yoyo_test_priority");
let _ = std::fs::create_dir_all(&tmp);
std::fs::write(tmp.join("Cargo.toml"), "[package]").unwrap();
std::fs::write(tmp.join("Makefile"), "test:").unwrap();
assert_eq!(detect_project_type(&tmp), ProjectType::Rust);
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn test_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 test_scan_important_files_in_current_project() {
let cwd = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let files = scan_important_files(&cwd);
assert!(
files.contains(&"Cargo.toml".to_string()),
"Should find Cargo.toml: {files:?}"
);
}
#[test]
fn test_scan_important_files_empty_dir() {
let tmp = std::env::temp_dir().join("yoyo_test_init_empty");
let _ = std::fs::create_dir_all(&tmp);
let files = scan_important_files(&tmp);
assert!(files.is_empty(), "Empty dir should have no important files");
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn test_scan_important_files_with_readme() {
let tmp = std::env::temp_dir().join("yoyo_test_init_readme");
let _ = std::fs::create_dir_all(&tmp);
std::fs::write(tmp.join("README.md"), "# Hello").unwrap();
std::fs::write(tmp.join("package.json"), "{}").unwrap();
let files = scan_important_files(&tmp);
assert!(
files.contains(&"README.md".to_string()),
"Should find README.md"
);
assert!(
files.contains(&"package.json".to_string()),
"Should find package.json"
);
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn test_scan_important_dirs_in_current_project() {
let cwd = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let dirs = scan_important_dirs(&cwd);
assert!(
dirs.contains(&"src".to_string()),
"Should find src/ dir: {dirs:?}"
);
}
#[test]
fn test_scan_important_dirs_empty_dir() {
let tmp = std::env::temp_dir().join("yoyo_test_init_dirs_empty");
let _ = std::fs::create_dir_all(&tmp);
let dirs = scan_important_dirs(&tmp);
assert!(dirs.is_empty(), "Empty dir should have no important dirs");
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn test_scan_important_dirs_with_subdirs() {
let tmp = std::env::temp_dir().join("yoyo_test_init_subdirs");
let _ = std::fs::create_dir_all(tmp.join("src"));
let _ = std::fs::create_dir_all(tmp.join("tests"));
let _ = std::fs::create_dir_all(tmp.join("docs"));
let dirs = scan_important_dirs(&tmp);
assert!(dirs.contains(&"src".to_string()), "Should find src/");
assert!(dirs.contains(&"tests".to_string()), "Should find tests/");
assert!(dirs.contains(&"docs".to_string()), "Should find docs/");
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn test_build_commands_for_rust() {
let cmds = build_commands_for_project(&ProjectType::Rust);
assert!(!cmds.is_empty(), "Rust should have build commands");
let labels: Vec<&str> = cmds.iter().map(|(l, _)| *l).collect();
assert!(labels.contains(&"Build"), "Should have Build command");
assert!(labels.contains(&"Test"), "Should have Test command");
assert!(labels.contains(&"Lint"), "Should have Lint command");
}
#[test]
fn test_build_commands_for_node() {
let cmds = build_commands_for_project(&ProjectType::Node);
assert!(!cmds.is_empty(), "Node should have build commands");
let labels: Vec<&str> = cmds.iter().map(|(l, _)| *l).collect();
assert!(labels.contains(&"Test"), "Should have Test command");
}
#[test]
fn test_build_commands_for_unknown() {
let cmds = build_commands_for_project(&ProjectType::Unknown);
assert!(
cmds.is_empty(),
"Unknown project should have no build commands"
);
}
#[test]
fn test_detect_project_name_rust() {
let cwd = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let name = detect_project_name(&cwd);
assert_eq!(
name, "yoyo-agent",
"Should detect project name 'yoyo-agent' from Cargo.toml"
);
}
#[test]
fn test_detect_project_name_fallback_to_dir() {
let tmp = std::env::temp_dir().join("yoyo_test_name_fallback");
let _ = std::fs::create_dir_all(&tmp);
let name = detect_project_name(&tmp);
assert_eq!(
name, "yoyo_test_name_fallback",
"Should fall back to directory name"
);
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn test_detect_project_name_from_readme() {
let tmp = std::env::temp_dir().join("yoyo_test_name_readme");
let _ = std::fs::create_dir_all(&tmp);
std::fs::write(tmp.join("README.md"), "# My Awesome Project\n\nSome text.").unwrap();
let name = detect_project_name(&tmp);
assert_eq!(
name, "My Awesome Project",
"Should extract name from README title"
);
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn test_detect_project_name_from_package_json() {
let tmp = std::env::temp_dir().join("yoyo_test_name_pkg");
let _ = std::fs::create_dir_all(&tmp);
std::fs::write(
tmp.join("package.json"),
"{\n \"name\": \"cool-app\",\n \"version\": \"1.0.0\"\n}",
)
.unwrap();
let name = detect_project_name(&tmp);
assert_eq!(name, "cool-app", "Should extract name from package.json");
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn test_generate_init_content_rust_project() {
let cwd = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let content = generate_init_content(&cwd);
assert!(
content.contains("yoyo"),
"Should contain project name: {}",
&content[..200.min(content.len())]
);
assert!(content.contains("Rust"), "Should mention Rust project type");
assert!(
content.contains("cargo build"),
"Should include cargo build command"
);
assert!(
content.contains("cargo test"),
"Should include cargo test command"
);
assert!(
content.contains("## Build & Test"),
"Should have Build & Test section"
);
assert!(
content.contains("## Important Files"),
"Should have Important Files section"
);
assert!(
content.contains("## Coding Conventions"),
"Should have Coding Conventions section"
);
assert!(
content.contains("Cargo.toml"),
"Should list Cargo.toml as important"
);
assert!(
content.contains("`src/`"),
"Should list src/ as important dir"
);
}
#[test]
fn test_generate_init_content_empty_dir() {
let tmp = std::env::temp_dir().join("yoyo_test_init_gen_empty");
let _ = std::fs::create_dir_all(&tmp);
let content = generate_init_content(&tmp);
assert!(content.contains("# Project Context"));
assert!(content.contains("## About This Project"));
assert!(content.contains("## Build & Test"));
assert!(content.contains("## Coding Conventions"));
assert!(content.contains("## Important Files"));
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn test_generate_init_content_node_project() {
let tmp = std::env::temp_dir().join("yoyo_test_init_gen_node");
let _ = std::fs::create_dir_all(&tmp);
std::fs::write(
tmp.join("package.json"),
"{\n \"name\": \"my-app\",\n \"version\": \"1.0.0\"\n}",
)
.unwrap();
let _ = std::fs::create_dir_all(tmp.join("src"));
let content = generate_init_content(&tmp);
assert!(
content.contains("my-app"),
"Should detect project name from package.json"
);
assert!(content.contains("Node"), "Should detect Node project type");
assert!(content.contains("npm"), "Should include npm commands");
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn test_parse_plan_task_extracts_task() {
let result = parse_plan_task("/plan add error handling");
assert_eq!(result, Some("add error handling".to_string()));
}
#[test]
fn test_parse_plan_task_empty_returns_none() {
assert!(parse_plan_task("/plan").is_none());
assert!(parse_plan_task("/plan ").is_none());
}
#[test]
fn test_build_plan_prompt_structure() {
let prompt = build_plan_prompt("migrate database schema");
assert!(prompt.contains("migrate database schema"));
assert!(prompt.contains("Do NOT execute any tools"));
assert!(prompt.contains("Files to examine"));
assert!(prompt.contains("Step-by-step"));
}
#[test]
fn test_docs_command_recognized() {
use crate::commands::{is_unknown_command, KNOWN_COMMANDS};
assert!(!is_unknown_command("/docs"));
assert!(!is_unknown_command("/docs serde"));
assert!(!is_unknown_command("/docs tokio"));
assert!(
KNOWN_COMMANDS.contains(&"/docs"),
"/docs should be in KNOWN_COMMANDS"
);
}
#[test]
fn test_docs_command_matching() {
let docs_matches = |s: &str| s == "/docs" || s.starts_with("/docs ");
assert!(docs_matches("/docs"));
assert!(docs_matches("/docs serde"));
assert!(docs_matches("/docs tokio-runtime"));
assert!(!docs_matches("/docstring"));
assert!(!docs_matches("/docsify"));
}
#[test]
fn test_docs_crate_arg_extraction() {
let input = "/docs serde";
let crate_name = input.trim_start_matches("/docs ").trim();
assert_eq!(crate_name, "serde");
let input2 = "/docs tokio-runtime";
let crate_name2 = input2.trim_start_matches("/docs ").trim();
assert_eq!(crate_name2, "tokio-runtime");
let input_bare = "/docs";
assert_eq!(input_bare, "/docs");
assert!(!input_bare.starts_with("/docs "));
}
#[test]
fn test_plan_in_known_commands() {
use crate::commands::KNOWN_COMMANDS;
assert!(
KNOWN_COMMANDS.contains(&"/plan"),
"/plan should be in KNOWN_COMMANDS"
);
}
#[test]
fn test_plan_in_help_text() {
use crate::help::help_text;
let help = help_text();
assert!(help.contains("/plan"), "/plan should appear in help text");
assert!(
help.contains("architect"),
"Help text should mention architect mode"
);
}
#[test]
fn test_skill_in_known_commands() {
assert!(
KNOWN_COMMANDS.contains(&"/skill"),
"/skill should be in KNOWN_COMMANDS"
);
}
#[test]
fn test_skill_in_help_text() {
let help = help_text();
assert!(help.contains("/skill"), "/skill should appear in help text");
assert!(help.contains("skills"), "Help text should mention skills");
}
#[test]
fn test_skill_list_with_real_skills() {
let skills = yoagent::skills::SkillSet::load(&["./skills"]).unwrap();
assert!(
skills.len() >= 4,
"Expected at least 4 core skills, got {}",
skills.len()
);
let names: Vec<&str> = skills.skills().iter().map(|s| s.name.as_str()).collect();
assert!(names.contains(&"evolve"), "evolve skill should be loaded");
assert!(
names.contains(&"communicate"),
"communicate skill should be loaded"
);
}
#[test]
fn test_skill_list_empty() {
let skills = yoagent::skills::SkillSet::empty();
handle_skill("/skill list", &skills);
handle_skill("/skill", &skills);
}
#[test]
fn test_skill_show_existing() {
let skills = yoagent::skills::SkillSet::load(&["./skills"]).unwrap();
handle_skill("/skill show evolve", &skills);
}
#[test]
fn test_skill_show_nonexistent() {
let skills = yoagent::skills::SkillSet::load(&["./skills"]).unwrap();
handle_skill("/skill show nonexistent-skill", &skills);
}
#[test]
fn test_skill_path() {
let skills = yoagent::skills::SkillSet::load(&["./skills"]).unwrap();
handle_skill("/skill path", &skills);
}
#[test]
fn test_skill_path_empty() {
let skills = yoagent::skills::SkillSet::empty();
handle_skill("/skill path", &skills);
}
#[test]
fn test_skill_unknown_subcommand() {
let skills = yoagent::skills::SkillSet::empty();
handle_skill("/skill foobar", &skills);
}
#[test]
fn test_skill_show_bare() {
let skills = yoagent::skills::SkillSet::empty();
handle_skill("/skill show", &skills);
}
#[test]
fn test_skill_with_temp_dir() {
let tmp = TempDir::new().unwrap();
let skill_dir = tmp.path().join("my-skill");
fs::create_dir_all(&skill_dir).unwrap();
fs::write(
skill_dir.join("SKILL.md"),
"---\nname: my-skill\ndescription: A test skill\n---\n\n# My Skill\n\nDoes things.\n",
)
.unwrap();
let skills = yoagent::skills::SkillSet::load(&[tmp.path()]).unwrap();
assert_eq!(skills.len(), 1);
assert_eq!(skills.skills()[0].name, "my-skill");
assert_eq!(skills.skills()[0].description, "A test skill");
handle_skill("/skill list", &skills);
handle_skill("/skill show my-skill", &skills);
handle_skill("/skill path", &skills);
}
}