返回 CodeWhale
init.rs
根目录 / crates / tui / src / commands / groups / project / init.rs
1 //! /init command - Generate AGENTS.md for project
2 //!
3 //! Gathers rich project context (directory structure, build system, git info, CI/CD,
4 //! test frameworks) and delegates AGENTS.md generation to the LLM agent via
5 //! `AppAction::SendMessage`. This mirrors Claude Code's `/init` behavior — the agent
6 //! reads key source files, understands the architecture, and produces a customized,
7 //! comprehensive project guide.
8
9 use std::io::Read;
10 use std::path::{Path, PathBuf};
11 use std::process::Command;
12
13 use codewhale_command_contract::handler::CommandContexts;
14
15 use crate::project_context;
16 use crate::tui::app::AppAction;
17
18 use crate::commands::CommandResult;
19
20 /// Generate an AGENTS.md file for the current project by gathering context and
21 /// delegating content generation to the LLM agent.
22 ///
23 /// FEAT-021 portable dispatch: consumes only the workspace path (supplied
24 /// through the `WORKSPACE` facet); the workspace scan and prompt composition
25 /// stay handler-owned (D2). `crate::utils`/`crate::project_context` are leaf,
26 /// path-parameterized helpers (no App/service state), consistent with D8.
27 fn init(workspace: &Path) -> CommandResult {
28 // Ensure .deepseek/ is gitignored if we're inside a git repo.
29 ensure_deepseek_gitignored(workspace);
30
31 // Check if AGENTS.md already exists — update it in place rather than refusing.
32 let agents_path = workspace.join("AGENTS.md");
33 let already_exists = agents_path.exists();
34
35 // Gather rich project context for the agent.
36 let context = gather_project_context(workspace);
37
38 // Read existing AGENTS.md content if updating.
39 let existing_content = if already_exists {
40 read_existing_agents_md(workspace)
41 } else {
42 None
43 };
44
45 // Construct the prompt for the LLM agent.
46 let prompt = build_init_prompt(&context, existing_content.as_deref(), already_exists);
47
48 // Display message to user AND send the prompt to the agent.
49 let verb = if already_exists {
50 "Updating"
51 } else {
52 "Creating"
53 };
54 let msg = format!(
55 "{verb} AGENTS.md at {}\n\nThe agent will analyze the codebase and generate a customized project guide.",
56 agents_path.display()
57 );
58
59 CommandResult::with_message_and_action(msg, AppAction::SendMessage(prompt))
60 }
61
62 /// If `workspace` is inside a git repository, ensure workspace-local CodeWhale
63 /// state is listed in the nearest `.gitignore` so snapshots, auto-generated
64 /// instructions, and other runtime state are not accidentally committed — while
65 /// keeping the authored `.codewhale/constitution.json` repo authority policy
66 /// committable (a directory exclude cannot be overridden, so `.codewhale/*` plus
67 /// a negation is required).
68 fn ensure_deepseek_gitignored(workspace: &Path) {
69 let Some(git_root) = git_root(workspace) else {
70 return;
71 };
72
73 let gitignore = git_root.join(".gitignore");
74 let entries = [
75 "**/.codewhale/*",
76 "!**/.codewhale/constitution.json",
77 ".deepseek/",
78 ];
79
80 // Read existing contents once.
81 let existing = std::fs::read_to_string(&gitignore).unwrap_or_default();
82 let mut missing: Vec<&str> = Vec::new();
83 for entry in entries {
84 let entry_no_slash = entry.trim_end_matches('/');
85 let already_ignored = existing.lines().any(|line| {
86 let trimmed = line.trim();
87 trimmed == entry || trimmed == entry_no_slash
88 });
89 if !already_ignored {
90 missing.push(entry);
91 }
92 }
93
94 if missing.is_empty() {
95 return;
96 }
97
98 // Append missing entries. If .gitignore doesn't exist yet, create it.
99 use std::io::Write;
100 if let Ok(mut file) = std::fs::OpenOptions::new()
101 .create(true)
102 .append(true)
103 .open(&gitignore)
104 {
105 // If the file is non-empty and doesn't end with a newline, add one first.
106 if let Ok(meta) = file.metadata()
107 && meta.len() > 0
108 && let Ok(mut f) = std::fs::File::open(&gitignore)
109 {
110 use std::io::Seek;
111 if f.seek(std::io::SeekFrom::End(-1)).is_ok() {
112 let mut buf = [0u8; 1];
113 if f.read_exact(&mut buf).is_ok() && buf[0] != b'\n' {
114 let _ = writeln!(file);
115 }
116 }
117 }
118 for entry in &missing {
119 let _ = writeln!(file, "{entry}");
120 }
121 }
122 }
123
124 // ---------------------------------------------------------------------------
125 // Context gathering functions
126 // ---------------------------------------------------------------------------
127
128 /// Orchestrate all context gathering and return structured Markdown for the agent prompt.
129 fn gather_project_context(workspace: &Path) -> String {
130 let mut ctx = String::new();
131
132 // Project type summary (from existing utility).
133 let summary = crate::utils::summarize_project(workspace);
134 ctx.push_str("## Project Summary\n\n");
135 ctx.push_str(&summary);
136 ctx.push_str("\n\n");
137
138 // Cargo.toml analysis.
139 if let Some(info) = parse_cargo_toml(workspace) {
140 ctx.push_str("## Rust / Cargo\n\n");
141 ctx.push_str(&info);
142 ctx.push_str("\n\n");
143 }
144
145 // package.json analysis.
146 if let Some(info) = parse_package_json(workspace) {
147 ctx.push_str("## Node.js / npm\n\n");
148 ctx.push_str(&info);
149 ctx.push_str("\n\n");
150 }
151
152 // Git repository info.
153 if let Some(info) = gather_git_info(workspace) {
154 ctx.push_str("## Git Repository\n\n");
155 ctx.push_str(&info);
156 ctx.push_str("\n\n");
157 }
158
159 // CI/CD systems.
160 let ci = detect_ci_systems(workspace);
161 if !ci.is_empty() {
162 ctx.push_str("## CI/CD\n\n");
163 for system in &ci {
164 let _ = std::fmt::write(&mut ctx, format_args!("- {system}\n"));
165 }
166 ctx.push('\n');
167 }
168
169 // Build systems.
170 let build = detect_build_systems(workspace);
171 if !build.is_empty() {
172 ctx.push_str("## Additional Build Systems\n\n");
173 for system in &build {
174 let _ = std::fmt::write(&mut ctx, format_args!("- {system}\n"));
175 }
176 ctx.push('\n');
177 }
178
179 // Test frameworks.
180 let tests = detect_test_frameworks(workspace);
181 if !tests.is_empty() {
182 ctx.push_str("## Test Frameworks\n\n");
183 for framework in &tests {
184 let _ = std::fmt::write(&mut ctx, format_args!("- {framework}\n"));
185 }
186 ctx.push('\n');
187 }
188
189 // Directory tree (from existing utility).
190 let tree = crate::utils::project_tree(workspace, 3, false);
191 ctx.push_str("## Directory Structure (depth 3)\n\n```\n");
192 ctx.push_str(&tree);
193 ctx.push_str("\n```\n\n");
194
195 // Structured project context pack (from existing utility).
196 if let Some(pack) = project_context::generate_project_context_pack(workspace) {
197 ctx.push_str("## Detailed Project Context\n\n```json\n");
198 ctx.push_str(&pack);
199 ctx.push_str("\n```\n\n");
200 }
201
202 ctx
203 }
204
205 /// Parse `Cargo.toml` and return a human-readable summary of the Rust project structure.
206 fn parse_cargo_toml(workspace: &Path) -> Option<String> {
207 let cargo_path = workspace.join("Cargo.toml");
208 let raw = std::fs::read_to_string(&cargo_path).ok()?;
209 let doc: toml::Value = toml::from_str(&raw).ok()?;
210
211 let mut lines: Vec<String> = Vec::new();
212
213 // Package info.
214 if let Some(package) = doc.get("package") {
215 if let Some(name) = package.get("name").and_then(|v| v.as_str()) {
216 lines.push(format!("- Package name: `{name}`"));
217 }
218 if let Some(version) = package.get("version").and_then(|v| v.as_str()) {
219 lines.push(format!("- Version: {version}"));
220 }
221 if let Some(edition) = package.get("edition").and_then(|v| v.as_str()) {
222 lines.push(format!("- Rust edition: {edition}"));
223 }
224 }
225
226 // Workspace info.
227 if let Some(workspace_section) = doc.get("workspace") {
228 lines.push("- **This is a workspace root**".to_string());
229 if let Some(members) = workspace_section.get("members").and_then(|v| v.as_array()) {
230 let mut member_names: Vec<&str> = members.iter().filter_map(|m| m.as_str()).collect();
231 member_names.sort_unstable();
232 if !member_names.is_empty() {
233 lines.push(format!("- Workspace members: {}", member_names.join(", ")));
234 }
235 }
236 }
237
238 // Dependencies.
239 if let Some(deps) = doc.get("dependencies").and_then(|v| v.as_table()) {
240 let mut dep_names: Vec<&str> = deps.keys().map(|k| k.as_str()).collect();
241 dep_names.sort_unstable();
242 if !dep_names.is_empty() {
243 lines.push(format!("- Key dependencies: {}", dep_names.join(", ")));
244 }
245 }
246
247 // Dev dependencies — test frameworks.
248 if let Some(dev_deps) = doc.get("dev-dependencies").and_then(|v| v.as_table()) {
249 let mut dev_names: Vec<&str> = dev_deps.keys().map(|k| k.as_str()).collect();
250 dev_names.sort_unstable();
251 if !dev_names.is_empty() {
252 lines.push(format!("- Dev dependencies: {}", dev_names.join(", ")));
253 }
254 }
255
256 // Workspace-level dependencies (shared across workspace members).
257 if let Some(ws_deps) = doc
258 .get("workspace")
259 .and_then(|w| w.get("dependencies"))
260 .and_then(|v| v.as_table())
261 {
262 let mut ws_dep_names: Vec<&str> = ws_deps.keys().map(|k| k.as_str()).collect();
263 ws_dep_names.sort_unstable();
264 if !ws_dep_names.is_empty() {
265 lines.push(format!(
266 "- Workspace dependencies: {}",
267 ws_dep_names.join(", ")
268 ));
269 }
270 }
271
272 // Features.
273 if let Some(features) = doc.get("features").and_then(|v| v.as_table()) {
274 let mut feat_names: Vec<&str> = features.keys().map(|k| k.as_str()).collect();
275 feat_names.sort_unstable();
276 if !feat_names.is_empty() {
277 lines.push(format!("- Features: {}", feat_names.join(", ")));
278 }
279 }
280
281 if lines.is_empty() {
282 None
283 } else {
284 Some(lines.join("\n"))
285 }
286 }
287
288 /// Parse `package.json` and return a human-readable summary of the Node.js project.
289 fn parse_package_json(workspace: &Path) -> Option<String> {
290 let pkg_path = workspace.join("package.json");
291 let raw = std::fs::read_to_string(&pkg_path).ok()?;
292 let doc: serde_json::Value = serde_json::from_str(&raw).ok()?;
293
294 let mut lines: Vec<String> = Vec::new();
295
296 if let Some(name) = doc.get("name").and_then(|v| v.as_str()) {
297 lines.push(format!("- Package name: `{name}`"));
298 }
299
300 // Scripts.
301 if let Some(scripts) = doc.get("scripts").and_then(|v| v.as_object()) {
302 let mut script_names: Vec<&str> = scripts.keys().map(|k| k.as_str()).collect();
303 script_names.sort_unstable();
304 if !script_names.is_empty() {
305 lines.push(format!("- Scripts: {}", script_names.join(", ")));
306 }
307 }
308
309 // Dependencies.
310 if let Some(deps) = doc.get("dependencies").and_then(|v| v.as_object()) {
311 let mut dep_keys: Vec<&str> = deps.keys().map(|k| k.as_str()).collect();
312 dep_keys.sort_unstable();
313 if !dep_keys.is_empty() {
314 // Detect frameworks from runtime deps.
315 let frameworks = detect_js_frameworks(&dep_keys);
316 if !frameworks.is_empty() {
317 lines.push(format!("- Frameworks detected: {}", frameworks.join(", ")));
318 }
319 lines.push(format!("- Dependencies: {}", dep_keys.join(", ")));
320 }
321 }
322
323 // Dev dependencies.
324 if let Some(dev_deps) = doc.get("devDependencies").and_then(|v| v.as_object()) {
325 let mut dev_keys: Vec<&str> = dev_deps.keys().map(|k| k.as_str()).collect();
326 dev_keys.sort_unstable();
327 if !dev_keys.is_empty() {
328 // Also detect build-tool/framework entries from devDependencies
329 // (Vite, webpack, esbuild, Turbopack, etc.).
330 let dev_frameworks = detect_js_frameworks(&dev_keys);
331 if !dev_frameworks.is_empty() {
332 lines.push(format!(
333 "- Dev frameworks/tools: {}",
334 dev_frameworks.join(", ")
335 ));
336 }
337 lines.push(format!("- Dev dependencies: {}", dev_keys.join(", ")));
338 }
339 }
340
341 if lines.is_empty() {
342 None
343 } else {
344 Some(lines.join("\n"))
345 }
346 }
347
348 /// Detect JS frameworks from dependency names.
349 fn detect_js_frameworks(deps: &[&str]) -> Vec<String> {
350 let mut found: Vec<String> = Vec::new();
351 let candidates: &[(&str, &str)] = &[
352 ("react", "React"),
353 ("next", "Next.js"),
354 ("vue", "Vue"),
355 ("nuxt", "Nuxt"),
356 ("@sveltejs/kit", "SvelteKit"),
357 ("svelte", "Svelte"),
358 ("sveltekit", "SvelteKit"),
359 ("astro", "Astro"),
360 ("express", "Express"),
361 ("fastify", "Fastify"),
362 ("hono", "Hono"),
363 ("vite", "Vite"),
364 ("webpack", "Webpack"),
365 ("esbuild", "esbuild"),
366 ("turbo", "Turbopack"),
367 ("tailwindcss", "Tailwind CSS"),
368 ];
369 for dep in deps {
370 let lower = dep.to_lowercase();
371 for (key, label) in candidates {
372 if lower == *key && !found.contains(&label.to_string()) {
373 found.push((*label).to_string());
374 }
375 }
376 }
377 found
378 }
379
380 /// Strip userinfo (username:password or username) from a URL to avoid leaking
381 /// embedded credentials into the LLM prompt.
382 fn strip_url_credentials(url: &str) -> String {
383 // Handle SSH-style URLs: git@host:org/repo.git — no embedded password.
384 if url.contains('@') && !url.contains("://") {
385 return url.to_string();
386 }
387 // HTTP(S) remotes: strip only authority userinfo. `@` in a path, query,
388 // or fragment is repository data, not credentials. SSH remotes such as
389 // `git@host:org/repo.git` and `ssh://git@host/org/repo.git` keep their
390 // user component because it is protocol syntax, not an embedded token.
391 if let Some(scheme_end) = url.find("://") {
392 let scheme_name = url[..scheme_end].to_ascii_lowercase();
393 if scheme_name != "http" && scheme_name != "https" {
394 return url.to_string();
395 }
396 let scheme = &url[..scheme_end + 3];
397 let after_scheme = &url[scheme_end + 3..];
398 let authority_end = after_scheme
399 .find(['/', '?', '#'])
400 .unwrap_or(after_scheme.len());
401 let (authority, suffix) = after_scheme.split_at(authority_end);
402 if let Some(at_pos) = authority.rfind('@') {
403 return format!("{scheme}{}{suffix}", &authority[at_pos + 1..]);
404 }
405 }
406 url.to_string()
407 }
408
409 /// Find the enclosing git repository root. Works for nested workspaces and
410 /// worktrees where `.git` is a file instead of a directory.
411 fn git_root(workspace: &Path) -> Option<PathBuf> {
412 let direct_git_marker = workspace.join(".git");
413 let discovered = Command::new("git")
414 .args(["rev-parse", "--show-toplevel"])
415 .current_dir(workspace)
416 .output()
417 .ok()
418 .and_then(|out| {
419 if out.status.success() {
420 String::from_utf8(out.stdout)
421 .ok()
422 .map(|s| s.trim().to_string())
423 .filter(|s| !s.is_empty())
424 .map(PathBuf::from)
425 } else {
426 None
427 }
428 });
429 discovered.or_else(|| direct_git_marker.exists().then(|| workspace.to_path_buf()))
430 }
431
432 /// Gather git repository information via subprocess calls.
433 fn gather_git_info(workspace: &Path) -> Option<String> {
434 let git_root = git_root(workspace)?;
435
436 let run = |args: &[&str]| -> Option<String> {
437 Command::new("git")
438 .args(args)
439 .current_dir(&git_root)
440 .output()
441 .ok()
442 .and_then(|out| {
443 if out.status.success() {
444 String::from_utf8(out.stdout)
445 .ok()
446 .map(|s| s.trim().to_string())
447 .filter(|s| !s.is_empty())
448 } else {
449 None
450 }
451 })
452 };
453
454 let mut lines: Vec<String> = Vec::new();
455
456 // Remote URL (strip embedded credentials to avoid leaking tokens to the LLM).
457 if let Some(url) = run(&["remote", "get-url", "origin"]) {
458 let sanitized = strip_url_credentials(&url);
459 lines.push(format!("- Remote: {sanitized}"));
460 }
461
462 // Current branch.
463 if let Some(branch) = run(&["rev-parse", "--abbrev-ref", "HEAD"]) {
464 lines.push(format!("- Branch: {branch}"));
465 }
466
467 // Status summary.
468 let status_output = Command::new("git")
469 .args(["status", "--porcelain=v1", "--untracked-files=no"])
470 // Read-only probe: never take the index lock in the user's repo
471 // (#5617).
472 .env("GIT_OPTIONAL_LOCKS", "0")
473 .current_dir(&git_root)
474 .output()
475 .ok();
476 if let Some(out) = status_output
477 && out.status.success()
478 {
479 let status_str = String::from_utf8_lossy(&out.stdout);
480 let staged = status_str
481 .lines()
482 .filter(|l| {
483 let b = l.as_bytes();
484 b.len() >= 2 && b[0] != b' ' && b[0] != b'?'
485 })
486 .count();
487 let unstaged = status_str
488 .lines()
489 .filter(|l| {
490 let b = l.as_bytes();
491 b.len() >= 2 && b[1] != b' ' && b[1] != b'?'
492 })
493 .count();
494 if staged > 0 || unstaged > 0 {
495 let mut parts = Vec::new();
496 if staged > 0 {
497 parts.push(format!("{staged} staged"));
498 }
499 if unstaged > 0 {
500 parts.push(format!("{unstaged} modified"));
501 }
502 lines.push(format!("- Working tree: {}", parts.join(", ")));
503 }
504 }
505
506 // Recent commits.
507 if let Some(log) = run(&["log", "--oneline", "-5"]) {
508 let commits: Vec<&str> = log.lines().collect();
509 if !commits.is_empty() {
510 lines.push("- Recent commits:".to_string());
511 for c in commits {
512 lines.push(format!(" - {c}"));
513 }
514 }
515 }
516
517 if lines.is_empty() {
518 None
519 } else {
520 Some(lines.join("\n"))
521 }
522 }
523
524 /// Detect CI/CD systems configured in the project.
525 fn detect_ci_systems(workspace: &Path) -> Vec<String> {
526 let mut found: Vec<String> = Vec::new();
527
528 if workspace.join(".github").join("workflows").is_dir()
529 && let Ok(entries) = std::fs::read_dir(workspace.join(".github").join("workflows"))
530 {
531 let files: Vec<String> = entries
532 .filter_map(|e| e.ok())
533 .filter_map(|e| {
534 let name = e.file_name().to_string_lossy().into_owned();
535 if name.ends_with(".yml") || name.ends_with(".yaml") {
536 Some(name)
537 } else {
538 None
539 }
540 })
541 .collect();
542 let mut files = files;
543 files.sort_unstable();
544 if files.is_empty() {
545 found.push("GitHub Actions".to_string());
546 } else {
547 found.push(format!("GitHub Actions ({})", files.join(", ")));
548 }
549 }
550 if workspace.join(".gitlab-ci.yml").exists() {
551 found.push("GitLab CI".to_string());
552 }
553 if workspace.join("Jenkinsfile").exists() {
554 found.push("Jenkins".to_string());
555 }
556 if workspace.join(".circleci").join("config.yml").exists() {
557 found.push("CircleCI".to_string());
558 }
559 if workspace.join(".travis.yml").exists() {
560 found.push("Travis CI".to_string());
561 }
562 if workspace.join("azure-pipelines.yml").exists() {
563 found.push("Azure Pipelines".to_string());
564 }
565
566 found
567 }
568
569 /// Detect additional build systems beyond Cargo/npm.
570 fn detect_build_systems(workspace: &Path) -> Vec<String> {
571 let mut found: Vec<String> = Vec::new();
572
573 if workspace.join("Makefile").exists() {
574 found.push("Makefile".to_string());
575 }
576 if workspace.join("Justfile").exists() {
577 found.push("Justfile".to_string());
578 }
579 if workspace.join("CMakeLists.txt").exists() {
580 found.push("CMake".to_string());
581 }
582 if workspace.join("meson.build").exists() {
583 found.push("Meson".to_string());
584 }
585 if workspace.join("BUILD.bazel").exists() || workspace.join("BUILD").exists() {
586 found.push("Bazel".to_string());
587 }
588 if workspace.join("scripts").is_dir()
589 && let Ok(entries) = std::fs::read_dir(workspace.join("scripts"))
590 {
591 let scripts: Vec<String> = entries
592 .filter_map(|e| e.ok())
593 .filter_map(|e| {
594 let name = e.file_name().to_string_lossy().into_owned();
595 let path = e.path();
596 if (name.ends_with(".sh") || name.ends_with(".py") || name.ends_with(".js"))
597 && path.is_file()
598 {
599 Some(name)
600 } else {
601 None
602 }
603 })
604 .collect();
605 let mut scripts = scripts;
606 scripts.sort_unstable();
607 if !scripts.is_empty() {
608 found.push(format!("scripts/ ({})", scripts.join(", ")));
609 }
610 }
611
612 found
613 }
614
615 /// Detect test frameworks from project configuration.
616 fn detect_test_frameworks(workspace: &Path) -> Vec<String> {
617 let mut found: Vec<String> = Vec::new();
618
619 // Rust: check Cargo.toml dev-dependencies (both crate and workspace level).
620 if let Ok(raw) = std::fs::read_to_string(workspace.join("Cargo.toml"))
621 && let Ok(doc) = toml::from_str::<toml::Value>(&raw)
622 {
623 let mut dep_keys: Vec<&str> = Vec::new();
624 if let Some(dev_deps) = doc.get("dev-dependencies").and_then(|v| v.as_table()) {
625 dep_keys.extend(dev_deps.keys().map(|k| k.as_str()));
626 }
627 if let Some(ws_dev_deps) = doc
628 .get("workspace")
629 .and_then(|w| w.get("dev-dependencies"))
630 .and_then(|v| v.as_table())
631 {
632 dep_keys.extend(ws_dev_deps.keys().map(|k| k.as_str()));
633 }
634
635 let rust_test_frameworks: &[(&str, &str)] = &[
636 ("tokio-test", "tokio-test"),
637 ("proptest", "proptest"),
638 ("quickcheck", "quickcheck"),
639 ("rstest", "rstest"),
640 ("criterion", "criterion (benchmark)"),
641 ("mockall", "mockall"),
642 ("pretty_assertions", "pretty_assertions"),
643 ];
644 for (dep_key, label) in rust_test_frameworks {
645 if dep_keys.contains(dep_key) {
646 found.push((*label).to_string());
647 }
648 }
649 }
650
651 // Node.js: check package.json devDependencies.
652 if let Ok(raw) = std::fs::read_to_string(workspace.join("package.json"))
653 && let Ok(doc) = serde_json::from_str::<serde_json::Value>(&raw)
654 && let Some(dev_deps) = doc.get("devDependencies").and_then(|v| v.as_object())
655 {
656 let dev_keys: Vec<&str> = dev_deps.keys().map(|k| k.as_str()).collect();
657
658 let js_test_frameworks: &[(&str, &str)] = &[
659 ("jest", "Jest"),
660 ("vitest", "Vitest"),
661 ("mocha", "Mocha"),
662 ("jasmine", "Jasmine"),
663 ("ava", "AVA"),
664 ("playwright", "Playwright"),
665 ("cypress", "Cypress"),
666 ("@testing-library/react", "Testing Library"),
667 ];
668 for (dep_key, label) in js_test_frameworks {
669 if dev_keys.contains(dep_key) {
670 found.push((*label).to_string());
671 }
672 }
673 }
674
675 // Python: check common test config files.
676 if workspace.join("pytest.ini").exists()
677 || workspace.join("tox.ini").exists()
678 || workspace.join("conftest.py").exists()
679 || (workspace.join("pyproject.toml").exists()
680 && std::fs::read_to_string(workspace.join("pyproject.toml"))
681 .ok()
682 .is_some_and(|raw| raw.contains("[tool.pytest")))
683 {
684 found.push("pytest".to_string());
685 }
686
687 found
688 }
689
690 /// Read existing AGENTS.md content (up to 100KB) for in-place update.
691 fn read_existing_agents_md(workspace: &Path) -> Option<String> {
692 let path = workspace.join("AGENTS.md");
693 let meta = std::fs::metadata(&path).ok()?;
694 let limit = 100 * 1024;
695 let len = meta.len() as usize;
696 let content = if len > limit {
697 let mut f = std::fs::File::open(&path).ok()?;
698 let mut buf = vec![0u8; limit];
699 f.read_exact(&mut buf).ok()?;
700 String::from_utf8_lossy(&buf).into_owned()
701 } else {
702 std::fs::read_to_string(&path).ok()?
703 };
704 if content.trim().is_empty() {
705 None
706 } else {
707 Some(content)
708 }
709 }
710
711 // ---------------------------------------------------------------------------
712 // Prompt builder
713 // ---------------------------------------------------------------------------
714
715 /// Build the SendMessage prompt instructing the agent to analyze and generate AGENTS.md.
716 fn build_init_prompt(
717 context: &str,
718 existing_content: Option<&str>,
719 already_exists: bool,
720 ) -> String {
721 let mut prompt = String::new();
722
723 prompt.push_str(
724 "You are generating a comprehensive AGENTS.md file for this project. \
725 Your task is to deeply analyze the codebase and produce a customized, \
726 actionable project guide that will help future AI agents work effectively here.\n\n",
727 );
728
729 prompt.push_str("## Project Context (pre-gathered)\n\n");
730 prompt.push_str(context);
731 prompt.push('\n');
732
733 if let Some(existing) = existing_content {
734 prompt.push_str("## Existing AGENTS.md\n\n");
735 prompt.push_str("Below is the current AGENTS.md content. ");
736 if already_exists {
737 prompt.push_str(
738 "Update it in place: preserve any custom sections that still apply, \
739 replace stale or incorrect information with your fresh analysis. ",
740 );
741 }
742 prompt.push_str("\n\n```markdown\n");
743 prompt.push_str(existing);
744 prompt.push_str("\n```\n\n");
745 }
746
747 prompt.push_str("## Instructions\n\n");
748
749 prompt.push_str(
750 "1. **Read key source files** to understand the architecture:\n\
751 - Start with the main entry point(s) (e.g., main.rs, index.ts, app.py)\n\
752 - Read the top-level module structure to understand component boundaries\n\
753 - Read a few representative files from each major module or crate\n\
754 - Read config files (config.example.toml, tsconfig.json, etc.) to understand settings\n\n\
755 2. **Generate AGENTS.md** at the workspace root. Use `AGENTS.md` as the filename. \
756 Include these sections as applicable:\n\n\
757 ### Build / Test / Lint\n\
758 - Exact commands for: build, test (all + single), lint, format, run, install deps\n\
759 - Be specific — if there's a Justfile, use `just <target>`; if nextest, use `cargo nextest run`\n\n\
760 ### Architecture\n\
761 - High-level description of the project's purpose\n\
762 - Component or module tree with 1-2 sentence descriptions each\n\
763 - Data flow through the system (if determinable)\n\n\
764 ### Key Files & Directories\n\
765 - What each top-level directory contains\n\
766 - Important config files and what they control\n\n\
767 ### Coding Conventions\n\
768 - What you observe from reading source files: naming, error handling patterns, \
769 module organization, test patterns\n\
770 - Code generation (build.rs, protobuf, etc.) if present\n\n\
771 ### Git Workflow\n\
772 - Branch naming conventions (if observable from recent commits)\n\
773 - Commit message style\n\n\
774 ### CI/CD\n\
775 - How tests run in CI, what's checked on PRs\n\n\
776 ### Tips for AI Agents\n\
777 - Common pitfalls in the codebase structure\n\
778 - Where to look for specific kinds of things\n\
779 - Any gotchas in the build setup\n\n\
780 3. **Style requirements**:\n\
781 - Be concise and actionable. This is a reference document, not a tutorial.\n\
782 - Use markdown headings, code blocks, and bullet lists.\n\
783 - Keep the total under ~150 lines unless the project genuinely needs more.\n\
784 - Write in English.\n\
785 - Do NOT include placeholder HTML comments like \"<!-- add stuff here -->\".\n\
786 - If you cannot determine something with confidence, omit that section rather than guessing.\n\n\
787 4. **Write the file** using the file write tool. \
788 The file should be named `AGENTS.md` at the workspace root.\n\n",
789 );
790
791 if already_exists {
792 prompt.push_str(
793 "The file already exists — update it in place, \
794 preserving custom content that still applies but replacing stale information.\n\n",
795 );
796 }
797
798 prompt.push_str(
799 "5. After writing, briefly summarize what you learned and what you put into AGENTS.md.\n",
800 );
801
802 prompt
803 }
804
805 pub(in crate::commands) const INIT_INFO: codewhale_command_contract::metadata::CommandInfo =
806 codewhale_command_contract::metadata::CommandInfo {
807 name: "init",
808 aliases: &[],
809 usage: "/init",
810 description_key: "cmd_init_description",
811 };
812
813 pub(in crate::commands) struct InitCmd;
814
815 impl codewhale_command_contract::metadata::RegisterCommand<crate::commands::CommandResult>
816 for InitCmd
817 {
818 fn info() -> &'static codewhale_command_contract::metadata::CommandInfo {
819 &INIT_INFO
820 }
821
822 fn handler()
823 -> codewhale_command_contract::handler::CommandHandler<crate::commands::CommandResult> {
824 codewhale_command_contract::handler::CommandHandler::Contextual {
825 capabilities: codewhale_command_contract::handler::CommandCapabilities::WORKSPACE,
826 handler: init_contextual,
827 }
828 }
829 }
830
831 /// Contextual `/init` dispatch (FEAT-021 Phase 4).
832 ///
833 /// Destructures the declared `WORKSPACE` facet with a safe missing-facet
834 /// error. The workspace path is consumed via the `WORKSPACE` facet (D2);
835 /// `/init` consumes no project-facet method, so it destructures exactly
836 /// `WORKSPACE` (D4, least-capability — the initial PROJECT|WORKSPACE
837 /// declaration was amended after the critical audit; main's model expresses
838 /// the declaration as exact facet destructuring rather than a bitmask).
839 fn init_contextual(contexts: CommandContexts<'_>, _arg: Option<&str>) -> CommandResult {
840 let parts = contexts.into_parts();
841 let Some(workspace) = parts.workspace.as_deref() else {
842 return crate::commands::CommandResult::error("Command capability unavailable: workspace");
843 };
844 init(&workspace.workspace())
845 }
846
847 // ---------------------------------------------------------------------------
848 // Tests
849 // ---------------------------------------------------------------------------
850
851 #[cfg(test)]
852 mod tests {
853 use super::*;
854 use tempfile::TempDir;
855
856 // --- init() integration tests ---
857
858 #[test]
859 fn init_returns_send_message_action() {
860 let tmpdir = TempDir::new().unwrap();
861 let result = init(tmpdir.path());
862 assert!(result.message.is_some());
863 let msg = result.message.unwrap();
864 assert!(msg.contains("Creating AGENTS.md"));
865 assert!(
866 matches!(result.action, Some(AppAction::SendMessage(_))),
867 "expected SendMessage action"
868 );
869 }
870
871 #[test]
872 fn init_says_updating_when_agents_md_exists() {
873 let tmpdir = TempDir::new().unwrap();
874 std::fs::write(tmpdir.path().join("AGENTS.md"), "existing content").unwrap();
875 let result = init(tmpdir.path());
876 assert!(result.message.unwrap().contains("Updating AGENTS.md"));
877 assert!(matches!(result.action, Some(AppAction::SendMessage(_))));
878 }
879
880 #[test]
881 fn init_includes_gitignore_handling() {
882 let tmpdir = TempDir::new().unwrap();
883 std::fs::create_dir_all(tmpdir.path().join(".git")).unwrap();
884 let result = init(tmpdir.path());
885 assert!(!result.is_error);
886 // Should have added .deepseek/ to .gitignore.
887 let gi = std::fs::read_to_string(tmpdir.path().join(".gitignore")).unwrap();
888 assert!(gi.contains(".deepseek/"));
889 }
890
891 #[test]
892 fn init_prompt_includes_context_for_rust_project() {
893 let tmpdir = TempDir::new().unwrap();
894 std::fs::write(
895 tmpdir.path().join("Cargo.toml"),
896 "[package]\nname = \"test-crate\"\nversion = \"0.1.0\"\n",
897 )
898 .unwrap();
899 let result = init(tmpdir.path());
900 let Some(AppAction::SendMessage(prompt)) = result.action else {
901 panic!("expected SendMessage action");
902 };
903 assert!(
904 prompt.contains("test-crate"),
905 "prompt should mention crate name"
906 );
907 assert!(
908 prompt.contains("Read key source files"),
909 "should have instructions"
910 );
911 assert!(
912 prompt.contains("AGENTS.md"),
913 "should mention AGENTS.md filename"
914 );
915 }
916
917 #[test]
918 fn init_prompt_includes_existing_content() {
919 let tmpdir = TempDir::new().unwrap();
920 std::fs::write(
921 tmpdir.path().join("AGENTS.md"),
922 "# My Project\n\nCustom instructions here.",
923 )
924 .unwrap();
925 let result = init(tmpdir.path());
926 let Some(AppAction::SendMessage(prompt)) = result.action else {
927 panic!("expected SendMessage action");
928 };
929 assert!(prompt.contains("Custom instructions here"));
930 assert!(prompt.contains("update it in place"));
931 }
932
933 #[test]
934 fn missing_workspace_facet_fails_safely() {
935 // An empty envelope must fail safely — never panic and never perform a
936 // partial workspace mutation.
937 let result = init_contextual(CommandContexts::empty(), None);
938 assert!(result.is_error);
939 assert!(
940 result
941 .message
942 .unwrap()
943 .contains("Command capability unavailable: workspace"),
944 "missing workspace facet must fail safely"
945 );
946 }
947
948 // --- parse_cargo_toml tests ---
949
950 #[test]
951 fn parse_cargo_toml_single_crate() {
952 let tmpdir = TempDir::new().unwrap();
953 std::fs::write(
954 tmpdir.path().join("Cargo.toml"),
955 "[package]\nname = \"my-crate\"\nversion = \"1.0.0\"\nedition = \"2021\"\n\n\
956 [dependencies]\ntokio = \"1\"\nserde = \"1\"\n",
957 )
958 .unwrap();
959 let info = parse_cargo_toml(tmpdir.path()).unwrap();
960 assert!(info.contains("my-crate"));
961 assert!(info.contains("1.0.0"));
962 assert!(info.contains("2021"));
963 assert!(info.contains("tokio"));
964 assert!(info.contains("serde"));
965 }
966
967 #[test]
968 fn parse_cargo_toml_workspace() {
969 let tmpdir = TempDir::new().unwrap();
970 std::fs::write(
971 tmpdir.path().join("Cargo.toml"),
972 "[workspace]\nmembers = [\"crates/cli\", \"crates/tui\"]\n\n\
973 [workspace.dependencies]\nserde = \"1\"\n",
974 )
975 .unwrap();
976 let info = parse_cargo_toml(tmpdir.path()).unwrap();
977 assert!(info.contains("workspace root"));
978 assert!(info.contains("crates/cli"));
979 assert!(info.contains("crates/tui"));
980 }
981
982 #[test]
983 fn parse_cargo_toml_missing() {
984 let tmpdir = TempDir::new().unwrap();
985 assert!(parse_cargo_toml(tmpdir.path()).is_none());
986 }
987
988 #[test]
989 fn parse_cargo_toml_invalid() {
990 let tmpdir = TempDir::new().unwrap();
991 std::fs::write(tmpdir.path().join("Cargo.toml"), "not valid toml {{{").unwrap();
992 assert!(parse_cargo_toml(tmpdir.path()).is_none());
993 }
994
995 // --- parse_package_json tests ---
996
997 #[test]
998 fn parse_package_json_basic() {
999 let tmpdir = TempDir::new().unwrap();
1000 std::fs::write(
1001 tmpdir.path().join("package.json"),
1002 r#"{"name":"my-app","scripts":{"build":"tsc","test":"jest"},"dependencies":{"react":"^18"},"devDependencies":{"jest":"^29"}}"#,
1003 )
1004 .unwrap();
1005 let info = parse_package_json(tmpdir.path()).unwrap();
1006 assert!(info.contains("my-app"));
1007 assert!(info.contains("build"));
1008 assert!(info.contains("test"));
1009 assert!(info.contains("React"));
1010 assert!(info.contains("jest"));
1011 }
1012
1013 #[test]
1014 fn parse_package_json_sorts_context_keys() {
1015 let tmpdir = TempDir::new().unwrap();
1016 std::fs::write(
1017 tmpdir.path().join("package.json"),
1018 r#"{
1019 "scripts":{"zeta":"node z.js","alpha":"node a.js"},
1020 "dependencies":{"react":"^18","axios":"^1"},
1021 "devDependencies":{"vitest":"^1","@sveltejs/kit":"^2"}
1022 }"#,
1023 )
1024 .unwrap();
1025
1026 let info = parse_package_json(tmpdir.path()).unwrap();
1027
1028 assert!(info.contains("- Scripts: alpha, zeta"));
1029 assert!(info.contains("- Dependencies: axios, react"));
1030 assert!(info.contains("- Dev dependencies: @sveltejs/kit, vitest"));
1031 }
1032
1033 #[test]
1034 fn parse_package_json_detects_sveltekit_from_dev_dependencies() {
1035 let tmpdir = TempDir::new().unwrap();
1036 std::fs::write(
1037 tmpdir.path().join("package.json"),
1038 r#"{"devDependencies":{"@sveltejs/kit":"^2","vite":"^5"}}"#,
1039 )
1040 .unwrap();
1041
1042 let info = parse_package_json(tmpdir.path()).unwrap();
1043
1044 assert!(info.contains("SvelteKit"));
1045 assert!(info.contains("Vite"));
1046 }
1047
1048 #[test]
1049 fn parse_package_json_missing() {
1050 let tmpdir = TempDir::new().unwrap();
1051 assert!(parse_package_json(tmpdir.path()).is_none());
1052 }
1053
1054 // --- gather_git_info tests ---
1055
1056 #[test]
1057 fn strip_url_credentials_removes_authority_userinfo() {
1058 assert_eq!(
1059 strip_url_credentials("https://user:token@github.com/org/repo.git"),
1060 "https://github.com/org/repo.git"
1061 );
1062 assert_eq!(
1063 strip_url_credentials("https://token@github.com/org/repo.git"),
1064 "https://github.com/org/repo.git"
1065 );
1066 }
1067
1068 #[test]
1069 fn strip_url_credentials_preserves_non_authority_at_signs() {
1070 assert_eq!(
1071 strip_url_credentials("https://github.com/org/repo@feature.git"),
1072 "https://github.com/org/repo@feature.git"
1073 );
1074 assert_eq!(
1075 strip_url_credentials("https://github.com/org/repo.git?ref=user@example.com"),
1076 "https://github.com/org/repo.git?ref=user@example.com"
1077 );
1078 assert_eq!(
1079 strip_url_credentials("git@github.com:org/repo.git"),
1080 "git@github.com:org/repo.git"
1081 );
1082 assert_eq!(
1083 strip_url_credentials("ssh://git@github.com/org/repo.git"),
1084 "ssh://git@github.com/org/repo.git"
1085 );
1086 }
1087
1088 #[test]
1089 fn gather_git_info_no_repo_returns_none() {
1090 let tmpdir = TempDir::new().unwrap();
1091 assert!(gather_git_info(tmpdir.path()).is_none());
1092 }
1093
1094 #[test]
1095 fn gather_git_info_in_repo_returns_branch() {
1096 let tmpdir = TempDir::new().unwrap();
1097 // Init a real git repo.
1098 Command::new("git")
1099 .args(["init"])
1100 .current_dir(tmpdir.path())
1101 .output()
1102 .unwrap();
1103 Command::new("git")
1104 .args(["config", "user.email", "test@test.com"])
1105 .current_dir(tmpdir.path())
1106 .output()
1107 .unwrap();
1108 Command::new("git")
1109 .args(["config", "user.name", "Test"])
1110 .current_dir(tmpdir.path())
1111 .output()
1112 .unwrap();
1113 Command::new("git")
1114 .args(["config", "core.autocrlf", "false"])
1115 .current_dir(tmpdir.path())
1116 .output()
1117 .unwrap();
1118 Command::new("git")
1119 .args(["checkout", "-b", "main"])
1120 .current_dir(tmpdir.path())
1121 .output()
1122 .unwrap();
1123 // Create a commit so rev-parse works.
1124 std::fs::write(tmpdir.path().join("hello.txt"), "hi").unwrap();
1125 Command::new("git")
1126 .args(["add", "."])
1127 .current_dir(tmpdir.path())
1128 .output()
1129 .unwrap();
1130 Command::new("git")
1131 .args(["commit", "-m", "initial"])
1132 .current_dir(tmpdir.path())
1133 .output()
1134 .unwrap();
1135
1136 let info = gather_git_info(tmpdir.path()).unwrap();
1137 assert!(
1138 info.contains("main") || info.contains("master"),
1139 "should show branch: {info}"
1140 );
1141 }
1142
1143 #[test]
1144 fn gather_git_info_works_from_nested_workspace() {
1145 let tmpdir = TempDir::new().unwrap();
1146 Command::new("git")
1147 .args(["init"])
1148 .current_dir(tmpdir.path())
1149 .output()
1150 .unwrap();
1151 Command::new("git")
1152 .args(["config", "user.email", "test@test.com"])
1153 .current_dir(tmpdir.path())
1154 .output()
1155 .unwrap();
1156 Command::new("git")
1157 .args(["config", "user.name", "Test"])
1158 .current_dir(tmpdir.path())
1159 .output()
1160 .unwrap();
1161 Command::new("git")
1162 .args(["config", "core.autocrlf", "false"])
1163 .current_dir(tmpdir.path())
1164 .output()
1165 .unwrap();
1166 Command::new("git")
1167 .args(["checkout", "-b", "main"])
1168 .current_dir(tmpdir.path())
1169 .output()
1170 .unwrap();
1171 std::fs::write(tmpdir.path().join("hello.txt"), "hi").unwrap();
1172 Command::new("git")
1173 .args(["add", "."])
1174 .current_dir(tmpdir.path())
1175 .output()
1176 .unwrap();
1177 Command::new("git")
1178 .args(["commit", "-m", "initial"])
1179 .current_dir(tmpdir.path())
1180 .output()
1181 .unwrap();
1182 let nested = tmpdir.path().join("nested").join("app");
1183 std::fs::create_dir_all(&nested).unwrap();
1184
1185 let info = gather_git_info(&nested).unwrap();
1186
1187 assert!(info.contains("Branch: main"), "git info was: {info}");
1188 }
1189
1190 // --- detect_ci_systems tests ---
1191
1192 #[test]
1193 fn detect_ci_github_actions() {
1194 let tmpdir = TempDir::new().unwrap();
1195 let wf_dir = tmpdir.path().join(".github").join("workflows");
1196 std::fs::create_dir_all(&wf_dir).unwrap();
1197 std::fs::write(wf_dir.join("ci.yml"), "").unwrap();
1198 let ci = detect_ci_systems(tmpdir.path());
1199 assert!(ci.iter().any(|s| s.contains("GitHub Actions")));
1200 }
1201
1202 #[test]
1203 fn detect_ci_github_actions_sorts_workflow_files() {
1204 let tmpdir = TempDir::new().unwrap();
1205 let wf_dir = tmpdir.path().join(".github").join("workflows");
1206 std::fs::create_dir_all(&wf_dir).unwrap();
1207 std::fs::write(wf_dir.join("z.yml"), "").unwrap();
1208 std::fs::write(wf_dir.join("a.yaml"), "").unwrap();
1209
1210 let ci = detect_ci_systems(tmpdir.path());
1211
1212 assert_eq!(ci[0], "GitHub Actions (a.yaml, z.yml)");
1213 }
1214
1215 #[test]
1216 fn detect_ci_none() {
1217 let tmpdir = TempDir::new().unwrap();
1218 assert!(detect_ci_systems(tmpdir.path()).is_empty());
1219 }
1220
1221 // --- detect_build_systems tests ---
1222
1223 #[test]
1224 fn detect_makefile() {
1225 let tmpdir = TempDir::new().unwrap();
1226 std::fs::write(tmpdir.path().join("Makefile"), "").unwrap();
1227 let build = detect_build_systems(tmpdir.path());
1228 assert!(build.contains(&"Makefile".to_string()));
1229 }
1230
1231 #[test]
1232 fn detect_justfile() {
1233 let tmpdir = TempDir::new().unwrap();
1234 std::fs::write(tmpdir.path().join("Justfile"), "").unwrap();
1235 let build = detect_build_systems(tmpdir.path());
1236 assert!(build.contains(&"Justfile".to_string()));
1237 }
1238
1239 #[test]
1240 fn detect_build_systems_sorts_scripts() {
1241 let tmpdir = TempDir::new().unwrap();
1242 let scripts = tmpdir.path().join("scripts");
1243 std::fs::create_dir_all(&scripts).unwrap();
1244 std::fs::write(scripts.join("z.sh"), "").unwrap();
1245 std::fs::write(scripts.join("a.py"), "").unwrap();
1246
1247 let build = detect_build_systems(tmpdir.path());
1248
1249 assert!(build.contains(&"scripts/ (a.py, z.sh)".to_string()));
1250 }
1251
1252 // --- detect_test_frameworks tests ---
1253
1254 #[test]
1255 fn detect_rust_test_frameworks_from_cargo() {
1256 let tmpdir = TempDir::new().unwrap();
1257 std::fs::write(
1258 tmpdir.path().join("Cargo.toml"),
1259 "[dev-dependencies]\ntokio-test = \"1\"\nproptest = \"1\"\n",
1260 )
1261 .unwrap();
1262 let frameworks = detect_test_frameworks(tmpdir.path());
1263 assert!(frameworks.contains(&"tokio-test".to_string()));
1264 assert!(frameworks.contains(&"proptest".to_string()));
1265 }
1266
1267 #[test]
1268 fn detect_js_test_frameworks_from_package_json() {
1269 let tmpdir = TempDir::new().unwrap();
1270 std::fs::write(
1271 tmpdir.path().join("package.json"),
1272 r#"{"devDependencies":{"jest":"^29","vitest":"^1"}}"#,
1273 )
1274 .unwrap();
1275 let frameworks = detect_test_frameworks(tmpdir.path());
1276 assert!(frameworks.contains(&"Jest".to_string()));
1277 assert!(frameworks.contains(&"Vitest".to_string()));
1278 }
1279
1280 // --- read_existing_agents_md tests ---
1281
1282 #[test]
1283 fn read_existing_agents_md_present() {
1284 let tmpdir = TempDir::new().unwrap();
1285 std::fs::write(tmpdir.path().join("AGENTS.md"), "hello world").unwrap();
1286 let content = read_existing_agents_md(tmpdir.path());
1287 assert_eq!(content, Some("hello world".to_string()));
1288 }
1289
1290 #[test]
1291 fn read_existing_agents_md_missing() {
1292 let tmpdir = TempDir::new().unwrap();
1293 assert!(read_existing_agents_md(tmpdir.path()).is_none());
1294 }
1295
1296 #[test]
1297 fn read_existing_agents_md_empty_file_returns_none() {
1298 let tmpdir = TempDir::new().unwrap();
1299 std::fs::write(tmpdir.path().join("AGENTS.md"), "").unwrap();
1300 assert!(read_existing_agents_md(tmpdir.path()).is_none());
1301 }
1302
1303 // --- build_init_prompt tests ---
1304
1305 #[test]
1306 fn build_init_prompt_contains_all_sections() {
1307 let ctx = "## Project Summary\n\nA Rust project\n";
1308 let prompt = build_init_prompt(ctx, None, false);
1309 assert!(prompt.contains("Project Context"));
1310 assert!(prompt.contains("A Rust project"));
1311 assert!(prompt.contains("Read key source files"));
1312 assert!(prompt.contains("Build / Test / Lint"));
1313 assert!(prompt.contains("Architecture"));
1314 assert!(prompt.contains("AGENTS.md"));
1315 }
1316
1317 #[test]
1318 fn build_init_prompt_with_existing_content() {
1319 let ctx = "## Project Summary\n\nA Rust project\n";
1320 let existing = "# Old AGENTS.md content";
1321 let prompt = build_init_prompt(ctx, Some(existing), true);
1322 assert!(prompt.contains("Old AGENTS.md content"));
1323 assert!(prompt.contains("Update it in place"));
1324 }
1325
1326 #[test]
1327 fn build_init_prompt_new_file_no_update_instruction() {
1328 let ctx = "## Project Summary\n\nA Rust project\n";
1329 let prompt = build_init_prompt(ctx, None, false);
1330 assert!(!prompt.contains("The file already exists"));
1331 }
1332
1333 // --- js framework detection ---
1334
1335 #[test]
1336 fn detect_js_frameworks_react() {
1337 let deps = ["react", "react-dom", "vite"];
1338 let frameworks = detect_js_frameworks(&deps);
1339 assert!(frameworks.contains(&"React".to_string()));
1340 assert!(frameworks.contains(&"Vite".to_string()));
1341 }
1342
1343 #[test]
1344 fn detect_js_frameworks_none() {
1345 let deps = ["lodash", "axios"];
1346 assert!(detect_js_frameworks(&deps).is_empty());
1347 }
1348
1349 // --- ensure_deepseek_gitignored (preserved tests) ---
1350
1351 #[test]
1352 fn ensure_deepseek_gitignored_creates_gitignore() {
1353 let tmpdir = TempDir::new().unwrap();
1354 std::fs::create_dir_all(tmpdir.path().join(".git")).unwrap();
1355 ensure_deepseek_gitignored(tmpdir.path());
1356 let content = std::fs::read_to_string(tmpdir.path().join(".gitignore")).unwrap();
1357 assert!(content.contains(".deepseek/"));
1358 // .codewhale/ is ignored at any depth, but the committed
1359 // constitution.json is kept.
1360 assert!(content.contains("**/.codewhale/*"));
1361 assert!(content.contains("!**/.codewhale/constitution.json"));
1362 }
1363
1364 #[test]
1365 fn ensure_deepseek_gitignored_appends_to_existing() {
1366 let tmpdir = TempDir::new().unwrap();
1367 std::fs::create_dir_all(tmpdir.path().join(".git")).unwrap();
1368 std::fs::write(tmpdir.path().join(".gitignore"), "target/\n").unwrap();
1369 ensure_deepseek_gitignored(tmpdir.path());
1370 let content = std::fs::read_to_string(tmpdir.path().join(".gitignore")).unwrap();
1371 assert!(content.contains("target/"));
1372 assert!(content.contains(".deepseek/"));
1373 }
1374
1375 #[test]
1376 fn ensure_deepseek_gitignored_idempotent() {
1377 let tmpdir = TempDir::new().unwrap();
1378 std::fs::create_dir_all(tmpdir.path().join(".git")).unwrap();
1379 ensure_deepseek_gitignored(tmpdir.path());
1380 ensure_deepseek_gitignored(tmpdir.path());
1381 let content = std::fs::read_to_string(tmpdir.path().join(".gitignore")).unwrap();
1382 assert_eq!(content.matches(".deepseek/").count(), 1);
1383 }
1384
1385 #[test]
1386 fn ensure_deepseek_gitignored_skips_non_git_repo() {
1387 let tmpdir = TempDir::new().unwrap();
1388 ensure_deepseek_gitignored(tmpdir.path());
1389 assert!(!tmpdir.path().join(".gitignore").exists());
1390 }
1391
1392 #[test]
1393 fn ensure_deepseek_gitignored_handles_no_trailing_newline() {
1394 let tmpdir = TempDir::new().unwrap();
1395 std::fs::create_dir_all(tmpdir.path().join(".git")).unwrap();
1396 std::fs::write(tmpdir.path().join(".gitignore"), "target/").unwrap();
1397 ensure_deepseek_gitignored(tmpdir.path());
1398 let content = std::fs::read_to_string(tmpdir.path().join(".gitignore")).unwrap();
1399 assert!(content.contains("target/"));
1400 assert!(content.contains(".deepseek/"));
1401 let lines: Vec<&str> = content.lines().collect();
1402 assert!(lines.len() >= 2);
1403 }
1404
1405 #[test]
1406 fn ensure_deepseek_gitignored_detects_variant_without_slash() {
1407 let tmpdir = TempDir::new().unwrap();
1408 std::fs::create_dir_all(tmpdir.path().join(".git")).unwrap();
1409 std::fs::write(tmpdir.path().join(".gitignore"), ".deepseek\n").unwrap();
1410 ensure_deepseek_gitignored(tmpdir.path());
1411 let content = std::fs::read_to_string(tmpdir.path().join(".gitignore")).unwrap();
1412 assert_eq!(content.matches(".deepseek").count(), 1);
1413 }
1414
1415 #[test]
1416 fn ensure_deepseek_gitignored_updates_repo_root_from_nested_workspace() {
1417 let tmpdir = TempDir::new().unwrap();
1418 Command::new("git")
1419 .args(["init"])
1420 .current_dir(tmpdir.path())
1421 .output()
1422 .unwrap();
1423 let nested = tmpdir.path().join("nested").join("app");
1424 std::fs::create_dir_all(&nested).unwrap();
1425
1426 ensure_deepseek_gitignored(&nested);
1427
1428 let content = std::fs::read_to_string(tmpdir.path().join(".gitignore")).unwrap();
1429 assert!(content.contains(".deepseek/"));
1430 assert!(!nested.join(".gitignore").exists());
1431 }
1432 }
1433
1433 lines RUST