Skip to main content

vtcode_core/commands/
init.rs

1//! Guided `/init` support for repository analysis and `AGENTS.md` generation.
2
3use 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(&section);
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(&section);
335    }
336    if let Some(section) = build_additional_guidance_section(analysis, orientation_doc.as_deref()) {
337        content.push_str(&section);
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}