Skip to content

Commit 703f152

Browse files
authored
chore(tools): improve internal toolkit functionality (#364)
Enhance the internal maintenance tools used for code manipulation and verification. - `fs_read_file`: Add support for `start_line` and `end_line` parameters to allow reading specific file ranges, reducing context overhead. - `fs_modify_file`: Return a unified diff of the applied changes instead of a simple confirmation message. - `cargo_check`: Include all targets in clippy checks and strip ANSI escape codes from the output for better readability. - `cargo_test`: Capture and report stderr when no tests are found to aid in troubleshooting tool failures. - `git_commit`: Change default execution mode to `ask` to ensure user review before committing. --------- Signed-off-by: Jean Mertz <[email protected]>
1 parent e516cbe commit 703f152

24 files changed

+277
-66
lines changed

.config/jp/tools/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,12 +45,14 @@ reqwest = { workspace = true, features = [
4545
serde = { workspace = true, features = ["std", "derive", "alloc"] }
4646
serde_json = { workspace = true, features = ["std", "preserve_order", "alloc"] }
4747
similar = { workspace = true, features = ["text", "unicode", "inline"] }
48+
strip-ansi-escapes = { workspace = true }
4849
time = { workspace = true, features = ["serde-human-readable"] }
4950
tokio = { workspace = true, features = ["full"] }
5051
url = { workspace = true, features = ["serde", "std"] }
5152

5253
[dev-dependencies]
5354
assert_matches = { workspace = true }
55+
insta = { workspace = true, features = ["colors"] }
5456
pretty_assertions = { workspace = true, features = ["std"] }
5557
tempfile = { workspace = true }
5658
test-log = { workspace = true }

.config/jp/tools/src/cargo/check.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,11 @@ pub(crate) async fn cargo_check(ctx: &Context, package: Option<String>) -> Resul
3232
}
3333

3434
let content = String::from_utf8_lossy(&result.stderr);
35+
36+
// Strip ANSI escape codes
37+
let content = strip_ansi_escapes::strip_str(&content);
3538
let content = content.trim();
39+
3640
if content.is_empty() {
3741
Ok("Check succeeded. No warnings or errors found.".to_owned())
3842
} else {

.config/jp/tools/src/cargo/test.rs

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,13 +45,18 @@ pub(crate) async fn cargo_test(
4545
.dir(&ctx.root)
4646
.env("NEXTEST_EXPERIMENTAL_LIBTEST_JSON", "1")
4747
.env("RUST_BACKTRACE", "1")
48+
.stderr_capture()
49+
.stdout_capture()
4850
.unchecked()
49-
.read()?;
51+
.run()?;
52+
53+
let stdout = String::from_utf8_lossy(&result.stdout);
54+
let stderr = String::from_utf8_lossy(&result.stderr);
5055

5156
let mut total_tests = 0;
5257
let mut ran_tests = 0;
5358
let mut failure = vec![];
54-
for l in result.lines().filter_map(|s| from_str::<Value>(s).ok()) {
59+
for l in stdout.lines().filter_map(|s| from_str::<Value>(s).ok()) {
5560
let kind = l.get("type").and_then(Value::as_str).unwrap_or_default();
5661
let event = l.get("event").and_then(Value::as_str).unwrap_or_default();
5762

@@ -86,7 +91,9 @@ pub(crate) async fn cargo_test(
8691
let failed_tests = failure.len();
8792

8893
if ran_tests == 0 {
89-
return Err("Unable to find any tests. Are the package and test name correct?")?;
94+
return Err(format!(
95+
"Unable to find any tests. Are the package and test name correct?\n\n{stderr}"
96+
))?;
9097
}
9198

9299
let mut response =

.config/jp/tools/src/fs.rs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,14 @@ pub async fn run(ctx: Context, t: Tool) -> std::result::Result<Outcome, Error> {
2222
.and_then(to_xml)
2323
.map(Into::into),
2424

25-
"read_file" => fs_read_file(ctx.root, t.req("path")?).await.map(Into::into),
25+
"read_file" => fs_read_file(
26+
ctx.root,
27+
t.req("path")?,
28+
t.opt("start_line")?,
29+
t.opt("end_line")?,
30+
)
31+
.await
32+
.map(Into::into),
2633

2734
"grep_files" => fs_grep_files(
2835
ctx.root,

.config/jp/tools/src/fs/modify_file.rs

Lines changed: 98 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,14 @@ use std::{
88
fs::{self},
99
ops::{Deref, DerefMut},
1010
path::{Path, PathBuf},
11+
time::Duration,
1112
};
1213

1314
use crossterm::style::{ContentStyle, Stylize as _};
1415
use fancy_regex::RegexBuilder;
1516
use jp_tool::{AnswerType, Outcome, Question};
1617
use serde_json::{Map, Value};
17-
use similar::{ChangeTag, TextDiff};
18+
use similar::{ChangeTag, TextDiff, udiff::UnifiedDiff};
1819

1920
use super::utils::is_file_dirty;
2021
use crate::{Context, Error};
@@ -200,8 +201,12 @@ fn format_changes(changes: Vec<Change>, root: &Path) -> String {
200201
.into_iter()
201202
.map(|change| {
202203
let path = root.join(change.path.to_string_lossy().trim_start_matches('/'));
203-
let diff = file_diff(&change.before, &change.after);
204-
format!("{}:\n\n```diff\n{diff}\n```", path.display())
204+
205+
let diff = text_diff(&change.before, &change.after);
206+
let unified = unified_diff(&diff, &path.display().to_string());
207+
let colored = colored_diff(&diff, &unified);
208+
209+
format!("{}:\n\n```diff\n{colored}\n```", path.display())
205210
})
206211
.collect::<Vec<_>>()
207212
.join("\n\n")
@@ -212,12 +217,14 @@ fn apply_changes(
212217
root: &Path,
213218
answers: &Map<String, Value>,
214219
) -> Result<Outcome, Error> {
215-
let modified = changes
216-
.iter()
217-
.map(|c| c.path.to_string_lossy().to_string())
218-
.collect::<Vec<_>>();
219-
220-
for Change { path, after, .. } in changes {
220+
let mut modified = vec![];
221+
let count = changes.len();
222+
for Change {
223+
path,
224+
after,
225+
before,
226+
} in changes
227+
{
221228
if is_file_dirty(root, &path)? {
222229
match answers.get("modify_dirty_file").and_then(Value::as_bool) {
223230
Some(true) => {}
@@ -242,12 +249,28 @@ fn apply_changes(
242249
}
243250
}
244251

245-
let absolute_path = root.join(path.to_string_lossy().trim_start_matches('/'));
252+
let file_path = path.to_string_lossy();
253+
let file_path = file_path.trim_start_matches('/');
254+
let absolute_path = root.join(file_path);
255+
256+
fs::write(absolute_path, &after)?;
246257

247-
fs::write(absolute_path, after)?;
258+
let diff = text_diff(&before, &after);
259+
let diff = unified_diff(&diff, file_path);
260+
261+
modified.push(diff.to_string());
248262
}
249263

250-
Ok(format!("File(s) modified successfully:\n\n{}.", modified.join("\n")).into())
264+
Ok(format!(
265+
"{} modified successfully:\n\n{}",
266+
if count == 1 { "File" } else { "Files" },
267+
modified
268+
.into_iter()
269+
.map(|diff| format!("```diff\n{diff}```"))
270+
.collect::<Vec<_>>()
271+
.join("\n\n")
272+
)
273+
.into())
251274
}
252275

253276
struct Line(Option<usize>);
@@ -261,15 +284,32 @@ impl fmt::Display for Line {
261284
}
262285
}
263286

264-
fn file_diff(old: &str, new: &str) -> String {
265-
let diff = TextDiff::from_lines(old, new);
287+
fn text_diff<'old, 'new, 'bufs>(
288+
old: &'old str,
289+
new: &'new str,
290+
) -> TextDiff<'old, 'new, 'bufs, str> {
291+
similar::TextDiff::configure()
292+
.algorithm(similar::Algorithm::Patience)
293+
.timeout(Duration::from_secs(2))
294+
.diff_lines(old, new)
295+
}
266296

297+
fn unified_diff<'diff, 'old, 'new, 'bufs>(
298+
diff: &'diff TextDiff<'old, 'new, 'bufs, str>,
299+
file: &str,
300+
) -> UnifiedDiff<'diff, 'old, 'new, 'bufs, str> {
301+
let mut unified = diff.unified_diff();
302+
unified.context_radius(3).header(file, file);
303+
unified
304+
}
305+
306+
fn colored_diff<'old, 'new, 'diff: 'old + 'new, 'bufs>(
307+
diff: &'diff TextDiff<'old, 'new, 'bufs, str>,
308+
unified: &UnifiedDiff<'diff, 'old, 'new, 'bufs, str>,
309+
) -> String {
267310
let mut buf = String::new();
268-
for (idx, group) in diff.grouped_ops(3).iter().enumerate() {
269-
if idx > 0 {
270-
let _ = writeln!(buf, "{:-^1$}", "-", 80);
271-
}
272-
for op in group {
311+
for hunk in unified.iter_hunks() {
312+
for op in hunk.ops() {
273313
for change in diff.iter_inline_changes(op) {
274314
let (sign, s) = match change.tag() {
275315
ChangeTag::Delete => ("-", ContentStyle::new().red()),
@@ -297,7 +337,6 @@ fn file_diff(old: &str, new: &str) -> String {
297337
}
298338
}
299339

300-
buf.push_str("".reset().to_string().as_str());
301340
buf
302341
}
303342

@@ -317,59 +356,43 @@ mod tests {
317356
start_content: &'static str,
318357
string_to_replace: &'static str,
319358
new_string: &'static str,
320-
final_content: &'static str,
321-
output: Result<&'static str, &'static str>,
322359
}
323360

324361
let cases = vec![
325-
("replace first line", TestCase {
362+
("replace_first_line", TestCase {
326363
start_content: "hello world\n",
327364
string_to_replace: "hello world",
328365
new_string: "hello universe",
329-
final_content: "hello universe\n",
330-
output: Ok("File(s) modified successfully:\n\ntest.txt."),
331366
}),
332-
("delete first line", TestCase {
367+
("delete_first_line", TestCase {
333368
start_content: "hello world\n",
334369
string_to_replace: "hello world",
335370
new_string: "",
336-
final_content: "\n",
337-
output: Ok("File(s) modified successfully:\n\ntest.txt."),
338371
}),
339-
("replace first line with multiple lines", TestCase {
372+
("replace_first_line_with_multiple_lines", TestCase {
340373
start_content: "hello world\n",
341374
string_to_replace: "hello world",
342375
new_string: "hello\nworld\n",
343-
final_content: "hello\nworld\n",
344-
output: Ok("File(s) modified successfully:\n\ntest.txt."),
345376
}),
346-
("replace whole line without newline", TestCase {
377+
("replace_whole_line_without_newline", TestCase {
347378
start_content: "hello world\nhello universe",
348379
string_to_replace: "hello world",
349380
new_string: "hello there",
350-
final_content: "hello there\nhello universe",
351-
output: Ok("File(s) modified successfully:\n\ntest.txt."),
352381
}),
353-
("replace subset of line", TestCase {
382+
("replace_subset_of_line", TestCase {
354383
start_content: "hello world how are you doing?",
355384
string_to_replace: "world",
356385
new_string: "universe",
357-
final_content: "hello universe how are you doing?",
358-
output: Ok("File(s) modified successfully:\n\ntest.txt."),
359386
}),
360-
("replace subset across multiple lines", TestCase {
387+
("replace_subset_across_multiple_lines", TestCase {
361388
start_content: "hello world\nhow are you doing?",
362389
string_to_replace: "world\nhow",
363390
new_string: "universe\nwhat",
364-
final_content: "hello universe\nwhat are you doing?",
365-
output: Ok("File(s) modified successfully:\n\ntest.txt."),
366391
}),
367-
("ignore replacement if no match", TestCase {
392+
("ignore_replacement_if_no_match", TestCase {
368393
start_content: "hello world how are you doing?",
369394
string_to_replace: "universe",
370395
new_string: "galaxy",
371-
final_content: "hello world how are you doing?",
372-
output: Err("Cannot find pattern to replace"),
373396
}),
374397
];
375398

@@ -397,19 +420,24 @@ mod tests {
397420
false,
398421
)
399422
.await
423+
.map(|v| v.into_content().unwrap_or_default())
400424
.map_err(|e| e.to_string());
401425

402-
assert_eq!(
403-
actual,
404-
test_case.output.map(Into::into).map_err(str::to_owned),
405-
"test case: {name}"
406-
);
426+
let response = match &actual {
427+
Ok(v) => v,
428+
Err(e) => e,
429+
};
407430

408-
assert_eq!(
409-
&fs::read_to_string(&absolute_file_path).unwrap(),
410-
test_case.final_content,
411-
"test case: {name}"
412-
);
431+
insta::with_settings!({
432+
snapshot_suffix => name,
433+
omit_expression => true,
434+
prepend_module_to_snapshot => false,
435+
}, {
436+
insta::assert_snapshot!(&response);
437+
438+
let file_content = fs::read_to_string(&absolute_file_path).unwrap();
439+
insta::assert_snapshot!(&file_content);
440+
});
413441
}
414442
}
415443

@@ -505,14 +533,17 @@ mod tests {
505533
string_to_replace: r"(\w+)\s\w+",
506534
new_string: "$1 universe",
507535
final_content: "hello universe\n",
508-
output: Ok("File(s) modified successfully:\n\ntest.txt."),
536+
output: Ok("File modified successfully:\n\n```diff\n--- test.txt\n+++ \
537+
test.txt\n@@ -1 +1 @@\n-hello world\n+hello universe\n```"),
509538
}),
510539
("delete", TestCase {
511540
start_content: "hello world\n",
512541
string_to_replace: "h(.+?)d\n",
513542
new_string: "$1",
514543
final_content: "ello worl",
515-
output: Ok("File(s) modified successfully:\n\ntest.txt."),
544+
output: Ok("File modified successfully:\n\n```diff\n--- test.txt\n+++ \
545+
test.txt\n@@ -1 +1 @@\n-hello world\n+ello worl\n\\ No newline at \
546+
end of file\n```"),
516547
}),
517548
];
518549

@@ -542,11 +573,18 @@ mod tests {
542573
.await
543574
.map_err(|e| e.to_string());
544575

545-
assert_eq!(
546-
actual,
547-
test_case.output.map(Into::into).map_err(str::to_owned),
548-
"test case: {name}"
549-
);
576+
match (actual, test_case.output) {
577+
(Ok(Outcome::Success { content }), Ok(expected)) => {
578+
assert_eq!(&content, expected, "test case: {name}");
579+
}
580+
(actual, expected) => {
581+
assert_eq!(
582+
actual,
583+
expected.map(Into::into).map_err(str::to_owned),
584+
"test case: {name}"
585+
);
586+
}
587+
}
550588

551589
assert_eq!(
552590
&fs::read_to_string(&absolute_file_path).unwrap(),

.config/jp/tools/src/fs/read_file.rs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ use crate::Error;
55
pub(crate) async fn fs_read_file(
66
root: PathBuf,
77
path: String,
8+
start_line: Option<usize>,
9+
end_line: Option<usize>,
810
) -> std::result::Result<String, Error> {
911
let absolute_path = root.join(path.trim_start_matches('/'));
1012
if !absolute_path.exists() {
@@ -18,7 +20,17 @@ pub(crate) async fn fs_read_file(
1820
.and_then(OsStr::to_str)
1921
.unwrap_or_default();
2022

21-
let contents = std::fs::read_to_string(&absolute_path)?;
23+
let mut contents = std::fs::read_to_string(&absolute_path)?;
24+
25+
if let Some(start_line) = start_line {
26+
contents = contents.split('\n').skip(start_line).collect::<String>();
27+
contents.insert_str(0, &format!("... (starting from line #{start_line}) ...\n"));
28+
}
29+
30+
if let Some(end_line) = end_line {
31+
contents = contents.split('\n').take(end_line).collect::<String>();
32+
contents.push_str(&format!("\n... (truncated after line #{end_line}) ..."));
33+
}
2234

2335
Ok(indoc::formatdoc! {"
2436
```{ext}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
---
2+
source: ".config/jp/tools/src/fs/modify_file.rs"
3+
---
4+
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
source: ".config/jp/tools/src/fs/modify_file.rs"
3+
---
4+
File modified successfully:
5+
6+
```diff
7+
--- test.txt
8+
+++ test.txt
9+
@@ -1 +1 @@
10+
-hello world
11+
+
12+
```

0 commit comments

Comments
 (0)