prux 1.1.0

A minimal terminal coding agent: TUI, built-in coding tools, and an extension system
Documentation
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
// 路径工具

use std::{
    ffi::OsStr,
    io::{BufRead, BufReader},
    path::{Component, Path, PathBuf},
    time::SystemTime,
};

/// 目录项类型:区分普通文件与目录,符号链接会先跟随再判定。
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum EntryKind {
    /// 普通文件(含指向文件的符号链接)。
    File,
    /// 目录(含指向目录的符号链接)。
    Dir,
}

/// 解析目录项类型并跟随符号链接(断链跳过)
pub fn entry_kind(path: &Path) -> Option<EntryKind> {
    match std::fs::symlink_metadata(path) {
        Ok(md) if md.file_type().is_symlink() => match std::fs::metadata(path) {
            Ok(real) => {
                if real.is_dir() {
                    Some(EntryKind::Dir)
                } else if real.is_file() {
                    Some(EntryKind::File)
                } else {
                    None
                }
            }
            Err(_) => None,
        },
        Ok(md) if md.is_dir() => Some(EntryKind::Dir),
        Ok(md) if md.is_file() => Some(EntryKind::File),
        _ => None,
    }
}

/// 路径 → POSIX 风格字符串(反斜杠统一为 `/`,非 UTF-8 用替换字符)。
#[inline(always)]
pub fn to_posix(p: &Path) -> String {
    p.to_string_lossy().replace('\\', "/")
}

/// `target` 相对 `root` 的 POSIX 风格路径;不在 `root` 下时原样返回 `target`。
pub fn relative_posix(root: &Path, target: &Path) -> String {
    match target.strip_prefix(root) {
        Ok(rel) => to_posix(rel),
        Err(_) => to_posix(target),
    }
}

/// 展开 ~ 与 ~/ 前缀
pub fn expand_tilde(path: PathBuf) -> PathBuf {
    let s = path.to_string_lossy();
    if s == "~" {
        return home_dir();
    }
    if let Some(rest) = s.strip_prefix("~/") {
        return home_dir().join(rest);
    }
    path
}

/// 把字符串开头的 `~/` 展开为 `$HOME`;其它形式(含裸 `~`)原样返回。
pub fn expand_home(s: &str) -> String {
    if let Some(rest) = s.strip_prefix("~/")
        && let Some(home) = std::env::var_os("HOME")
    {
        return Path::new(&home).join(rest).to_string_lossy().to_string();
    }
    s.to_string()
}

/// Windows 上把 `HOME` 环境变量统一成 [`dirs::home_dir`](启动时调用一次)。
///
/// **不管 `HOME` 已有何值都重写**:本仓处处以 `HOME` 为 home 目录的唯一来源
/// ([`home_dir`] → 全局 agent 目录、`~` 展开、主题 / 技能发现、项目信任……),
/// 而各家终端给的值并不一致:PowerShell 根本**不导出** `HOME`(只有 `$HOME` 变量和
/// `%USERPROFILE%`),git bash / MSYS 则导出 `/c/Users/xx` 这类 POSIX 形式。
/// 于是同一台机器“换个 shell 就换一份配置”:agent 目录从 `C:\Users\xx\.prux`
/// 变成 cwd 下的 `.\prux`(缺 HOME 时 [`home_dir`] 回退 `.`),主题 / 会话历史全对不上;
/// 而且项目所在卷未必支持硬链接,sidecar 锁会跟着失败(见 [`crate::utils::file_lock`])。
/// 统一写 `dirs::home_dir()`(Windows = `SHGetKnownFolderPath(FOLDERID_Profile)`,
/// 即 `%USERPROFILE%`)后,所有终端环境指向同一个目录,也与 pi 的
/// `os.homedir()`(Node 在 Windows 上同样只看 profile 目录、不看 `HOME`)对齐。
///
/// 代价:Windows 上用户显式设置的 `HOME`(如把配置放 D 盘的便携安装)不再生效,
/// 要用 `PRUX_AGENT_DIR` 改 agent 目录。
///
/// 非 Windows 平台无操作;`dirs` 也拿不到目录时保持原状。
/// 调用方必须保证在任何线程诞生之前调用(写在 `main` 第一句),
/// 否则 `set_var` 会与其它线程读 env 并发。
pub fn ensure_home_env() {
    // 非 Windows 的 shell(bash / zsh / …)都导出 HOME,且它就是约定的 home 来源。
    if !cfg!(windows) {
        return;
    }

    let Some(home) = dirs::home_dir().filter(|h| !h.as_os_str().is_empty()) else {
        return;
    };

    // SAFETY: 只由 `main` 在任何线程诞生之前调用一次,此刻无并发 env 访问。
    unsafe { std::env::set_var("HOME", home) };
}

