Skip to main content

tldr_cli/commands/
context.rs

1//! Context command - Build LLM context
2//!
3//! Generates token-efficient LLM context from an entry point.
4//! Auto-routes through daemon when available for ~35x speedup.
5
6use std::path::{Path, PathBuf};
7
8use anyhow::Result;
9use clap::Args;
10
11use tldr_core::types::RelevantContext;
12use tldr_core::{get_relevant_context, Language};
13
14use crate::commands::daemon_router::{params_with_entry_depth, try_daemon_route};
15use crate::output::{OutputFormat, OutputWriter};
16
17/// Build LLM-ready context from entry point
18#[derive(Debug, Args)]
19pub struct ContextArgs {
20    /// Entry point function name
21    pub entry: String,
22
23    /// Project root directory as positional argument (mirrors sibling
24    /// path-taking commands like `impact`, `whatbreaks`). When set, this
25    /// takes precedence over `--project`. (med-cleanup-bundle-v1 / M1)
26    #[arg(default_value = ".")]
27    pub path: PathBuf,
28
29    /// Project root directory (deprecated alias for the positional path
30    /// argument; kept for back-compat). (med-cleanup-bundle-v1 / M1)
31    #[arg(long, short = 'p')]
32    pub project: Option<PathBuf>,
33
34    /// Programming language
35    #[arg(long, short = 'l')]
36    pub lang: Option<Language>,
37
38    /// Maximum traversal depth
39    #[arg(long, short = 'd', default_value = "3")]
40    pub depth: usize,
41
42    /// Include function docstrings
43    #[arg(long)]
44    pub include_docstrings: bool,
45
46    /// Filter to functions in this file (for disambiguating common names like "render")
47    #[arg(long)]
48    pub file: Option<PathBuf>,
49}
50
51impl ContextArgs {
52    /// Resolve the effective project path. The positional `path` argument
53    /// is the canonical input; `--project` is kept as a back-compat alias
54    /// and only wins when the positional path is left at its default ".".
55    /// (med-cleanup-bundle-v1 / M1)
56    fn effective_project(&self) -> PathBuf {
57        match &self.project {
58            Some(p) if self.path == PathBuf::from(".") => p.clone(),
59            _ => self.path.clone(),
60        }
61    }
62
63    /// Run the context command
64    pub fn run(&self, format: OutputFormat, quiet: bool) -> Result<()> {
65        let writer = OutputWriter::new(format, quiet);
66
67        let mut project_path = self.effective_project();
68
69        // language-adapter-fixes-v1 (P13.AGG13-5) /
70        // context-file-func-cross-lang-and-cpp-qualified-v1 (P14.AGG13-5,
71        // AGG14-8): accept the `<file>:<func>` shorthand so users can
72        // disambiguate common function names without typing `--file`
73        // separately. The shape mirrors `tldr explain <file> <func>` and
74        // `tldr resources <file> <func>`.
75        //
76        // We walk colons RIGHT-TO-LEFT and pick the leftmost split whose
77        // file_part exists on disk. The legacy single-rfind form failed
78        // for C++ qualified names because
79        // `path/x.cpp:XMLDocument::Parse`'s last `:` lands inside `::`,
80        // leaving file_part = `path/x.cpp:XMLDocument:` which is not a
81        // file. Walking colons backward fixes this: the second-to-last
82        // colon yields file_part = `path/x.cpp` (valid file) and
83        // func_part = `XMLDocument::Parse` — the form the per-function
84        // lookup now accepts (P14.AGG14-3 in `find_function_node`).
85        // Windows drive letters (`C:\foo\bar.js:foo`) keep working
86        // because the leftmost split where `C:\foo\bar.js` is a file
87        // wins (the earlier `C:` split returns a non-file).
88        let (entry, derived_file): (String, Option<PathBuf>) =
89            match split_file_func_shorthand(&self.entry) {
90                Some((file, func)) => (func, Some(file)),
91                None => (self.entry.clone(), None),
92            };
93
94        // The user-supplied --file (if any) wins over the derived form so
95        // explicit flags always take precedence over inferred shorthands.
96        let effective_file: Option<PathBuf> =
97            self.file.clone().or_else(|| derived_file.clone());
98
99        // Auto-derive project root from file when shorthand was used and
100        // the user didn't supply an explicit one. Honour `.git` /
101        // `package.json` / `Cargo.toml` markers; otherwise fall back to
102        // the file's immediate parent directory. This keeps the
103        // shorthand useful from any cwd.
104        if derived_file.is_some()
105            && self.path == PathBuf::from(".")
106            && self.project.is_none()
107        {
108            if let Some(file) = effective_file.as_ref() {
109                if let Some(root) = infer_project_root_from_file(file) {
110                    project_path = root;
111                }
112            }
113        }
114
115        // Determine language (auto-detect from directory, default to Python)
116        let language = self
117            .lang
118            .unwrap_or_else(|| Language::from_directory(&project_path).unwrap_or(Language::Python));
119
120        // Try daemon first for cached result. Only route through the
121        // daemon when there is no derived-file disambiguation, since the
122        // daemon protocol does not currently propagate the `--file`
123        // filter (would silently ignore the disambiguator).
124        if effective_file.is_none() {
125            if let Some(context) = try_daemon_route::<RelevantContext>(
126                &project_path,
127                "context",
128                params_with_entry_depth(&entry, Some(self.depth)),
129            ) {
130                // Output based on format
131                if writer.is_text() {
132                    // Use the built-in LLM string format
133                    let text = context.to_llm_string();
134                    writer.write_text(&text)?;
135                    return Ok(());
136                } else {
137                    writer.write(&context)?;
138                    return Ok(());
139                }
140            }
141        }
142
143        // Fallback to direct compute
144        writer.progress(&format!(
145            "Building context for {} (depth={})...",
146            entry, self.depth
147        ));
148
149        // Get relevant context
150        let context = get_relevant_context(
151            &project_path,
152            &entry,
153            self.depth,
154            language,
155            self.include_docstrings,
156            effective_file.as_deref(),
157        )?;
158
159        // Output based on format
160        if writer.is_text() {
161            // Use the built-in LLM string format
162            let text = context.to_llm_string();
163            writer.write_text(&text)?;
164        } else {
165            writer.write(&context)?;
166        }
167
168        Ok(())
169    }
170}
171
172/// Parse the `<file>:<func>` shorthand argument into a `(file_path,
173/// func_name)` pair, walking colons right-to-left to find the leftmost
174/// split point whose file_part exists on disk.
175///
176/// context-file-func-cross-lang-and-cpp-qualified-v1
177/// (P14.AGG13-5 / AGG14-3): the legacy `rfind(':')` form failed for
178/// names that themselves contain `:` (notably C++ `Class::method`).
179/// For input `path/x.cpp:XMLDocument::Parse` we now try the rightmost
180/// colon first (file_part = `path/x.cpp:XMLDocument:`, not a file →
181/// reject), then the next colon (file_part = `path/x.cpp`, valid →
182/// accept) and emit func_part = `XMLDocument::Parse`. This keeps
183/// Windows drive-letter paths working (`C:\foo\bar.js:foo` returns the
184/// `C:\foo\bar.js` split because the earlier `C:` split is not a file).
185///
186/// Returns `None` when no split is valid; callers fall back to the
187/// bare-name interpretation for genuine names containing `:` like
188/// `Module::Sub::fn` invoked without a file prefix.
189fn split_file_func_shorthand(entry: &str) -> Option<(PathBuf, String)> {
190    let mut idx = entry.rfind(':')?;
191    loop {
192        if idx == 0 || idx + 1 >= entry.len() {
193            // Search further-left colons (idx==0 means leading ':').
194            match entry[..idx].rfind(':') {
195                Some(prev) => {
196                    idx = prev;
197                    continue;
198                }
199                None => return None,
200            }
201        }
202        let file_part = &entry[..idx];
203        let func_part = &entry[idx + 1..];
204        // func_part starts with `:` => we landed inside a `::` group;
205        // the next iteration will move further left, but the candidate
206        // file_part is also invalid as a file in that case (ends with
207        // `:`), so a single `is_file()` check correctly rejects it.
208        let candidate = PathBuf::from(file_part);
209        if candidate.is_file() && !func_part.is_empty() && !func_part.starts_with(':') {
210            return Some((candidate, func_part.to_string()));
211        }
212        match entry[..idx].rfind(':') {
213            Some(prev) => idx = prev,
214            None => return None,
215        }
216    }
217}
218
219/// Walk upward from `file`'s parent directory until we hit a directory
220/// containing one of the common project-root markers (`.git`,
221/// `package.json`, `Cargo.toml`, `go.mod`, `pyproject.toml`,
222/// `pom.xml`, `build.gradle*`, `*.csproj`, `mix.exs`, `dune-project`).
223/// Returns `Some(parent_dir)` as a fallback if no marker is found.
224///
225/// language-adapter-fixes-v1 (P13.AGG13-5): used by the context command
226/// when the user invokes the `<file>:<func>` shorthand without an
227/// explicit project path. Lets `tldr context /path/to/repo/src/x.js:foo`
228/// resolve from any cwd, mirroring `cd /path/to/repo && tldr context foo`.
229fn infer_project_root_from_file(file: &Path) -> Option<PathBuf> {
230    let abs = file.canonicalize().unwrap_or_else(|_| file.to_path_buf());
231    let parent = abs.parent()?;
232    const MARKERS: &[&str] = &[
233        ".git",
234        "package.json",
235        "Cargo.toml",
236        "go.mod",
237        "pyproject.toml",
238        "pom.xml",
239        "build.gradle",
240        "build.gradle.kts",
241        "mix.exs",
242        "dune-project",
243        "Package.swift",
244    ];
245    let mut cursor: Option<&Path> = Some(parent);
246    while let Some(dir) = cursor {
247        for m in MARKERS {
248            if dir.join(m).exists() {
249                return Some(dir.to_path_buf());
250            }
251        }
252        // Also accept any *.csproj sibling (C# projects).
253        if let Ok(entries) = std::fs::read_dir(dir) {
254            for entry in entries.flatten() {
255                if entry
256                    .path()
257                    .extension()
258                    .and_then(|e| e.to_str())
259                    .map(|e| e == "csproj" || e == "sln")
260                    .unwrap_or(false)
261                {
262                    return Some(dir.to_path_buf());
263                }
264            }
265        }
266        cursor = dir.parent();
267    }
268    Some(parent.to_path_buf())
269}