1use crate::tools::ToolRegistry;
4use crate::utils::colors::style;
5use anyhow::{Context, Result};
6use indexmap::IndexMap;
7use serde::{Deserialize, Serialize};
8use serde_json::Value as JsonValue;
9use std::collections::{BTreeMap, BTreeSet};
10use std::fs;
11use std::path::{Path, PathBuf};
12use std::process::Command;
13use vtcode_commons::walk::{build_default_walker, is_excluded_dir};
14
15const AGENTS_FILENAME: &str = "AGENTS.md";
16const MAX_SCAN_DEPTH: usize = 4;
17const MAX_FILE_BYTES: usize = 64 * 1024;
18const CLEAR_SCORE_GAP: u32 = 3;
19const CONTROL_GENERIC: &str = "__generic__";
20const CONTROL_NONE: &str = "__none__";
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23enum PackageManager {
24 Npm,
25 Pnpm,
26 Yarn,
27}
28
29impl PackageManager {
30 fn command(self) -> &'static str {
31 match self {
32 Self::Npm => "npm",
33 Self::Pnpm => "pnpm",
34 Self::Yarn => "yarn",
35 }
36 }
37}
38
39#[derive(Debug, Clone)]
40struct SignalCandidate {
41 value: String,
42 label: String,
43 description: String,
44 score: u32,
45}
46
47#[derive(Debug, Default)]
48struct CandidateAccumulator {
49 values: BTreeMap<String, SignalCandidate>,
50}
51
52impl CandidateAccumulator {
53 fn add(
54 &mut self,
55 value: impl Into<String>,
56 label: impl Into<String>,
57 description: impl Into<String>,
58 score: u32,
59 ) {
60 let value = value.into();
61 let normalized = value.trim();
62 if normalized.is_empty() {
63 return;
64 }
65
66 let label = label.into();
67 let description = description.into();
68 let entry = self
69 .values
70 .entry(normalized.to_owned())
71 .or_insert_with(|| SignalCandidate {
72 value: normalized.to_owned(),
73 label,
74 description,
75 score: 0,
76 });
77
78 entry.score += score;
79 }
80
81 fn into_sorted(self) -> Vec<SignalCandidate> {
82 let mut values: Vec<_> = self.values.into_values().collect();
83 values.sort_by(|left, right| {
84 right
85 .score
86 .cmp(&left.score)
87 .then_with(|| left.value.cmp(&right.value))
88 });
89 values
90 }
91}
92
93#[derive(Debug, Clone)]
94struct ProjectAnalysis {
95 project_name: String,
96 grounded_project_summary: Option<String>,
97 languages: Vec<String>,
98 build_systems: Vec<String>,
99 scripts: Vec<String>,
100 dependencies: IndexMap<String, Vec<String>>,
101 source_dirs: Vec<String>,
102 config_files: Vec<String>,
103 documentation_files: Vec<String>,
104 commit_patterns: Vec<String>,
105 has_git_history: bool,
106 is_library: bool,
107 is_application: bool,
108 has_ci_cd: bool,
109 has_docker: bool,
110 package_manager: Option<PackageManager>,
111 verification_candidates: Vec<SignalCandidate>,
112 orientation_candidates: Vec<SignalCandidate>,
113 critical_instruction_candidates: Vec<SignalCandidate>,
114 selected_verification_command: Option<String>,
115 selected_orientation_doc: Option<String>,
116 selected_critical_instruction: Option<String>,
117 grounded_verification_command: Option<String>,
118 grounded_orientation_doc: Option<String>,
119 grounded_critical_instruction: Option<String>,
120}
121
122#[derive(Debug, Clone, Copy, PartialEq, Eq)]
123pub enum GenerateAgentsFileStatus {
124 Created,
125 Overwritten,
126 SkippedExisting,
127}
128
129#[derive(Debug, Clone)]
130pub struct GenerateAgentsFileReport {
131 pub path: PathBuf,
132 pub status: GenerateAgentsFileStatus,
133}
134
135#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
136pub struct GuidedInitGrounding {
137 #[serde(default, skip_serializing_if = "Option::is_none")]
138 pub project_summary: Option<String>,
139 #[serde(default, skip_serializing_if = "Option::is_none")]
140 pub verification_command: Option<String>,
141 #[serde(default, skip_serializing_if = "Option::is_none")]
142 pub orientation_doc: Option<String>,
143 #[serde(default, skip_serializing_if = "Option::is_none")]
144 pub critical_instruction: Option<String>,
145}
146
147impl GuidedInitGrounding {
148 #[must_use]
149 pub fn has_any(&self) -> bool {
150 self.project_summary.is_some()
151 || self.verification_command.is_some()
152 || self.orientation_doc.is_some()
153 || self.critical_instruction.is_some()
154 }
155}
156
157#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
158pub enum GuidedInitQuestionKey {
159 VerificationCommand,
160 OrientationDoc,
161 CriticalInstruction,
162}
163
164impl GuidedInitQuestionKey {
165 pub fn as_str(self) -> &'static str {
166 match self {
167 Self::VerificationCommand => "verification_command",
168 Self::OrientationDoc => "orientation_doc",
169 Self::CriticalInstruction => "critical_instruction",
170 }
171 }
172
173 pub fn header(self) -> &'static str {
174 match self {
175 Self::VerificationCommand => "Verify",
176 Self::OrientationDoc => "Orient",
177 Self::CriticalInstruction => "Rule",
178 }
179 }
180
181 pub fn custom_label(self) -> &'static str {
182 match self {
183 Self::VerificationCommand => "Custom command",
184 Self::OrientationDoc => "Custom path",
185 Self::CriticalInstruction => "Custom instruction",
186 }
187 }
188
189 pub fn custom_placeholder(self) -> &'static str {
190 match self {
191 Self::VerificationCommand => "cargo nextest run",
192 Self::OrientationDoc => "docs/ARCHITECTURE.md",
193 Self::CriticalInstruction => "State the one rule agents should always follow",
194 }
195 }
196
197 fn blank_custom_value(self) -> &'static str {
198 match self {
199 Self::CriticalInstruction => CONTROL_NONE,
200 Self::VerificationCommand | Self::OrientationDoc => CONTROL_GENERIC,
201 }
202 }
203}
204
205impl std::str::FromStr for GuidedInitQuestionKey {
206 type Err = anyhow::Error;
207
208 fn from_str(value: &str) -> Result<Self> {
209 match value {
210 "verification_command" => Ok(Self::VerificationCommand),
211 "orientation_doc" => Ok(Self::OrientationDoc),
212 "critical_instruction" => Ok(Self::CriticalInstruction),
213 other => anyhow::bail!("unknown guided init question key: {other}"),
214 }
215 }
216}
217
218#[derive(Debug, Clone, PartialEq, Eq)]
219pub struct GuidedInitQuestionOption {
220 pub value: String,
221 pub label: String,
222 pub description: String,
223 pub recommended: bool,
224}
225
226#[derive(Debug, Clone, PartialEq, Eq)]
227pub struct GuidedInitQuestion {
228 pub key: GuidedInitQuestionKey,
229 pub header: String,
230 pub prompt: String,
231 pub options: Vec<GuidedInitQuestionOption>,
232 pub allow_custom: bool,
233}
234
235#[derive(Debug, Clone, PartialEq, Eq)]
236pub struct GuidedInitAnswer {
237 pub key: GuidedInitQuestionKey,
238 pub selected: String,
239 pub custom: Option<String>,
240}
241
242impl GuidedInitAnswer {
243 pub fn from_input(
244 key: GuidedInitQuestionKey,
245 selected: Option<&str>,
246 custom: Option<&str>,
247 ) -> Option<Self> {
248 let custom_selected = custom.is_some();
249 if let Some(custom) = custom.map(str::trim).filter(|value| !value.is_empty()) {
250 return Some(Self {
251 key,
252 selected: String::new(),
253 custom: Some(custom.to_owned()),
254 });
255 }
256
257 if let Some(selected) = selected.map(str::trim).filter(|value| !value.is_empty()) {
258 return Some(Self {
259 key,
260 selected: selected.to_owned(),
261 custom: None,
262 });
263 }
264
265 custom_selected.then(|| Self {
266 key,
267 selected: key.blank_custom_value().to_owned(),
268 custom: None,
269 })
270 }
271}
272
273#[derive(Debug, Clone, Default, PartialEq, Eq)]
274pub struct GuidedInitAnswers {
275 answers: BTreeMap<GuidedInitQuestionKey, GuidedInitAnswer>,
276}
277
278impl GuidedInitAnswers {
279 pub fn insert(&mut self, answer: GuidedInitAnswer) {
280 self.answers.insert(answer.key, answer);
281 }
282
283 pub fn answer(&self, key: GuidedInitQuestionKey) -> Option<&GuidedInitAnswer> {
284 self.answers.get(&key)
285 }
286}
287
288#[derive(Debug, Clone, Copy, PartialEq, Eq)]
289pub enum GuidedInitOverwriteState {
290 Skip,
291 Confirm,
292 Force,
293}
294
295impl GuidedInitOverwriteState {
296 pub fn requires_confirmation(self) -> bool {
297 matches!(self, Self::Confirm)
298 }
299}
300
301#[derive(Debug, Clone)]
302pub struct GuidedInitPlan {
303 pub path: PathBuf,
304 pub questions: Vec<GuidedInitQuestion>,
305 pub overwrite_state: GuidedInitOverwriteState,
306 analysis: ProjectAnalysis,
307}
308
309impl GuidedInitPlan {
310 #[must_use]
311 pub fn with_grounding(mut self, grounding: GuidedInitGrounding) -> Self {
312 if !grounding.has_any() {
313 return self;
314 }
315
316 self.analysis.apply_grounding(grounding);
317 self.questions = build_guided_questions(&self.analysis);
318 self
319 }
320}
321
322pub async fn handle_init_command(_registry: &mut ToolRegistry, workspace: &Path) -> Result<()> {
324 println!(
325 "{}",
326 style("Initializing project with AGENTS.md...")
327 .cyan()
328 .bold()
329 );
330 println!("{}", style("1. Analyzing project structure...").dim());
331 let plan = prepare_guided_init(workspace, true)?;
332 println!("{}", style("2. Rendering AGENTS.md content...").dim());
333 let content = render_agents_md(&plan, &GuidedInitAnswers::default())?;
334 println!("{}", style("3. Writing AGENTS.md file...").dim());
335 let report = write_agents_file(workspace, &content, true)?;
336 println!(
337 "{} {}",
338 style("[OK]").green().bold(),
339 style("AGENTS.md generated successfully!").green()
340 );
341 println!("{} {}", style(" Location:").cyan(), report.path.display());
342 Ok(())
343}
344
345pub async fn generate_agents_file(
347 _registry: &mut ToolRegistry,
348 workspace: &Path,
349 overwrite: bool,
350) -> Result<GenerateAgentsFileReport> {
351 let plan = prepare_guided_init(workspace, overwrite)?;
352 let content = render_agents_md(&plan, &GuidedInitAnswers::default())?;
353 write_agents_file(workspace, &content, overwrite)
354}
355
356pub fn prepare_guided_init(workspace: &Path, force: bool) -> Result<GuidedInitPlan> {
357 let analysis = analyze_project(workspace)?;
358 let path = workspace.join(AGENTS_FILENAME);
359 let overwrite_state = if force {
360 GuidedInitOverwriteState::Force
361 } else if path.exists() {
362 GuidedInitOverwriteState::Confirm
363 } else {
364 GuidedInitOverwriteState::Skip
365 };
366
367 Ok(GuidedInitPlan {
368 path,
369 questions: build_guided_questions(&analysis),
370 overwrite_state,
371 analysis,
372 })
373}
374
375pub fn render_agents_md(plan: &GuidedInitPlan, answers: &GuidedInitAnswers) -> Result<String> {
376 let analysis = &plan.analysis;
377 let verification_command = resolve_verification_command(analysis, answers);
378 let orientation_doc = resolve_orientation_doc(analysis, answers);
379 let critical_instruction = resolve_critical_instruction(analysis, answers);
380
381 let mut content = String::new();
382 content.push_str("# AGENTS.md\n\n");
383 content.push_str(&build_quick_start_section(
384 analysis,
385 verification_command.as_deref(),
386 ));
387 content.push_str(&build_architecture_section(
388 analysis,
389 orientation_doc.as_deref(),
390 ));
391 if let Some(section) = build_important_instructions_section(critical_instruction.as_deref()) {
392 content.push_str(§ion);
393 }
394 content.push_str(&build_code_style_section(analysis));
395 content.push_str(&build_testing_section(
396 analysis,
397 verification_command.as_deref(),
398 ));
399 content.push_str(&build_performance_section());
400 if let Some(section) = build_pr_guidelines_section(analysis) {
401 content.push_str(§ion);
402 }
403 if let Some(section) = build_additional_guidance_section(analysis, orientation_doc.as_deref()) {
404 content.push_str(§ion);
405 }
406
407 Ok(content)
408}
409
410pub fn write_agents_file(
411 workspace: &Path,
412 content: &str,
413 overwrite: bool,
414) -> Result<GenerateAgentsFileReport> {
415 let path = workspace.join(AGENTS_FILENAME);
416 let existed_before = path.exists();
417
418 if existed_before && !overwrite {
419 return Ok(GenerateAgentsFileReport {
420 path,
421 status: GenerateAgentsFileStatus::SkippedExisting,
422 });
423 }
424
425 fs::write(&path, content)
426 .with_context(|| format!("failed to write AGENTS.md to {}", path.display()))?;
427
428 let status = if existed_before {
429 GenerateAgentsFileStatus::Overwritten
430 } else {
431 GenerateAgentsFileStatus::Created
432 };
433
434 Ok(GenerateAgentsFileReport { path, status })
435}
436
437fn analyze_project(workspace: &Path) -> Result<ProjectAnalysis> {
438 let root_files = collect_workspace_files(workspace)?;
439 let package_manager = detect_package_manager(workspace);
440 let project_name = workspace
441 .file_name()
442 .and_then(|name| name.to_str())
443 .unwrap_or("project")
444 .to_owned();
445
446 let mut analysis = ProjectAnalysis {
447 project_name,
448 grounded_project_summary: None,
449 languages: Vec::new(),
450 build_systems: Vec::new(),
451 scripts: Vec::new(),
452 dependencies: IndexMap::new(),
453 source_dirs: Vec::new(),
454 config_files: Vec::new(),
455 documentation_files: Vec::new(),
456 commit_patterns: Vec::new(),
457 has_git_history: false,
458 is_library: false,
459 is_application: false,
460 has_ci_cd: false,
461 has_docker: false,
462 package_manager,
463 verification_candidates: Vec::new(),
464 orientation_candidates: Vec::new(),
465 critical_instruction_candidates: Vec::new(),
466 selected_verification_command: None,
467 selected_orientation_doc: None,
468 selected_critical_instruction: None,
469 grounded_verification_command: None,
470 grounded_orientation_doc: None,
471 grounded_critical_instruction: None,
472 };
473
474 for path in &root_files {
475 analyze_file(&mut analysis, path);
476 }
477
478 load_dependency_signals(workspace, &mut analysis)?;
479 analyze_git_history(workspace, &mut analysis);
480 analyze_project_characteristics(&mut analysis);
481
482 let text_samples = load_text_samples(workspace, &root_files)?;
483 analysis.verification_candidates =
484 build_verification_candidates(workspace, &analysis, &text_samples);
485 analysis.selected_verification_command =
486 choose_clear_candidate(&analysis.verification_candidates, 4)
487 .map(|candidate| candidate.value.clone());
488
489 analysis.orientation_candidates = build_orientation_candidates(&analysis, &text_samples);
490 analysis.selected_orientation_doc = choose_clear_candidate(&analysis.orientation_candidates, 4)
491 .map(|candidate| candidate.value.clone());
492
493 analysis.critical_instruction_candidates = build_critical_instruction_candidates(&text_samples);
494 analysis.selected_critical_instruction =
495 choose_clear_candidate(&analysis.critical_instruction_candidates, 7)
496 .map(|candidate| candidate.value.clone());
497
498 Ok(analysis)
499}
500
501fn collect_workspace_files(workspace: &Path) -> Result<Vec<String>> {
502 let mut files = BTreeSet::new();
503 for entry in build_default_walker(workspace)
504 .max_depth(Some(MAX_SCAN_DEPTH))
505 .filter_entry(|entry| !is_excluded_dir(entry))
506 .build()
507 .filter_map(|entry| entry.ok())
508 .filter(|entry| entry.file_type().is_some_and(|ft| ft.is_file()))
509 {
510 let relative = entry
511 .path()
512 .strip_prefix(workspace)
513 .with_context(|| format!("failed to relativize {}", entry.path().display()))?;
514 files.insert(relative.to_string_lossy().replace('\\', "/"));
515 }
516 Ok(files.into_iter().collect())
517}
518
519fn analyze_file(analysis: &mut ProjectAnalysis, path: &str) {
520 match path {
521 "Cargo.toml" => {
522 analysis.languages.push("Rust".to_owned());
523 analysis.build_systems.push("Cargo".to_owned());
524 }
525 "Cargo.lock" => {
526 analysis.config_files.push("Cargo.lock".to_owned());
527 }
528 "package.json" => {
529 analysis.languages.push("JavaScript/TypeScript".to_owned());
530 analysis.build_systems.push("npm/yarn/pnpm".to_owned());
531 }
532 "package-lock.json" | "pnpm-lock.yaml" | "yarn.lock" => {
533 analysis.config_files.push(path.to_owned());
534 }
535 "requirements.txt" | "pyproject.toml" | "setup.py" | "Pipfile" => {
536 analysis.languages.push("Python".to_owned());
537 analysis.build_systems.push("pip/poetry".to_owned());
538 analysis.config_files.push(path.to_owned());
539 }
540 "go.mod" | "go.sum" => {
541 analysis.languages.push("Go".to_owned());
542 analysis.build_systems.push("Go Modules".to_owned());
543 analysis.config_files.push(path.to_owned());
544 }
545 "pom.xml" | "build.gradle" | "build.gradle.kts" => {
546 analysis.languages.push("Java/Kotlin".to_owned());
547 analysis.build_systems.push("Maven/Gradle".to_owned());
548 analysis.config_files.push(path.to_owned());
549 }
550 "README.md"
551 | "CHANGELOG.md"
552 | "CONTRIBUTING.md"
553 | "LICENSE"
554 | "LICENSE.md"
555 | "AGENTS.md"
556 | "AGENT.md"
557 | "docs/README.md"
558 | "docs/ARCHITECTURE.md"
559 | "docs/modules/vtcode_docs_map.md" => {
560 analysis.documentation_files.push(path.to_owned());
561 }
562 ".gitignore" | ".editorconfig" | ".prettierrc" | ".eslintrc" | ".eslintrc.js"
563 | ".eslintrc.json" | "vtcode.toml" | "sgconfig.yml" => {
564 analysis.config_files.push(path.to_owned());
565 }
566 "Dockerfile" | "docker-compose.yml" | "docker-compose.yaml" | ".dockerignore" => {
567 analysis.config_files.push(path.to_owned());
568 }
569 path if path.starts_with(".github/workflows/") => {
570 analysis.config_files.push(path.to_owned());
571 }
572 path if path.starts_with("scripts/") && path.ends_with(".sh") => {
573 analysis.scripts.push(path.to_owned());
574 }
575 "run.sh" | "run-debug.sh" | "run-dev.sh" | "run-prod.sh" => {
576 analysis.scripts.push(path.to_owned());
577 }
578 path if path.starts_with("src/")
579 || path.starts_with("tests/")
580 || path.starts_with("lib/")
581 || path.starts_with("app/")
582 || path.starts_with("cmd/")
583 || path.starts_with("core/") =>
584 {
585 if let Some(root) = path.split('/').next() {
586 analysis.source_dirs.push(root.to_owned());
587 }
588 }
589 _ => {}
590 }
591}
592
593fn load_dependency_signals(workspace: &Path, analysis: &mut ProjectAnalysis) -> Result<()> {
594 let cargo_path = workspace.join("Cargo.toml");
595 if cargo_path.exists()
596 && let Some(content) = read_text_file(&cargo_path)?
597 {
598 extract_cargo_dependencies(analysis, &content);
599 }
600
601 let package_path = workspace.join("package.json");
602 if package_path.exists()
603 && let Some(content) = read_text_file(&package_path)?
604 {
605 extract_package_dependencies(analysis, &content);
606 }
607
608 Ok(())
609}
610
611fn read_text_file(path: &Path) -> Result<Option<String>> {
612 if !path.exists() {
613 return Ok(None);
614 }
615
616 let bytes = fs::read(path).with_context(|| format!("failed to read {}", path.display()))?;
617 let truncated = bytes.into_iter().take(MAX_FILE_BYTES).collect::<Vec<_>>();
618 Ok(Some(String::from_utf8_lossy(&truncated).into_owned()))
619}
620
621fn extract_cargo_dependencies(analysis: &mut ProjectAnalysis, content: &str) {
622 let Ok(value) = content.parse::<toml::Value>() else {
623 return;
624 };
625
626 let mut deps = Vec::new();
627 for key in ["dependencies", "dev-dependencies", "workspace.dependencies"] {
628 let Some(table) = lookup_toml_table(&value, key) else {
629 continue;
630 };
631 deps.extend(table.keys().cloned());
632 }
633
634 if !deps.is_empty() {
635 deps.sort();
636 deps.dedup();
637 analysis.dependencies.insert(
638 "Rust (Cargo)".to_owned(),
639 deps.into_iter().take(8).collect(),
640 );
641 }
642}
643
644fn lookup_toml_table<'a>(
645 value: &'a toml::Value,
646 dotted_key: &str,
647) -> Option<&'a toml::map::Map<String, toml::Value>> {
648 let mut current = value;
649 for part in dotted_key.split('.') {
650 current = current.get(part)?;
651 }
652 current.as_table()
653}
654
655fn extract_package_dependencies(analysis: &mut ProjectAnalysis, content: &str) {
656 let Ok(value) = serde_json::from_str::<JsonValue>(content) else {
657 return;
658 };
659
660 let mut deps = Vec::new();
661 for key in ["dependencies", "devDependencies"] {
662 let Some(map) = value.get(key).and_then(|value| value.as_object()) else {
663 continue;
664 };
665 deps.extend(map.keys().cloned());
666 }
667
668 if !deps.is_empty() {
669 deps.sort();
670 deps.dedup();
671 analysis.dependencies.insert(
672 "JavaScript/TypeScript".to_owned(),
673 deps.into_iter().take(8).collect(),
674 );
675 }
676}
677
678fn detect_package_manager(workspace: &Path) -> Option<PackageManager> {
679 if workspace.join("pnpm-lock.yaml").exists() {
680 Some(PackageManager::Pnpm)
681 } else if workspace.join("yarn.lock").exists() {
682 Some(PackageManager::Yarn)
683 } else if workspace.join("package-lock.json").exists()
684 || workspace.join("package.json").exists()
685 {
686 Some(PackageManager::Npm)
687 } else {
688 None
689 }
690}
691
692fn analyze_git_history(workspace: &Path, analysis: &mut ProjectAnalysis) {
693 if !workspace.join(".git").exists() {
694 analysis
695 .commit_patterns
696 .push("No version control detected".to_owned());
697 return;
698 }
699
700 analysis.has_git_history = true;
701 let output = Command::new("git")
702 .arg("-C")
703 .arg(workspace)
704 .args(["log", "--pretty=format:%s", "-20"])
705 .output();
706
707 let Ok(output) = output else {
708 analysis
709 .commit_patterns
710 .push("Standard commit messages".to_owned());
711 return;
712 };
713
714 let stdout = String::from_utf8_lossy(&output.stdout);
715 let mut conventional = 0usize;
716 let mut total = 0usize;
717 for line in stdout.lines() {
718 total += 1;
719 let line = line.trim();
720 if [
721 "feat:",
722 "fix:",
723 "docs:",
724 "style:",
725 "refactor:",
726 "test:",
727 "chore:",
728 ]
729 .iter()
730 .any(|prefix| line.starts_with(prefix))
731 {
732 conventional += 1;
733 }
734 }
735
736 if total > 0 && conventional * 100 / total > 50 {
737 analysis
738 .commit_patterns
739 .push("Conventional Commits".to_owned());
740 } else {
741 analysis
742 .commit_patterns
743 .push("Standard commit messages".to_owned());
744 }
745}
746
747fn analyze_project_characteristics(analysis: &mut ProjectAnalysis) {
748 analysis.languages = unique_preserving_order(&analysis.languages);
749 analysis.build_systems = unique_preserving_order(&analysis.build_systems);
750 analysis.scripts = unique_preserving_order(&analysis.scripts);
751 analysis.source_dirs = unique_preserving_order(&analysis.source_dirs);
752 analysis.config_files = unique_preserving_order(&analysis.config_files);
753 analysis.documentation_files = unique_preserving_order(&analysis.documentation_files);
754
755 analysis.is_library = analysis.build_systems.iter().any(|system| {
756 matches!(
757 system.as_str(),
758 "Cargo" | "npm/yarn/pnpm" | "pip/poetry" | "Go Modules"
759 )
760 });
761 analysis.is_application = analysis
762 .source_dirs
763 .iter()
764 .any(|dir| matches!(dir.as_str(), "src" | "app" | "cmd"));
765 analysis.has_ci_cd = analysis
766 .config_files
767 .iter()
768 .any(|path| path.starts_with(".github/workflows/"));
769 analysis.has_docker = analysis.config_files.iter().any(|path| {
770 matches!(
771 path.as_str(),
772 "Dockerfile" | "docker-compose.yml" | "docker-compose.yaml" | ".dockerignore"
773 )
774 });
775}
776
777fn load_text_samples(workspace: &Path, files: &[String]) -> Result<BTreeMap<String, String>> {
778 let mut samples = BTreeMap::new();
779 for path in files {
780 if !is_text_sample_candidate(path) {
781 continue;
782 }
783 let absolute = workspace.join(path);
784 if let Some(content) = read_text_file(&absolute)? {
785 samples.insert(path.clone(), content);
786 }
787 }
788 Ok(samples)
789}
790
791fn is_text_sample_candidate(path: &str) -> bool {
792 matches!(
793 path,
794 "README.md"
795 | "CONTRIBUTING.md"
796 | "AGENTS.md"
797 | "package.json"
798 | "docs/README.md"
799 | "docs/ARCHITECTURE.md"
800 | "docs/modules/vtcode_docs_map.md"
801 | "scripts/README.md"
802 ) || path.starts_with(".github/workflows/")
803}
804
805fn build_verification_candidates(
806 workspace: &Path,
807 analysis: &ProjectAnalysis,
808 text_samples: &BTreeMap<String, String>,
809) -> Vec<SignalCandidate> {
810 let mut candidates = CandidateAccumulator::default();
811 let package_manager = analysis.package_manager.map(PackageManager::command);
812
813 if workspace.join("scripts/check.sh").exists() {
814 candidates.add(
815 "./scripts/check.sh",
816 "./scripts/check.sh",
817 "Run the repository quality gate script.",
818 7,
819 );
820 }
821
822 if analysis
823 .build_systems
824 .iter()
825 .any(|system| system == "Cargo")
826 {
827 candidates.add(
828 "cargo check",
829 "cargo check",
830 "Run a fast Rust compile check.",
831 2,
832 );
833 candidates.add(
834 "cargo nextest run",
835 "cargo nextest run",
836 "Run the Rust test suite with nextest.",
837 1,
838 );
839 }
840
841 if analysis
842 .build_systems
843 .iter()
844 .any(|system| system == "Go Modules")
845 {
846 candidates.add(
847 "go test ./...",
848 "go test ./...",
849 "Run the Go test suite.",
850 4,
851 );
852 }
853
854 if let Some(command) = package_manager {
855 let package_scripts = text_samples
856 .get("package.json")
857 .and_then(|content| parse_package_json_scripts(content.as_str()))
858 .unwrap_or_default();
859 if package_scripts.contains(&"test".to_owned()) {
860 candidates.add(
861 format!("{command} test"),
862 format!("{command} test"),
863 "Run the package test script.",
864 5,
865 );
866 }
867 if package_scripts.contains(&"check".to_owned()) {
868 candidates.add(
869 format!("{command} run check"),
870 format!("{command} run check"),
871 "Run the package verification script.",
872 5,
873 );
874 }
875 }
876
877 for content in text_samples.values() {
878 for (command, label, description, score) in [
879 (
880 "./scripts/check.sh",
881 "./scripts/check.sh",
882 "Run the repository quality gate script.",
883 3,
884 ),
885 (
886 "cargo nextest run",
887 "cargo nextest run",
888 "Run the Rust test suite with nextest.",
889 4,
890 ),
891 (
892 "cargo check",
893 "cargo check",
894 "Run a fast Rust compile check.",
895 2,
896 ),
897 ("cargo test", "cargo test", "Run the Rust test suite.", 3),
898 (
899 "cargo clippy --workspace --all-targets -- -D warnings",
900 "cargo clippy --workspace --all-targets -- -D warnings",
901 "Run the strict Rust linter.",
902 4,
903 ),
904 (
905 "go test ./...",
906 "go test ./...",
907 "Run the Go test suite.",
908 3,
909 ),
910 ] {
911 if content.contains(command) {
912 candidates.add(command, label, description, score);
913 }
914 }
915
916 if let Some(command) = package_manager {
917 for suffix in ["test", "run check", "run lint"] {
918 let candidate = format!("{command} {suffix}");
919 if content.contains(&candidate) {
920 candidates.add(
921 candidate.clone(),
922 candidate.clone(),
923 "Run the JavaScript/TypeScript verification script.",
924 3,
925 );
926 }
927 }
928 }
929 }
930
931 candidates.into_sorted()
932}
933
934fn parse_package_json_scripts(content: &str) -> Option<Vec<String>> {
935 let value = serde_json::from_str::<JsonValue>(content).ok()?;
936 let scripts = value.get("scripts")?.as_object()?;
937 Some(scripts.keys().cloned().collect())
938}
939
940fn build_orientation_candidates(
941 analysis: &ProjectAnalysis,
942 text_samples: &BTreeMap<String, String>,
943) -> Vec<SignalCandidate> {
944 let mut candidates = CandidateAccumulator::default();
945
946 for (path, description, score) in [
947 (
948 "README.md",
949 "Start here for repository overview and local setup.",
950 4,
951 ),
952 (
953 "docs/ARCHITECTURE.md",
954 "Use this for system design and architecture context.",
955 5,
956 ),
957 (
958 "docs/modules/vtcode_docs_map.md",
959 "Use this to map VT Code modules and docs quickly.",
960 5,
961 ),
962 (
963 "docs/README.md",
964 "Use this to browse project documentation.",
965 3,
966 ),
967 (
968 "CONTRIBUTING.md",
969 "Use this for contribution workflow and repo expectations.",
970 2,
971 ),
972 ] {
973 if analysis.documentation_files.iter().any(|file| file == path) {
974 candidates.add(path, path, description, score);
975 }
976 }
977
978 for content in text_samples.values() {
979 for (path, description, score) in [
980 (
981 "docs/modules/vtcode_docs_map.md",
982 "Use this to map VT Code modules and docs quickly.",
983 4,
984 ),
985 (
986 "docs/ARCHITECTURE.md",
987 "Use this for system design and architecture context.",
988 2,
989 ),
990 (
991 "README.md",
992 "Start here for repository overview and local setup.",
993 1,
994 ),
995 ] {
996 if content.contains(path) {
997 candidates.add(path, path, description, score);
998 }
999 }
1000 }
1001
1002 candidates.into_sorted()
1003}
1004
1005fn build_critical_instruction_candidates(
1006 text_samples: &BTreeMap<String, String>,
1007) -> Vec<SignalCandidate> {
1008 let mut candidates = CandidateAccumulator::default();
1009 for (path, content) in text_samples {
1010 let base_score = match path.as_str() {
1011 "AGENTS.md" => 5,
1012 "CONTRIBUTING.md" => 4,
1013 "README.md" => 3,
1014 _ => 2,
1015 };
1016
1017 for raw_line in content.lines().take(400) {
1018 let Some(line) = normalized_instruction_line(raw_line) else {
1019 continue;
1020 };
1021 let lower = line.to_ascii_lowercase();
1022 let description = format!("Inferred from {}.", path);
1023
1024 if lower.contains("conventional commit") {
1025 candidates.add(
1026 "Use Conventional Commits (`type(scope): subject`).",
1027 "Use Conventional Commits (`type(scope): subject`).",
1028 description.clone(),
1029 base_score + 4,
1030 );
1031 }
1032
1033 if lower.contains("no unsafe")
1034 || lower.contains("do not use unsafe")
1035 || lower.contains("never use unsafe")
1036 {
1037 candidates.add(
1038 "Do not use `unsafe` code.",
1039 "Do not use `unsafe` code.",
1040 description.clone(),
1041 base_score + 5,
1042 );
1043 }
1044
1045 if lower.contains("cargo check")
1046 && lower.contains("cargo nextest")
1047 && lower.contains("cargo clippy")
1048 {
1049 candidates.add(
1050 "Run `cargo check`, `cargo nextest`, and `cargo clippy` after changes.",
1051 "Run `cargo check`, `cargo nextest`, and `cargo clippy` after changes.",
1052 description.clone(),
1053 base_score + 4,
1054 );
1055 }
1056
1057 if lower.contains("keep changes surgical") {
1058 candidates.add(
1059 "Keep changes surgical and avoid unrelated cleanup.",
1060 "Keep changes surgical and avoid unrelated cleanup.",
1061 description.clone(),
1062 base_score + 3,
1063 );
1064 }
1065
1066 if lower.contains("vt code") && lower.contains("capitalization") {
1067 candidates.add(
1068 "Use the product name `VT Code` with proper capitalization and spacing.",
1069 "Use the product name `VT Code` with proper capitalization and spacing.",
1070 description,
1071 base_score + 3,
1072 );
1073 }
1074 }
1075 }
1076
1077 candidates.into_sorted()
1078}
1079
1080fn normalized_instruction_line(raw: &str) -> Option<String> {
1081 let trimmed = raw.trim();
1082 if trimmed.is_empty()
1083 || trimmed.starts_with('#')
1084 || trimmed.starts_with("```")
1085 || trimmed.starts_with('|')
1086 {
1087 return None;
1088 }
1089
1090 let trimmed = trimmed
1091 .trim_start_matches(['-', '*', ' ', '\t'])
1092 .trim_start_matches(|ch: char| ch.is_ascii_digit() || matches!(ch, '.' | ')' | ' '));
1093 let trimmed = trimmed.trim();
1094 if trimmed.len() < 18 || trimmed.len() > 140 {
1095 return None;
1096 }
1097 Some(trimmed.to_owned())
1098}
1099
1100fn choose_clear_candidate(
1101 candidates: &[SignalCandidate],
1102 minimum_score: u32,
1103) -> Option<&SignalCandidate> {
1104 let first = candidates.first()?;
1105 if first.score < minimum_score {
1106 return None;
1107 }
1108 if candidates.len() == 1 {
1109 return Some(first);
1110 }
1111 if first.score >= candidates[1].score + CLEAR_SCORE_GAP {
1112 return Some(first);
1113 }
1114 None
1115}
1116
1117impl ProjectAnalysis {
1118 fn apply_grounding(&mut self, grounding: GuidedInitGrounding) {
1119 self.grounded_project_summary = normalize_grounding_value(grounding.project_summary);
1120 self.grounded_verification_command =
1121 normalize_grounding_value(grounding.verification_command);
1122 self.grounded_orientation_doc = normalize_grounding_value(grounding.orientation_doc);
1123 self.grounded_critical_instruction =
1124 normalize_grounding_value(grounding.critical_instruction);
1125 }
1126}
1127
1128fn normalize_grounding_value(value: Option<String>) -> Option<String> {
1129 value
1130 .map(|value| value.trim().to_owned())
1131 .filter(|value| !value.is_empty())
1132}
1133
1134fn build_guided_questions(analysis: &ProjectAnalysis) -> Vec<GuidedInitQuestion> {
1135 let mut questions = Vec::new();
1136
1137 let verification_needs_question = analysis.selected_verification_command.is_none()
1138 || grounded_differs_from_selected(
1139 analysis.selected_verification_command.as_deref(),
1140 analysis.grounded_verification_command.as_deref(),
1141 );
1142 if verification_needs_question {
1143 let options = build_guided_options(
1144 analysis.grounded_verification_command.as_deref(),
1145 "Use the explorer-grounded verification command.",
1146 &analysis.verification_candidates,
1147 Some(GuidedInitQuestionOption {
1148 value: CONTROL_GENERIC.to_owned(),
1149 label: "Keep generic guidance".to_owned(),
1150 description: "Leave verification instructions generic for now.".to_owned(),
1151 recommended: analysis.verification_candidates.is_empty()
1152 && analysis.grounded_verification_command.is_none(),
1153 }),
1154 3,
1155 );
1156 questions.push(GuidedInitQuestion {
1157 key: GuidedInitQuestionKey::VerificationCommand,
1158 header: GuidedInitQuestionKey::VerificationCommand
1159 .header()
1160 .to_owned(),
1161 prompt: "Which command should agents run by default before claiming the work is done?"
1162 .to_owned(),
1163 options,
1164 allow_custom: true,
1165 });
1166 }
1167
1168 let orientation_needs_question = analysis.selected_orientation_doc.is_none()
1169 || grounded_differs_from_selected(
1170 analysis.selected_orientation_doc.as_deref(),
1171 analysis.grounded_orientation_doc.as_deref(),
1172 );
1173 if orientation_needs_question {
1174 let options = build_guided_options(
1175 analysis.grounded_orientation_doc.as_deref(),
1176 "Use the explorer-grounded orientation doc.",
1177 &analysis.orientation_candidates,
1178 Some(GuidedInitQuestionOption {
1179 value: CONTROL_GENERIC.to_owned(),
1180 label: "Keep generic orientation".to_owned(),
1181 description: "Avoid pinning one doc as the first read.".to_owned(),
1182 recommended: analysis.orientation_candidates.is_empty()
1183 && analysis.grounded_orientation_doc.is_none(),
1184 }),
1185 3,
1186 );
1187 questions.push(GuidedInitQuestion {
1188 key: GuidedInitQuestionKey::OrientationDoc,
1189 header: GuidedInitQuestionKey::OrientationDoc.header().to_owned(),
1190 prompt: "Which file should agents read first when they need repo orientation?"
1191 .to_owned(),
1192 options,
1193 allow_custom: true,
1194 });
1195 }
1196
1197 let critical_needs_question = (analysis.selected_critical_instruction.is_none()
1198 && (!analysis.critical_instruction_candidates.is_empty()
1199 || analysis.grounded_critical_instruction.is_some()))
1200 || grounded_differs_from_selected(
1201 analysis.selected_critical_instruction.as_deref(),
1202 analysis.grounded_critical_instruction.as_deref(),
1203 );
1204 if critical_needs_question {
1205 let options = build_guided_options(
1206 analysis.grounded_critical_instruction.as_deref(),
1207 "Use the explorer-grounded repo-wide instruction.",
1208 &analysis.critical_instruction_candidates,
1209 Some(GuidedInitQuestionOption {
1210 value: CONTROL_NONE.to_owned(),
1211 label: "No repo-wide rule".to_owned(),
1212 description: "Do not add a dedicated always-follow instruction.".to_owned(),
1213 recommended: false,
1214 }),
1215 3,
1216 );
1217 questions.push(GuidedInitQuestion {
1218 key: GuidedInitQuestionKey::CriticalInstruction,
1219 header: GuidedInitQuestionKey::CriticalInstruction
1220 .header()
1221 .to_owned(),
1222 prompt: "Is there one repo-wide instruction agents should always follow?".to_owned(),
1223 options,
1224 allow_custom: true,
1225 });
1226 }
1227
1228 questions
1229}
1230
1231fn grounded_differs_from_selected(selected: Option<&str>, grounded: Option<&str>) -> bool {
1232 let Some(grounded) = grounded.map(str::trim).filter(|value| !value.is_empty()) else {
1233 return false;
1234 };
1235 let Some(selected) = selected.map(str::trim).filter(|value| !value.is_empty()) else {
1236 return false;
1237 };
1238 grounded != selected
1239}
1240
1241fn build_guided_options(
1242 grounded_value: Option<&str>,
1243 grounded_description: &str,
1244 candidates: &[SignalCandidate],
1245 trailing: Option<GuidedInitQuestionOption>,
1246 max_candidates: usize,
1247) -> Vec<GuidedInitQuestionOption> {
1248 let mut options = Vec::new();
1249
1250 if let Some(value) = grounded_value
1251 .map(str::trim)
1252 .filter(|value| !value.is_empty())
1253 {
1254 options.push(GuidedInitQuestionOption {
1255 value: value.to_owned(),
1256 label: value.to_owned(),
1257 description: grounded_description.to_owned(),
1258 recommended: true,
1259 });
1260 }
1261
1262 for (index, candidate) in candidates.iter().take(max_candidates).enumerate() {
1263 if options
1264 .iter()
1265 .any(|existing| existing.value == candidate.value)
1266 {
1267 continue;
1268 }
1269 options.push(GuidedInitQuestionOption {
1270 value: candidate.value.clone(),
1271 label: candidate.label.clone(),
1272 description: candidate.description.clone(),
1273 recommended: grounded_value.is_none() && index == 0,
1274 });
1275 }
1276
1277 if let Some(option) = trailing
1278 && options
1279 .iter()
1280 .all(|existing| existing.value != option.value)
1281 {
1282 options.push(option);
1283 }
1284
1285 if options.is_empty() {
1286 options.push(GuidedInitQuestionOption {
1287 value: CONTROL_GENERIC.to_owned(),
1288 label: "Keep generic guidance".to_owned(),
1289 description: "Leave this section generic for now.".to_owned(),
1290 recommended: true,
1291 });
1292 }
1293
1294 options
1295}
1296
1297fn resolve_verification_command(
1298 analysis: &ProjectAnalysis,
1299 answers: &GuidedInitAnswers,
1300) -> Option<String> {
1301 resolve_guided_answer(
1302 answers.answer(GuidedInitQuestionKey::VerificationCommand),
1303 analysis.grounded_verification_command.clone(),
1304 analysis.selected_verification_command.clone(),
1305 analysis
1306 .verification_candidates
1307 .first()
1308 .map(|candidate| candidate.value.clone()),
1309 )
1310}
1311
1312fn resolve_orientation_doc(
1313 analysis: &ProjectAnalysis,
1314 answers: &GuidedInitAnswers,
1315) -> Option<String> {
1316 resolve_guided_answer(
1317 answers.answer(GuidedInitQuestionKey::OrientationDoc),
1318 analysis.grounded_orientation_doc.clone(),
1319 analysis.selected_orientation_doc.clone(),
1320 analysis
1321 .orientation_candidates
1322 .first()
1323 .map(|candidate| candidate.value.clone()),
1324 )
1325}
1326
1327fn resolve_critical_instruction(
1328 analysis: &ProjectAnalysis,
1329 answers: &GuidedInitAnswers,
1330) -> Option<String> {
1331 resolve_guided_answer(
1332 answers.answer(GuidedInitQuestionKey::CriticalInstruction),
1333 analysis.grounded_critical_instruction.clone(),
1334 analysis.selected_critical_instruction.clone(),
1335 None,
1336 )
1337}
1338
1339fn resolve_guided_answer(
1340 answer: Option<&GuidedInitAnswer>,
1341 grounded: Option<String>,
1342 selected: Option<String>,
1343 fallback: Option<String>,
1344) -> Option<String> {
1345 match resolve_answered_value(answer) {
1346 AnsweredValue::Value(value) => Some(value),
1347 AnsweredValue::Control => None,
1348 AnsweredValue::Missing => grounded.or(selected).or(fallback),
1349 }
1350}
1351
1352enum AnsweredValue {
1353 Missing,
1354 Control,
1355 Value(String),
1356}
1357
1358fn resolve_answered_value(answer: Option<&GuidedInitAnswer>) -> AnsweredValue {
1359 let Some(answer) = answer else {
1360 return AnsweredValue::Missing;
1361 };
1362
1363 if let Some(custom) = answer
1364 .custom
1365 .as_ref()
1366 .map(|value| value.trim())
1367 .filter(|value| !value.is_empty())
1368 {
1369 return AnsweredValue::Value(custom.to_owned());
1370 }
1371
1372 let selected = answer.selected.trim();
1373 if selected.is_empty() {
1374 return AnsweredValue::Missing;
1375 }
1376
1377 if matches!(selected, CONTROL_GENERIC | CONTROL_NONE) {
1378 return AnsweredValue::Control;
1379 }
1380
1381 AnsweredValue::Value(selected.to_owned())
1382}
1383
1384fn build_quick_start_section(
1385 analysis: &ProjectAnalysis,
1386 verification_command: Option<&str>,
1387) -> String {
1388 let mut lines = Vec::new();
1389
1390 if let Some(command) = verification_command {
1391 lines.push(format!(
1392 "Default verification command: `{command}` before calling work complete."
1393 ));
1394 }
1395
1396 if analysis
1397 .build_systems
1398 .iter()
1399 .any(|system| system == "Cargo")
1400 {
1401 lines.push("Build with `cargo check` (preferred) or `cargo build --release`.".to_owned());
1402 lines.push(
1403 "Format via `cargo fmt` and lint with `cargo clippy` before committing.".to_owned(),
1404 );
1405 lines.push(
1406 "Run tests with `cargo nextest run` or `cargo test <name> -- --nocapture`.".to_owned(),
1407 );
1408 }
1409
1410 if let Some(package_manager) = analysis.package_manager.map(PackageManager::command) {
1411 lines.push(format!(
1412 "Install JavaScript dependencies with `{package_manager} install`."
1413 ));
1414 }
1415
1416 if analysis.scripts.iter().any(|script| script == "run.sh") {
1417 lines.push("Start interactive sessions with `./run.sh`.".to_owned());
1418 }
1419
1420 if lines.is_empty() {
1421 lines.push(
1422 "Install dependencies and run the standard build before starting new work.".to_owned(),
1423 );
1424 }
1425
1426 render_section("Quick start", lines).unwrap_or_else(|| {
1427 "## Quick start\n\n- Install dependencies and run the standard build before starting new work.\n\n".to_owned()
1428 })
1429}
1430
1431fn build_architecture_section(analysis: &ProjectAnalysis, orientation_doc: Option<&str>) -> String {
1432 let mut lines = Vec::new();
1433
1434 if let Some(summary) = analysis.grounded_project_summary.as_deref() {
1435 lines.push(summary.to_owned());
1436 }
1437
1438 if let Some(path) = orientation_doc {
1439 lines.push(format!(
1440 "Start with `{path}` when you need repo orientation or architectural context."
1441 ));
1442 }
1443
1444 lines.push(format!("Repository: {}.", analysis.project_name));
1445
1446 if !analysis.languages.is_empty() {
1447 lines.push(format!(
1448 "Primary languages: {}.",
1449 analysis.languages.join(", ")
1450 ));
1451 }
1452
1453 if !analysis.source_dirs.is_empty() {
1454 let dirs = analysis
1455 .source_dirs
1456 .iter()
1457 .map(|dir| format!("`{dir}/`"))
1458 .collect::<Vec<_>>();
1459 lines.push(format!("Key source directories: {}.", dirs.join(", ")));
1460 }
1461
1462 if analysis.is_application {
1463 lines.push("Application entrypoints live under the primary source directories.".to_owned());
1464 } else if analysis.is_library {
1465 lines.push("Library-style project; expect reusable crates and packages.".to_owned());
1466 }
1467
1468 if analysis.has_ci_cd {
1469 lines.push(
1470 "CI workflows detected under `.github/workflows/`; match those expectations locally."
1471 .to_owned(),
1472 );
1473 }
1474
1475 if analysis.has_docker {
1476 lines.push(
1477 "Docker assets are present; some integration flows may depend on container setup."
1478 .to_owned(),
1479 );
1480 }
1481
1482 render_section("Architecture & layout", lines).unwrap_or_else(|| {
1483 "## Architecture & layout\n\n- Review the repository layout before editing.\n\n".to_owned()
1484 })
1485}
1486
1487fn build_important_instructions_section(instruction: Option<&str>) -> Option<String> {
1488 let instruction = instruction?.trim();
1489 if instruction.is_empty() {
1490 return None;
1491 }
1492 render_section("Important instructions", vec![instruction.to_owned()])
1493}
1494
1495fn build_code_style_section(analysis: &ProjectAnalysis) -> String {
1496 let mut lines = Vec::new();
1497
1498 for language in &analysis.languages {
1499 match language.as_str() {
1500 "Rust" => {
1501 lines.push("Rust code uses 4-space indentation, snake_case functions, PascalCase types, and `anyhow::Result<T>` with `.with_context()` for fallible paths.".to_owned());
1502 lines.push("Run `cargo fmt` before committing and avoid hardcoded configuration.".to_owned());
1503 }
1504 "JavaScript/TypeScript" => lines.push(
1505 "Use the repository formatter and linter settings; match existing component and module patterns."
1506 .to_owned(),
1507 ),
1508 "Python" => lines.push(
1509 "Follow PEP 8, prefer Black-compatible formatting, and add type hints when practical."
1510 .to_owned(),
1511 ),
1512 "Go" => lines.push(
1513 "Use `gofmt`/`go vet` and keep exported APIs intentional.".to_owned(),
1514 ),
1515 other => lines.push(format!(
1516 "Match the surrounding {other} conventions and run the project formatter before pushing."
1517 )),
1518 }
1519 }
1520
1521 if lines.is_empty() {
1522 lines.push(
1523 "Match the surrounding style and keep commits free of formatting noise.".to_owned(),
1524 );
1525 }
1526
1527 render_section("Code style", lines).unwrap_or_else(|| {
1528 "## Code style\n\n- Match the surrounding style and keep commits free of formatting noise.\n\n".to_owned()
1529 })
1530}
1531
1532fn build_testing_section(analysis: &ProjectAnalysis, verification_command: Option<&str>) -> String {
1533 let mut lines = Vec::new();
1534
1535 if let Some(command) = verification_command {
1536 lines.push(format!("Default verification command: `{command}`."));
1537 }
1538
1539 if analysis
1540 .build_systems
1541 .iter()
1542 .any(|system| system == "Cargo")
1543 {
1544 lines.push(
1545 "Rust suite: `cargo nextest run` for speed, or `cargo test` for targeted fallback."
1546 .to_owned(),
1547 );
1548 lines.push(
1549 "Run `cargo clippy --workspace --all-targets -- -D warnings` for lint coverage."
1550 .to_owned(),
1551 );
1552 }
1553
1554 if let Some(package_manager) = analysis.package_manager.map(PackageManager::command) {
1555 lines.push(format!(
1556 "Run JavaScript/TypeScript checks with `{package_manager} test` or the repo's `check` script when present."
1557 ));
1558 }
1559
1560 if analysis
1561 .build_systems
1562 .iter()
1563 .any(|system| system == "Go Modules")
1564 {
1565 lines.push("Run `go test ./...` for Go coverage.".to_owned());
1566 }
1567
1568 if analysis.has_ci_cd {
1569 lines.push("Keep CI green by mirroring workflow steps locally before pushing.".to_owned());
1570 }
1571
1572 if lines.is_empty() {
1573 lines.push("Run the project's automated checks before submitting changes.".to_owned());
1574 }
1575
1576 render_section("Testing", lines).unwrap_or_else(|| {
1577 "## Testing\n\n- Run the project's automated checks before submitting changes.\n\n"
1578 .to_owned()
1579 })
1580}
1581
1582fn build_performance_section() -> String {
1583 render_section(
1584 "Performance & simplicity",
1585 vec![
1586 "Do not guess at bottlenecks; measure before optimizing.".to_owned(),
1587 "Prefer simple algorithms and data structures until workload data proves otherwise."
1588 .to_owned(),
1589 "Keep performance changes surgical and behavior-preserving.".to_owned(),
1590 ],
1591 )
1592 .unwrap_or_else(|| "## Performance & simplicity\n\n- Measure before optimizing.\n\n".to_owned())
1593}
1594
1595fn build_pr_guidelines_section(analysis: &ProjectAnalysis) -> Option<String> {
1596 let mut lines = Vec::new();
1597
1598 if analysis
1599 .commit_patterns
1600 .iter()
1601 .any(|pattern| pattern == "Conventional Commits")
1602 {
1603 lines.push(
1604 "Use Conventional Commits (`type(scope): subject`) with short, descriptive summaries."
1605 .to_owned(),
1606 );
1607 } else {
1608 lines.push("Write descriptive, imperative commit messages.".to_owned());
1609 }
1610
1611 lines.push("Reference issues with `Fixes #123` or `Closes #123` when applicable.".to_owned());
1612 lines.push(
1613 "Keep pull requests focused and include test evidence for non-trivial changes.".to_owned(),
1614 );
1615
1616 render_section("PR guidelines", lines)
1617}
1618
1619fn build_additional_guidance_section(
1620 analysis: &ProjectAnalysis,
1621 orientation_doc: Option<&str>,
1622) -> Option<String> {
1623 let mut lines = Vec::new();
1624
1625 if let Some(path) = orientation_doc {
1626 lines.push(format!("Preferred orientation doc: `{path}`."));
1627 }
1628
1629 if !analysis.documentation_files.is_empty() {
1630 lines.push(format!(
1631 "Repository docs spotted: {}.",
1632 analysis.documentation_files.join(", ")
1633 ));
1634 }
1635
1636 if !analysis.dependencies.is_empty() {
1637 let highlights = analysis
1638 .dependencies
1639 .iter()
1640 .map(|(ecosystem, deps)| format!("{ecosystem} ({})", deps.join(", ")))
1641 .collect::<Vec<_>>();
1642 lines.push(format!("Notable dependencies: {}.", highlights.join("; ")));
1643 }
1644
1645 if analysis.scripts.iter().any(|script| script == "run.sh")
1646 && analysis
1647 .scripts
1648 .iter()
1649 .any(|script| script == "run-debug.sh")
1650 {
1651 lines.push(
1652 "Use `./run.sh` for release runs and `./run-debug.sh` for debug sessions.".to_owned(),
1653 );
1654 }
1655
1656 render_section("Additional guidance", lines)
1657}
1658
1659fn render_section(title: &str, lines: Vec<String>) -> Option<String> {
1660 if lines.is_empty() {
1661 return None;
1662 }
1663
1664 let mut section = String::new();
1665 section.push_str("## ");
1666 section.push_str(title);
1667 section.push_str("\n\n");
1668
1669 for line in lines {
1670 section.push_str("- ");
1671 section.push_str(&line);
1672 section.push('\n');
1673 }
1674 section.push('\n');
1675 Some(section)
1676}
1677
1678fn unique_preserving_order(values: &[String]) -> Vec<String> {
1679 let mut seen = BTreeSet::new();
1680 let mut unique = Vec::new();
1681 for value in values {
1682 if seen.insert(value.clone()) {
1683 unique.push(value.clone());
1684 }
1685 }
1686 unique
1687}
1688
1689#[cfg(test)]
1690mod tests {
1691 use super::*;
1692 use tempfile::TempDir;
1693
1694 fn write_file(dir: &TempDir, relative: &str, contents: &str) {
1695 let path = dir.path().join(relative);
1696 if let Some(parent) = path.parent() {
1697 fs::create_dir_all(parent).expect("create parent");
1698 }
1699 fs::write(path, contents).expect("write file");
1700 }
1701
1702 #[test]
1703 fn no_questions_when_clear_signals_exist() {
1704 let workspace = TempDir::new().expect("workspace");
1705 write_file(
1706 &workspace,
1707 "Cargo.toml",
1708 "[package]\nname = \"demo\"\nversion = \"0.1.0\"\n",
1709 );
1710 write_file(&workspace, "README.md", "# Demo\n");
1711 write_file(&workspace, "scripts/check.sh", "#!/bin/sh\ncargo check\n");
1712
1713 let plan = prepare_guided_init(workspace.path(), false).expect("plan");
1714
1715 assert!(plan.questions.is_empty());
1716 assert_eq!(plan.overwrite_state, GuidedInitOverwriteState::Skip);
1717 }
1718
1719 #[test]
1720 fn emits_verification_question_when_multiple_strong_candidates_exist() {
1721 let workspace = TempDir::new().expect("workspace");
1722 write_file(
1723 &workspace,
1724 "Cargo.toml",
1725 "[package]\nname = \"demo\"\nversion = \"0.1.0\"\n",
1726 );
1727 write_file(
1728 &workspace,
1729 "README.md",
1730 "Run ./scripts/check.sh for the full gate.\nUse cargo nextest run during local work.\n",
1731 );
1732 write_file(
1733 &workspace,
1734 "scripts/check.sh",
1735 "#!/bin/sh\ncargo nextest run\n",
1736 );
1737 write_file(
1738 &workspace,
1739 ".github/workflows/ci.yml",
1740 "jobs:\n test:\n steps:\n - run: cargo nextest run\n",
1741 );
1742
1743 let plan = prepare_guided_init(workspace.path(), false).expect("plan");
1744
1745 assert!(
1746 plan.questions
1747 .iter()
1748 .any(|question| question.key == GuidedInitQuestionKey::VerificationCommand)
1749 );
1750 }
1751
1752 #[test]
1753 fn emits_orientation_question_when_multiple_docs_are_plausible() {
1754 let workspace = TempDir::new().expect("workspace");
1755 write_file(
1756 &workspace,
1757 "README.md",
1758 "See docs/ARCHITECTURE.md for design.\n",
1759 );
1760 write_file(&workspace, "docs/ARCHITECTURE.md", "# Architecture\n");
1761 write_file(
1762 &workspace,
1763 "docs/modules/vtcode_docs_map.md",
1764 "# Docs Map\nUse this first.\n",
1765 );
1766
1767 let plan = prepare_guided_init(workspace.path(), false).expect("plan");
1768
1769 assert!(
1770 plan.questions
1771 .iter()
1772 .any(|question| question.key == GuidedInitQuestionKey::OrientationDoc)
1773 );
1774 }
1775
1776 #[test]
1777 fn chosen_answers_override_heuristics() {
1778 let workspace = TempDir::new().expect("workspace");
1779 write_file(
1780 &workspace,
1781 "Cargo.toml",
1782 "[package]\nname = \"demo\"\nversion = \"0.1.0\"\n",
1783 );
1784 write_file(&workspace, "README.md", "# Demo\n");
1785 let plan = prepare_guided_init(workspace.path(), false).expect("plan");
1786
1787 let mut answers = GuidedInitAnswers::default();
1788 answers.insert(GuidedInitAnswer {
1789 key: GuidedInitQuestionKey::VerificationCommand,
1790 selected: "cargo nextest run".to_owned(),
1791 custom: None,
1792 });
1793
1794 let rendered = render_agents_md(&plan, &answers).expect("rendered");
1795 assert!(rendered.contains("Default verification command: `cargo nextest run`."));
1796 }
1797
1798 #[test]
1799 fn critical_instruction_none_omits_section() {
1800 let workspace = TempDir::new().expect("workspace");
1801 write_file(&workspace, "README.md", "Use Conventional Commits.\n");
1802 let plan = prepare_guided_init(workspace.path(), false).expect("plan");
1803
1804 let mut answers = GuidedInitAnswers::default();
1805 answers.insert(GuidedInitAnswer {
1806 key: GuidedInitQuestionKey::CriticalInstruction,
1807 selected: CONTROL_NONE.to_owned(),
1808 custom: None,
1809 });
1810
1811 let rendered = render_agents_md(&plan, &answers).expect("rendered");
1812 assert!(!rendered.contains("## Important instructions"));
1813 }
1814
1815 #[test]
1816 fn render_is_deterministic_for_same_analysis_and_answers() {
1817 let workspace = TempDir::new().expect("workspace");
1818 write_file(&workspace, "README.md", "# Demo\n");
1819 let plan = prepare_guided_init(workspace.path(), false).expect("plan");
1820 let answers = GuidedInitAnswers::default();
1821
1822 let left = render_agents_md(&plan, &answers).expect("left");
1823 let right = render_agents_md(&plan, &answers).expect("right");
1824
1825 assert_eq!(left, right);
1826 }
1827
1828 #[test]
1829 fn existing_agents_requires_confirmation_without_force() {
1830 let workspace = TempDir::new().expect("workspace");
1831 write_file(&workspace, "AGENTS.md", "# Existing\n");
1832
1833 let plan = prepare_guided_init(workspace.path(), false).expect("plan");
1834
1835 assert_eq!(plan.overwrite_state, GuidedInitOverwriteState::Confirm);
1836 }
1837
1838 #[test]
1839 fn force_skips_confirmation_for_existing_agents() {
1840 let workspace = TempDir::new().expect("workspace");
1841 write_file(&workspace, "AGENTS.md", "# Existing\n");
1842
1843 let plan = prepare_guided_init(workspace.path(), true).expect("plan");
1844
1845 assert_eq!(plan.overwrite_state, GuidedInitOverwriteState::Force);
1846 }
1847
1848 #[test]
1849 fn write_agents_file_skips_existing_when_not_overwriting() {
1850 let workspace = TempDir::new().expect("workspace");
1851 write_file(&workspace, "AGENTS.md", "# Existing\n");
1852
1853 let report = write_agents_file(workspace.path(), "# Fresh\n", false).expect("report");
1854
1855 assert_eq!(report.status, GenerateAgentsFileStatus::SkippedExisting);
1856 }
1857
1858 #[test]
1859 fn blank_custom_answers_normalize_to_control_values() {
1860 let verification = GuidedInitAnswer::from_input(
1861 GuidedInitQuestionKey::VerificationCommand,
1862 None,
1863 Some(" "),
1864 )
1865 .expect("verification");
1866 assert_eq!(verification.selected, CONTROL_GENERIC);
1867 assert_eq!(verification.custom, None);
1868
1869 let critical = GuidedInitAnswer::from_input(
1870 GuidedInitQuestionKey::CriticalInstruction,
1871 None,
1872 Some(""),
1873 )
1874 .expect("critical");
1875 assert_eq!(critical.selected, CONTROL_NONE);
1876 assert_eq!(critical.custom, None);
1877 }
1878
1879 #[test]
1880 fn grounding_conflict_reopens_verification_choice() {
1881 let workspace = TempDir::new().expect("workspace");
1882 write_file(
1883 &workspace,
1884 "Cargo.toml",
1885 "[package]\nname = \"demo\"\nversion = \"0.1.0\"\n",
1886 );
1887 write_file(&workspace, "README.md", "# Demo\n");
1888 write_file(&workspace, "scripts/check.sh", "#!/bin/sh\ncargo check\n");
1889
1890 let plan = prepare_guided_init(workspace.path(), false)
1891 .expect("plan")
1892 .with_grounding(GuidedInitGrounding {
1893 verification_command: Some("cargo nextest run".to_owned()),
1894 ..GuidedInitGrounding::default()
1895 });
1896
1897 assert!(
1898 plan.questions
1899 .iter()
1900 .any(|question| question.key == GuidedInitQuestionKey::VerificationCommand)
1901 );
1902 }
1903
1904 #[test]
1905 fn grounding_summary_and_defaults_render_into_agents() {
1906 let workspace = TempDir::new().expect("workspace");
1907 write_file(&workspace, "README.md", "# Demo\n");
1908
1909 let plan = prepare_guided_init(workspace.path(), false)
1910 .expect("plan")
1911 .with_grounding(GuidedInitGrounding {
1912 project_summary: Some(
1913 "Terminal-first coding agent for repository work.".to_owned(),
1914 ),
1915 verification_command: Some("cargo nextest run".to_owned()),
1916 ..GuidedInitGrounding::default()
1917 });
1918
1919 let rendered = render_agents_md(&plan, &GuidedInitAnswers::default()).expect("rendered");
1920 assert!(rendered.contains("Terminal-first coding agent for repository work."));
1921 assert!(rendered.contains("Default verification command: `cargo nextest run`."));
1922 }
1923}