/// HOME 目录(测试接缝:`test_support::HomeGuard`,仅测试构建存在)。
///
/// 也是全局 agent 目录 [`crate::core::settings_manager::agent_dir`] 的基准目录。
pub(crate) fn home_dir() -> PathBuf {
    // 测试接缝:线程本地 HOME override(HomeGuard)优先,避免 set_var 竞态。
    #[cfg(any(test, feature = "test-support"))]
    if let Some(home) = crate::test_support::home_override() {
        return home;
    }

    PathBuf::from(std::env::var("HOME").unwrap_or_else(|_| ".".to_string()))
}

/// 与 [`expand_home`] 相反:`$HOME` 前缀缩写为 `~`(长路径省宽度,与 shell 习惯一致)。
/// 只在路径分段边界上缩写(`/home/user2/a` 不会被 home=`/home/user` 命中)。
pub fn shorten_home(s: &str) -> String {
    match std::env::var_os("HOME").and_then(|h| h.into_string().ok()) {
        Some(home) => shorten_home_in(s, &home),
        None => s.to_string(),
    }
}

/// [`shorten_home`] 的测试接缝:把 `home` 前缀缩写为 `~`,仅在路径分段边界命中。
fn shorten_home_in(s: &str, home: &str) -> String {
    if !home.is_empty()
        && let Some(rest) = s.strip_prefix(home)
        && (rest.is_empty() || rest.starts_with('/'))
    {
        return format!("~{}", rest);
    }
    s.to_string()
}

/// 以 cwd 为基准解析参数路径(~、绝对路径、相对路径)
pub fn resolve_path(raw: &str, cwd: &str) -> PathBuf {
    let raw = raw.trim();
    if raw == "~" {
        return home_dir();
    }
    if let Some(rest) = raw.strip_prefix("~/") {
        return home_dir().join(rest);
    }
    let path = PathBuf::from(raw);
    if path.is_absolute() {
        path
    } else {
        PathBuf::from(cwd).join(path)
    }
}

/// 展开 ~ 前缀的裸路径
pub fn expand_tilde_path(raw: String) -> PathBuf {
    resolve_path(&raw, ".")
}

/// 文件元数据(不存在 → `(None, None)`,因此「删除」也会让缓存失效)。
pub fn file_stamp(path: &Path) -> (Option<SystemTime>, Option<u64>) {
    match std::fs::metadata(path) {
        Ok(m) => (m.modified().ok(), Some(m.len())),
        Err(_) => (None, None),
    }
}

/// 从主题参数中提取主题名(取文件名主干)
pub fn theme_name_from_arg(arg: &str) -> String {
    let path = Path::new(arg);
    path.file_stem()
        .and_then(|s| s.to_str())
        .unwrap_or(arg)
        .to_string()
}

/// 当前工作目录;读取失败时回退 `.`。
///
/// 测试构建下线程本地 override 优先([`crate::test_support::CwdGuard`]):
/// 项目层配置(`.prux/settings.json`)按它定位,避免并行测试靠进程级
/// `set_current_dir` 互踩。
pub fn cwd() -> PathBuf {
    #[cfg(any(test, feature = "test-support"))]
    if let Some(dir) = crate::test_support::cwd_override() {
        return dir;
    }

    std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
}

