prux 1.3.0

A minimal terminal coding agent: TUI, built-in coding tools, and an extension system
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
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
//! 进程辅助:执行外部命令时的平台差异、输出收集与回收。
//!
//! 十件事:
//! - [`shell_command`]:按平台构造「执行一条命令字符串」的 shell 调用;
//! - [`shell_tokenize`]:按 shell 引号规则把一条命令行拆成参数;
//! - [`shell_quote`]:把一段文本包成 shell 里的一个安全字面量([`shell_tokenize`] 的逆操作);
//! - [`wrap_cd`]:把一条命令包进「先 cd 到某目录,失败则退出」的壳;
//! - [`run_stdout`]:跑一条现成的命令并取 stdout,失败时把 stderr 带进错误;
//! - [`drain_tail`]:持续读空管道而只保留尾部(限量,且不会因为管道写满把子进程卡死);
//! - [`spawn_detached`]:拉起一个**不占用终端**的长命子进程(GUI 应用等),输出落临时日志;
//! - [`kill_process_tree`]:连同子进程一起强杀(Unix 进程组 / Windows `taskkill /T`);
//! - [`blocking`]:在同步接口里安全地等一个阻塞动作;
//! - [`spawn_blocking`]:`async fn` 里把阻塞动作丢到阻塞线程池。
//!
//! 这些是「跑一条用户配置的命令」所需的最小公共面:内置 `bash` 工具(异步流式)与
//! 需要同步等待结果的扩展(如 `vet`)共用同一套进程回收语义,避免各写一份。
//!
//! **所有外部命令的输出都必须有去处**:要被读就走 `run_stdout` / `output()`(管道),
//! 不读就接 `null` 或走 [`spawn_detached`] 落日志。让子进程继承终端的 stdout/stderr
//! 会把它的横幅与告警直接覆盖到 TUI 上(`computer_launch` 起的浏览器就这么糊过一次屏)。

use crate::utils::output_files::create_output_file_stream;
use std::{
    io::Read,
    path::PathBuf,
    process::{Command, Stdio},
};

/// 按平台构造执行一条命令字符串的 shell 调用。
///
/// Unix 用 `sh -c`,Windows 用 `cmd /C`——两者都是该平台上必然存在的解释器。
/// 刻意不复用内置 `bash` 工具的 shell 选择(`shellPath` / `/bin/bash` 兜底):那是给
/// **模型**执行命令用的,这里的命令来自用户配置,只需要「能跑起来」的最小保证。
pub fn shell_command(command: &str) -> Command {
    #[cfg(windows)]
    {
        let mut cmd = Command::new("cmd");
        cmd.args(["/C", command]);
        cmd
    }

    #[cfg(not(windows))]
    {
        let mut cmd = Command::new("sh");
        cmd.args(["-c", command]);
        cmd
    }
}

/// 按 shell 的引号规则把一条命令行拆成参数列表。
///
/// 只做最小分词:单引号与双引号成对时丢弃引号并保留其中的空白,引号外按空白切分,
/// 没有任何转义、变量展开或通配符处理。未闭合的引号不报错,余下内容原样成为一个参数。
/// 返回的参数不含空串(连续空白不产生空参数)。
pub fn shell_tokenize(input: &str) -> Vec<String> {
    let mut out = Vec::new();
    let mut current = String::new();
    let mut quote: Option<char> = None;

    for ch in input.chars() {
        match quote {
            Some(q) => {
                if ch == q {
                    quote = None;
                } else {
                    current.push(ch);
                }
            }
            None => match ch {
                '\'' | '"' => quote = Some(ch),
                c if c.is_whitespace() => {
                    if !current.is_empty() {
                        out.push(std::mem::take(&mut current));
                    }
                }
                c => current.push(c),
            },
        }
    }

    if !current.is_empty() {
        out.push(current);
    }
    out
}

/// 用单引号把一段文本包成 shell 里的一个安全字面量。
///
/// 单引号内除 `'` 外不解释任何字符(变量、通配符、空格都成字面量),所以只需把 `'`
/// 替换成 `'\''`;空串必须显式包成 `''`,否则会被 shell 吞掉,参数个数就变了。
pub fn shell_quote(value: &str) -> String {
    if value.is_empty() {
        return "''".to_string();
    }
    let escaped = value.replace('\'', "'\\''");
    format!("'{escaped}'")
}

