Skip to main content

lean_ctx/core/
profile_suggest.rs

1//! Repo-stack-aware profile recommendation (#851).
2//!
3//! Powers `lean-ctx profile suggest`: scan the current repo for deterministic
4//! signals (languages, source-file count, monorepo layout, build/CI markers,
5//! configured LLM providers) and recommend a context profile plus a few key
6//! settings (`history_mode`, output density, `effort`).
7//!
8//! Strictly **read-only and local-only**: it prints a suggestion and the exact
9//! commands to apply it, and never writes config. All signals come from the
10//! filesystem + local config/env, so the output is a deterministic function of
11//! the repo and environment (no network, no telemetry).
12//!
13//! Reuses existing detectors rather than reinventing them:
14//! [`language_for_ext`] for language classification and
15//! [`crate::core::pathutil::has_multi_repo_children`] for the monorepo check.
16//! The pure mapping ([`suggest`]) is separated from the I/O scan ([`analyze`])
17//! so the heuristic is unit-tested without touching disk.
18
19use std::collections::BTreeMap;
20use std::path::Path;
21
22use crate::core::config::Config;
23use crate::core::language_capabilities::language_for_ext;
24
25/// A repo is "large" past this many indexed source files (favors broad context).
26const LARGE_REPO_FILES: usize = 2000;
27/// A repo is "small" at or below this many source files (a focused default fits).
28const SMALL_REPO_FILES: usize = 60;
29/// This many distinct languages counts as polyglot (favors broad context).
30const POLYGLOT_LANGS: usize = 4;
31/// Hard cap on files visited during the scan, so `suggest` stays fast on huge trees.
32const MAX_WALK_FILES: usize = 50_000;
33
34/// Files counted for one language. Serialized for `--json`.
35#[derive(Debug, Clone, serde::Serialize)]
36pub struct LanguageCount {
37    pub language: String,
38    pub files: usize,
39}
40
41/// Deterministic, locally-detected signals about the repo + environment.
42#[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/// Key settings the suggestion recommends alongside the profile.
56#[derive(Debug, Clone, serde::Serialize)]
57pub struct RecommendedSettings {
58    /// `proxy.history_mode` — `None` ⇒ leave the default untouched.
59    pub history_mode: Option<String>,
60    /// `output_density`.
61    pub output_density: String,
62    /// `proxy.effort` — `None` ⇒ leave off (opt-in; never inferred from a repo).
63    pub effort: Option<String>,
64}
65
66/// A task-oriented profile the user can switch to, with the situation it fits.
67#[derive(Debug, Clone, serde::Serialize)]
68pub struct ProfileAlternative {
69    pub profile: String,
70    pub when: String,
71}
72
73/// The full recommendation: a primary profile, why, the settings, and
74/// task-oriented alternatives.
75#[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/// Scans `root` and collects [`RepoSignals`]. Respects `.gitignore` (so vendored
84/// / build dirs don't skew the language mix) and is bounded by `MAX_WALK_FILES`.
85#[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    // Stable order: most files first, ties broken by name.
120    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/// Maps signals to a recommendation. Pure and deterministic (no I/O), so the
154/// heuristic is fully unit-tested.
155#[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    // `effort` is a cost/latency knob with no repo signal — never inferred here.
207    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
241/// Monorepo / workspace marker files at the repo root (deterministic file probes).
242fn 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    // A Cargo workspace is expressed inside Cargo.toml rather than its own file.
257    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
265/// Build-tool markers at the repo root, de-duplicated by tool name.
266fn 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
290/// LLM providers in use, from local config upstreams + environment API keys.
291fn 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}