diff --git a/.github/AUTHOR_MAP b/.github/AUTHOR_MAP index d1997277..6b55ca30 100644 --- a/.github/AUTHOR_MAP +++ b/.github/AUTHOR_MAP @@ -16,6 +16,9 @@ Hu Qiantao = HUQIANTAO <58421104+HUQIANTAO@users.noreply.github.com> huqiantao@users.noreply.github.com = HUQIANTAO <58421104+HUQIANTAO@users.noreply.github.com> huqiantao@HudeMacBook-Air.local = HUQIANTAO <58421104+HUQIANTAO@users.noreply.github.com> tom_huu@qq.com = HUQIANTAO <58421104+HUQIANTAO@users.noreply.github.com> +punkcanyang = Punkcan Yang <36871858+punkcanyang@users.noreply.github.com> +Punkcan Yang = Punkcan Yang <36871858+punkcanyang@users.noreply.github.com> +bucunzai@gmail.com = Punkcan Yang <36871858+punkcanyang@users.noreply.github.com> merchloubna70-dot = merchloubna70-dot <258170091+merchloubna70-dot@users.noreply.github.com> h3c-hexin = h3c-hexin <13790929+h3c-hexin@users.noreply.github.com> he.xin@h3c.com = h3c-hexin <13790929+h3c-hexin@users.noreply.github.com> diff --git a/crates/tui/src/commands/init.rs b/crates/tui/src/commands/init.rs index 7ca53ec9..3ce9d092 100644 --- a/crates/tui/src/commands/init.rs +++ b/crates/tui/src/commands/init.rs @@ -1,14 +1,22 @@ //! /init command - Generate AGENTS.md for project +//! +//! Gathers rich project context (directory structure, build system, git info, CI/CD, +//! test frameworks) and delegates AGENTS.md generation to the LLM agent via +//! `AppAction::SendMessage`. This mirrors Claude Code's `/init` behavior — the agent +//! reads key source files, understands the architecture, and produces a customized, +//! comprehensive project guide. -use std::fmt::Write; use std::io::Read; -use std::path::Path; +use std::path::{Path, PathBuf}; +use std::process::Command; -use crate::tui::app::App; +use crate::project_context; +use crate::tui::app::{App, AppAction}; use super::CommandResult; -/// Generate an AGENTS.md file for the current project +/// Generate an AGENTS.md file for the current project by gathering context and +/// delegating content generation to the LLM agent. pub fn init(app: &mut App) -> CommandResult { let workspace = &app.workspace; @@ -19,20 +27,31 @@ pub fn init(app: &mut App) -> CommandResult { let agents_path = workspace.join("AGENTS.md"); let already_exists = agents_path.exists(); - // Detect project type and generate appropriate content - let content = generate_project_doc(workspace); + // Gather rich project context for the agent. + let context = gather_project_context(workspace); - // Write the file - match std::fs::write(&agents_path, &content) { - Ok(()) => { - let verb = if already_exists { "Updated" } else { "Created" }; - CommandResult::message(format!( - "{verb} AGENTS.md at {}\n\nEdit this file to customize agent behavior for your project.", - agents_path.display() - )) - } - Err(e) => CommandResult::error(format!("Failed to write AGENTS.md: {e}")), - } + // Read existing AGENTS.md content if updating. + let existing_content = if already_exists { + read_existing_agents_md(workspace) + } else { + None + }; + + // Construct the prompt for the LLM agent. + let prompt = build_init_prompt(&context, existing_content.as_deref(), already_exists); + + // Display message to user AND send the prompt to the agent. + let verb = if already_exists { + "Updating" + } else { + "Creating" + }; + let msg = format!( + "{verb} AGENTS.md at {}\n\nThe agent will analyze the codebase and generate a customized project guide.", + agents_path.display() + ); + + CommandResult::with_message_and_action(msg, AppAction::SendMessage(prompt)) } /// If `workspace` is inside a git repository, ensure workspace-local CodeWhale @@ -42,12 +61,11 @@ pub fn init(app: &mut App) -> CommandResult { /// committable (a directory exclude cannot be overridden, so `.codewhale/*` plus /// a negation is required). fn ensure_deepseek_gitignored(workspace: &Path) { - // Only act if this workspace is a git repo. - if !workspace.join(".git").exists() { + let Some(git_root) = git_root(workspace) else { return; - } + }; - let gitignore = workspace.join(".gitignore"); + let gitignore = git_root.join(".gitignore"); let entries = [ "**/.codewhale/*", "!**/.codewhale/constitution.json", @@ -98,171 +116,688 @@ fn ensure_deepseek_gitignored(workspace: &Path) { } } -/// Generate project documentation based on detected project type -fn generate_project_doc(workspace: &Path) -> String { - let mut doc = String::new(); +// --------------------------------------------------------------------------- +// Context gathering functions +// --------------------------------------------------------------------------- - // Header - doc.push_str("# Project Instructions\n\n"); - doc.push_str("This file provides context for AI assistants working on this project.\n\n"); +/// Orchestrate all context gathering and return structured Markdown for the agent prompt. +fn gather_project_context(workspace: &Path) -> String { + let mut ctx = String::new(); - // Detect project type - let project_info = detect_project_type(workspace); - doc.push_str(&project_info); + // Project type summary (from existing utility). + let summary = crate::utils::summarize_project(workspace); + ctx.push_str("## Project Summary\n\n"); + ctx.push_str(&summary); + ctx.push_str("\n\n"); - // Agent behavior — conventions, gotchas, testing - doc.push_str("## Agent Guidance\n\n"); - doc.push_str("\n"); - doc.push_str("\n"); - doc.push_str("\n"); - doc.push('\n'); - doc.push_str("- **CodeWhale reads this file as:** AGENTS.md (canonical cross-agent project instructions). \n"); - doc.push_str( - "- **Read-only surface:** \n", - ); - doc.push_str( - "- **Never edit:** \n", - ); - doc.push_str("- **Always test with:** \n"); - doc.push('\n'); + // Cargo.toml analysis. + if let Some(info) = parse_cargo_toml(workspace) { + ctx.push_str("## Rust / Cargo\n\n"); + ctx.push_str(&info); + ctx.push_str("\n\n"); + } - // Architecture — the "big picture" that requires reading multiple files - doc.push_str("## Architecture\n\n"); - doc.push_str("\n"); - doc.push_str("\n"); - doc.push('\n'); - doc.push_str("### Entry Points\n"); - doc.push_str( - "\n", - ); - doc.push('\n'); - doc.push_str("### Key Modules\n"); - doc.push_str("\n"); - doc.push('\n'); - doc.push_str("### Data Flow\n"); - doc.push_str("\n"); - doc.push('\n'); + // package.json analysis. + if let Some(info) = parse_package_json(workspace) { + ctx.push_str("## Node.js / npm\n\n"); + ctx.push_str(&info); + ctx.push_str("\n\n"); + } - // Cache-aware editing — helps maintain prefix-cache hit rates - doc.push_str("## Cache Stability\n\n"); - doc.push_str("\n"); - doc.push_str( - "\n", - ); - doc.push('\n'); - doc.push_str("- **Frequently-rebuilt files:** \n"); - doc.push_str("- **Stable scaffolding:** \n"); - doc.push_str("- **Append, don't reorder:** \n"); - doc.push('\n'); + // Git repository info. + if let Some(info) = gather_git_info(workspace) { + ctx.push_str("## Git Repository\n\n"); + ctx.push_str(&info); + ctx.push_str("\n\n"); + } - // Guidelines - doc.push_str("## Guidelines\n\n"); - doc.push_str("- Follow existing code style and patterns\n"); - doc.push_str("- Write tests for new functionality\n"); - doc.push_str("- Keep changes focused and atomic\n"); - doc.push_str("- Document public APIs\n"); - doc.push_str("- Update this file when project conventions change\n"); + // CI/CD systems. + let ci = detect_ci_systems(workspace); + if !ci.is_empty() { + ctx.push_str("## CI/CD\n\n"); + for system in &ci { + let _ = std::fmt::write(&mut ctx, format_args!("- {system}\n")); + } + ctx.push('\n'); + } - doc + // Build systems. + let build = detect_build_systems(workspace); + if !build.is_empty() { + ctx.push_str("## Additional Build Systems\n\n"); + for system in &build { + let _ = std::fmt::write(&mut ctx, format_args!("- {system}\n")); + } + ctx.push('\n'); + } + + // Test frameworks. + let tests = detect_test_frameworks(workspace); + if !tests.is_empty() { + ctx.push_str("## Test Frameworks\n\n"); + for framework in &tests { + let _ = std::fmt::write(&mut ctx, format_args!("- {framework}\n")); + } + ctx.push('\n'); + } + + // Directory tree (from existing utility). + let tree = crate::utils::project_tree(workspace, 3); + ctx.push_str("## Directory Structure (depth 3)\n\n```\n"); + ctx.push_str(&tree); + ctx.push_str("\n```\n\n"); + + // Structured project context pack (from existing utility). + if let Some(pack) = project_context::generate_project_context_pack(workspace) { + ctx.push_str("## Detailed Project Context\n\n```json\n"); + ctx.push_str(&pack); + ctx.push_str("\n```\n\n"); + } + + ctx } -/// Detect project type and return relevant information -fn detect_project_type(workspace: &Path) -> String { - let mut info = String::new(); +/// Parse `Cargo.toml` and return a human-readable summary of the Rust project structure. +fn parse_cargo_toml(workspace: &Path) -> Option { + let cargo_path = workspace.join("Cargo.toml"); + let raw = std::fs::read_to_string(&cargo_path).ok()?; + let doc: toml::Value = toml::from_str(&raw).ok()?; - // Check for Rust project - if workspace.join("Cargo.toml").exists() { - info.push_str("## Project Type: Rust\n\n"); - info.push_str("### Commands\n"); - info.push_str("- Build: `cargo build`\n"); - info.push_str("- Test: `cargo test`\n"); - info.push_str("- Run: `cargo run`\n"); - info.push_str("- Check: `cargo check`\n"); - info.push_str("- Format: `cargo fmt`\n"); - info.push_str("- Lint: `cargo clippy`\n\n"); + let mut lines: Vec = Vec::new(); - // Try to extract project name from Cargo.toml - if let Some(name) = std::fs::read_to_string(workspace.join("Cargo.toml")) - .ok() - .and_then(|content| extract_cargo_name(&content)) - { - let _ = write!(info, "### Project: {name}\n\n"); + // Package info. + if let Some(package) = doc.get("package") { + if let Some(name) = package.get("name").and_then(|v| v.as_str()) { + lines.push(format!("- Package name: `{name}`")); + } + if let Some(version) = package.get("version").and_then(|v| v.as_str()) { + lines.push(format!("- Version: {version}")); + } + if let Some(edition) = package.get("edition").and_then(|v| v.as_str()) { + lines.push(format!("- Rust edition: {edition}")); } } - // Check for Node.js project - else if workspace.join("package.json").exists() { - info.push_str("## Project Type: Node.js\n\n"); - info.push_str("### Commands\n"); - info.push_str("- Install: `npm install`\n"); - info.push_str("- Test: `npm test`\n"); - info.push_str("- Build: `npm run build`\n"); - info.push_str("- Start: `npm start`\n\n"); - // Check for common frameworks - if workspace.join("next.config.js").exists() || workspace.join("next.config.ts").exists() { - info.push_str("### Framework: Next.js\n\n"); - } else if workspace.join("vite.config.js").exists() - || workspace.join("vite.config.ts").exists() - { - info.push_str("### Framework: Vite\n\n"); - } - } - // Check for Python project - else if workspace.join("pyproject.toml").exists() || workspace.join("setup.py").exists() { - info.push_str("## Project Type: Python\n\n"); - info.push_str("### Commands\n"); - if workspace.join("pyproject.toml").exists() { - info.push_str("- Install: `pip install -e .`\n"); - } - info.push_str("- Test: `pytest`\n"); - info.push_str("- Format: `black .`\n"); - info.push_str("- Lint: `ruff check .`\n\n"); - } - // Check for Go project - else if workspace.join("go.mod").exists() { - info.push_str("## Project Type: Go\n\n"); - info.push_str("### Commands\n"); - info.push_str("- Build: `go build`\n"); - info.push_str("- Test: `go test ./...`\n"); - info.push_str("- Run: `go run .`\n"); - info.push_str("- Format: `go fmt ./...`\n\n"); - } - // Unknown project type - else { - info.push_str("## Project Type: Unknown\n\n"); - info.push_str("\n\n"); - } - - // Check for README - if workspace.join("README.md").exists() { - info.push_str("### Documentation\n"); - info.push_str("See README.md for project overview.\n\n"); - } - - // Check for .gitignore - if workspace.join(".gitignore").exists() { - info.push_str("### Version Control\n"); - info.push_str("This project uses Git. See .gitignore for excluded files.\n\n"); - } - - info -} - -/// Extract project name from Cargo.toml -fn extract_cargo_name(content: &str) -> Option { - for line in content.lines() { - let line = line.trim(); - if line.starts_with("name") && line.contains('=') { - let parts: Vec<&str> = line.splitn(2, '=').collect(); - if parts.len() == 2 { - let name = parts[1].trim().trim_matches('"').trim_matches('\''); - return Some(name.to_string()); + // Workspace info. + if let Some(workspace_section) = doc.get("workspace") { + lines.push("- **This is a workspace root**".to_string()); + if let Some(members) = workspace_section.get("members").and_then(|v| v.as_array()) { + let mut member_names: Vec<&str> = members.iter().filter_map(|m| m.as_str()).collect(); + member_names.sort_unstable(); + if !member_names.is_empty() { + lines.push(format!("- Workspace members: {}", member_names.join(", "))); } } } - None + + // Dependencies. + if let Some(deps) = doc.get("dependencies").and_then(|v| v.as_table()) { + let mut dep_names: Vec<&str> = deps.keys().map(|k| k.as_str()).collect(); + dep_names.sort_unstable(); + if !dep_names.is_empty() { + lines.push(format!("- Key dependencies: {}", dep_names.join(", "))); + } + } + + // Dev dependencies — test frameworks. + if let Some(dev_deps) = doc.get("dev-dependencies").and_then(|v| v.as_table()) { + let mut dev_names: Vec<&str> = dev_deps.keys().map(|k| k.as_str()).collect(); + dev_names.sort_unstable(); + if !dev_names.is_empty() { + lines.push(format!("- Dev dependencies: {}", dev_names.join(", "))); + } + } + + // Workspace-level dependencies (shared across workspace members). + if let Some(ws_deps) = doc + .get("workspace") + .and_then(|w| w.get("dependencies")) + .and_then(|v| v.as_table()) + { + let mut ws_dep_names: Vec<&str> = ws_deps.keys().map(|k| k.as_str()).collect(); + ws_dep_names.sort_unstable(); + if !ws_dep_names.is_empty() { + lines.push(format!( + "- Workspace dependencies: {}", + ws_dep_names.join(", ") + )); + } + } + + // Features. + if let Some(features) = doc.get("features").and_then(|v| v.as_table()) { + let mut feat_names: Vec<&str> = features.keys().map(|k| k.as_str()).collect(); + feat_names.sort_unstable(); + if !feat_names.is_empty() { + lines.push(format!("- Features: {}", feat_names.join(", "))); + } + } + + if lines.is_empty() { + None + } else { + Some(lines.join("\n")) + } } +/// Parse `package.json` and return a human-readable summary of the Node.js project. +fn parse_package_json(workspace: &Path) -> Option { + let pkg_path = workspace.join("package.json"); + let raw = std::fs::read_to_string(&pkg_path).ok()?; + let doc: serde_json::Value = serde_json::from_str(&raw).ok()?; + + let mut lines: Vec = Vec::new(); + + if let Some(name) = doc.get("name").and_then(|v| v.as_str()) { + lines.push(format!("- Package name: `{name}`")); + } + + // Scripts. + if let Some(scripts) = doc.get("scripts").and_then(|v| v.as_object()) { + let mut script_names: Vec<&str> = scripts.keys().map(|k| k.as_str()).collect(); + script_names.sort_unstable(); + if !script_names.is_empty() { + lines.push(format!("- Scripts: {}", script_names.join(", "))); + } + } + + // Dependencies. + if let Some(deps) = doc.get("dependencies").and_then(|v| v.as_object()) { + let mut dep_keys: Vec<&str> = deps.keys().map(|k| k.as_str()).collect(); + dep_keys.sort_unstable(); + if !dep_keys.is_empty() { + // Detect frameworks from runtime deps. + let frameworks = detect_js_frameworks(&dep_keys); + if !frameworks.is_empty() { + lines.push(format!("- Frameworks detected: {}", frameworks.join(", "))); + } + lines.push(format!("- Dependencies: {}", dep_keys.join(", "))); + } + } + + // Dev dependencies. + if let Some(dev_deps) = doc.get("devDependencies").and_then(|v| v.as_object()) { + let mut dev_keys: Vec<&str> = dev_deps.keys().map(|k| k.as_str()).collect(); + dev_keys.sort_unstable(); + if !dev_keys.is_empty() { + // Also detect build-tool/framework entries from devDependencies + // (Vite, webpack, esbuild, Turbopack, etc.). + let dev_frameworks = detect_js_frameworks(&dev_keys); + if !dev_frameworks.is_empty() { + lines.push(format!( + "- Dev frameworks/tools: {}", + dev_frameworks.join(", ") + )); + } + lines.push(format!("- Dev dependencies: {}", dev_keys.join(", "))); + } + } + + if lines.is_empty() { + None + } else { + Some(lines.join("\n")) + } +} + +/// Detect JS frameworks from dependency names. +fn detect_js_frameworks(deps: &[&str]) -> Vec { + let mut found: Vec = Vec::new(); + let candidates: &[(&str, &str)] = &[ + ("react", "React"), + ("next", "Next.js"), + ("vue", "Vue"), + ("nuxt", "Nuxt"), + ("@sveltejs/kit", "SvelteKit"), + ("svelte", "Svelte"), + ("sveltekit", "SvelteKit"), + ("astro", "Astro"), + ("express", "Express"), + ("fastify", "Fastify"), + ("hono", "Hono"), + ("vite", "Vite"), + ("webpack", "Webpack"), + ("esbuild", "esbuild"), + ("turbo", "Turbopack"), + ("tailwindcss", "Tailwind CSS"), + ]; + for dep in deps { + let lower = dep.to_lowercase(); + for (key, label) in candidates { + if lower == *key && !found.contains(&label.to_string()) { + found.push((*label).to_string()); + } + } + } + found +} + +/// Strip userinfo (username:password or username) from a URL to avoid leaking +/// embedded credentials into the LLM prompt. +fn strip_url_credentials(url: &str) -> String { + // Handle SSH-style URLs: git@host:org/repo.git — no embedded password. + if url.contains('@') && !url.contains("://") { + return url.to_string(); + } + // HTTP(S) remotes: strip only authority userinfo. `@` in a path, query, + // or fragment is repository data, not credentials. SSH remotes such as + // `git@host:org/repo.git` and `ssh://git@host/org/repo.git` keep their + // user component because it is protocol syntax, not an embedded token. + if let Some(scheme_end) = url.find("://") { + let scheme_name = url[..scheme_end].to_ascii_lowercase(); + if scheme_name != "http" && scheme_name != "https" { + return url.to_string(); + } + let scheme = &url[..scheme_end + 3]; + let after_scheme = &url[scheme_end + 3..]; + let authority_end = after_scheme + .find(['/', '?', '#']) + .unwrap_or(after_scheme.len()); + let (authority, suffix) = after_scheme.split_at(authority_end); + if let Some(at_pos) = authority.rfind('@') { + return format!("{scheme}{}{suffix}", &authority[at_pos + 1..]); + } + } + url.to_string() +} + +/// Find the enclosing git repository root. Works for nested workspaces and +/// worktrees where `.git` is a file instead of a directory. +fn git_root(workspace: &Path) -> Option { + let direct_git_marker = workspace.join(".git"); + let discovered = Command::new("git") + .args(["rev-parse", "--show-toplevel"]) + .current_dir(workspace) + .output() + .ok() + .and_then(|out| { + if out.status.success() { + String::from_utf8(out.stdout) + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .map(PathBuf::from) + } else { + None + } + }); + discovered.or_else(|| direct_git_marker.exists().then(|| workspace.to_path_buf())) +} + +/// Gather git repository information via subprocess calls. +fn gather_git_info(workspace: &Path) -> Option { + let git_root = git_root(workspace)?; + + let run = |args: &[&str]| -> Option { + Command::new("git") + .args(args) + .current_dir(&git_root) + .output() + .ok() + .and_then(|out| { + if out.status.success() { + String::from_utf8(out.stdout) + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + } else { + None + } + }) + }; + + let mut lines: Vec = Vec::new(); + + // Remote URL (strip embedded credentials to avoid leaking tokens to the LLM). + if let Some(url) = run(&["remote", "get-url", "origin"]) { + let sanitized = strip_url_credentials(&url); + lines.push(format!("- Remote: {sanitized}")); + } + + // Current branch. + if let Some(branch) = run(&["rev-parse", "--abbrev-ref", "HEAD"]) { + lines.push(format!("- Branch: {branch}")); + } + + // Status summary. + let status_output = Command::new("git") + .args(["status", "--porcelain=v1", "--untracked-files=no"]) + .current_dir(&git_root) + .output() + .ok(); + if let Some(out) = status_output + && out.status.success() + { + let status_str = String::from_utf8_lossy(&out.stdout); + let staged = status_str + .lines() + .filter(|l| { + let b = l.as_bytes(); + b.len() >= 2 && b[0] != b' ' && b[0] != b'?' + }) + .count(); + let unstaged = status_str + .lines() + .filter(|l| { + let b = l.as_bytes(); + b.len() >= 2 && b[1] != b' ' && b[1] != b'?' + }) + .count(); + if staged > 0 || unstaged > 0 { + let mut parts = Vec::new(); + if staged > 0 { + parts.push(format!("{staged} staged")); + } + if unstaged > 0 { + parts.push(format!("{unstaged} modified")); + } + lines.push(format!("- Working tree: {}", parts.join(", "))); + } + } + + // Recent commits. + if let Some(log) = run(&["log", "--oneline", "-5"]) { + let commits: Vec<&str> = log.lines().collect(); + if !commits.is_empty() { + lines.push("- Recent commits:".to_string()); + for c in commits { + lines.push(format!(" - {c}")); + } + } + } + + if lines.is_empty() { + None + } else { + Some(lines.join("\n")) + } +} + +/// Detect CI/CD systems configured in the project. +fn detect_ci_systems(workspace: &Path) -> Vec { + let mut found: Vec = Vec::new(); + + if workspace.join(".github").join("workflows").is_dir() + && let Ok(entries) = std::fs::read_dir(workspace.join(".github").join("workflows")) + { + let files: Vec = entries + .filter_map(|e| e.ok()) + .filter_map(|e| { + let name = e.file_name().to_string_lossy().into_owned(); + if name.ends_with(".yml") || name.ends_with(".yaml") { + Some(name) + } else { + None + } + }) + .collect(); + let mut files = files; + files.sort_unstable(); + if files.is_empty() { + found.push("GitHub Actions".to_string()); + } else { + found.push(format!("GitHub Actions ({})", files.join(", "))); + } + } + if workspace.join(".gitlab-ci.yml").exists() { + found.push("GitLab CI".to_string()); + } + if workspace.join("Jenkinsfile").exists() { + found.push("Jenkins".to_string()); + } + if workspace.join(".circleci").join("config.yml").exists() { + found.push("CircleCI".to_string()); + } + if workspace.join(".travis.yml").exists() { + found.push("Travis CI".to_string()); + } + if workspace.join("azure-pipelines.yml").exists() { + found.push("Azure Pipelines".to_string()); + } + + found +} + +/// Detect additional build systems beyond Cargo/npm. +fn detect_build_systems(workspace: &Path) -> Vec { + let mut found: Vec = Vec::new(); + + if workspace.join("Makefile").exists() { + found.push("Makefile".to_string()); + } + if workspace.join("Justfile").exists() { + found.push("Justfile".to_string()); + } + if workspace.join("CMakeLists.txt").exists() { + found.push("CMake".to_string()); + } + if workspace.join("meson.build").exists() { + found.push("Meson".to_string()); + } + if workspace.join("BUILD.bazel").exists() || workspace.join("BUILD").exists() { + found.push("Bazel".to_string()); + } + if workspace.join("scripts").is_dir() + && let Ok(entries) = std::fs::read_dir(workspace.join("scripts")) + { + let scripts: Vec = entries + .filter_map(|e| e.ok()) + .filter_map(|e| { + let name = e.file_name().to_string_lossy().into_owned(); + let path = e.path(); + if (name.ends_with(".sh") || name.ends_with(".py") || name.ends_with(".js")) + && path.is_file() + { + Some(name) + } else { + None + } + }) + .collect(); + let mut scripts = scripts; + scripts.sort_unstable(); + if !scripts.is_empty() { + found.push(format!("scripts/ ({})", scripts.join(", "))); + } + } + + found +} + +/// Detect test frameworks from project configuration. +fn detect_test_frameworks(workspace: &Path) -> Vec { + let mut found: Vec = Vec::new(); + + // Rust: check Cargo.toml dev-dependencies (both crate and workspace level). + if let Ok(raw) = std::fs::read_to_string(workspace.join("Cargo.toml")) + && let Ok(doc) = toml::from_str::(&raw) + { + let mut dep_keys: Vec<&str> = Vec::new(); + if let Some(dev_deps) = doc.get("dev-dependencies").and_then(|v| v.as_table()) { + dep_keys.extend(dev_deps.keys().map(|k| k.as_str())); + } + if let Some(ws_dev_deps) = doc + .get("workspace") + .and_then(|w| w.get("dev-dependencies")) + .and_then(|v| v.as_table()) + { + dep_keys.extend(ws_dev_deps.keys().map(|k| k.as_str())); + } + + let rust_test_frameworks: &[(&str, &str)] = &[ + ("tokio-test", "tokio-test"), + ("proptest", "proptest"), + ("quickcheck", "quickcheck"), + ("rstest", "rstest"), + ("criterion", "criterion (benchmark)"), + ("mockall", "mockall"), + ("pretty_assertions", "pretty_assertions"), + ]; + for (dep_key, label) in rust_test_frameworks { + if dep_keys.contains(dep_key) { + found.push((*label).to_string()); + } + } + } + + // Node.js: check package.json devDependencies. + if let Ok(raw) = std::fs::read_to_string(workspace.join("package.json")) + && let Ok(doc) = serde_json::from_str::(&raw) + && let Some(dev_deps) = doc.get("devDependencies").and_then(|v| v.as_object()) + { + let dev_keys: Vec<&str> = dev_deps.keys().map(|k| k.as_str()).collect(); + + let js_test_frameworks: &[(&str, &str)] = &[ + ("jest", "Jest"), + ("vitest", "Vitest"), + ("mocha", "Mocha"), + ("jasmine", "Jasmine"), + ("ava", "AVA"), + ("playwright", "Playwright"), + ("cypress", "Cypress"), + ("@testing-library/react", "Testing Library"), + ]; + for (dep_key, label) in js_test_frameworks { + if dev_keys.contains(dep_key) { + found.push((*label).to_string()); + } + } + } + + // Python: check common test config files. + if workspace.join("pytest.ini").exists() + || workspace.join("tox.ini").exists() + || workspace.join("conftest.py").exists() + || (workspace.join("pyproject.toml").exists() + && std::fs::read_to_string(workspace.join("pyproject.toml")) + .ok() + .is_some_and(|raw| raw.contains("[tool.pytest"))) + { + found.push("pytest".to_string()); + } + + found +} + +/// Read existing AGENTS.md content (up to 100KB) for in-place update. +fn read_existing_agents_md(workspace: &Path) -> Option { + let path = workspace.join("AGENTS.md"); + let meta = std::fs::metadata(&path).ok()?; + let limit = 100 * 1024; + let len = meta.len() as usize; + let content = if len > limit { + let mut f = std::fs::File::open(&path).ok()?; + let mut buf = vec![0u8; limit]; + f.read_exact(&mut buf).ok()?; + String::from_utf8_lossy(&buf).into_owned() + } else { + std::fs::read_to_string(&path).ok()? + }; + if content.trim().is_empty() { + None + } else { + Some(content) + } +} + +// --------------------------------------------------------------------------- +// Prompt builder +// --------------------------------------------------------------------------- + +/// Build the SendMessage prompt instructing the agent to analyze and generate AGENTS.md. +fn build_init_prompt( + context: &str, + existing_content: Option<&str>, + already_exists: bool, +) -> String { + let mut prompt = String::new(); + + prompt.push_str( + "You are generating a comprehensive AGENTS.md file for this project. \ + Your task is to deeply analyze the codebase and produce a customized, \ + actionable project guide that will help future AI agents work effectively here.\n\n", + ); + + prompt.push_str("## Project Context (pre-gathered)\n\n"); + prompt.push_str(context); + prompt.push('\n'); + + if let Some(existing) = existing_content { + prompt.push_str("## Existing AGENTS.md\n\n"); + prompt.push_str("Below is the current AGENTS.md content. "); + if already_exists { + prompt.push_str( + "Update it in place: preserve any custom sections that still apply, \ + replace stale or incorrect information with your fresh analysis. ", + ); + } + prompt.push_str("\n\n```markdown\n"); + prompt.push_str(existing); + prompt.push_str("\n```\n\n"); + } + + prompt.push_str("## Instructions\n\n"); + + prompt.push_str( + "1. **Read key source files** to understand the architecture:\n\ + - Start with the main entry point(s) (e.g., main.rs, index.ts, app.py)\n\ + - Read the top-level module structure to understand component boundaries\n\ + - Read a few representative files from each major module or crate\n\ + - Read config files (config.example.toml, tsconfig.json, etc.) to understand settings\n\n\ + 2. **Generate AGENTS.md** at the workspace root. Use `AGENTS.md` as the filename. \ + Include these sections as applicable:\n\n\ + ### Build / Test / Lint\n\ + - Exact commands for: build, test (all + single), lint, format, run, install deps\n\ + - Be specific — if there's a Justfile, use `just `; if nextest, use `cargo nextest run`\n\n\ + ### Architecture\n\ + - High-level description of the project's purpose\n\ + - Component or module tree with 1-2 sentence descriptions each\n\ + - Data flow through the system (if determinable)\n\n\ + ### Key Files & Directories\n\ + - What each top-level directory contains\n\ + - Important config files and what they control\n\n\ + ### Coding Conventions\n\ + - What you observe from reading source files: naming, error handling patterns, \ + module organization, test patterns\n\ + - Code generation (build.rs, protobuf, etc.) if present\n\n\ + ### Git Workflow\n\ + - Branch naming conventions (if observable from recent commits)\n\ + - Commit message style\n\n\ + ### CI/CD\n\ + - How tests run in CI, what's checked on PRs\n\n\ + ### Tips for AI Agents\n\ + - Common pitfalls in the codebase structure\n\ + - Where to look for specific kinds of things\n\ + - Any gotchas in the build setup\n\n\ + 3. **Style requirements**:\n\ + - Be concise and actionable. This is a reference document, not a tutorial.\n\ + - Use markdown headings, code blocks, and bullet lists.\n\ + - Keep the total under ~150 lines unless the project genuinely needs more.\n\ + - Write in English.\n\ + - Do NOT include placeholder HTML comments like \"\".\n\ + - If you cannot determine something with confidence, omit that section rather than guessing.\n\n\ + 4. **Write the file** using the file write tool. \ + The file should be named `AGENTS.md` at the workspace root.\n\n", + ); + + if already_exists { + prompt.push_str( + "The file already exists — update it in place, \ + preserving custom content that still applies but replacing stale information.\n\n", + ); + } + + prompt.push_str( + "5. After writing, briefly summarize what you learned and what you put into AGENTS.md.\n", + ); + + prompt +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + #[cfg(test)] mod tests { use super::*; @@ -295,110 +830,486 @@ mod tests { App::new(options, &Config::default()) } + // --- init() integration tests --- + #[test] - fn test_init_creates_agents_md() { + fn init_returns_send_message_action() { let tmpdir = TempDir::new().unwrap(); let mut app = create_test_app_with_tmpdir(&tmpdir); let result = init(&mut app); assert!(result.message.is_some()); let msg = result.message.unwrap(); - assert!(msg.contains("Created AGENTS.md")); - let agents_path = tmpdir.path().join("AGENTS.md"); - assert!(agents_path.exists()); + assert!(msg.contains("Creating AGENTS.md")); + assert!( + matches!(result.action, Some(AppAction::SendMessage(_))), + "expected SendMessage action" + ); } #[test] - fn test_init_updates_if_exists() { + fn init_says_updating_when_agents_md_exists() { let tmpdir = TempDir::new().unwrap(); let mut app = create_test_app_with_tmpdir(&tmpdir); - // Create file first with stale content - let agents_path = tmpdir.path().join("AGENTS.md"); - std::fs::write(&agents_path, "existing stale content").unwrap(); + std::fs::write(tmpdir.path().join("AGENTS.md"), "existing content").unwrap(); let result = init(&mut app); - assert!(!result.is_error); - assert!(result.message.is_some()); - assert!(result.message.unwrap().contains("Updated AGENTS.md")); - let new_content = std::fs::read_to_string(&agents_path).unwrap(); - assert!(new_content.contains("# Project Instructions")); - assert!(!new_content.contains("existing stale content")); + assert!(result.message.unwrap().contains("Updating AGENTS.md")); + assert!(matches!(result.action, Some(AppAction::SendMessage(_)))); } #[test] - fn test_detect_project_type_rust() { + fn init_includes_gitignore_handling() { + let tmpdir = TempDir::new().unwrap(); + let mut app = create_test_app_with_tmpdir(&tmpdir); + std::fs::create_dir_all(tmpdir.path().join(".git")).unwrap(); + let result = init(&mut app); + assert!(!result.is_error); + // Should have added .deepseek/ to .gitignore. + let gi = std::fs::read_to_string(tmpdir.path().join(".gitignore")).unwrap(); + assert!(gi.contains(".deepseek/")); + } + + #[test] + fn init_prompt_includes_context_for_rust_project() { + let tmpdir = TempDir::new().unwrap(); + let mut app = create_test_app_with_tmpdir(&tmpdir); + std::fs::write( + tmpdir.path().join("Cargo.toml"), + "[package]\nname = \"test-crate\"\nversion = \"0.1.0\"\n", + ) + .unwrap(); + let result = init(&mut app); + let Some(AppAction::SendMessage(prompt)) = result.action else { + panic!("expected SendMessage action"); + }; + assert!( + prompt.contains("test-crate"), + "prompt should mention crate name" + ); + assert!( + prompt.contains("Read key source files"), + "should have instructions" + ); + assert!( + prompt.contains("AGENTS.md"), + "should mention AGENTS.md filename" + ); + } + + #[test] + fn init_prompt_includes_existing_content() { + let tmpdir = TempDir::new().unwrap(); + let mut app = create_test_app_with_tmpdir(&tmpdir); + std::fs::write( + tmpdir.path().join("AGENTS.md"), + "# My Project\n\nCustom instructions here.", + ) + .unwrap(); + let result = init(&mut app); + let Some(AppAction::SendMessage(prompt)) = result.action else { + panic!("expected SendMessage action"); + }; + assert!(prompt.contains("Custom instructions here")); + assert!(prompt.contains("update it in place")); + } + + // --- parse_cargo_toml tests --- + + #[test] + fn parse_cargo_toml_single_crate() { let tmpdir = TempDir::new().unwrap(); std::fs::write( tmpdir.path().join("Cargo.toml"), - "[package]\nname = \"test\"", + "[package]\nname = \"my-crate\"\nversion = \"1.0.0\"\nedition = \"2021\"\n\n\ + [dependencies]\ntokio = \"1\"\nserde = \"1\"\n", ) .unwrap(); - let info = detect_project_type(tmpdir.path()); - assert!(info.contains("Project Type: Rust")); - assert!(info.contains("cargo build")); - assert!(info.contains("cargo test")); + let info = parse_cargo_toml(tmpdir.path()).unwrap(); + assert!(info.contains("my-crate")); + assert!(info.contains("1.0.0")); + assert!(info.contains("2021")); + assert!(info.contains("tokio")); + assert!(info.contains("serde")); } #[test] - fn test_detect_project_type_node() { + fn parse_cargo_toml_workspace() { let tmpdir = TempDir::new().unwrap(); - std::fs::write(tmpdir.path().join("package.json"), "{}").unwrap(); - let info = detect_project_type(tmpdir.path()); - assert!(info.contains("Project Type: Node.js")); - assert!(info.contains("npm install")); + std::fs::write( + tmpdir.path().join("Cargo.toml"), + "[workspace]\nmembers = [\"crates/cli\", \"crates/tui\"]\n\n\ + [workspace.dependencies]\nserde = \"1\"\n", + ) + .unwrap(); + let info = parse_cargo_toml(tmpdir.path()).unwrap(); + assert!(info.contains("workspace root")); + assert!(info.contains("crates/cli")); + assert!(info.contains("crates/tui")); } #[test] - fn test_detect_project_type_python() { + fn parse_cargo_toml_missing() { let tmpdir = TempDir::new().unwrap(); - std::fs::write(tmpdir.path().join("pyproject.toml"), "[project]").unwrap(); - let info = detect_project_type(tmpdir.path()); - assert!(info.contains("Project Type: Python")); + assert!(parse_cargo_toml(tmpdir.path()).is_none()); } #[test] - fn test_detect_project_type_go() { + fn parse_cargo_toml_invalid() { let tmpdir = TempDir::new().unwrap(); - std::fs::write(tmpdir.path().join("go.mod"), "module test").unwrap(); - let info = detect_project_type(tmpdir.path()); - assert!(info.contains("Project Type: Go")); + std::fs::write(tmpdir.path().join("Cargo.toml"), "not valid toml {{{").unwrap(); + assert!(parse_cargo_toml(tmpdir.path()).is_none()); } + // --- parse_package_json tests --- + #[test] - fn test_detect_project_type_unknown() { + fn parse_package_json_basic() { let tmpdir = TempDir::new().unwrap(); - let info = detect_project_type(tmpdir.path()); - assert!(info.contains("Project Type: Unknown")); + std::fs::write( + tmpdir.path().join("package.json"), + r#"{"name":"my-app","scripts":{"build":"tsc","test":"jest"},"dependencies":{"react":"^18"},"devDependencies":{"jest":"^29"}}"#, + ) + .unwrap(); + let info = parse_package_json(tmpdir.path()).unwrap(); + assert!(info.contains("my-app")); + assert!(info.contains("build")); + assert!(info.contains("test")); + assert!(info.contains("React")); + assert!(info.contains("jest")); } #[test] - fn test_extract_cargo_name() { - let cargo = r#" -[package] -name = "my-project" -version = "1.0.0" -"#; - assert_eq!(extract_cargo_name(cargo), Some("my-project".to_string())); + fn parse_package_json_sorts_context_keys() { + let tmpdir = TempDir::new().unwrap(); + std::fs::write( + tmpdir.path().join("package.json"), + r#"{ + "scripts":{"zeta":"node z.js","alpha":"node a.js"}, + "dependencies":{"react":"^18","axios":"^1"}, + "devDependencies":{"vitest":"^1","@sveltejs/kit":"^2"} + }"#, + ) + .unwrap(); + + let info = parse_package_json(tmpdir.path()).unwrap(); + + assert!(info.contains("- Scripts: alpha, zeta")); + assert!(info.contains("- Dependencies: axios, react")); + assert!(info.contains("- Dev dependencies: @sveltejs/kit, vitest")); } #[test] - fn test_extract_cargo_name_single_quotes() { - let cargo = r#"name = 'single-quoted'"#; - assert_eq!(extract_cargo_name(cargo), Some("single-quoted".to_string())); + fn parse_package_json_detects_sveltekit_from_dev_dependencies() { + let tmpdir = TempDir::new().unwrap(); + std::fs::write( + tmpdir.path().join("package.json"), + r#"{"devDependencies":{"@sveltejs/kit":"^2","vite":"^5"}}"#, + ) + .unwrap(); + + let info = parse_package_json(tmpdir.path()).unwrap(); + + assert!(info.contains("SvelteKit")); + assert!(info.contains("Vite")); } #[test] - fn test_extract_cargo_name_not_found() { - let cargo = "[package]\nversion = \"1.0.0\""; - assert_eq!(extract_cargo_name(cargo), None); + fn parse_package_json_missing() { + let tmpdir = TempDir::new().unwrap(); + assert!(parse_package_json(tmpdir.path()).is_none()); } + // --- gather_git_info tests --- + + #[test] + fn strip_url_credentials_removes_authority_userinfo() { + assert_eq!( + strip_url_credentials("https://user:token@github.com/org/repo.git"), + "https://github.com/org/repo.git" + ); + assert_eq!( + strip_url_credentials("https://token@github.com/org/repo.git"), + "https://github.com/org/repo.git" + ); + } + + #[test] + fn strip_url_credentials_preserves_non_authority_at_signs() { + assert_eq!( + strip_url_credentials("https://github.com/org/repo@feature.git"), + "https://github.com/org/repo@feature.git" + ); + assert_eq!( + strip_url_credentials("https://github.com/org/repo.git?ref=user@example.com"), + "https://github.com/org/repo.git?ref=user@example.com" + ); + assert_eq!( + strip_url_credentials("git@github.com:org/repo.git"), + "git@github.com:org/repo.git" + ); + assert_eq!( + strip_url_credentials("ssh://git@github.com/org/repo.git"), + "ssh://git@github.com/org/repo.git" + ); + } + + #[test] + fn gather_git_info_no_repo_returns_none() { + let tmpdir = TempDir::new().unwrap(); + assert!(gather_git_info(tmpdir.path()).is_none()); + } + + #[test] + fn gather_git_info_in_repo_returns_branch() { + let tmpdir = TempDir::new().unwrap(); + // Init a real git repo. + Command::new("git") + .args(["init"]) + .current_dir(tmpdir.path()) + .output() + .unwrap(); + Command::new("git") + .args(["config", "user.email", "test@test.com"]) + .current_dir(tmpdir.path()) + .output() + .unwrap(); + Command::new("git") + .args(["config", "user.name", "Test"]) + .current_dir(tmpdir.path()) + .output() + .unwrap(); + Command::new("git") + .args(["checkout", "-b", "main"]) + .current_dir(tmpdir.path()) + .output() + .unwrap(); + // Create a commit so rev-parse works. + std::fs::write(tmpdir.path().join("hello.txt"), "hi").unwrap(); + Command::new("git") + .args(["add", "."]) + .current_dir(tmpdir.path()) + .output() + .unwrap(); + Command::new("git") + .args(["commit", "-m", "initial"]) + .current_dir(tmpdir.path()) + .output() + .unwrap(); + + let info = gather_git_info(tmpdir.path()).unwrap(); + assert!( + info.contains("main") || info.contains("master"), + "should show branch: {info}" + ); + } + + #[test] + fn gather_git_info_works_from_nested_workspace() { + let tmpdir = TempDir::new().unwrap(); + Command::new("git") + .args(["init"]) + .current_dir(tmpdir.path()) + .output() + .unwrap(); + Command::new("git") + .args(["config", "user.email", "test@test.com"]) + .current_dir(tmpdir.path()) + .output() + .unwrap(); + Command::new("git") + .args(["config", "user.name", "Test"]) + .current_dir(tmpdir.path()) + .output() + .unwrap(); + Command::new("git") + .args(["checkout", "-b", "main"]) + .current_dir(tmpdir.path()) + .output() + .unwrap(); + std::fs::write(tmpdir.path().join("hello.txt"), "hi").unwrap(); + Command::new("git") + .args(["add", "."]) + .current_dir(tmpdir.path()) + .output() + .unwrap(); + Command::new("git") + .args(["commit", "-m", "initial"]) + .current_dir(tmpdir.path()) + .output() + .unwrap(); + let nested = tmpdir.path().join("nested").join("app"); + std::fs::create_dir_all(&nested).unwrap(); + + let info = gather_git_info(&nested).unwrap(); + + assert!(info.contains("Branch: main"), "git info was: {info}"); + } + + // --- detect_ci_systems tests --- + + #[test] + fn detect_ci_github_actions() { + let tmpdir = TempDir::new().unwrap(); + let wf_dir = tmpdir.path().join(".github").join("workflows"); + std::fs::create_dir_all(&wf_dir).unwrap(); + std::fs::write(wf_dir.join("ci.yml"), "").unwrap(); + let ci = detect_ci_systems(tmpdir.path()); + assert!(ci.iter().any(|s| s.contains("GitHub Actions"))); + } + + #[test] + fn detect_ci_github_actions_sorts_workflow_files() { + let tmpdir = TempDir::new().unwrap(); + let wf_dir = tmpdir.path().join(".github").join("workflows"); + std::fs::create_dir_all(&wf_dir).unwrap(); + std::fs::write(wf_dir.join("z.yml"), "").unwrap(); + std::fs::write(wf_dir.join("a.yaml"), "").unwrap(); + + let ci = detect_ci_systems(tmpdir.path()); + + assert_eq!(ci[0], "GitHub Actions (a.yaml, z.yml)"); + } + + #[test] + fn detect_ci_none() { + let tmpdir = TempDir::new().unwrap(); + assert!(detect_ci_systems(tmpdir.path()).is_empty()); + } + + // --- detect_build_systems tests --- + + #[test] + fn detect_makefile() { + let tmpdir = TempDir::new().unwrap(); + std::fs::write(tmpdir.path().join("Makefile"), "").unwrap(); + let build = detect_build_systems(tmpdir.path()); + assert!(build.contains(&"Makefile".to_string())); + } + + #[test] + fn detect_justfile() { + let tmpdir = TempDir::new().unwrap(); + std::fs::write(tmpdir.path().join("Justfile"), "").unwrap(); + let build = detect_build_systems(tmpdir.path()); + assert!(build.contains(&"Justfile".to_string())); + } + + #[test] + fn detect_build_systems_sorts_scripts() { + let tmpdir = TempDir::new().unwrap(); + let scripts = tmpdir.path().join("scripts"); + std::fs::create_dir_all(&scripts).unwrap(); + std::fs::write(scripts.join("z.sh"), "").unwrap(); + std::fs::write(scripts.join("a.py"), "").unwrap(); + + let build = detect_build_systems(tmpdir.path()); + + assert!(build.contains(&"scripts/ (a.py, z.sh)".to_string())); + } + + // --- detect_test_frameworks tests --- + + #[test] + fn detect_rust_test_frameworks_from_cargo() { + let tmpdir = TempDir::new().unwrap(); + std::fs::write( + tmpdir.path().join("Cargo.toml"), + "[dev-dependencies]\ntokio-test = \"1\"\nproptest = \"1\"\n", + ) + .unwrap(); + let frameworks = detect_test_frameworks(tmpdir.path()); + assert!(frameworks.contains(&"tokio-test".to_string())); + assert!(frameworks.contains(&"proptest".to_string())); + } + + #[test] + fn detect_js_test_frameworks_from_package_json() { + let tmpdir = TempDir::new().unwrap(); + std::fs::write( + tmpdir.path().join("package.json"), + r#"{"devDependencies":{"jest":"^29","vitest":"^1"}}"#, + ) + .unwrap(); + let frameworks = detect_test_frameworks(tmpdir.path()); + assert!(frameworks.contains(&"Jest".to_string())); + assert!(frameworks.contains(&"Vitest".to_string())); + } + + // --- read_existing_agents_md tests --- + + #[test] + fn read_existing_agents_md_present() { + let tmpdir = TempDir::new().unwrap(); + std::fs::write(tmpdir.path().join("AGENTS.md"), "hello world").unwrap(); + let content = read_existing_agents_md(tmpdir.path()); + assert_eq!(content, Some("hello world".to_string())); + } + + #[test] + fn read_existing_agents_md_missing() { + let tmpdir = TempDir::new().unwrap(); + assert!(read_existing_agents_md(tmpdir.path()).is_none()); + } + + #[test] + fn read_existing_agents_md_empty_file_returns_none() { + let tmpdir = TempDir::new().unwrap(); + std::fs::write(tmpdir.path().join("AGENTS.md"), "").unwrap(); + assert!(read_existing_agents_md(tmpdir.path()).is_none()); + } + + // --- build_init_prompt tests --- + + #[test] + fn build_init_prompt_contains_all_sections() { + let ctx = "## Project Summary\n\nA Rust project\n"; + let prompt = build_init_prompt(ctx, None, false); + assert!(prompt.contains("Project Context")); + assert!(prompt.contains("A Rust project")); + assert!(prompt.contains("Read key source files")); + assert!(prompt.contains("Build / Test / Lint")); + assert!(prompt.contains("Architecture")); + assert!(prompt.contains("AGENTS.md")); + } + + #[test] + fn build_init_prompt_with_existing_content() { + let ctx = "## Project Summary\n\nA Rust project\n"; + let existing = "# Old AGENTS.md content"; + let prompt = build_init_prompt(ctx, Some(existing), true); + assert!(prompt.contains("Old AGENTS.md content")); + assert!(prompt.contains("Update it in place")); + } + + #[test] + fn build_init_prompt_new_file_no_update_instruction() { + let ctx = "## Project Summary\n\nA Rust project\n"; + let prompt = build_init_prompt(ctx, None, false); + assert!(!prompt.contains("The file already exists")); + } + + // --- js framework detection --- + + #[test] + fn detect_js_frameworks_react() { + let deps = ["react", "react-dom", "vite"]; + let frameworks = detect_js_frameworks(&deps); + assert!(frameworks.contains(&"React".to_string())); + assert!(frameworks.contains(&"Vite".to_string())); + } + + #[test] + fn detect_js_frameworks_none() { + let deps = ["lodash", "axios"]; + assert!(detect_js_frameworks(&deps).is_empty()); + } + + // --- ensure_deepseek_gitignored (preserved tests) --- + #[test] fn ensure_deepseek_gitignored_creates_gitignore() { let tmpdir = TempDir::new().unwrap(); - // Simulate a git repo. std::fs::create_dir_all(tmpdir.path().join(".git")).unwrap(); - ensure_deepseek_gitignored(tmpdir.path()); - let content = std::fs::read_to_string(tmpdir.path().join(".gitignore")).unwrap(); assert!(content.contains(".deepseek/")); // .codewhale/ is ignored at any depth, but the committed @@ -412,9 +1323,7 @@ version = "1.0.0" let tmpdir = TempDir::new().unwrap(); std::fs::create_dir_all(tmpdir.path().join(".git")).unwrap(); std::fs::write(tmpdir.path().join(".gitignore"), "target/\n").unwrap(); - ensure_deepseek_gitignored(tmpdir.path()); - let content = std::fs::read_to_string(tmpdir.path().join(".gitignore")).unwrap(); assert!(content.contains("target/")); assert!(content.contains(".deepseek/")); @@ -424,10 +1333,8 @@ version = "1.0.0" fn ensure_deepseek_gitignored_idempotent() { let tmpdir = TempDir::new().unwrap(); std::fs::create_dir_all(tmpdir.path().join(".git")).unwrap(); - ensure_deepseek_gitignored(tmpdir.path()); ensure_deepseek_gitignored(tmpdir.path()); - let content = std::fs::read_to_string(tmpdir.path().join(".gitignore")).unwrap(); assert_eq!(content.matches(".deepseek/").count(), 1); } @@ -435,10 +1342,7 @@ version = "1.0.0" #[test] fn ensure_deepseek_gitignored_skips_non_git_repo() { let tmpdir = TempDir::new().unwrap(); - // No .git directory — not a git repo. - ensure_deepseek_gitignored(tmpdir.path()); - assert!(!tmpdir.path().join(".gitignore").exists()); } @@ -446,16 +1350,11 @@ version = "1.0.0" fn ensure_deepseek_gitignored_handles_no_trailing_newline() { let tmpdir = TempDir::new().unwrap(); std::fs::create_dir_all(tmpdir.path().join(".git")).unwrap(); - // Write a file that does NOT end with a newline. std::fs::write(tmpdir.path().join(".gitignore"), "target/").unwrap(); - ensure_deepseek_gitignored(tmpdir.path()); - let content = std::fs::read_to_string(tmpdir.path().join(".gitignore")).unwrap(); - // Must have both entries on separate lines. assert!(content.contains("target/")); assert!(content.contains(".deepseek/")); - // The entries should be on different lines. let lines: Vec<&str> = content.lines().collect(); assert!(lines.len() >= 2); } @@ -464,13 +1363,27 @@ version = "1.0.0" fn ensure_deepseek_gitignored_detects_variant_without_slash() { let tmpdir = TempDir::new().unwrap(); std::fs::create_dir_all(tmpdir.path().join(".git")).unwrap(); - // Write .deepseek without trailing slash. std::fs::write(tmpdir.path().join(".gitignore"), ".deepseek\n").unwrap(); - ensure_deepseek_gitignored(tmpdir.path()); - let content = std::fs::read_to_string(tmpdir.path().join(".gitignore")).unwrap(); - // Should NOT add a duplicate entry. assert_eq!(content.matches(".deepseek").count(), 1); } + + #[test] + fn ensure_deepseek_gitignored_updates_repo_root_from_nested_workspace() { + let tmpdir = TempDir::new().unwrap(); + Command::new("git") + .args(["init"]) + .current_dir(tmpdir.path()) + .output() + .unwrap(); + let nested = tmpdir.path().join("nested").join("app"); + std::fs::create_dir_all(&nested).unwrap(); + + ensure_deepseek_gitignored(&nested); + + let content = std::fs::read_to_string(tmpdir.path().join(".gitignore")).unwrap(); + assert!(content.contains(".deepseek/")); + assert!(!nested.join(".gitignore").exists()); + } }