use crate::format::{DIM, RESET};
pub const PROJECT_CONTEXT_FILES: &[&str] = &["YOYO.md", "CLAUDE.md", ".yoyo/instructions.md"];
pub const MAX_PROJECT_FILES: usize = 200;
pub const MAX_RECENT_FILES: usize = 20;
pub fn get_project_file_listing() -> Option<String> {
let stdout = crate::git::run_git(&["ls-files"]).ok()?;
let files: Vec<&str> = stdout.lines().filter(|l| !l.is_empty()).collect();
if files.is_empty() {
return None;
}
let total = files.len();
let capped: Vec<&str> = files.into_iter().take(MAX_PROJECT_FILES).collect();
let mut listing = capped.join("\n");
if total > MAX_PROJECT_FILES {
listing.push_str(&format!(
"\n... and {} more files",
total - MAX_PROJECT_FILES
));
}
Some(listing)
}
pub fn get_git_status_context() -> Option<String> {
let branch = crate::git::git_branch()?;
let uncommitted = crate::git::run_git(&["status", "--porcelain"])
.ok()
.map(|s| s.lines().filter(|l| !l.is_empty()).count())
.unwrap_or(0);
let staged = crate::git::run_git(&["diff", "--cached", "--name-only"])
.ok()
.map(|s| s.lines().filter(|l| !l.is_empty()).count())
.unwrap_or(0);
let mut result = String::from("## Git Status\n\n");
result.push_str(&format!("Branch: {branch}\n"));
if uncommitted > 0 {
result.push_str(&format!(
"Uncommitted changes: {} file{}\n",
uncommitted,
if uncommitted == 1 { "" } else { "s" }
));
}
if staged > 0 {
result.push_str(&format!(
"Staged: {} file{}\n",
staged,
if staged == 1 { "" } else { "s" }
));
}
Some(result)
}
pub fn get_recently_changed_files(max_files: usize) -> Option<Vec<String>> {
let stdout = crate::git::run_git(&[
"log",
"--diff-filter=M",
"--name-only",
"--pretty=format:",
"-n",
"20",
])
.ok()?;
let mut seen = std::collections::HashSet::new();
let files: Vec<String> = stdout
.lines()
.filter(|l| !l.is_empty())
.filter(|l| seen.insert(l.to_string()))
.take(max_files)
.map(|l| l.to_string())
.collect();
if files.is_empty() {
None
} else {
Some(files)
}
}
pub fn load_project_context() -> Option<String> {
let mut context = String::new();
let mut found = Vec::new();
for name in PROJECT_CONTEXT_FILES {
if let Ok(content) = std::fs::read_to_string(name) {
let content = content.trim();
if !content.is_empty() {
if !context.is_empty() {
context.push_str("\n\n");
}
context.push_str(content);
found.push(*name);
}
}
}
if let Some(file_listing) = get_project_file_listing() {
if !context.is_empty() {
context.push_str("\n\n");
}
context.push_str("## Project Files\n\n");
context.push_str(&file_listing);
if found.is_empty() {
eprintln!("{DIM} context: project file listing{RESET}");
}
}
if let Some(recent_files) = get_recently_changed_files(MAX_RECENT_FILES) {
if !context.is_empty() {
context.push_str("\n\n");
}
context.push_str("## Recently Changed Files\n\n");
context.push_str(&recent_files.join("\n"));
}
let git_branch_name = if let Some(git_status) = get_git_status_context() {
if !context.is_empty() {
context.push_str("\n\n");
}
let branch = crate::git::git_branch();
context.push_str(&git_status);
branch
} else {
None
};
let memory = crate::memory::load_memories();
if let Some(memories_section) = crate::memory::format_memories_for_prompt(&memory) {
if !context.is_empty() {
context.push_str("\n\n");
}
context.push_str(&memories_section);
}
if found.is_empty() && context.is_empty() {
None
} else {
for name in &found {
eprintln!("{DIM} context: {name}{RESET}");
}
if context.contains("## Recently Changed Files") {
eprintln!("{DIM} context: recently changed files{RESET}");
}
if let Some(branch) = &git_branch_name {
eprintln!("{DIM} context: git status (branch: {branch}){RESET}");
}
if !memory.entries.is_empty() {
eprintln!(
"{DIM} context: {} project memories{RESET}",
memory.entries.len()
);
}
Some(context)
}
}
pub fn list_project_context_files() -> Vec<(&'static str, usize)> {
let mut result = Vec::new();
for name in PROJECT_CONTEXT_FILES {
if let Ok(content) = std::fs::read_to_string(name) {
let content = content.trim();
if !content.is_empty() {
let lines = content.lines().count();
result.push((*name, lines));
}
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_project_context_file_names_not_empty() {
assert_eq!(PROJECT_CONTEXT_FILES.len(), 3);
assert_eq!(PROJECT_CONTEXT_FILES[0], "YOYO.md");
assert_eq!(PROJECT_CONTEXT_FILES[1], "CLAUDE.md");
assert_eq!(PROJECT_CONTEXT_FILES[2], ".yoyo/instructions.md");
for name in PROJECT_CONTEXT_FILES {
assert!(!name.is_empty());
}
}
#[test]
fn test_max_project_files_constant() {
assert_eq!(MAX_PROJECT_FILES, 200);
}
#[test]
fn test_max_recent_files_constant() {
assert_eq!(MAX_RECENT_FILES, 20);
}
#[test]
fn test_list_project_context_files_returns_vec() {
let files = list_project_context_files();
for (name, lines) in &files {
assert!(!name.is_empty());
assert!(*lines > 0);
}
}
#[test]
fn test_get_project_file_listing_no_panic() {
let result = get_project_file_listing();
if let Some(listing) = &result {
assert!(!listing.is_empty(), "File listing should not be empty");
let lines: Vec<&str> = listing.lines().collect();
assert!(
lines.len() <= MAX_PROJECT_FILES + 1, "File listing should be capped at {} files",
MAX_PROJECT_FILES
);
assert!(
listing.contains("Cargo.toml"),
"File listing should contain Cargo.toml"
);
}
}
#[test]
fn test_load_project_context_includes_file_listing() {
let result = load_project_context();
if let Some(context) = &result {
if get_project_file_listing().is_some() {
assert!(
context.contains("## Project Files"),
"Context should contain Project Files section"
);
}
}
}
#[test]
fn test_get_recently_changed_files_in_git_repo() {
let result = get_recently_changed_files(20);
if let Some(files) = &result {
assert!(!files.is_empty(), "Should have recently changed files");
let unique: std::collections::HashSet<&String> = files.iter().collect();
assert_eq!(
files.len(),
unique.len(),
"Recently changed files should be deduplicated"
);
assert!(files.len() <= 20, "Should not exceed max_files limit");
}
}
#[test]
fn test_get_recently_changed_files_respects_limit() {
let result = get_recently_changed_files(2);
if let Some(files) = &result {
assert!(
files.len() <= 2,
"Should respect max_files=2, got {}",
files.len()
);
}
}
#[test]
fn test_get_recently_changed_files_no_duplicates() {
let result = get_recently_changed_files(50);
if let Some(files) = &result {
let unique: std::collections::HashSet<&String> = files.iter().collect();
assert_eq!(files.len(), unique.len(), "Files should be deduplicated");
}
}
#[test]
fn test_load_project_context_includes_recently_changed() {
let result = load_project_context();
if let Some(context) = &result {
if get_recently_changed_files(MAX_RECENT_FILES).is_some() {
assert!(
context.contains("## Recently Changed Files"),
"Context should contain Recently Changed Files section"
);
}
}
}
#[test]
fn test_get_git_status_context_in_repo() {
let result = get_git_status_context();
assert!(result.is_some(), "Should return Some when in a git repo");
assert!(
result.as_ref().unwrap().contains("Branch:"),
"Should contain 'Branch:' label"
);
}
#[test]
fn test_get_git_status_context_contains_branch() {
let result = get_git_status_context().expect("Should be in a git repo");
let branch = crate::git::git_branch().expect("Should get branch name");
assert!(
result.contains(&format!("Branch: {branch}")),
"Should contain actual branch name: {branch}"
);
}
#[test]
fn test_git_status_context_format() {
let result = get_git_status_context().expect("Should be in a git repo");
assert!(
result.starts_with("## Git Status\n\n"),
"Should start with '## Git Status' header"
);
}
#[test]
fn test_load_project_context_includes_git_status() {
let result = load_project_context();
if let Some(context) = &result {
if get_git_status_context().is_some() {
assert!(
context.contains("## Git Status"),
"Context should contain Git Status section"
);
}
}
}
#[test]
fn test_yoyo_md_is_primary_context_file() {
assert_eq!(
PROJECT_CONTEXT_FILES[0], "YOYO.md",
"YOYO.md must be the primary context file"
);
assert!(
PROJECT_CONTEXT_FILES.contains(&"CLAUDE.md"),
"CLAUDE.md should still be supported for compatibility"
);
assert_ne!(
PROJECT_CONTEXT_FILES[0], "CLAUDE.md",
"CLAUDE.md should not be the primary context file"
);
}
}