/// 规范化 cwd(绝对路径、折叠 . 和 ..;不做 fs realpath)
pub fn normalize_cwd(path: &Path) -> String {
    if let Ok(real) = std::fs::canonicalize(path) {
        return real.to_string_lossy().to_string();
    }
    let absolute = if path.is_absolute() {
        path.to_path_buf()
    } else {
        std::env::current_dir()
            .map(|cwd| cwd.join(path))
            .unwrap_or_else(|_| path.to_path_buf())
    };
    let mut out = PathBuf::new();
    for component in absolute.components() {
        match component {
            Component::RootDir => out.push(component.as_os_str()),
            Component::CurDir => {}
            Component::ParentDir => {
                out.pop();
            }
            Component::Normal(part) => out.push(part),
            Component::Prefix(prefix) => out.push(prefix.as_os_str()),
        }
    }
    out.to_string_lossy().to_string()
}

/// 剥掉 Windows 规范化路径的 verbatim 前缀(`\\?\` 与 `\\?\UNC\`)。
///
/// `std::fs::canonicalize` 在 Windows 上返回 verbatim 路径(`\\?\C:\...`、
/// `\\?\UNC\server\share\...`),而 `?` 是**非法文件名字符**:把这种路径当作
/// 目录名/文件名的一部分编码出去(prompt 历史按项目根分桶即如此),
/// `CreateFile` 会直接以 `ERROR_INVALID_NAME` 失败,写入被静默丢掉。
///
/// - `\\?\UNC\server\share\x` → `\\server\share\x`(还原成普通 UNC)
/// - `\\?\C:\x` → `C:\x`(还原成普通盘符路径)
/// - 其余(含非 Windows 路径、本就没有前缀的普通路径)原样返回,故 Linux 行为不变。
///
/// 剥离后同一目录的 verbatim / 普通两种写法编码结果一致,避免同一个桶被拆成两份。
pub fn strip_verbatim_prefix(path: &str) -> String {
    if let Some(rest) = path.strip_prefix(r"\\?\UNC\") {
        return format!(r"\\{rest}");
    }

    if let Some(rest) = path.strip_prefix(r"\\?\") {
        return rest.to_string();
    }
    path.to_string()
}

/// 项目根:从 `cwd` 向上找最近的 `.git`(目录或文件都认——文件是 git worktree / 链接工作树的写法),
/// 找到即返回该层规范路径;一路到根都没有则回退 [`normalize_cwd`]。
///
/// 纯文件系统判定,不起 `git` 子进程、不依赖 PATH。用于把「同一个项目的子目录」
/// 归到同一个桶(prompt 历史按项目根分桶)。
pub fn project_root(cwd: &str) -> String {
    let normalized = normalize_cwd(Path::new(cwd));
    let mut current = PathBuf::from(&normalized);
    loop {
        if current.join(".git").exists() {
            return normalize_cwd(&current);
        }
        if !current.pop() {
            return normalized;
        }
    }
}

/// 归一化 home 路径:空路径视为根 `/`,否则原样返回。
pub fn resolve_home(home_dir: &Path) -> PathBuf {
    if home_dir.as_os_str().is_empty() {
        PathBuf::from("/")
    } else {
        home_dir.to_path_buf()
    }
}

/// 路径相对化:绝对路径 → 相对搜索目录
pub fn relativize_path(result_path: &str, search_path: &Path) -> String {
    let had_trailing = result_path.ends_with('/');
    let rel = if Path::new(result_path).is_absolute() {
        match Path::new(result_path).strip_prefix(search_path) {
            Ok(r) => r.to_string_lossy().to_string(),
            Err(_) => result_path.to_string(),
        }
    } else {
        result_path.to_string()
    };
    let posix = rel.replace('\\', "/");
    if had_trailing && !posix.ends_with('/') {
        format!("{}/", posix)
    } else {
        posix
    }
}

