1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
//! cli-error-clarity-v2: close 3 MED bugs sharing the surface
//! "CLI says one thing, runtime does another".
//!
//! - P2.BUG-4 — `tldr hubs|impact|whatbreaks|change-impact <file>` previously
//! said "Path not found" (false: the file exists) or surfaced the cryptic
//! git "Not a directory (os error 20)". The four directory-taking commands
//! now return a clear error mentioning the file path and how to fix it.
//!
//! - P2.BUG-5 — Per-command `--help` advertised every value of the global
//! `--format` enum (sarif, dot, …) even when the runtime rejects them with
//! "not supported by <cmd>". The global `--format` flag now hides its
//! possible values; the long-help text instead enumerates which commands
//! actually emit each command-specific format. As a result, for any
//! command that does NOT support sarif, the help text no longer lists
//! "sarif" as a possible value, eliminating the help/runtime mismatch.
//!
//! - P2.BUG-8 — `tldr context <fn> <project-root>` returned 0 functions when
//! the same function was found from a deeper directory. Root cause:
//! `find_function_in_graph` returned the FIRST edge match. If a test
//! fixture (e.g. `tests/test_config.py`) defined a placeholder class with
//! the same name as the real implementation, the first edge could land on
//! the placeholder. The fix iterates ALL candidate locations and prefers
//! ones whose extracted module actually contains the function definition,
//! with non-test paths preferred when ties remain.
use assert_cmd::prelude::*;
use serde_json::Value;
use std::fs;
use std::process::Command;
use tempfile::TempDir;
fn tldr_cmd() -> Command {
Command::new(assert_cmd::cargo::cargo_bin!("tldr"))
}
// =============================================================================
// P2.BUG-4: directory-taking commands reject files with a clear error
// =============================================================================
/// Build a temp project containing a single .py file. Returns (TempDir, file
/// path) so the caller can reference both. The directory itself is a valid
/// project root; the file is a regular file under it.
fn make_temp_project_with_file() -> (TempDir, std::path::PathBuf) {
let temp = TempDir::new().unwrap();
let path = temp.path().join("foo.py");
fs::write(&path, "def bar():\n return 1\n").unwrap();
(temp, path)
}
/// Helper: assert the command failed with a clear "requires a directory"
/// error mentioning the file path.
fn assert_clear_file_error(cmd_name: &str, args: &[&str]) {
let assert = tldr_cmd().args(args).assert().failure();
let stderr = String::from_utf8_lossy(&assert.get_output().stderr).to_string();
assert!(
stderr.contains(&format!("{} requires a directory", cmd_name)),
"expected '{} requires a directory' in stderr; got:\n{}",
cmd_name,
stderr
);
// Must mention the file path so the user sees their mistake echoed back.
let file_arg = args.iter().rev().find(|a| a.contains("foo.py")).unwrap();
assert!(
stderr.contains(*file_arg),
"expected file path '{}' in stderr; got:\n{}",
file_arg,
stderr
);
}
#[test]
fn hubs_on_file_clear_error() {
let (_temp, file) = make_temp_project_with_file();
let file_str = file.to_string_lossy().to_string();
assert_clear_file_error("hubs", &["hubs", &file_str, "-q"]);
}
#[test]
fn impact_on_file_clear_error() {
let (_temp, file) = make_temp_project_with_file();
let file_str = file.to_string_lossy().to_string();
assert_clear_file_error("impact", &["impact", "bar", &file_str, "-q"]);
}
#[test]
fn whatbreaks_on_file_clear_error() {
let (_temp, file) = make_temp_project_with_file();
let file_str = file.to_string_lossy().to_string();
assert_clear_file_error("whatbreaks", &["whatbreaks", "bar", &file_str, "-q"]);
}
#[test]
fn change_impact_on_file_clear_error() {
let (_temp, file) = make_temp_project_with_file();
let file_str = file.to_string_lossy().to_string();
assert_clear_file_error("change-impact", &["change-impact", &file_str, "-q"]);
}
// =============================================================================
// P2.BUG-5: per-command --help no longer advertises formats the runtime
// rejects. We test by parsing the `--help` output and confirming that for
// commands which DO NOT support sarif/dot, the help text does not list those
// values under "Possible values".
// =============================================================================
/// Extract the body of the `--format` help block from `tldr <cmd> --help`.
fn format_help_body(cmd: &str) -> String {
let out = tldr_cmd()
.args([cmd, "--help"])
.assert()
.success()
.get_output()
.stdout
.clone();
let s = String::from_utf8(out).unwrap();
// Take everything from `--format` to the next double-newline-section
// break. We use a generous slice since the body may span multiple lines.
let start = s
.find("--format")
.expect("--format present in help");
s[start..].to_string()
}
#[test]
fn format_help_matches_runtime_calls() {
// `calls` does NOT support sarif. Its help must not list sarif as a
// possible value, and its runtime rejects sarif. The two surfaces are
// therefore consistent.
let body = format_help_body("calls");
assert!(
!body.contains("Possible values:") || !body.contains("- sarif"),
"calls --help must not list sarif as a possible value; got:\n{}",
body
);
// Confirm runtime still rejects sarif on calls (this guards against a
// regression where someone "fixes" the help by silently widening the
// runtime).
let temp = TempDir::new().unwrap();
fs::write(temp.path().join("a.py"), "def x():\n pass\n").unwrap();
let assert = tldr_cmd()
.args([
"calls",
temp.path().to_str().unwrap(),
"--format",
"sarif",
"-q",
])
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assert.get_output().stderr).to_string();
assert!(
stderr.contains("not supported by calls"),
"calls --format sarif should still be rejected by runtime; got:\n{}",
stderr
);
}
#[test]
fn format_help_matches_runtime_structure() {
// `structure` does NOT support sarif or dot. Confirm both sides agree.
let body = format_help_body("structure");
assert!(
!body.contains("Possible values:") || !body.contains("- sarif"),
"structure --help must not list sarif; got:\n{}",
body
);
assert!(
!body.contains("Possible values:") || !body.contains("- dot"),
"structure --help must not list dot; got:\n{}",
body
);
let temp = TempDir::new().unwrap();
fs::write(temp.path().join("a.py"), "def x():\n pass\n").unwrap();
for fmt in ["sarif", "dot"] {
let assert = tldr_cmd()
.args([
"structure",
temp.path().to_str().unwrap(),
"--format",
fmt,
"-q",
])
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assert.get_output().stderr).to_string();
assert!(
stderr.contains(&format!("not supported by structure")),
"structure --format {} should be rejected; got:\n{}",
fmt,
stderr
);
}
}
// =============================================================================
// P2.BUG-8: `context` works from the project root, not just from inner src/
// =============================================================================
#[test]
fn context_works_from_repo_root() {
// Build a fixture mini-repo whose layout mimics the bug repro:
//
// <root>/
// src/
// pkg/
// core.py # defines class Foo with method bar
// tests/
// test_dummy.py # placeholder class Foo with NO methods
//
// Pre-fix: walking from `<root>` made the call graph see the placeholder
// first; `tldr context bar <root>` returned 0 functions. Walking from
// `<root>/src` worked. Post-fix: both should return >= 1 function.
let temp = TempDir::new().unwrap();
let src = temp.path().join("src/pkg");
fs::create_dir_all(&src).unwrap();
fs::write(
src.join("core.py"),
"class Foo:\n def bar(self):\n return helper()\n\ndef helper():\n return 1\n",
)
.unwrap();
let tests = temp.path().join("tests");
fs::create_dir_all(&tests).unwrap();
fs::write(
tests.join("test_dummy.py"),
// Placeholder Foo with no body — same name, no methods. This is what
// makes find_function_in_graph pick the wrong edge pre-fix.
"class Foo:\n pass\n",
)
.unwrap();
// From the repo root: must find Foo.bar (>= 1 function in context).
let out_root = tldr_cmd()
.args([
"context",
"Foo.bar",
temp.path().to_str().unwrap(),
"--depth",
"1",
"-q",
])
.assert()
.success()
.get_output()
.stdout
.clone();
let v: Value = serde_json::from_slice(&out_root).expect("context returns JSON");
let n_root = v["functions"]
.as_array()
.map(|a| a.len())
.unwrap_or(0);
assert!(
n_root >= 1,
"context Foo.bar from repo root should find >= 1 function, got {}; output: {}",
n_root,
String::from_utf8_lossy(&out_root)
);
// From the inner src/ directory the count should be at least the same
// (this is the scope the bug report contrasts against).
let out_src = tldr_cmd()
.args([
"context",
"Foo.bar",
temp.path().join("src").to_str().unwrap(),
"--depth",
"1",
"-q",
])
.assert()
.success()
.get_output()
.stdout
.clone();
let v_src: Value = serde_json::from_slice(&out_src).unwrap();
let n_src = v_src["functions"]
.as_array()
.map(|a| a.len())
.unwrap_or(0);
assert!(
n_root >= n_src,
"context from repo root ({}) should be >= context from src ({})",
n_root,
n_src
);
}