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