feat: 新增 disk usage 内置命令(跨平台 du 等价)

- 注册表新增 Variant.Builtin 内置动作,与 Argv 互斥并参与平台匹配
- disk.usage 支持位置参数与 --path/--path=,输出按大小降序并带合计
- Unix 用设备号实现 -x 不跨文件系统,Windows 降级为不跟随 reparse point
- list/help/explain 支持展示内置动作,补充单元与 CLI 测试、README
This commit is contained in:
2026-10-31 13:11:24 +08:00
parent 7d9852958f
commit 84f7361396
12 changed files with 672 additions and 9 deletions
+37
View File
@@ -0,0 +1,37 @@
package executor
import (
"context"
"io"
)
// BuiltinRequest carries the caller's arguments and output streams to an
// in-process command implementation.
type BuiltinRequest struct {
Args []string
Stdout io.Writer
Stderr io.Writer
}
// Builtin executes a command inside kopsc instead of spawning a child. It is
// used where no portable system utility exists (for example du on Windows).
type Builtin func(ctx context.Context, req BuiltinRequest) error
var builtins = map[string]Builtin{
"disk.usage": runDiskUsage,
}
// LookupBuiltin returns the implementation registered under name.
func LookupBuiltin(name string) (Builtin, bool) {
fn, ok := builtins[name]
return fn, ok
}
// BuiltinNames lists the registered builtin implementations.
func BuiltinNames() []string {
names := make([]string, 0, len(builtins))
for name := range builtins {
names = append(names, name)
}
return names
}
+26
View File
@@ -0,0 +1,26 @@
//go:build unix
package executor
import (
"os"
"syscall"
)
// deviceOf returns the device ID backing a file, used to implement du's -x
// (do not cross filesystems).
func deviceOf(info os.FileInfo) (uint64, bool) {
stat, ok := info.Sys().(*syscall.Stat_t)
if !ok {
return 0, false
}
return uint64(stat.Dev), true
}
func deviceOfPath(path string) (uint64, bool) {
info, err := os.Stat(path)
if err != nil {
return 0, false
}
return deviceOf(info)
}
+12
View File
@@ -0,0 +1,12 @@
//go:build windows
package executor
import "os"
// Windows has no stable per-file device ID in os.FileInfo. Directory walks do
// not cross volumes on their own, so -x degrades to "do not follow reparse
// points", which filepath.WalkDir already guarantees.
func deviceOf(os.FileInfo) (uint64, bool) { return 0, false }
func deviceOfPath(string) (uint64, bool) { return 0, false }
+188
View File
@@ -0,0 +1,188 @@
package executor
import (
"context"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
)
// runDiskUsage implements `du -x --max-depth=1 -h <path> | sort -hr`.
//
// It reports every direct child of the given directory: regular files by their
// size, directories by the recursive size of their regular files. Symbolic
// links are not followed and, where the platform exposes device IDs, nested
// filesystems are not crossed (-x).
func runDiskUsage(ctx context.Context, req BuiltinRequest) error {
paths, err := parseDiskUsageArgs(req.Args)
if err != nil {
return err
}
root := paths[0]
rootInfo, err := os.Stat(root)
if err != nil {
return fmt.Errorf("无法访问 %q: %w", root, err)
}
if !rootInfo.IsDir() {
return fmt.Errorf("%q 不是目录", root)
}
entries, err := os.ReadDir(root)
if err != nil {
return fmt.Errorf("读取目录 %q 失败: %w", root, err)
}
rootDev, checkDev := deviceOf(rootInfo)
type entrySize struct {
path string
size int64
}
items := make([]entrySize, 0, len(entries))
var total int64
for _, entry := range entries {
if err := ctx.Err(); err != nil {
return err
}
path := filepath.Join(root, entry.Name())
info, err := entry.Info()
if err != nil {
// Unreadable entries are skipped rather than failing the whole run.
continue
}
var size int64
switch {
case info.IsDir():
if checkDev {
if dev, ok := deviceOf(info); ok && dev != rootDev {
// -x: a different filesystem contributes nothing.
break
}
}
size, err = dirSize(ctx, path, rootDev, checkDev)
if err != nil {
return err
}
case info.Mode().IsRegular():
size = info.Size()
}
items = append(items, entrySize{path: path, size: size})
total += size
}
sort.SliceStable(items, func(i, j int) bool {
if items[i].size != items[j].size {
return items[i].size > items[j].size
}
return items[i].path < items[j].path
})
w := req.Stdout
for _, item := range items {
fmt.Fprintf(w, "%-8s %s\n", humanSize(item.size), item.path)
}
fmt.Fprintf(w, "%-8s 总计 %s\n", humanSize(total), root)
return nil
}
// parseDiskUsageArgs accepts either a positional path or --path/--path=value.
// Exactly one path is required.
func parseDiskUsageArgs(args []string) ([]string, error) {
var paths []string
positionalOnly := false
for i := 0; i < len(args); i++ {
arg := args[i]
switch {
case positionalOnly:
paths = append(paths, arg)
case arg == "--":
positionalOnly = true
case arg == "--path":
if i+1 >= len(args) {
return nil, errors.New("--path 缺少参数,例如 --path /var")
}
paths = append(paths, args[i+1])
i++
case strings.HasPrefix(arg, "--path="):
value := strings.TrimPrefix(arg, "--path=")
if value == "" {
return nil, errors.New("--path 缺少参数,例如 --path /var")
}
paths = append(paths, value)
case strings.HasPrefix(arg, "-") && arg != "-":
return nil, fmt.Errorf("未知选项 %q:深度(-d 1)、人类可读(-h)与不跨文件系统(-x)已固定", arg)
default:
paths = append(paths, arg)
}
}
if len(paths) == 0 {
return nil, errors.New("必须提供路径,例如 kopsc disk usage /var 或 kopsc disk usage --path /var")
}
if len(paths) > 1 {
return nil, fmt.Errorf("一次只支持一个路径,收到 %d 个", len(paths))
}
return paths, nil
}
// dirSize sums the sizes of regular files under dir without following symbolic
// links or crossing into another filesystem.
func dirSize(ctx context.Context, dir string, rootDev uint64, checkDev bool) (int64, error) {
var total int64
err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, walkErr error) error {
if walkErr != nil {
// Permission problems on one subtree must not abort the report.
return nil
}
if err := ctx.Err(); err != nil {
return err
}
if d.IsDir() {
if path != dir && checkDev {
if dev, ok := deviceOfPath(path); ok && dev != rootDev {
return fs.SkipDir
}
}
return nil
}
info, err := d.Info()
if err != nil {
return nil
}
if info.Mode().IsRegular() {
total += info.Size()
}
return nil
})
if err != nil {
return total, err
}
return total, nil
}
// humanSize formats bytes the way `du -h` does: bytes below 1 KiB are printed
// bare, larger values use binary units with one decimal.
func humanSize(n int64) string {
if n < 1024 {
return strconv.FormatInt(n, 10)
}
units := []string{"K", "M", "G", "T", "P", "E"}
value := float64(n)
unit := -1
for value >= 1024 && unit < len(units)-1 {
value /= 1024
unit++
}
return fmt.Sprintf("%.1f%s", value, units[unit])
}
+198
View File
@@ -0,0 +1,198 @@
package executor
import (
"bytes"
"context"
"os"
"path/filepath"
"strings"
"testing"
"gitea.odboy.cn/odboy/kenaito-kopsc/internal/registry"
)
func registryVariantBuiltin(name string) registry.Variant {
return registry.Variant{Builtin: name, Match: registry.Match{OS: "linux"}}
}
func writeSizedFile(t *testing.T, path string, size int) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("mkdir %s: %v", filepath.Dir(path), err)
}
if err := os.WriteFile(path, bytes.Repeat([]byte("x"), size), 0o644); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}
// fixture builds:
//
// root/big/big.bin 4096
// root/direct.txt 2048
// root/small/small.txt 1024
// root/empty/ (no files)
func diskFixture(t *testing.T) string {
t.Helper()
root := t.TempDir()
writeSizedFile(t, filepath.Join(root, "big", "big.bin"), 4096)
writeSizedFile(t, filepath.Join(root, "direct.txt"), 2048)
writeSizedFile(t, filepath.Join(root, "small", "small.txt"), 1024)
if err := os.MkdirAll(filepath.Join(root, "empty"), 0o755); err != nil {
t.Fatal(err)
}
return root
}
func runDisk(t *testing.T, args ...string) (string, error) {
t.Helper()
var out bytes.Buffer
err := runDiskUsage(context.Background(), BuiltinRequest{Args: args, Stdout: &out})
return out.String(), err
}
func TestDiskUsageOrdersBySizeDescending(t *testing.T) {
root := diskFixture(t)
out, err := runDisk(t, root)
if err != nil {
t.Fatalf("runDiskUsage error: %v", err)
}
lines := strings.Split(strings.TrimSpace(out), "\n")
if len(lines) != 5 {
t.Fatalf("expected 4 entries + total, got %d:\n%s", len(lines), out)
}
wantOrder := []struct {
size string
name string
}{
{"4.0K", "big"},
{"2.0K", "direct.txt"},
{"1.0K", "small"},
{"0", "empty"},
}
for i, want := range wantOrder {
if !strings.HasPrefix(lines[i], want.size) || !strings.Contains(lines[i], want.name) {
t.Errorf("line %d = %q, want size %s and name %s", i, lines[i], want.size, want.name)
}
}
if !strings.HasSuffix(lines[4], "总计 "+root) {
t.Errorf("total line = %q, want suffix %q", lines[4], "总计 "+root)
}
if !strings.HasPrefix(lines[4], "7.0K") {
t.Errorf("total line = %q, want 7.0K", lines[4])
}
}
func TestDiskUsageAcceptsPathFlag(t *testing.T) {
root := diskFixture(t)
for _, args := range [][]string{
{"--path", root},
{"--path=" + root},
{"--path", root, "--"},
} {
out, err := runDisk(t, args...)
if err != nil {
t.Fatalf("args %v: %v", args, err)
}
if !strings.Contains(out, "big") || !strings.Contains(out, "总计") {
t.Errorf("args %v produced unexpected output:\n%s", args, out)
}
}
}
func TestDiskUsageRequiresPath(t *testing.T) {
_, err := runDisk(t)
if err == nil || !strings.Contains(err.Error(), "必须提供路径") {
t.Fatalf("err = %v, want missing-path error", err)
}
}
func TestDiskUsageRejectsBadArgs(t *testing.T) {
tests := []struct {
args []string
want string
}{
{[]string{"--max-depth=2", "/var"}, "未知选项"},
{[]string{"-h", "/var"}, "未知选项"},
{[]string{"/var", "/tmp"}, "一次只支持一个路径"},
{[]string{"--path"}, "--path 缺少参数"},
{[]string{"--path="}, "--path 缺少参数"},
}
for _, tc := range tests {
_, err := runDisk(t, tc.args...)
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Errorf("args %v: err = %v, want %q", tc.args, err, tc.want)
}
}
}
func TestDiskUsageRejectsFile(t *testing.T) {
file := filepath.Join(t.TempDir(), "f.txt")
writeSizedFile(t, file, 10)
_, err := runDisk(t, file)
if err == nil || !strings.Contains(err.Error(), "不是目录") {
t.Fatalf("err = %v, want not-a-directory error", err)
}
}
func TestHumanSize(t *testing.T) {
cases := map[int64]string{
0: "0",
512: "512",
1023: "1023",
1024: "1.0K",
1536: "1.5K",
1024 * 1024: "1.0M",
1024 * 1024 * 1024: "1.0G",
1 << 40: "1.0T",
}
for in, want := range cases {
if got := humanSize(in); got != want {
t.Errorf("humanSize(%d) = %q, want %q", in, got, want)
}
}
}
func TestBuiltinIsRegistered(t *testing.T) {
if _, ok := LookupBuiltin("disk.usage"); !ok {
t.Fatal("disk.usage builtin not registered")
}
if _, ok := LookupBuiltin("missing"); ok {
t.Fatal("unexpected builtin hit")
}
}
func TestPlanRunsBuiltin(t *testing.T) {
root := diskFixture(t)
plan := samplePlan()
plan.Result.Variant = registryVariantBuiltin("disk.usage")
plan.ExtraArgs = []string{root}
var out bytes.Buffer
if err := plan.Run(context.Background(), Options{Stdout: &out, Stderr: &out}); err != nil {
t.Fatalf("Run() error: %v", err)
}
if !strings.Contains(out.String(), "总计") {
t.Errorf("builtin output missing total:\n%s", out.String())
}
if !strings.Contains(plan.Display(), "(内置) disk.usage") {
t.Errorf("Display() = %q", plan.Display())
}
}
func TestPlanBuiltinDryRunDoesNotExecute(t *testing.T) {
plan := samplePlan()
plan.Result.Variant = registryVariantBuiltin("disk.usage")
plan.ExtraArgs = []string{filepath.Join(t.TempDir(), "missing")}
var out bytes.Buffer
if err := plan.Run(context.Background(), Options{DryRun: true, Stdout: &out}); err != nil {
t.Fatalf("dry-run should not execute: %v", err)
}
if !strings.Contains(out.String(), "内置实现") {
t.Errorf("dry-run output lacks builtin note:\n%s", out.String())
}
}
+17 -2
View File
@@ -42,6 +42,10 @@ func (p Plan) Argv() []string {
// Display renders the command line the plan would run.
func (p Plan) Display() string {
if p.Result.Variant.Builtin != "" {
parts := append([]string{"(内置)", p.Result.Variant.Builtin}, p.ExtraArgs...)
return strings.Join(parts, " ")
}
argv := p.Argv()
if p.Result.Variant.Shell {
flag := "-c"
@@ -61,9 +65,12 @@ func (p Plan) Explain(w io.Writer) {
variant := p.Result.Variant
fmt.Fprintf(w, "将执行: %s\n", p.Display())
fmt.Fprintf(w, "平台: %s\n", resolve.DescribeInfo(p.Result.Info))
if variant.Shell {
switch {
case variant.Builtin != "":
fmt.Fprintf(w, "方式: 内置实现(%s,不启动外部进程)\n", variant.Builtin)
case variant.Shell:
fmt.Fprintf(w, "方式: shell (%s)\n", shellName())
} else {
default:
fmt.Fprintf(w, "方式: 直接执行\n")
}
note := variant.Note
@@ -94,6 +101,14 @@ func (p Plan) Run(ctx context.Context, opts Options) error {
return nil
}
if variant := p.Result.Variant; variant.Builtin != "" {
fn, ok := LookupBuiltin(variant.Builtin)
if !ok {
return fmt.Errorf("未注册的内置动作 %q", variant.Builtin)
}
return fn(ctx, BuiltinRequest{Args: p.ExtraArgs, Stdout: opts.Stdout, Stderr: opts.Stderr})
}
name, args := p.command()
cmd := exec.CommandContext(ctx, name, args...)
cmd.Stdin = opts.Stdin