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