/// 把命令包进 `cd <cwd> 2>/dev/null || exit 1; ...`,让它在指定目录下执行。
///
/// `cwd` 为空或为 `~` 时原样返回(默认目录本就是 HOME);目录不可用时以退出码 1 结束,
/// 而不是默默在 HOME 里执行——那会让「在错的目录跑对的命令」看起来像成功。
pub fn wrap_cd(command: &str, cwd: &str) -> String {
    let trimmed = cwd.trim();
    if trimmed.is_empty() || trimmed == "~" {
        return command.to_string();
    }
    format!(
        "cd {} 2>/dev/null || exit 1; {}",
        shell_dir(trimmed),
        command
    )
}

/// 把目录交给 shell 展开:`~` 由 shell 解析,其余部分照常用单引号保护。
///
/// 不能直接 [`shell_quote`]:单引号里的 `~` 是普通字符,`cd '~/proj'` 必定失败,
/// 而 `~/proj` 是用户与配置里最常见的写法之一。
fn shell_dir(dir: &str) -> String {
    let Some(rest) = dir.strip_prefix('~') else {
        return shell_quote(dir);
    };
    let (home, tail) = match rest.split_once('/') {
        Some((home, tail)) => (home, tail),
        None => (rest, ""),
    };
    let mut out = format!("~{home}");
    if !tail.is_empty() {
        out.push('/');
        out.push_str(&shell_quote(tail));
    }
    out
}

/// 持续读空管道,只保留尾部 `limit` 字节。返回 `(文本, 是否丢弃过前缀)`。
///
/// 必须持续读到 EOF:只要不读,子进程写满管道就会阻塞,表现为「命令卡住」而不是
/// 「输出太多被截断」。因此这里的取舍是**读全部、留尾部**,超过 `2 * limit` 时滚动
/// 丢弃前缀(摊还 O(n),不必每读一次就搬动整个缓冲)。
pub fn drain_tail(mut reader: impl Read, limit: usize) -> (String, bool) {
    let mut buf: Vec<u8> = Vec::new();
    let mut chunk = [0u8; 8192];
    let mut truncated = false;

    loop {
        match reader.read(&mut chunk) {
            Ok(0) => break,
            Ok(n) => {
                buf.extend_from_slice(&chunk[..n]);
                if buf.len() > limit * 2 {
                    let drop = buf.len() - limit;
                    buf.drain(..drop);
                    truncated = true;
                }
            }
            Err(_) => break,
        }
    }

    if buf.len() > limit {
        let drop = buf.len() - limit;
        buf.drain(..drop);
        truncated = true;
    }

    let text = String::from_utf8_lossy(&buf).to_string();
    (text, truncated)
}

/// 跑一条命令并取它的 stdout;失败时把 stderr 带进错误。
///
/// `cmd` 的 stdin 强制接到 null、stdout/stderr 收成管道(调用方预先设好的重定向会被覆盖)。
/// `what` 只用于拼错误前缀,写“在做什么”(如 `swaymsg -t get_tree`)而不是命令本身。
/// 非零退出码返回错误,带状态码与 stderr 末段(stderr 为空时给一句可读说明);
/// 进程起不来(可执行文件不在 PATH 上等)也返回错误。
pub fn run_stdout(mut cmd: Command, what: &str) -> Result<Vec<u8>, String> {
    let output = cmd
        .stdin(Stdio::null())
        .stderr(Stdio::piped())
        .stdout(Stdio::piped())
        .output()
        .map_err(|e| format!("{what}: {e}"))?;
    if !output.status.success() {
        let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
        return Err(format!(
            "{what} failed ({}): {}",
            output.status,
            if err.is_empty() { "no stderr" } else { &err }
        ));
    }
    Ok(output.stdout)
}

