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.chatgpt_upstream.is_some() {
310 push_unique(&mut providers, "chatgpt");
311 }
312 if cfg.proxy.gemini_upstream.is_some() {
313 push_unique(&mut providers, "gemini");
314 }
315
316 providers.sort();
317 providers
318}
319
320fn push_unique(v: &mut Vec<String>, name: &str) {
321 if !v.iter().any(|p| p == name) {
322 v.push(name.to_string());
323 }
324}
325
326fn env_set(key: &str) -> bool {
327 std::env::var(key).is_ok_and(|v| !v.trim().is_empty())
328}
329
330#[cfg(test)]
331mod tests {
332 use super::*;
333
334 fn signals(source_files: usize, langs: &[&str], monorepo: bool) -> RepoSignals {
335 RepoSignals {
336 root: "/tmp/x".to_string(),
337 source_files,
338 languages: langs
339 .iter()
340 .map(|l| LanguageCount {
341 language: (*l).to_string(),
342 files: 1,
343 })
344 .collect(),
345 monorepo,
346 workspace_markers: vec![],
347 build_markers: vec![],
348 ci: false,
349 providers: vec![],
350 proxy_enabled: false,
351 }
352 }
353
354 #[test]
355 fn monorepo_suggests_exploration() {
356 let s = suggest(&signals(300, &["rust", "typescript"], true));
357 assert_eq!(s.profile, "exploration");
358 assert_eq!(s.settings.output_density, "terse");
359 }
360
361 #[test]
362 fn large_repo_suggests_exploration() {
363 let s = suggest(&signals(5000, &["rust"], false));
364 assert_eq!(s.profile, "exploration");
365 assert_eq!(s.settings.output_density, "terse");
366 }
367
368 #[test]
369 fn polyglot_suggests_exploration() {
370 let s = suggest(&signals(
371 300,
372 &["rust", "go", "python", "typescript"],
373 false,
374 ));
375 assert_eq!(s.profile, "exploration");
376 }
377
378 #[test]
379 fn small_repo_suggests_coder_normal_density() {
380 let s = suggest(&signals(20, &["rust"], false));
381 assert_eq!(s.profile, "coder");
382 assert_eq!(s.settings.output_density, "normal");
383 }
384
385 #[test]
386 fn typical_repo_suggests_coder() {
387 let s = suggest(&signals(400, &["rust", "typescript"], false));
388 assert_eq!(s.profile, "coder");
389 assert_eq!(s.settings.output_density, "normal");
390 }
391
392 #[test]
393 fn effort_is_never_inferred() {
394 let s = suggest(&signals(5000, &["rust", "go", "python", "ts"], true));
395 assert!(s.settings.effort.is_none());
396 }
397
398 #[test]
399 fn history_mode_recommended_only_with_providers() {
400 let mut s = signals(400, &["rust"], false);
401 assert!(suggest(&s).settings.history_mode.is_none());
402 s.providers = vec!["anthropic".to_string()];
403 assert_eq!(
404 suggest(&s).settings.history_mode.as_deref(),
405 Some("cache-aware")
406 );
407 }
408
409 #[test]
410 fn ci_adds_ci_debug_alternative_first() {
411 let mut s = signals(400, &["rust"], false);
412 s.ci = true;
413 let out = suggest(&s);
414 assert_eq!(out.alternatives.first().unwrap().profile, "ci-debug");
415 }
416
417 #[test]
418 fn suggestion_is_deterministic() {
419 let s = signals(5000, &["rust", "go"], true);
420 let a = suggest(&s);
421 let b = suggest(&s);
422 assert_eq!(a.profile, b.profile);
423 assert_eq!(a.rationale, b.rationale);
424 }
425}