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.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}