1use std::collections::BTreeMap;
20use std::path::Path;
21
22use crate::core::config::Config;
23use crate::core::language_capabilities::language_for_ext;
24
25const LARGE_REPO_FILES: usize = 2000;
27const SMALL_REPO_FILES: usize = 60;
29const POLYGLOT_LANGS: usize = 4;
31const MAX_WALK_FILES: usize = 50_000;
33
34#[derive(Debug, Clone, serde::Serialize)]
36pub struct LanguageCount {
37 pub language: String,
38 pub files: usize,
39}
40
41#[derive(Debug, Clone, serde::Serialize)]
43pub struct RepoSignals {
44 pub root: String,
45 pub source_files: usize,
46 pub languages: Vec<LanguageCount>,
47 pub monorepo: bool,
48 pub workspace_markers: Vec<String>,
49 pub build_markers: Vec<String>,
50 pub ci: bool,
51 pub providers: Vec<String>,
52 pub proxy_enabled: bool,
53}
54
55#[derive(Debug, Clone, serde::Serialize)]
57pub struct RecommendedSettings {
58 pub history_mode: Option<String>,
60 pub output_density: String,
62 pub effort: Option<String>,
64}
65
66#[derive(Debug, Clone, serde::Serialize)]
68pub struct ProfileAlternative {
69 pub profile: String,
70 pub when: String,
71}
72
73#[derive(Debug, Clone, serde::Serialize)]
76pub struct Suggestion {
77 pub profile: String,
78 pub rationale: Vec<String>,
79 pub settings: RecommendedSettings,
80 pub alternatives: Vec<ProfileAlternative>,
81}
82
83#[must_use]
86pub fn analyze(root: &str) -> RepoSignals {
87 let root_path = Path::new(root);
88
89 let mut lang_counts: BTreeMap<&'static str, usize> = BTreeMap::new();
90 let mut source_files = 0usize;
91
92 for entry in ignore::WalkBuilder::new(root_path)
93 .standard_filters(true)
94 .build()
95 .flatten()
96 {
97 if source_files >= MAX_WALK_FILES {
98 break;
99 }
100 if !entry.file_type().is_some_and(|t| t.is_file()) {
101 continue;
102 }
103 let Some(ext) = entry.path().extension().and_then(|e| e.to_str()) else {
104 continue;
105 };
106 if let Some(lang) = language_for_ext(ext) {
107 *lang_counts.entry(lang.id_str()).or_insert(0) += 1;
108 source_files += 1;
109 }
110 }
111
112 let mut languages: Vec<LanguageCount> = lang_counts
113 .into_iter()
114 .map(|(language, files)| LanguageCount {
115 language: language.to_string(),
116 files,
117 })
118 .collect();
119 languages.sort_by(|a, b| {
121 b.files
122 .cmp(&a.files)
123 .then_with(|| a.language.cmp(&b.language))
124 });
125
126 let workspace_markers = detect_workspace_markers(root_path);
127 let monorepo =
128 !workspace_markers.is_empty() || crate::core::pathutil::has_multi_repo_children(root_path);
129
130 let build_markers = detect_build_markers(root_path);
131 let ci = root_path.join(".github/workflows").is_dir()
132 || root_path.join(".gitlab-ci.yml").is_file()
133 || root_path.join(".circleci").is_dir()
134 || root_path.join("azure-pipelines.yml").is_file();
135
136 let cfg = Config::load();
137 let providers = detect_providers(&cfg);
138 let proxy_enabled = cfg.proxy_enabled.unwrap_or(false);
139
140 RepoSignals {
141 root: root.to_string(),
142 source_files,
143 languages,
144 monorepo,
145 workspace_markers,
146 build_markers,
147 ci,
148 providers,
149 proxy_enabled,
150 }
151}
152
153#[must_use]
156pub fn suggest(signals: &RepoSignals) -> Suggestion {
157 let polyglot = signals.languages.len() >= POLYGLOT_LANGS;
158 let large = signals.source_files >= LARGE_REPO_FILES;
159 let small = signals.source_files <= SMALL_REPO_FILES;
160
161 let mut rationale = Vec::new();
162
163 let profile = if signals.monorepo {
164 rationale.push("monorepo layout → broad cross-package context".to_string());
165 "exploration"
166 } else if large {
167 rationale.push(format!(
168 "large repo ({} source files) → wider, map-first context",
169 signals.source_files
170 ));
171 "exploration"
172 } else if polyglot {
173 rationale.push(format!(
174 "polyglot ({} languages) → wider context to span stacks",
175 signals.languages.len()
176 ));
177 "exploration"
178 } else if small {
179 rationale.push(format!(
180 "small repo ({} source files) → a focused default is enough",
181 signals.source_files
182 ));
183 "coder"
184 } else {
185 rationale.push(format!(
186 "typical project size ({} source files) → balanced default",
187 signals.source_files
188 ));
189 "coder"
190 };
191
192 let broad = signals.monorepo || large || polyglot;
193 let output_density = if broad { "terse" } else { "normal" };
194 if broad {
195 rationale.push("dense output (terse) to fit the larger surface in budget".to_string());
196 }
197
198 let history_mode = if signals.proxy_enabled || !signals.providers.is_empty() {
199 rationale
200 .push("provider proxy active → cache-aware history (cache-stable pruning)".to_string());
201 Some("cache-aware".to_string())
202 } else {
203 None
204 };
205
206 let effort = None;
208
209 let mut alternatives = Vec::new();
210 if signals.ci {
211 alternatives.push(ProfileAlternative {
212 profile: "ci-debug".to_string(),
213 when: "iterating on CI / shell failures".to_string(),
214 });
215 }
216 alternatives.push(ProfileAlternative {
217 profile: "hotfix".to_string(),
218 when: "urgent one-file fix — minimal context".to_string(),
219 });
220 alternatives.push(ProfileAlternative {
221 profile: "bugfix".to_string(),
222 when: "debugging a specific issue".to_string(),
223 });
224 alternatives.push(ProfileAlternative {
225 profile: "review".to_string(),
226 when: "read-only code review".to_string(),
227 });
228
229 Suggestion {
230 profile: profile.to_string(),
231 rationale,
232 settings: RecommendedSettings {
233 history_mode,
234 output_density: output_density.to_string(),
235 effort,
236 },
237 alternatives,
238 }
239}
240
241fn detect_workspace_markers(root: &Path) -> Vec<String> {
243 const MARKERS: &[&str] = &[
244 "pnpm-workspace.yaml",
245 "lerna.json",
246 "nx.json",
247 "turbo.json",
248 "rush.json",
249 "go.work",
250 ];
251 let mut found: Vec<String> = MARKERS
252 .iter()
253 .filter(|m| root.join(m).exists())
254 .map(|m| (*m).to_string())
255 .collect();
256 if std::fs::read_to_string(root.join("Cargo.toml"))
258 .is_ok_and(|s| s.lines().any(|l| l.trim_start().starts_with("[workspace]")))
259 {
260 found.push("Cargo.toml [workspace]".to_string());
261 }
262 found
263}
264
265fn detect_build_markers(root: &Path) -> Vec<String> {
267 const MARKERS: &[(&str, &str)] = &[
268 ("Cargo.toml", "cargo"),
269 ("package.json", "npm"),
270 ("go.mod", "go"),
271 ("pyproject.toml", "python"),
272 ("requirements.txt", "python"),
273 ("setup.py", "python"),
274 ("pom.xml", "maven"),
275 ("build.gradle", "gradle"),
276 ("Gemfile", "bundler"),
277 ("composer.json", "composer"),
278 ("Makefile", "make"),
279 ("Dockerfile", "docker"),
280 ];
281 let mut found: Vec<String> = Vec::new();
282 for (file, name) in MARKERS {
283 if root.join(file).exists() && !found.iter().any(|f| f == name) {
284 found.push((*name).to_string());
285 }
286 }
287 found
288}
289
290fn detect_providers(cfg: &Config) -> Vec<String> {
292 let mut providers: Vec<String> = Vec::new();
293
294 if env_set("ANTHROPIC_API_KEY") || env_set("ANTHROPIC_AUTH_TOKEN") {
295 push_unique(&mut providers, "anthropic");
296 }
297 if env_set("OPENAI_API_KEY") {
298 push_unique(&mut providers, "openai");
299 }
300 if env_set("GEMINI_API_KEY") || env_set("GOOGLE_API_KEY") {
301 push_unique(&mut providers, "gemini");
302 }
303 if cfg.proxy.anthropic_upstream.is_some() {
304 push_unique(&mut providers, "anthropic");
305 }
306 if cfg.proxy.openai_upstream.is_some() {
307 push_unique(&mut providers, "openai");
308 }
309 if cfg.proxy.gemini_upstream.is_some() {
310 push_unique(&mut providers, "gemini");
311 }
312
313 providers.sort();
314 providers
315}
316
317fn push_unique(v: &mut Vec<String>, name: &str) {
318 if !v.iter().any(|p| p == name) {
319 v.push(name.to_string());
320 }
321}
322
323fn env_set(key: &str) -> bool {
324 std::env::var(key).is_ok_and(|v| !v.trim().is_empty())
325}
326
327#[cfg(test)]
328mod tests {
329 use super::*;
330
331 fn signals(source_files: usize, langs: &[&str], monorepo: bool) -> RepoSignals {
332 RepoSignals {
333 root: "/tmp/x".to_string(),
334 source_files,
335 languages: langs
336 .iter()
337 .map(|l| LanguageCount {
338 language: (*l).to_string(),
339 files: 1,
340 })
341 .collect(),
342 monorepo,
343 workspace_markers: vec![],
344 build_markers: vec![],
345 ci: false,
346 providers: vec![],
347 proxy_enabled: false,
348 }
349 }
350
351 #[test]
352 fn monorepo_suggests_exploration() {
353 let s = suggest(&signals(300, &["rust", "typescript"], true));
354 assert_eq!(s.profile, "exploration");
355 assert_eq!(s.settings.output_density, "terse");
356 }
357
358 #[test]
359 fn large_repo_suggests_exploration() {
360 let s = suggest(&signals(5000, &["rust"], false));
361 assert_eq!(s.profile, "exploration");
362 assert_eq!(s.settings.output_density, "terse");
363 }
364
365 #[test]
366 fn polyglot_suggests_exploration() {
367 let s = suggest(&signals(
368 300,
369 &["rust", "go", "python", "typescript"],
370 false,
371 ));
372 assert_eq!(s.profile, "exploration");
373 }
374
375 #[test]
376 fn small_repo_suggests_coder_normal_density() {
377 let s = suggest(&signals(20, &["rust"], false));
378 assert_eq!(s.profile, "coder");
379 assert_eq!(s.settings.output_density, "normal");
380 }
381
382 #[test]
383 fn typical_repo_suggests_coder() {
384 let s = suggest(&signals(400, &["rust", "typescript"], false));
385 assert_eq!(s.profile, "coder");
386 assert_eq!(s.settings.output_density, "normal");
387 }
388
389 #[test]
390 fn effort_is_never_inferred() {
391 let s = suggest(&signals(5000, &["rust", "go", "python", "ts"], true));
392 assert!(s.settings.effort.is_none());
393 }
394
395 #[test]
396 fn history_mode_recommended_only_with_providers() {
397 let mut s = signals(400, &["rust"], false);
398 assert!(suggest(&s).settings.history_mode.is_none());
399 s.providers = vec!["anthropic".to_string()];
400 assert_eq!(
401 suggest(&s).settings.history_mode.as_deref(),
402 Some("cache-aware")
403 );
404 }
405
406 #[test]
407 fn ci_adds_ci_debug_alternative_first() {
408 let mut s = signals(400, &["rust"], false);
409 s.ci = true;
410 let out = suggest(&s);
411 assert_eq!(out.alternatives.first().unwrap().profile, "ci-debug");
412 }
413
414 #[test]
415 fn suggestion_is_deterministic() {
416 let s = signals(5000, &["rust", "go"], true);
417 let a = suggest(&s);
418 let b = suggest(&s);
419 assert_eq!(a.profile, b.profile);
420 assert_eq!(a.rationale, b.rationale);
421 }
422}