/// 强杀进程树:Unix 用 `killpg`(子进程需以 `process_group(0)` 起),
/// Windows 用 `taskkill /F /T` 连同子进程一起终止,其它平台只杀直接子进程。
///
/// 调用方仍应同时 `Child::kill()`:本函数按 pid 定位进程组,若子进程已经退出、
/// pid 被复用,单靠它可能落空。
pub fn kill_process_tree(pid: u32) {
    #[cfg(unix)]
    {
        _ = nix::sys::signal::killpg(
            nix::unistd::Pid::from_raw(pid as i32),
            nix::sys::signal::Signal::SIGKILL,
        );
    }

    #[cfg(windows)]
    {
        _ = Command::new("taskkill")
            .args(["/F", "/T", "/PID", &pid.to_string()])
            .stdout(std::process::Stdio::null())
            .stderr(std::process::Stdio::null())
            .status();
    }

    #[cfg(not(any(unix, windows)))]
    {
        let _ = pid;
    }
}

/// 在当前运行时里安全地执行阻塞工作。
///
/// 同步接口(如扩展的边界钩子)里等一个分钟级的子进程时,必须让出 tokio 的 worker:
/// 多线程运行时走 `block_in_place`(运行时另起线程接管调度),单线程运行时直接执行
/// (那里没有别的任务需要调度),不在运行时内(普通线程 / 单元测试)同样直接执行。
pub fn blocking<T>(f: impl FnOnce() -> T) -> T {
    match tokio::runtime::Handle::try_current() {
        Ok(handle) if handle.runtime_flavor() == tokio::runtime::RuntimeFlavor::MultiThread => {
            tokio::task::block_in_place(f)
        }
        _ => f(),
    }
}

/// 拉起一个**不占用终端**的子进程:stdin 接 null、stdout/stderr 写进临时日志、Unix 上另起进程组。
///
/// 给「启动一个长命应用」(GUI 程序、桌面集成)用。在 TUI 里继承终端是不行的:启动横幅、
/// 驱动告警(如 chromium 的 `--ozone-platform=wayland is not compatible with Vulkan`)
/// 会直接糊在界面上。子进程不随 prux 的 `Ctrl-C` / 退出一起消失,由后台线程回收。
///
/// `prefix` 只参与日志文件名(如 `computer-launch`),扩展名固定 `.log`。
/// 返回 `(pid, 日志路径)`;日志建不出来时输出被丢弃,路径为 `None`(不因此让启动失败)。
/// 只有进程起不来(可执行文件不在 PATH 上等)才返回 `Err`。
pub fn spawn_detached(
    prefix: &str,
    program: &str,
    args: &[String],
) -> std::io::Result<(u32, Option<PathBuf>)> {
    let log = create_output_file_stream(prefix, ".log").ok();

    let mut cmd = Command::new(program);
    cmd.args(args).stdin(Stdio::null());
    let log_path = match log {
        Some((file, path)) => match file.try_clone() {
            Ok(stderr) => {
                cmd.stdout(Stdio::from(file)).stderr(Stdio::from(stderr));
                Some(path)
            }
            Err(_) => {
                cmd.stdout(Stdio::null()).stderr(Stdio::null());
                None
            }
        },
        None => {
            cmd.stdout(Stdio::null()).stderr(Stdio::null());
            None
        }
    };

    #[cfg(unix)]
    {
        use std::os::unix::process::CommandExt;
        cmd.process_group(0);
    }

    #[cfg(windows)]
    {
        use std::os::windows::process::CommandExt;
        /// 不继承控制台。
        const DETACHED_PROCESS: u32 = 0x0000_0008;
        /// 新建进程组,`Ctrl-C` 不会广播给它。
        const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
        cmd.creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP);
    }

    let mut child = cmd.spawn()?;
    let pid = child.id();
    std::thread::spawn(move || {
        _ = child.wait();
    });
    Ok((pid, log_path))
}