/// fs realpath(解析符号链接);路径不存在时原样返回入参。
pub fn canonicalize(p: &str) -> String {
    std::fs::canonicalize(p)
        .map(|x| x.to_string_lossy().to_string())
        .unwrap_or_else(|_| p.to_string())
}

/// /import 目标路径:同名已存在时递增编号 `name-1.jsonl`、`name-2.jsonl`…
pub fn uniquify_import_destination(dir: &Path, file_name: &OsStr) -> PathBuf {
    let first = dir.join(file_name);
    if !first.exists() {
        return first;
    }
    let p = Path::new(file_name);
    let stem = p
        .file_stem()
        .map(|s| s.to_string_lossy().to_string())
        .unwrap_or_else(|| file_name.to_string_lossy().to_string());
    let ext = p
        .extension()
        .map(|e| format!(".{}", e.to_string_lossy()))
        .unwrap_or_default();
    let mut n = 1;
    loop {
        let candidate = dir.join(format!("{stem}-{n}{ext}"));
        if !candidate.exists() {
            return candidate;
        }
        n += 1;
    }
}

/// 不覆盖复制(O_EXCL 语义):目标已存在时直接报错,绝不静默覆盖。
pub fn copy_no_overwrite(src: &Path, dst: &Path) -> std::io::Result<()> {
    let mut input = std::fs::File::open(src)?;
    let mut output = std::fs::OpenOptions::new()
        .write(true)
        .create_new(true)
        .open(dst)?;
    std::io::copy(&mut input, &mut output)?;
    Ok(())
}

/// 自身是否为符号链接(不跟随);元数据不可读时为 false。
pub fn is_symlink(path: &Path) -> bool {
    std::fs::symlink_metadata(path)
        .map(|m| m.file_type().is_symlink())
        .unwrap_or(false)
}

/// 文件修改时间(缺失/不可读视为最旧,仍会尝试加载)。
pub fn mtime_of(path: &Path) -> SystemTime {
    std::fs::metadata(path)
        .and_then(|m| m.modified())
        .unwrap_or(SystemTime::UNIX_EPOCH)
}

/// 只读文件首行(header)。会话发现路径上高频调用,不解析 JSON。
pub fn read_first_line(path: &Path) -> Option<String> {
    let file = std::fs::File::open(path).ok()?;
    let mut reader = BufReader::new(file);
    let mut line = String::new();
    reader.read_line(&mut line).ok()?;
    if line.is_empty() { None } else { Some(line) }
}

/// 归一化目录路径(尾部斜杠/相对段差异不影响比较)。
pub fn normalize_dir(dir: &Path) -> PathBuf {
    std::fs::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf())
}

#[cfg(test)]
mod tests {
    use super::*;

    /// 非 Windows 平台不得动 `HOME`:那里 HOME 就是约定的 home 来源,
    /// 重写成 `dirs::home_dir()` 会把用户显式设的(如 MSYS 的 `/c/Users/x`)改成别的。
    #[cfg(not(windows))]
    #[test]
    fn ensure_home_env_leaves_home_alone_off_windows() {
        let before = std::env::var_os("HOME");

        ensure_home_env();

        assert_eq!(std::env::var_os("HOME"), before);
    }

    #[test]
    fn project_root_walks_up_to_git_dir() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path().join("repo");
        let nested = root.join("src").join("deep");
        std::fs::create_dir_all(&nested).unwrap();
        std::fs::create_dir_all(root.join(".git")).unwrap();

