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}