diff --git a/README.md b/README.md index 41b31d6..d951e1a 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,8 @@ $ jup init Initialized javaup project. $ jup run mvn test +? Select a Maven project +> myproject /work/myproject [INFO] BUILD SUCCESS ``` @@ -56,7 +58,7 @@ With `jup`, each project gets one explicit, inspectable toolchain: | Switch projects | Edit `JAVA_HOME` and `PATH` | Use the JDK saved for the project | | Select Maven | Depend on whatever is on PATH | Prefer the project Wrapper, otherwise save Maven from PATH | | Use private repositories | Repeat `--settings` or replace a global file | Bind a named `settings.xml` to the project | -| Build from a submodule | Return to the root and reconstruct the environment | Resolve the initialized project from any descendant directory | +| Build from anywhere | Change directories and reconstruct the environment | Select any initialized project from a ranked list | | Preserve the shell | Risk affecting later commands | Change only the spawned build process | ### How it fits with existing tools @@ -163,9 +165,15 @@ Java home: /opt/jdks/temurin-17 Maven settings: default ``` -`jup run mvn` connects Maven directly to the current terminal, preserving -interactive input, logs, and exit codes. It starts Maven in the current -directory, so module-specific builds work as expected. +In an interactive terminal, `jup run mvn` lists every initialized Maven project +regardless of the current directory. Use the arrow keys and Enter to select a +project; Maven starts in that project's saved root directory. Frequently and +recently used projects rise to the top. Maven remains connected directly to the +terminal, preserving interactive input, logs, and exit codes. + +In non-interactive environments such as CI and redirected pipelines, `jup` +keeps the previous behavior: it resolves the nearest initialized project from +the current directory and starts Maven there without prompting. ## Highlights @@ -179,6 +187,8 @@ directory, so module-specific builds work as expected. - Sets `JAVA_HOME` and puts the selected JDK's `bin` first on PATH only for the spawned build process. - Binds reusable Maven `settings.xml` aliases to individual projects. +- Selects initialized projects globally and ranks them by decaying recent-use + frequency. - Resolves `status`, `run`, `settings use/unset`, and `uninit` from any descendant directory. - Runs on Windows, macOS, and Linux, with CI verification on all three. diff --git a/README.zh-CN.md b/README.zh-CN.md index de94b91..f71712d 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -31,6 +31,8 @@ $ jup init Initialized javaup project. $ jup run mvn test +? Select a Maven project +> myproject /work/myproject [INFO] BUILD SUCCESS ``` @@ -153,8 +155,13 @@ Java home: /opt/jdks/temurin-17 Maven settings: default ``` -`jup run mvn` 会将 Maven 直接连接到当前终端,保留交互输入、日志和退出码,并从 -当前目录而不是强制从项目根目录启动,因此可以正常执行针对子模块的构建。 +在交互式终端中,`jup run mvn` 会列出所有已经初始化的 Maven 项目,不受当前目录 +限制。使用上下方向键和回车选择项目后,Maven 会从该项目保存的根目录启动。最近且 +频繁使用的项目会自动排在前面。Maven 仍会直接连接到当前终端,保留交互输入、日志 +和退出码。 + +在 CI、输入重定向等非交互环境中,`jup` 保持原有行为:从当前目录向上寻找最近的 +已初始化项目,并直接从当前目录启动 Maven,不显示选择器。 ## 功能亮点 @@ -165,6 +172,7 @@ Maven settings: default - 为每个项目保存 Maven 可执行文件、JDK 路径、版本和初始化时间。 - 只在构建子进程中设置 `JAVA_HOME`,并把对应 JDK 的 `bin` 放到 PATH 首位。 - 支持将可复用的 Maven `settings.xml` 别名绑定到不同项目。 +- 可以从任意目录选择已初始化项目,并按带时间衰减的最近使用频率排序。 - 可以从项目任意子目录执行 `status`、`run`、`settings use/unset` 和 `uninit`。 - 支持 Windows、macOS 和 Linux,CI 会在三个平台执行完整验证。 diff --git a/docs/user-guide.md b/docs/user-guide.md index c398bf4..4c3a205 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -115,15 +115,26 @@ jup run mvn clean package -DskipTests jup run mvn dependency:tree ``` -For every invocation, `jup`: +In an interactive terminal, every invocation: -1. searches upward from the current directory for the nearest initialized +1. loads every initialized Maven project, even when the terminal is outside a project; -2. checks that the saved Maven executable still exists; -3. sets the saved `JAVA_HOME` for the child process; -4. places that JDK's `bin` first on the child process PATH; -5. prepends `--settings ` when the project uses a settings alias; -6. starts Maven in the current directory rather than forcing the project root. +2. orders projects by a time-decaying recent-use frequency score; +3. lets you choose with the Up/Down keys and Enter; +4. checks that the selected Maven executable still exists; +5. sets the saved `JAVA_HOME` for the child process; +6. places that JDK's `bin` first on the child process PATH; +7. prepends `--settings ` when the project uses a settings alias; +8. starts Maven in the selected project's saved root directory. + +The ranking score has a 14-day half-life. Repeatedly used projects rise quickly, +while the influence of projects that have not been used fades over time. The +project's full path is shown so projects with the same directory name remain +unambiguous. + +In non-interactive environments such as CI or redirected pipelines, `jup` does +not prompt. It searches upward for the nearest initialized project and starts +Maven in the current directory, preserving the existing automation behavior. Maven's standard input, output, and error streams connect directly to the current terminal. Interactive behavior, logs, and exit codes are preserved. @@ -183,10 +194,10 @@ that it is missing. Bind another alias or run `jup settings unset` to recover. jup uninit ``` -The command removes the nearest initialized project configuration found from -the current directory. It is idempotent: if no configuration exists, it -reports that there is nothing to remove. Project files, JDKs, Maven -installations, and settings files are never modified. +The command removes the nearest initialized project configuration and its usage +ranking record. It is idempotent: if no configuration exists, it reports that +there is nothing to remove. Project files, JDKs, Maven installations, and +settings files are never modified. ## Help and version @@ -276,10 +287,12 @@ Directory layout: .javaup/ ├── bin/ │ └── jup # jup.exe on Windows -└── config/ - ├── projects/ # one JSON document per initialized project - └── maven/ - └── settings.json # Maven settings alias registry +├── config/ +│ ├── projects/ # one JSON document per initialized project +│ └── maven/ +│ └── settings.json # Maven settings alias registry +└── state/ + └── project-usage.json # recent-frequency project ranking ``` Set `JAVAUP_HOME` to an absolute path before installing or running `jup` to use diff --git a/docs/user-guide.zh-CN.md b/docs/user-guide.zh-CN.md index af969f9..4300484 100644 --- a/docs/user-guide.zh-CN.md +++ b/docs/user-guide.zh-CN.md @@ -103,14 +103,22 @@ jup run mvn clean package -DskipTests jup run mvn dependency:tree ``` -每次运行时,`jup` 都会: +在交互式终端中,每次运行时 `jup` 都会: -1. 从当前目录向上查找最近的已初始化项目; -2. 检查保存的 Maven 可执行文件是否仍然存在; -3. 为子进程设置保存的 `JAVA_HOME`; -4. 将对应 JDK 的 `bin` 放到子进程 PATH 首位; -5. 如果项目绑定了 settings 别名,在参数前加入 `--settings `; -6. 从当前目录启动 Maven,而不是强制切换到项目根目录。 +1. 加载所有已初始化的 Maven 项目,即使当前终端不在项目目录中; +2. 按带时间衰减的最近使用频率排序; +3. 让用户使用上下方向键和回车选择项目; +4. 检查所选项目保存的 Maven 可执行文件是否仍然存在; +5. 为子进程设置保存的 `JAVA_HOME`; +6. 将对应 JDK 的 `bin` 放到子进程 PATH 首位; +7. 如果项目绑定了 settings 别名,在参数前加入 `--settings `; +8. 从所选项目保存的根目录启动 Maven。 + +排序分数采用 14 天半衰期。短时间内反复使用的项目会快速靠前,长期未使用项目的 +历史权重会逐渐降低。列表同时显示完整路径,因此同名项目也可以明确区分。 + +在 CI、输入重定向等非交互环境中,`jup` 不显示选择器,而是从当前目录向上查找 +最近的已初始化项目,并从当前目录启动 Maven,以保持现有自动化行为。 Maven 的标准输入、标准输出和标准错误会直接连接到当前终端,交互行为、日志和退出码 都会保留。当前 shell 的环境变量不会被修改。 @@ -166,8 +174,9 @@ jup settings remove intranet jup uninit ``` -该命令会删除从当前目录向上找到的最近一个已初始化项目配置。重复执行是安全的;如果 -没有配置,会提示无需删除。项目文件、JDK、Maven 安装和 settings 文件都不会被修改。 +该命令会删除从当前目录向上找到的最近一个已初始化项目配置及其使用排序记录。重复 +执行是安全的;如果没有配置,会提示无需删除。项目文件、JDK、Maven 安装和 settings +文件都不会被修改。 ## 帮助与版本 @@ -248,10 +257,12 @@ Release 安装器也会将可执行文件放在其中的 `bin` 目录。 .javaup/ ├── bin/ │ └── jup # Windows 上为 jup.exe -└── config/ - ├── projects/ # 每个已初始化项目一个 JSON 文件 - └── maven/ - └── settings.json # Maven settings 别名注册表 +├── config/ +│ ├── projects/ # 每个已初始化项目一个 JSON 文件 +│ └── maven/ +│ └── settings.json # Maven settings 别名注册表 +└── state/ + └── project-usage.json # 项目最近使用频率排序 ``` 安装或运行 `jup` 前,可以将 `JAVAUP_HOME` 设置为其他绝对路径。项目配置保存的是 diff --git a/go.mod b/go.mod index 3d9018c..bf1ca4b 100644 --- a/go.mod +++ b/go.mod @@ -4,17 +4,20 @@ go 1.26.5 require ( github.com/fatih/color v1.19.0 + github.com/gofrs/flock v0.13.0 + github.com/manifoldco/promptui v0.9.0 + github.com/mattn/go-isatty v0.0.20 github.com/olekukonko/tablewriter v1.1.4 github.com/spf13/cobra v1.10.2 ) require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e // indirect github.com/clipperhouse/displaywidth v0.10.0 // indirect github.com/clipperhouse/uax29/v2 v2.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.19 // indirect github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 // indirect github.com/olekukonko/errors v1.2.0 // indirect diff --git a/go.sum b/go.sum index a1d5e57..ea08275 100644 --- a/go.sum +++ b/go.sum @@ -1,14 +1,26 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5OhCuC+XN+r/bBCmeuuJtjz+bCNIf8= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 h1:q763qf9huN11kDQavWsoZXJNW3xEE4JJyHa5Q25/sd8= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/clipperhouse/displaywidth v0.10.0 h1:GhBG8WuerxjFQQYeuZAeVTuyxuX+UraiZGD4HJQ3Y8g= github.com/clipperhouse/displaywidth v0.10.0/go.mod h1:XqJajYsaiEwkxOj4bowCTMcT1SgvHo9flfF3jQasdbs= github.com/clipperhouse/uax29/v2 v2.6.0 h1:z0cDbUV+aPASdFb2/ndFnS9ts/WNXgTNNGFoKXuhpos= github.com/clipperhouse/uax29/v2 v2.6.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= +github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw= +github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA= +github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= @@ -23,13 +35,20 @@ github.com/olekukonko/ll v0.1.6 h1:lGVTHO+Qc4Qm+fce/2h2m5y9LvqaW+DCN7xW9hsU3uA= github.com/olekukonko/ll v0.1.6/go.mod h1:NVUmjBb/aCtUpjKk75BhWrOlARz3dqsM+OtszpY4o88= github.com/olekukonko/tablewriter v1.1.4 h1:ORUMI3dXbMnRlRggJX3+q7OzQFDdvgbN9nVWj1drm6I= github.com/olekukonko/tablewriter v1.1.4/go.mod h1:+kedxuyTtgoZLwif3P1Em4hARJs+mVnzKxmsCL/C5RY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/cli/project_picker.go b/internal/cli/project_picker.go new file mode 100644 index 0000000..e2c8f5c --- /dev/null +++ b/internal/cli/project_picker.go @@ -0,0 +1,115 @@ +package cli + +import ( + "context" + "errors" + "fmt" + "io" + "os" + + "github.com/manifoldco/promptui" + "github.com/mattn/go-isatty" + + "github.com/codeboyzhou/javaup/internal/buildtool" + "github.com/codeboyzhou/javaup/internal/project" +) + +type projectCatalog interface { + List(tool buildtool.Type) ([]project.Candidate, []error, error) +} + +type terminalProjectPicker struct { + catalog projectCatalog +} + +func newTerminalProjectPicker(catalog projectCatalog) *terminalProjectPicker { + return &terminalProjectPicker{catalog: catalog} +} + +func (p *terminalProjectPicker) Pick( + _ context.Context, + tool buildtool.Type, + streams project.Streams, +) (string, error) { + candidates, warnings, err := p.catalog.List(tool) + if err != nil { + return "", err + } + for _, warning := range warnings { + if streams.Stderr != nil { + _, _ = fmt.Fprintf(streams.Stderr, "jup: warning: %v\n", warning) + } + } + if len(candidates) == 0 { + return "", fmt.Errorf("no configured %s projects found; run jup init in a project first", tool.DisplayName()) + } + + selector := promptui.Select{ + Label: "Select a " + tool.DisplayName() + " project", + Items: candidates, + Size: min(len(candidates), 10), + Templates: &promptui.SelectTemplates{ + Active: `> {{ .Name | cyan }} {{ .ProjectRoot }}`, + Inactive: ` {{ .Name }} {{ .ProjectRoot }}`, + Selected: `{{ "Selected" | green }}: {{ .Name }} {{ .ProjectRoot }}`, + Help: "↑/↓ move • enter select • ctrl+c cancel", + }, + Stdin: promptInput(streams.Stdin), + Stdout: promptOutput(streams.Stdout), + } + index, _, err := selector.Run() + if err != nil { + if errors.Is(err, promptui.ErrInterrupt) || errors.Is(err, promptui.ErrAbort) || errors.Is(err, promptui.ErrEOF) { + return "", canceledCommandError{} + } + return "", fmt.Errorf("select project: %w", err) + } + return candidates[index].ProjectRoot, nil +} + +type readCloser struct { + io.Reader +} + +func (readCloser) Close() error { return nil } + +type writeCloser struct { + io.Writer +} + +func (writeCloser) Close() error { return nil } + +func promptInput(input io.Reader) io.ReadCloser { + if file, ok := input.(*os.File); ok && file == os.Stdin { + // A nil prompt input selects readline's platform-specific stdin. On + // Windows this is a console event reader that translates arrow keys. + return nil + } + return readCloser{Reader: input} +} + +func promptOutput(output io.Writer) io.WriteCloser { + if file, ok := output.(*os.File); ok && file == os.Stdout { + return nil + } + return writeCloser{Writer: output} +} + +type canceledCommandError struct{} + +func (canceledCommandError) Error() string { return "project selection canceled" } + +func (canceledCommandError) ExitCode() int { return 130 } + +func isInteractiveTerminal(stdin io.Reader, stdout io.Writer) bool { + input, inputOK := stdin.(*os.File) + output, outputOK := stdout.(*os.File) + if !inputOK || !outputOK { + return false + } + return isTerminalDescriptor(input.Fd()) && isTerminalDescriptor(output.Fd()) +} + +func isTerminalDescriptor(descriptor uintptr) bool { + return isatty.IsTerminal(descriptor) || isatty.IsCygwinTerminal(descriptor) +} diff --git a/internal/cli/project_picker_test.go b/internal/cli/project_picker_test.go new file mode 100644 index 0000000..7e68ee5 --- /dev/null +++ b/internal/cli/project_picker_test.go @@ -0,0 +1,80 @@ +package cli + +import ( + "bytes" + "context" + "errors" + "os" + "strings" + "testing" + + "github.com/codeboyzhou/javaup/internal/buildtool" + "github.com/codeboyzhou/javaup/internal/project" +) + +type fakeProjectCatalog struct { + candidates []project.Candidate + warnings []error + err error +} + +func (c fakeProjectCatalog) List(buildtool.Type) ([]project.Candidate, []error, error) { + return c.candidates, c.warnings, c.err +} + +func TestTerminalProjectPickerHandlesArrowKeySelection(t *testing.T) { + t.Parallel() + + input := bytes.NewBufferString("\x1b[B\r") + output := &bytes.Buffer{} + errorsOutput := &bytes.Buffer{} + picker := newTerminalProjectPicker(fakeProjectCatalog{ + candidates: []project.Candidate{ + {Name: "first", ProjectRoot: "/projects/first"}, + {Name: "second", ProjectRoot: "/projects/second"}, + }, + warnings: []error{errors.New("stale project ignored")}, + }) + + root, err := picker.Pick(context.Background(), buildtool.Maven, project.Streams{ + Stdin: input, Stdout: output, Stderr: errorsOutput, + }) + if err != nil { + t.Fatalf("Pick() error = %v", err) + } + if root != "/projects/second" { + t.Errorf("Pick() root = %q, want /projects/second", root) + } + if !strings.Contains(errorsOutput.String(), "stale project ignored") { + t.Errorf("Pick() stderr = %q, want warning", errorsOutput.String()) + } +} + +func TestPromptUsesPlatformStreamsForProcessTerminal(t *testing.T) { + t.Parallel() + + if got := promptInput(os.Stdin); got != nil { + t.Errorf("promptInput(os.Stdin) = %T, want nil platform input", got) + } + if got := promptOutput(os.Stdout); got != nil { + t.Errorf("promptOutput(os.Stdout) = %T, want nil platform output", got) + } + input := bytes.NewBuffer(nil) + output := &bytes.Buffer{} + if got := promptInput(input); got == nil { + t.Error("promptInput(buffer) = nil, want injected input") + } + if got := promptOutput(output); got == nil { + t.Error("promptOutput(buffer) = nil, want injected output") + } +} + +func TestTerminalProjectPickerRequiresConfiguredProject(t *testing.T) { + t.Parallel() + + picker := newTerminalProjectPicker(fakeProjectCatalog{}) + _, err := picker.Pick(context.Background(), buildtool.Maven, project.Streams{}) + if err == nil || !strings.Contains(err.Error(), "run jup init") { + t.Fatalf("Pick() error = %v, want initialization guidance", err) + } +} diff --git a/internal/cli/run.go b/internal/cli/run.go index 06bb1d7..4ec3d65 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -3,6 +3,7 @@ package cli import ( "context" "fmt" + "io" "github.com/spf13/cobra" @@ -22,7 +23,24 @@ type projectRunner interface { type runnerFactory func() (projectRunner, error) +type projectPicker interface { + Pick(ctx context.Context, tool buildtool.Type, streams project.Streams) (string, error) +} + +type projectPickerFactory func() (projectPicker, error) + +type interactiveTerminal func(stdin io.Reader, stdout io.Writer) bool + func newRunCommand(factory runnerFactory, workingDirectory func() (string, error)) *cobra.Command { + return newRunCommandWithPicker(factory, workingDirectory, defaultProjectPickerFactory, isInteractiveTerminal) +} + +func newRunCommandWithPicker( + factory runnerFactory, + workingDirectory func() (string, error), + pickerFactory projectPickerFactory, + isInteractive interactiveTerminal, +) *cobra.Command { command := &cobra.Command{ Use: "run", Short: "Run a project build tool with its detected toolchain", @@ -45,6 +63,8 @@ func newRunCommand(factory runnerFactory, workingDirectory func() (string, error buildTool.description, factory, workingDirectory, + pickerFactory, + isInteractive, )) } return command @@ -56,6 +76,8 @@ func newRunBuildToolCommand( description string, factory runnerFactory, workingDirectory func() (string, error), + pickerFactory projectPickerFactory, + isInteractive interactiveTerminal, ) *cobra.Command { return &cobra.Command{ Use: name + " [arguments...]", @@ -63,23 +85,45 @@ func newRunBuildToolCommand( Args: cobra.ArbitraryArgs, DisableFlagParsing: true, RunE: func(command *cobra.Command, args []string) error { - root, err := workingDirectory() + streams := project.Streams{ + Stdin: command.InOrStdin(), + Stdout: command.OutOrStdout(), + Stderr: command.ErrOrStderr(), + } + var root string + var err error + if isInteractive(streams.Stdin, streams.Stdout) { + picker, pickerErr := pickerFactory() + if pickerErr != nil { + return pickerErr + } + root, err = picker.Pick(command.Context(), tool, streams) + } else { + root, err = workingDirectory() + if err != nil { + err = fmt.Errorf("resolve current directory: %w", err) + } + } if err != nil { - return fmt.Errorf("resolve current directory: %w", err) + return err } runner, err := factory() if err != nil { return err } - return runner.Run(command.Context(), root, tool, args, project.Streams{ - Stdin: command.InOrStdin(), - Stdout: command.OutOrStdout(), - Stderr: command.ErrOrStderr(), - }) + return runner.Run(command.Context(), root, tool, args, streams) }, } } +func defaultProjectPickerFactory() (projectPicker, error) { + catalog, err := project.NewDefaultCatalog() + if err != nil { + return nil, err + } + return newTerminalProjectPicker(catalog), nil +} + func defaultRunnerFactory() (projectRunner, error) { return project.NewDefaultRunner() } diff --git a/internal/cli/run_test.go b/internal/cli/run_test.go index e3f3ab0..f768b3d 100644 --- a/internal/cli/run_test.go +++ b/internal/cli/run_test.go @@ -20,6 +20,23 @@ type recordingProjectRunner struct { err error } +type recordingProjectPicker struct { + root string + tool buildtool.Type + streams project.Streams + err error +} + +func (p *recordingProjectPicker) Pick( + _ context.Context, + tool buildtool.Type, + streams project.Streams, +) (string, error) { + p.tool = tool + p.streams = streams + return p.root, p.err +} + func (r *recordingProjectRunner) Run( _ context.Context, root string, @@ -83,6 +100,46 @@ func TestRunCommandForwardsMavenArguments(t *testing.T) { } } +func TestRunCommandUsesInteractiveProjectSelection(t *testing.T) { + t.Parallel() + + runner := &recordingProjectRunner{} + picker := &recordingProjectPicker{root: "selected-project"} + workingDirectoryCalled := false + command := newRunCommandWithPicker( + func() (projectRunner, error) { return runner, nil }, + func() (string, error) { + workingDirectoryCalled = true + return "current-project", nil + }, + func() (projectPicker, error) { return picker, nil }, + func(io.Reader, io.Writer) bool { return true }, + ) + input := bytes.NewBufferString("input") + output := &bytes.Buffer{} + errors := &bytes.Buffer{} + command.SetIn(input) + command.SetOut(output) + command.SetErr(errors) + command.SetArgs([]string{"mvn", "clean", "package"}) + + if err := command.ExecuteContext(context.Background()); err != nil { + t.Fatalf("ExecuteContext() error = %v", err) + } + if workingDirectoryCalled { + t.Error("working directory was resolved during interactive selection") + } + if picker.tool != buildtool.Maven || runner.root != "selected-project" { + t.Errorf("picker tool/runner root = %q/%q", picker.tool, runner.root) + } + if !reflect.DeepEqual(runner.args, []string{"clean", "package"}) { + t.Errorf("Run() args = %#v", runner.args) + } + if picker.streams.Stdin != io.Reader(input) || picker.streams.Stdout != io.Writer(output) || picker.streams.Stderr != io.Writer(errors) { + t.Error("picker streams were not forwarded") + } +} + func TestRunCommandRejectsJava(t *testing.T) { t.Parallel() diff --git a/internal/project/runner.go b/internal/project/runner.go index 708a322..72871c9 100644 --- a/internal/project/runner.go +++ b/internal/project/runner.go @@ -10,6 +10,7 @@ import ( "path/filepath" "runtime" "strings" + "time" "github.com/codeboyzhou/javaup/internal/buildtool" "github.com/codeboyzhou/javaup/internal/mavensettings" @@ -34,6 +35,10 @@ type processExecutor interface { Execute(ctx context.Context, spec processSpec) error } +type projectUsageRecorder interface { + Touch(ctx context.Context, projectRoot string, at time.Time) error +} + type osProcessExecutor struct{} func (osProcessExecutor) Execute(ctx context.Context, spec processSpec) error { @@ -51,6 +56,8 @@ type Runner struct { store configFinder settings mavenSettingsResolver executor processExecutor + usage projectUsageRecorder + now func() time.Time } // NewDefaultRunner creates a runner backed by the user project configuration store. @@ -63,12 +70,18 @@ func NewDefaultRunner() (*Runner, error) { if err != nil { return nil, err } - return NewRunnerWithMavenSettings(store, settings, osProcessExecutor{}), nil + usage, err := NewDefaultUsageStore() + if err != nil { + return nil, err + } + runner := NewRunnerWithMavenSettings(store, settings, osProcessExecutor{}) + runner.usage = usage + return runner, nil } // NewRunner creates a project command runner from replaceable services. func NewRunner(store configFinder, executor processExecutor) *Runner { - return &Runner{store: store, executor: executor} + return &Runner{store: store, executor: executor, now: time.Now} } // NewRunnerWithMavenSettings creates a runner that resolves saved Maven settings aliases. @@ -77,7 +90,7 @@ func NewRunnerWithMavenSettings( settings mavenSettingsResolver, executor processExecutor, ) *Runner { - return &Runner{store: store, settings: settings, executor: executor} + return &Runner{store: store, settings: settings, executor: executor, now: time.Now} } // Run executes the requested build tool saved for the project containing root. @@ -111,6 +124,11 @@ func (r *Runner) Run( if err != nil { return fmt.Errorf("resolve command directory: %w", err) } + if r.usage != nil { + if err := r.usage.Touch(ctx, config.ProjectRoot, r.now()); err != nil && streams.Stderr != nil { + _, _ = fmt.Fprintf(streams.Stderr, "jup: warning: update project usage: %v\n", err) + } + } err = r.executor.Execute(ctx, processSpec{ executable: executable, diff --git a/internal/project/runner_test.go b/internal/project/runner_test.go index e7bac7b..db502fb 100644 --- a/internal/project/runner_test.go +++ b/internal/project/runner_test.go @@ -9,6 +9,7 @@ import ( "runtime" "strings" "testing" + "time" "github.com/codeboyzhou/javaup/internal/buildtool" "github.com/codeboyzhou/javaup/internal/javainfo" @@ -32,6 +33,18 @@ type recordingProcessExecutor struct { err error } +type recordingUsageStore struct { + root string + at time.Time + err error +} + +func (s *recordingUsageStore) Touch(_ context.Context, root string, at time.Time) error { + s.root = root + s.at = at + return s.err +} + func (e *recordingProcessExecutor) Execute(_ context.Context, spec processSpec) error { e.spec = spec return e.err @@ -81,6 +94,33 @@ func TestRunnerRunsConfiguredMavenWithProjectJava(t *testing.T) { } } +func TestRunnerRecordsSelectedProjectBeforeExecution(t *testing.T) { + t.Parallel() + + root := t.TempDir() + mavenPath := filepath.Join(root, "maven", mavenExecutableName()) + writeRunnerExecutable(t, mavenPath) + usage := &recordingUsageStore{} + at := time.Date(2026, 7, 22, 12, 0, 0, 0, time.UTC) + runner := NewRunner(&fakeConfigFinder{ + found: true, + config: Config{ + ProjectRoot: root, + BuildTool: buildtool.Info{Type: buildtool.Maven, Version: "3.9.11", Executable: mavenPath}, + Java: javainfo.Installation{Version: "21", Home: filepath.Join(root, "jdk")}, + }, + }, &recordingProcessExecutor{}) + runner.usage = usage + runner.now = func() time.Time { return at } + + if err := runner.Run(context.Background(), root, buildtool.Maven, []string{"test"}, Streams{}); err != nil { + t.Fatalf("Run() error = %v", err) + } + if usage.root != root || !usage.at.Equal(at) { + t.Errorf("Touch() root/time = %q/%v, want %q/%v", usage.root, usage.at, root, at) + } +} + func TestRunnerUsesBuildToolSettingsAlias(t *testing.T) { t.Parallel() diff --git a/internal/project/store.go b/internal/project/store.go index 47f37aa..7fcb507 100644 --- a/internal/project/store.go +++ b/internal/project/store.go @@ -84,22 +84,12 @@ func (s *ConfigStore) Load(projectRoot string) (config Config, path string, foun return Config{}, "", false, err } path = filepath.Join(s.baseDir, configFileName(canonicalRoot)) - // #nosec G304 -- path is derived from the configured store directory and a hashed project identity. - content, err := os.ReadFile(path) + config, err = readProjectConfig(path) if err != nil { if errors.Is(err, os.ErrNotExist) { return Config{}, path, false, nil } - return Config{}, path, false, fmt.Errorf("read project configuration: %w", err) - } - if err := json.Unmarshal(content, &config); err != nil { - return Config{}, path, true, fmt.Errorf("decode project configuration %s: %w", path, err) - } - if config.SchemaVersion != currentSchemaVersion { - return Config{}, path, true, fmt.Errorf( - "project configuration schema %d is unsupported; run jup init again", - config.SchemaVersion, - ) + return Config{}, path, true, err } configuredRoot, err := canonicalProjectRoot(config.ProjectRoot) @@ -113,6 +103,71 @@ func (s *ConfigStore) Load(projectRoot string) (config Config, path string, foun return config, path, true, nil } +// List reads every valid project configuration. Invalid entries are returned as +// warnings so one stale project does not hide the rest of the catalog. +func (s *ConfigStore) List() (configs []Config, warnings []error, err error) { + entries, err := os.ReadDir(s.baseDir) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, nil, nil + } + return nil, nil, fmt.Errorf("list project configurations: %w", err) + } + + for _, entry := range entries { + if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" { + continue + } + path := filepath.Join(s.baseDir, entry.Name()) + config, readErr := readProjectConfig(path) + if readErr != nil { + warnings = append(warnings, readErr) + continue + } + canonicalRoot, rootErr := canonicalProjectRoot(config.ProjectRoot) + if rootErr != nil { + warnings = append(warnings, fmt.Errorf("resolve configured project root in %s: %w", path, rootErr)) + continue + } + if entry.Name() != configFileName(canonicalRoot) { + warnings = append(warnings, fmt.Errorf("project configuration filename does not match its root: %s", path)) + continue + } + info, statErr := os.Stat(canonicalRoot) + if statErr != nil { + warnings = append(warnings, fmt.Errorf("inspect configured project %s: %w", canonicalRoot, statErr)) + continue + } + if !info.IsDir() { + warnings = append(warnings, fmt.Errorf("configured project root is not a directory: %s", canonicalRoot)) + continue + } + config.ProjectRoot = canonicalRoot + configs = append(configs, config) + } + return configs, warnings, nil +} + +func readProjectConfig(path string) (Config, error) { + // #nosec G304 -- callers restrict paths to the configured store directory. + content, err := os.ReadFile(path) + if err != nil { + return Config{}, fmt.Errorf("read project configuration %s: %w", path, err) + } + var config Config + if err := json.Unmarshal(content, &config); err != nil { + return Config{}, fmt.Errorf("decode project configuration %s: %w", path, err) + } + if config.SchemaVersion != currentSchemaVersion { + return Config{}, fmt.Errorf( + "project configuration schema %d in %s is unsupported; run jup init again", + config.SchemaVersion, + path, + ) + } + return config, nil +} + // Find searches start and its parents for an initialized project. func (s *ConfigStore) Find(start string) (config Config, path string, found bool, err error) { directory, err := canonicalProjectRoot(start) @@ -149,13 +204,17 @@ func (s *ConfigStore) Delete(projectRoot string) (path string, removed bool, err } func configFileName(projectRoot string) string { + digest := sha256.Sum256([]byte(projectPathIdentity(projectRoot))) + hash := hex.EncodeToString(digest[:])[:12] + return sanitizeName(filepath.Base(projectRoot)) + "-" + hash + ".json" +} + +func projectPathIdentity(projectRoot string) string { identity := filepath.Clean(projectRoot) if runtime.GOOS == "windows" { identity = strings.ToLower(identity) } - digest := sha256.Sum256([]byte(identity)) - hash := hex.EncodeToString(digest[:])[:12] - return sanitizeName(filepath.Base(projectRoot)) + "-" + hash + ".json" + return identity } func sanitizeName(value string) string { diff --git a/internal/project/store_test.go b/internal/project/store_test.go index 5ae46a5..8f2d137 100644 --- a/internal/project/store_test.go +++ b/internal/project/store_test.go @@ -190,3 +190,46 @@ func TestConfigStoreRejectsOutdatedConfiguration(t *testing.T) { t.Errorf("Load() path/found = %q/%t, want %q/true", gotPath, found, path) } } + +func TestConfigStoreListsValidProjectsAndReportsStaleEntries(t *testing.T) { + t.Parallel() + + baseDir := filepath.Join(t.TempDir(), "projects") + store := NewConfigStore(baseDir) + validRoot := t.TempDir() + valid := Config{SchemaVersion: currentSchemaVersion, ProjectRoot: validRoot} + if _, err := store.Save(valid); err != nil { + t.Fatalf("Save(valid) error = %v", err) + } + missingRoot := filepath.Join(t.TempDir(), "missing") + if _, err := store.Save(Config{SchemaVersion: currentSchemaVersion, ProjectRoot: missingRoot}); err != nil { + t.Fatalf("Save(missing) error = %v", err) + } + if err := os.WriteFile(filepath.Join(baseDir, "broken.json"), []byte("{"), 0o600); err != nil { + t.Fatalf("WriteFile(broken) error = %v", err) + } + + configs, warnings, err := store.List() + if err != nil { + t.Fatalf("List() error = %v", err) + } + if len(configs) != 1 || !samePath(configs[0].ProjectRoot, validRoot) { + t.Errorf("List() configs = %#v, want only %s", configs, validRoot) + } + if len(warnings) != 2 { + t.Errorf("List() warnings = %d, want 2", len(warnings)) + } +} + +func TestConfigStoreListReturnsEmptyWhenDirectoryDoesNotExist(t *testing.T) { + t.Parallel() + + store := NewConfigStore(filepath.Join(t.TempDir(), "missing")) + configs, warnings, err := store.List() + if err != nil { + t.Fatalf("List() error = %v", err) + } + if len(configs) != 0 || len(warnings) != 0 { + t.Errorf("List() configs/warnings = %d/%d, want 0/0", len(configs), len(warnings)) + } +} diff --git a/internal/project/uninitializer.go b/internal/project/uninitializer.go index 8a77816..eb75c58 100644 --- a/internal/project/uninitializer.go +++ b/internal/project/uninitializer.go @@ -12,9 +12,14 @@ type configRemover interface { Delete(projectRoot string) (path string, removed bool, err error) } +type usageRemover interface { + Delete(ctx context.Context, projectRoot string) error +} + // Uninitializer removes a project's persisted local configuration. type Uninitializer struct { store configRemover + usage usageRemover } // NewDefaultUninitializer wires the platform-specific project configuration store. @@ -23,7 +28,13 @@ func NewDefaultUninitializer() (*Uninitializer, error) { if err != nil { return nil, err } - return NewUninitializer(store), nil + usage, err := NewDefaultUsageStore() + if err != nil { + return nil, err + } + uninitializer := NewUninitializer(store) + uninitializer.usage = usage + return uninitializer, nil } // NewUninitializer creates an uninitializer from a replaceable configuration store. @@ -77,6 +88,12 @@ func (u *Uninitializer) Uninitialize( reportUninitFailure(progress, 2, configStepName, err) return path, false, err } + if removed && u.usage != nil { + if err := u.usage.Delete(ctx, projectRoot); err != nil { + reportUninitFailure(progress, 2, configStepName, err) + return path, true, err + } + } message := path if !removed { message = "No saved configuration found" diff --git a/internal/project/uninitializer_test.go b/internal/project/uninitializer_test.go index 5f567fb..f3f1c77 100644 --- a/internal/project/uninitializer_test.go +++ b/internal/project/uninitializer_test.go @@ -16,6 +16,16 @@ type fakeConfigRemover struct { removed bool } +type recordingUsageRemover struct { + root string + err error +} + +func (r *recordingUsageRemover) Delete(_ context.Context, root string) error { + r.root = root + return r.err +} + func (r *fakeConfigRemover) Find(start string) (Config, string, bool, error) { r.start = start return r.config, r.path, r.found, nil @@ -37,6 +47,8 @@ func TestUninitializerRemovesCurrentProjectConfiguration(t *testing.T) { removed: true, } uninitializer := NewUninitializer(store) + usage := &recordingUsageRemover{} + uninitializer.usage = usage var events []ProgressEvent path, removed, err := uninitializer.Uninitialize(context.Background(), root, func(event ProgressEvent) { @@ -61,6 +73,9 @@ func TestUninitializerRemovesCurrentProjectConfiguration(t *testing.T) { if store.start != wantRoot { t.Errorf("Find() start = %q, want %q", store.start, wantRoot) } + if usage.root != wantRoot { + t.Errorf("usage Delete() root = %q, want %q", usage.root, wantRoot) + } wantEvents := []ProgressEvent{ {Step: 1, Total: 2, Name: projectStepName, Message: "Resolving current project directory", State: ProgressStarted}, diff --git a/internal/project/usage.go b/internal/project/usage.go new file mode 100644 index 0000000..5357b24 --- /dev/null +++ b/internal/project/usage.go @@ -0,0 +1,265 @@ +package project + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "math" + "os" + "path/filepath" + "sort" + "time" + + "github.com/codeboyzhou/javaup/internal/apphome" + "github.com/codeboyzhou/javaup/internal/buildtool" + "github.com/gofrs/flock" +) + +const ( + usageSchemaVersion = 1 + usageHalfLife = 14 * 24 * time.Hour +) + +// Usage describes how often and how recently a project has been built. +type Usage struct { + ProjectRoot string `json:"projectRoot"` + LastUsedAt time.Time `json:"lastUsedAt"` + UseCount uint64 `json:"useCount"` + Score float64 `json:"score"` +} + +type usageRegistry struct { + SchemaVersion int `json:"schemaVersion"` + Projects map[string]Usage `json:"projects"` +} + +// UsageStore persists project build usage independently from toolchain configuration. +type UsageStore struct { + path string +} + +// NewDefaultUsageStore uses the configured javaup application directory. +func NewDefaultUsageStore() (*UsageStore, error) { + home, err := apphome.Resolve() + if err != nil { + return nil, err + } + return NewUsageStore(filepath.Join(home, "state", "project-usage.json")), nil +} + +// NewUsageStore creates a usage store at path. +func NewUsageStore(path string) *UsageStore { + return &UsageStore{path: path} +} + +// Load returns all saved project usage records. +func (s *UsageStore) Load() (map[string]Usage, error) { + registry, err := s.loadRegistry() + if err != nil { + return nil, err + } + return registry.Projects, nil +} + +// Touch records one build attempt for projectRoot using a decaying frequency score. +func (s *UsageStore) Touch(ctx context.Context, projectRoot string, at time.Time) error { + canonicalRoot, err := canonicalProjectRoot(projectRoot) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil { + return fmt.Errorf("create project usage directory: %w", err) + } + lock := flock.New(s.path + ".lock") + locked, err := lock.TryLockContext(ctx, 25*time.Millisecond) + if err != nil { + return fmt.Errorf("lock project usage: %w", err) + } + if !locked { + return fmt.Errorf("lock project usage: canceled") + } + defer func() { _ = lock.Unlock() }() + + registry, err := s.loadRegistry() + if err != nil { + return err + } + key := projectPathIdentity(canonicalRoot) + usage := registry.Projects[key] + usage.Score = decayedUsageScore(usage, at) + 1 + usage.ProjectRoot = canonicalRoot + usage.LastUsedAt = at.Truncate(time.Second) + usage.UseCount++ + registry.Projects[key] = usage + return s.saveRegistry(registry) +} + +// Delete removes the usage record for projectRoot. +func (s *UsageStore) Delete(ctx context.Context, projectRoot string) error { + canonicalRoot, err := canonicalProjectRoot(projectRoot) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil { + return fmt.Errorf("create project usage directory: %w", err) + } + lock := flock.New(s.path + ".lock") + locked, err := lock.TryLockContext(ctx, 25*time.Millisecond) + if err != nil { + return fmt.Errorf("lock project usage: %w", err) + } + if !locked { + return fmt.Errorf("lock project usage: canceled") + } + defer func() { _ = lock.Unlock() }() + + registry, err := s.loadRegistry() + if err != nil { + return err + } + delete(registry.Projects, projectPathIdentity(canonicalRoot)) + if len(registry.Projects) == 0 { + if err := os.Remove(s.path); err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("remove project usage: %w", err) + } + return nil + } + return s.saveRegistry(registry) +} + +func (s *UsageStore) loadRegistry() (usageRegistry, error) { + registry := usageRegistry{SchemaVersion: usageSchemaVersion, Projects: make(map[string]Usage)} + // #nosec G304 -- path is fixed when the store is created. + content, err := os.ReadFile(s.path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return registry, nil + } + return usageRegistry{}, fmt.Errorf("read project usage: %w", err) + } + if err := json.Unmarshal(content, ®istry); err != nil { + return usageRegistry{}, fmt.Errorf("decode project usage %s: %w", s.path, err) + } + if registry.SchemaVersion != usageSchemaVersion { + return usageRegistry{}, fmt.Errorf("project usage schema %d is unsupported", registry.SchemaVersion) + } + if registry.Projects == nil { + registry.Projects = make(map[string]Usage) + } + return registry, nil +} + +func (s *UsageStore) saveRegistry(registry usageRegistry) error { + temporary, err := os.CreateTemp(filepath.Dir(s.path), ".project-usage-*.tmp") + if err != nil { + return fmt.Errorf("create temporary project usage: %w", err) + } + temporaryPath := temporary.Name() + defer func() { _ = os.Remove(temporaryPath) }() + + encoder := json.NewEncoder(temporary) + encoder.SetIndent("", " ") + encoder.SetEscapeHTML(false) + if err := encoder.Encode(registry); err != nil { + _ = temporary.Close() + return fmt.Errorf("encode project usage: %w", err) + } + if err := temporary.Sync(); err != nil { + _ = temporary.Close() + return fmt.Errorf("sync project usage: %w", err) + } + if err := temporary.Close(); err != nil { + return fmt.Errorf("close project usage: %w", err) + } + if err := os.Rename(temporaryPath, s.path); err != nil { + return fmt.Errorf("save project usage: %w", err) + } + return nil +} + +func decayedUsageScore(usage Usage, at time.Time) float64 { + if usage.Score <= 0 || usage.LastUsedAt.IsZero() { + return 0 + } + elapsed := at.Sub(usage.LastUsedAt) + if elapsed < 0 { + elapsed = 0 + } + return usage.Score * math.Pow(0.5, float64(elapsed)/float64(usageHalfLife)) +} + +// Candidate is one configured project available to the interactive selector. +type Candidate struct { + Name string + ProjectRoot string + Config Config + Usage Usage + Rank float64 +} + +// Catalog combines saved project configurations with their usage ranking. +type Catalog struct { + configs *ConfigStore + usage *UsageStore + now func() time.Time +} + +// NewDefaultCatalog creates a catalog backed by javaup's application directory. +func NewDefaultCatalog() (*Catalog, error) { + configs, err := NewDefaultConfigStore() + if err != nil { + return nil, err + } + usage, err := NewDefaultUsageStore() + if err != nil { + return nil, err + } + return NewCatalog(configs, usage), nil +} + +// NewCatalog creates a project catalog from replaceable stores. +func NewCatalog(configs *ConfigStore, usage *UsageStore) *Catalog { + return &Catalog{configs: configs, usage: usage, now: time.Now} +} + +// List returns configured projects for tool ordered by recent-frequency rank. +func (c *Catalog) List(tool buildtool.Type) ([]Candidate, []error, error) { + configs, warnings, err := c.configs.List() + if err != nil { + return nil, nil, err + } + usage, usageErr := c.usage.Load() + if usageErr != nil { + warnings = append(warnings, usageErr) + usage = make(map[string]Usage) + } + now := c.now() + candidates := make([]Candidate, 0, len(configs)) + for _, config := range configs { + if config.BuildTool.Type != tool { + continue + } + record := usage[projectPathIdentity(config.ProjectRoot)] + candidates = append(candidates, Candidate{ + Name: filepath.Base(config.ProjectRoot), + ProjectRoot: config.ProjectRoot, + Config: config, + Usage: record, + Rank: decayedUsageScore(record, now), + }) + } + sort.SliceStable(candidates, func(left, right int) bool { + if candidates[left].Rank != candidates[right].Rank { + return candidates[left].Rank > candidates[right].Rank + } + if !candidates[left].Usage.LastUsedAt.Equal(candidates[right].Usage.LastUsedAt) { + return candidates[left].Usage.LastUsedAt.After(candidates[right].Usage.LastUsedAt) + } + if candidates[left].Name != candidates[right].Name { + return candidates[left].Name < candidates[right].Name + } + return candidates[left].ProjectRoot < candidates[right].ProjectRoot + }) + return candidates, warnings, nil +} diff --git a/internal/project/usage_test.go b/internal/project/usage_test.go new file mode 100644 index 0000000..3a017f0 --- /dev/null +++ b/internal/project/usage_test.go @@ -0,0 +1,115 @@ +package project + +import ( + "context" + "math" + "os" + "path/filepath" + "testing" + "time" + + "github.com/codeboyzhou/javaup/internal/buildtool" +) + +func TestUsageStoreAppliesFourteenDayHalfLife(t *testing.T) { + t.Parallel() + + root := t.TempDir() + store := NewUsageStore(filepath.Join(t.TempDir(), "state", "project-usage.json")) + started := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC) + if err := store.Touch(context.Background(), root, started); err != nil { + t.Fatalf("Touch(first) error = %v", err) + } + if err := store.Touch(context.Background(), root, started.Add(usageHalfLife)); err != nil { + t.Fatalf("Touch(second) error = %v", err) + } + + usage, err := store.Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + canonicalRoot, err := canonicalProjectRoot(root) + if err != nil { + t.Fatalf("canonicalProjectRoot() error = %v", err) + } + record := usage[projectPathIdentity(canonicalRoot)] + if record.UseCount != 2 { + t.Errorf("UseCount = %d, want 2", record.UseCount) + } + if math.Abs(record.Score-1.5) > 0.000001 { + t.Errorf("Score = %f, want 1.5", record.Score) + } +} + +func TestCatalogRanksByDecayedFrequencyAndFiltersBuildTool(t *testing.T) { + t.Parallel() + + baseDir := filepath.Join(t.TempDir(), "projects") + configs := NewConfigStore(baseDir) + recentRoot := filepath.Join(t.TempDir(), "same-name") + frequentRoot := filepath.Join(t.TempDir(), "same-name") + otherRoot := filepath.Join(t.TempDir(), "other") + for _, root := range []string{recentRoot, frequentRoot, otherRoot} { + if err := os.Mkdir(root, 0o750); err != nil { + t.Fatalf("Mkdir(%s) error = %v", root, err) + } + tool := buildtool.Maven + if root == otherRoot { + tool = buildtool.Type("gradle") + } + if _, err := configs.Save(Config{ + SchemaVersion: currentSchemaVersion, + ProjectRoot: root, + BuildTool: buildtool.Info{Type: tool}, + }); err != nil { + t.Fatalf("Save(%s) error = %v", root, err) + } + } + + usage := NewUsageStore(filepath.Join(t.TempDir(), "state", "project-usage.json")) + now := time.Date(2026, 7, 22, 12, 0, 0, 0, time.UTC) + if err := usage.Touch(context.Background(), recentRoot, now.Add(-time.Hour)); err != nil { + t.Fatalf("Touch(recent) error = %v", err) + } + for range 3 { + if err := usage.Touch(context.Background(), frequentRoot, now.Add(-7*24*time.Hour)); err != nil { + t.Fatalf("Touch(frequent) error = %v", err) + } + } + + catalog := NewCatalog(configs, usage) + catalog.now = func() time.Time { return now } + candidates, warnings, err := catalog.List(buildtool.Maven) + if err != nil { + t.Fatalf("List() error = %v", err) + } + if len(warnings) != 0 { + t.Fatalf("List() warnings = %v", warnings) + } + if len(candidates) != 2 { + t.Fatalf("List() candidates = %d, want 2", len(candidates)) + } + if !samePath(candidates[0].ProjectRoot, frequentRoot) || !samePath(candidates[1].ProjectRoot, recentRoot) { + t.Errorf("List() order = %q, %q; want frequent then recent", candidates[0].ProjectRoot, candidates[1].ProjectRoot) + } + if candidates[0].Name != candidates[1].Name { + t.Errorf("candidate names = %q/%q, want duplicate display names", candidates[0].Name, candidates[1].Name) + } +} + +func TestUsageStoreDeleteRemovesProjectRecord(t *testing.T) { + t.Parallel() + + root := t.TempDir() + path := filepath.Join(t.TempDir(), "state", "project-usage.json") + store := NewUsageStore(path) + if err := store.Touch(context.Background(), root, time.Now()); err != nil { + t.Fatalf("Touch() error = %v", err) + } + if err := store.Delete(context.Background(), root); err != nil { + t.Fatalf("Delete() error = %v", err) + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Errorf("Stat() error = %v, want usage file removed", err) + } +}