        let expected = normalize_cwd(&root);
        assert_eq!(project_root(nested.to_str().unwrap()), expected);
        // 根自身也命中
        assert_eq!(project_root(root.to_str().unwrap()), expected);
    }

    #[test]
    fn project_root_accepts_git_file_for_worktrees() {
        // 链接工作树的 `.git` 是文件(内容 `gitdir: ...`),也要认。
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path().join("wt");
        let nested = root.join("sub");
        std::fs::create_dir_all(&nested).unwrap();
        std::fs::write(root.join(".git"), "gitdir: /elsewhere/.git/worktrees/wt\n").unwrap();

        assert_eq!(project_root(nested.to_str().unwrap()), normalize_cwd(&root));
    }

    #[test]
    fn project_root_falls_back_to_cwd_outside_repo() {
        let dir = tempfile::tempdir().unwrap();
        let plain = dir.path().join("plain");
        std::fs::create_dir_all(&plain).unwrap();

        let expected = normalize_cwd(&plain);
        assert_eq!(project_root(plain.to_str().unwrap()), expected);
    }

    #[test]
    fn strip_verbatim_prefix_removes_windows_prefixes_only() {
        // Windows canonicalize 的两种 verbatim 形状
        assert_eq!(
            strip_verbatim_prefix(r"\\?\C:\Users\blue"),
            r"C:\Users\blue"
        );
        assert_eq!(
            strip_verbatim_prefix(r"\\?\UNC\srv\share\proj"),
            r"\\srv\share\proj"
        );
        // 普通路径(含 UNC、Unix 绝对路径)原样返回
        assert_eq!(strip_verbatim_prefix(r"C:\Users\blue"), r"C:\Users\blue");
        assert_eq!(strip_verbatim_prefix(r"\\srv\share"), r"\\srv\share");
        assert_eq!(strip_verbatim_prefix("/home/blue/repo"), "/home/blue/repo");
        // `\\?` 后面不跟分隔符的只是普通名字,不能误剥
        assert_eq!(strip_verbatim_prefix(r"\\?weird"), r"\\?weird");
    }

    #[test]
    fn uniquify_import_destination_honors_extension_and_holes() {
        // 无扩展名、连续编号、中间编号被占时跳到下一个空闲编号。
        let dir = tempfile::tempdir().unwrap();
        let p = dir.path();
        assert_eq!(
            uniquify_import_destination(p, std::ffi::OsStr::new("a.jsonl")),
            p.join("a.jsonl")
        );
        std::fs::write(p.join("a.jsonl"), "1").unwrap();
        std::fs::write(p.join("a-1.jsonl"), "2").unwrap();
        assert_eq!(
            uniquify_import_destination(p, std::ffi::OsStr::new("a.jsonl")),
            p.join("a-2.jsonl")
        );
        std::fs::write(p.join("noext"), "3").unwrap();
        assert_eq!(
            uniquify_import_destination(p, std::ffi::OsStr::new("noext")),
            p.join("noext-1")
        );
    }

    #[test]
    fn shorten_home_only_on_segment_boundary() {
        assert_eq!(
            shorten_home_in("/h/u/skills/a/SKILL.md", "/h/u"),
            "~/skills/a/SKILL.md"
        );
        assert_eq!(shorten_home_in("/h/user2/a.md", "/h/u"), "/h/user2/a.md");
        assert_eq!(shorten_home_in("/h/u", "/h/u"), "~");
        assert_eq!(shorten_home_in("/etc/x", ""), "/etc/x");
    }

    #[test]
    fn copy_no_overwrite_refuses_existing_target() {
        // O_EXCL 语义:目标已存在时返回错误,不回写内容。
        let dir = tempfile::tempdir().unwrap();
        let src = dir.path().join("src");
        let dst = dir.path().join("dst");
        std::fs::write(&src, "new").unwrap();
        std::fs::write(&dst, "old").unwrap();
        assert!(copy_no_overwrite(&src, &dst).is_err());
        assert_eq!(std::fs::read_to_string(&dst).unwrap(), "old");
        copy_no_overwrite(&src, &dir.path().join("free")).unwrap();
        assert_eq!(
            std::fs::read_to_string(dir.path().join("free")).unwrap(),
            "new"
        );
    }
}