/// 在 `async fn` 里把阻塞动作丢到 tokio 阻塞线程池并等它结束。
///
/// 与同步版 [`blocking`] 的分工:本函数给「有 `await` 可以交还控制权」的调用方(截图、
/// 输入注入、剪贴板命令等秒级动作),[`blocking`] 给「本来就在同步接口里」的调用方。
/// 任务 panic 或被取消时把 [`tokio::task::JoinError`] 原样交回,文案由调用方决定——
/// `utils` 不依赖 `core`,这里不能拼 `ToolError`。
pub async fn spawn_blocking<T, F>(f: F) -> Result<T, tokio::task::JoinError>
where
    F: FnOnce() -> T + Send + 'static,
    T: Send + 'static,
{
    tokio::task::spawn_blocking(f).await
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::{io::Cursor, time::Duration};

    /// 引号包裹后的文本不能带引号泄漏到参数里;空串必须被引号保住。
    #[test]
    fn shell_quote_roundtrips_through_tokenize() {
        assert_eq!(shell_quote("plain"), "'plain'");
        assert_eq!(shell_quote("it's"), "'it'\\''s'");
        assert_eq!(shell_quote(""), "''");
        // 分词不认反斜杠转义、且会丢掉空参数,含单引号与空串都无法往返,只校验其余情形
        for raw in ["plain", "a b", "$HOME"] {
            assert_eq!(shell_tokenize(&shell_quote(raw)), vec![raw.to_string()]);
        }
    }

    #[test]
    fn wrap_cd_quotes_the_working_directory() {
        assert_eq!(wrap_cd("ls", "~"), "ls");
        assert_eq!(
            wrap_cd("ls", "/srv/x"),
            "cd '/srv/x' 2>/dev/null || exit 1; ls"
        );
    }

    /// `~` 必须交给 shell 展开:单引号包起来后 `cd '~/proj'` 一定失败,命令根本不会执行
    #[test]
    fn wrap_cd_expands_tilde() {
        assert_eq!(
            wrap_cd("ls", "~/proj"),
            "cd ~/'proj' 2>/dev/null || exit 1; ls"
        );
        assert_eq!(
            wrap_cd("ls", "~/my dir"),
            "cd ~/'my dir' 2>/dev/null || exit 1; ls"
        );
        assert_eq!(
            wrap_cd("ls", "~bob/proj"),
            "cd ~bob/'proj' 2>/dev/null || exit 1; ls"
        );
    }

    /// `~` 交给 shell,路径其余部分仍要完整成词:空格、单引号都不能把目录拆开
    #[test]
    fn shell_dir_keeps_tilde_outside_quotes() {
        assert_eq!(shell_dir("~"), "~");
        assert_eq!(shell_dir("~/"), "~");
        assert_eq!(shell_dir("~/proj"), "~/'proj'");
        assert_eq!(shell_dir("~/my dir"), "~/'my dir'");
        assert_eq!(shell_dir("~bob"), "~bob");
        assert_eq!(shell_dir("~bob/proj"), "~bob/'proj'");

        // 不以 `~` 开头的路径整体加引号(`~` 以外的字符不做任何解释)
        assert_eq!(shell_dir("/srv/x"), "'/srv/x'");
        assert_eq!(shell_dir("sub dir/x"), "'sub dir/x'");
        assert_eq!(shell_dir(""), "''");
        assert_eq!(shell_dir("/srv/it's"), "'/srv/it'\\''s'");
    }

    /// 拼出来的片段必须仍是一个参数:`cd` 只能收到一个目录
    #[test]
    fn shell_dir_produces_a_single_word() {
        for dir in [
            "~",
            "~/proj",
            "~/my dir",
            "~bob/proj",
            "/srv/x",
            "sub dir/x",
        ] {
            assert_eq!(
                shell_tokenize(&shell_dir(dir)),
                vec![dir.to_string()],
                "shell_dir({dir:?}) 没能作为一个词交给 shell"
            );
        }
    }

    #[test]
    fn shell_tokenize_keeps_quoted_arguments_whole() {
        let tokens = shell_tokenize("ssh \"my host\" -p 22");
        assert_eq!(tokens, vec!["ssh", "my host", "-p", "22"]);
    }

    #[test]
    fn shell_tokenize_ignores_extra_whitespace() {
        assert_eq!(shell_tokenize("  a   'b c'  "), vec!["a", "b c"]);
        assert!(shell_tokenize("   ").is_empty());
    }

    #[test]
    fn drain_tail_keeps_everything_below_the_limit() {
        let (text, truncated) = drain_tail(Cursor::new(b"hello".to_vec()), 64);
        assert_eq!(text, "hello");
        assert!(!truncated);
    }

    #[test]
    fn drain_tail_keeps_only_the_tail_and_flags_it() {
        let input = "x".repeat(1000);
        let (text, truncated) = drain_tail(Cursor::new(input.into_bytes()), 100);
        assert!(truncated);
        assert_eq!(text.chars().count(), 100);
    }

    #[test]
    fn drain_tail_rolls_over_multiple_rounds_of_output() {
        // 远大于 2*limit:验证滚动丢弃(而不是只裁一次)
        let (text, truncated) = drain_tail(Cursor::new(vec![b'a'; 100_000]), 128);
        assert!(truncated);
        assert_eq!(text.len(), 128);
    }

    #[cfg(unix)]
    #[test]
    fn shell_command_runs_the_command_string() {
        let output = shell_command("echo shell-ok").output().unwrap();
        assert!(output.status.success());
        assert!(String::from_utf8_lossy(&output.stdout).contains("shell-ok"));
    }

    #[cfg(unix)]
    #[test]
    fn kill_process_tree_terminates_a_group_quickly() {
        use std::{
            os::unix::process::CommandExt as _,
            process::Stdio,
            time::{Duration, Instant},
        };

        let mut cmd = shell_command("sleep 30 & wait");
        cmd.stdout(Stdio::null()).stderr(Stdio::null());
        cmd.process_group(0);
        let mut child = cmd.spawn().unwrap();

        let pid = child.id();
        let started = Instant::now();
        kill_process_tree(pid);
        _ = child.kill();
        _ = child.wait();
        assert!(
            started.elapsed() < Duration::from_secs(5),
            "进程组应被强杀,而不是等满 30s"
        );
    }

    #[cfg(unix)]
    #[test]
    fn run_stdout_returns_stdout_on_success() {
        let out = run_stdout(shell_command("echo run-ok"), "echo").unwrap();
        assert!(String::from_utf8_lossy(&out).contains("run-ok"));
    }

    #[cfg(unix)]
    #[test]
    fn run_stdout_reports_stderr_on_failure() {
        let err = run_stdout(shell_command("echo boom >&2; exit 3"), "boom").unwrap_err();
        assert!(err.contains("boom failed"), "{err}");
        assert!(err.contains("boom"), "{err}");
    }

    /// 子进程的输出进临时日志,不回显到调用方的终端上。
    #[cfg(unix)]
    #[test]
    fn spawned_output_lands_in_the_log() {
        let args = vec!["-c".to_string(), "echo mark-from-child >&2".to_string()];
        let (pid, log) = spawn_detached("prux-test-launch", "sh", &args).unwrap();
        assert!(pid > 0);
        let path = log.expect("launch log path");

        for _ in 0..100 {
            let text = std::fs::read_to_string(&path).unwrap_or_default();
            if text.contains("mark-from-child") {
                return;
            }
            std::thread::sleep(Duration::from_millis(50));
        }
        panic!(
            "child stderr never reached {:?}: {:?}",
            path,
            std::fs::read_to_string(&path)
        );
    }

    /// Unix 上子进程自成进程组:`Ctrl-C` 不会连它一起带走,调用方可按进程组认回派生进程。
    #[cfg(unix)]
    #[test]
    fn spawned_child_gets_its_own_process_group() {
        use nix::unistd::Pid;
        let args = vec!["-c".to_string(), "sleep 1".to_string()];
        let (pid, _log) = spawn_detached("prux-test-launch", "sh", &args).unwrap();
        assert_eq!(
            nix::unistd::getpgid(Some(Pid::from_raw(pid as i32))),
            Ok(Pid::from_raw(pid as i32))
        );
    }

    #[test]
    fn blocking_runs_without_a_runtime() {
        assert_eq!(blocking(|| 41 + 1), 42);
    }

    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    async fn blocking_runs_inside_a_multi_thread_runtime() {
        assert_eq!(blocking(|| 6 * 7), 42);
    }
}