Skip to main content

vtcode_core/commands/
init.rs

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