From 9dcb3f3a491ee97bb28d488e67c0c8335b3ed5b2 Mon Sep 17 00:00:00 2001 From: "Moor.Zhou" Date: Tue, 18 Aug 2026 09:26:49 +0800 Subject: [PATCH] feat: import traditional runtime configs for v1.1.0 --- .github/workflows/release.yml | 6 +- CONTRIBUTING.md | 2 +- Cargo.lock | 92 +- Cargo.toml | 4 +- README.md | 14 +- README.zh-CN.md | 14 +- crates/pinset-core/Cargo.toml | 3 + crates/pinset-core/src/config.rs | 44 + crates/pinset-core/src/lib.rs | 8 + crates/pinset-core/src/project_discovery.rs | 1421 ++++++++++++++++++ crates/pinset/Cargo.toml | 2 +- crates/pinset/src/i18n.rs | 10 +- crates/pinset/src/main.rs | 455 +++++- crates/pinset/tests/discovery_cli.rs | 186 +++ crates/pinset/tests/flutter_cli.rs | 14 - crates/pinset/tests/mvp_cli.rs | 20 +- docs/commands.md | 30 +- docs/commands.zh-CN.md | 30 +- install.sh | 4 +- scripts/tests/ubuntu_runtime_acceptance.sh | 29 + scripts/tests/windows_runtime_acceptance.ps1 | 46 + 21 files changed, 2373 insertions(+), 61 deletions(-) create mode 100644 crates/pinset-core/src/project_discovery.rs create mode 100644 crates/pinset/tests/discovery_cli.rs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4d0c075..c7be2b9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -58,10 +58,10 @@ jobs: run: cargo fmt --all -- --check - name: Run Clippy - run: cargo clippy --workspace --all-targets --all-features -- -D warnings + run: cargo clippy --workspace --all-targets --all-features --locked -- -D warnings - name: Run Rust tests - run: cargo test --workspace --all-features + run: cargo test --workspace --all-features --locked - name: Test curl installer without network or runtime installation run: sh scripts/tests/install_sh_test.sh @@ -72,7 +72,7 @@ jobs: - name: Keep the shim dependency graph lightweight shell: bash run: | - cargo tree -p pinset-shim -e normal > shim-dependencies.txt + cargo tree -p pinset-shim -e normal --locked > shim-dependencies.txt if grep -E 'reqwest|rustls|tar v|xz2|zip v|tempfile' shim-dependencies.txt; then echo "pinset-shim unexpectedly includes download or archive dependencies" >&2 exit 1 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 53f5fda..2e15b0f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,6 @@ # Contributing to Pinset -感谢你帮助改进 Pinset。项目当前开发版本支持 Node、pnpm、Bun、Go、Flutter、Python、Java 与 Rust Provider,优先保证跨平台行为、安全边界和可复现安装。 +感谢你帮助改进 Pinset。项目当前开发版本支持 Node、pnpm、Bun、Go、Flutter、Python、Java、Rust 与 .NET Provider,优先保证跨平台行为、安全边界和可复现安装。 ## 开发环境 diff --git a/Cargo.lock b/Cargo.lock index f4bd31e..cf77046 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1586,6 +1586,17 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "json5" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96b0db21af676c1ce64250b5f40f3ce2cf27e4e47cb91ed91eb6fe9350b430c1" +dependencies = [ + "pest", + "pest_derive", + "serde", +] + [[package]] name = "k256" version = "0.13.4" @@ -1891,6 +1902,48 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pest" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a07a60cc7a4d00c91f95c685609d1d2f79050e6804b70ebedd7650f0b839bcf" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3a83744a5c8455b8b3e0dc5031362780a347c878bdd11584d1a8984228cc88d" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0cd3451aa3de60d4b9a1e736885e4dea6b31617598026f12256ad566d63304a" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pest_meta" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e04d3a0849e241d7dfce834c83b1c5edc8622009e8dd51a12ba1927c32f05496" +dependencies = [ + "pest", +] + [[package]] name = "pgp" version = "0.17.0" @@ -1968,7 +2021,7 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pinset-cli" -version = "1.0.0" +version = "1.1.0" dependencies = [ "clap", "hex", @@ -1984,13 +2037,14 @@ dependencies = [ [[package]] name = "pinset-core" -version = "1.0.0" +version = "1.1.0" dependencies = [ "atomic-write-file", "base64", "flate2", "fs4", "hex", + "json5", "p256", "pgp", "reqwest", @@ -1998,6 +2052,7 @@ dependencies = [ "semver", "serde", "serde_json", + "serde_yaml", "sha2 0.11.0", "tar", "tempfile", @@ -2010,7 +2065,7 @@ dependencies = [ [[package]] name = "pinset-shim" -version = "1.0.0" +version = "1.1.0" dependencies = [ "pinset-core", "tempfile", @@ -2503,6 +2558,12 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "same-file" version = "1.0.6" @@ -2617,6 +2678,19 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "serdect" version = "0.2.0" @@ -3141,6 +3215,12 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -3175,6 +3255,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "untrusted" version = "0.9.0" diff --git a/Cargo.toml b/Cargo.toml index f7fdf13..e561929 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,7 @@ resolver = "2" [workspace.package] edition = "2024" rust-version = "1.85" -version = "1.0.0" +version = "1.1.0" authors = ["Future Element"] [workspace.dependencies] @@ -18,6 +18,7 @@ base64 = "0.22" clap = { version = "4.5", features = ["derive"] } flate2 = "1" hex = "0.4" +json5 = "0.4" fs4 = "1.1.0" p256 = { version = "0.13", features = ["ecdsa", "pem"] } pgp = { version = "0.17.0", default-features = false } @@ -25,6 +26,7 @@ reqwest = { version = "0.13.4", default-features = false, features = ["blocking" semver = "1" serde = { version = "1", features = ["derive"] } serde_json = "1" +serde_yaml = "0.9" same-file = "1" sha2 = "0.11" tar = "0.4" diff --git a/README.md b/README.md index 7601c4a..3d365f5 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Pinset is a predictable, project-aware runtime version manager for polyglot development. -It manages Node.js, pnpm, Bun, Go, Python, Java, Rust, .NET, and Flutter/Dart through one configuration and lockfile model, without importing state from other runtime managers. +It manages Node.js, pnpm, Bun, Go, Python, Java, Rust, .NET, and Flutter/Dart through one configuration and lockfile model. Traditional version files are read only by the explicit `detect` and `import` migration commands, never as an implicit runtime fallback. ## Highlights @@ -19,6 +19,7 @@ It manages Node.js, pnpm, Bun, Go, Python, Java, Rust, .NET, and Flutter/Dart th - Provider integrity checks, safe extraction, atomic installs, ownership-aware uninstall, and a content-addressed download cache. - First-class English and Simplified Chinese output, JSON schema 1 for automation, and shell completion. - Project-owned Python `.venv` support without requiring shell activation. +- Read-only detection and explicit import of repository-local traditional version files. ## Install and upgrade @@ -39,7 +40,7 @@ export PATH="$HOME/.local/bin:$PATH" Run the same installer again to upgrade. To install an exact release or another absolute directory: ```sh -curl -fsSL https://raw.githubusercontent.com/Future-Element/pinset/main/install.sh | sh -s -- --version 1.0.0 +curl -fsSL https://raw.githubusercontent.com/Future-Element/pinset/main/install.sh | sh -s -- --version 1.1.0 PINSET_INSTALL_DIR=/opt/pinset/bin sh install.sh ``` @@ -128,6 +129,15 @@ pinset exec -- node --version Commit `pinset.toml` and `pinset.lock`. Selectors such as `latest`, `lts`, `stable`, or a version prefix are resolved to exact versions in the lockfile. +To migrate an existing repository, inspect traditional files locally first, then import and install their unambiguous selections: + +```sh +pinset detect --json +pinset import +``` + +`detect` never uses the network or writes files. `import --no-install` writes the Pinset config and exact lock without downloading runtimes. Pinset scans from the working directory to the nearest Git repository root and does not modify or delete the source files. + Discover available and installed versions: ```sh diff --git a/README.zh-CN.md b/README.zh-CN.md index 33f8c0f..cb6c5d0 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -8,7 +8,7 @@ Pinset 是一个行为可预测、理解项目边界的多语言运行时版本管理器。 -它通过统一的配置与锁文件模型管理 Node.js、pnpm、Bun、Go、Python、Java、Rust、.NET 和 Flutter/Dart,并且不会导入其他运行时管理器的状态。 +它通过统一的配置与锁文件模型管理 Node.js、pnpm、Bun、Go、Python、Java、Rust、.NET 和 Flutter/Dart。传统版本文件只会由显式的 `detect` 与 `import` 迁移命令读取,不会成为日常运行时解析的隐式回退来源。 ## 核心特性 @@ -19,6 +19,7 @@ Pinset 是一个行为可预测、理解项目边界的多语言运行时版本 - Provider 完整性校验、安全解压、原子安装、带所有权检查的卸载,以及内容寻址下载缓存。 - 原生支持英文和简体中文输出、自动化用 JSON schema 1 与 Shell 补全。 - 支持项目所有的 Python `.venv`,无需激活 Shell 环境。 +- 支持只读检测和显式导入仓库内的传统版本配置。 ## 安装与升级 @@ -39,7 +40,7 @@ export PATH="$HOME/.local/bin:$PATH" 再次运行同一安装脚本即可升级。也可以安装指定版本或使用其他绝对目录: ```sh -curl -fsSL https://raw.githubusercontent.com/Future-Element/pinset/main/install.sh | sh -s -- --version 1.0.0 +curl -fsSL https://raw.githubusercontent.com/Future-Element/pinset/main/install.sh | sh -s -- --version 1.1.0 PINSET_INSTALL_DIR=/opt/pinset/bin sh install.sh ``` @@ -128,6 +129,15 @@ pinset exec -- node --version 请提交 `pinset.toml` 和 `pinset.lock`。`latest`、`lts`、`stable` 或版本前缀等选择器会在锁文件中解析为精确版本。 +迁移现有仓库时,先在本地检查传统配置,再导入并安装其中无歧义的选择: + +```sh +pinset detect --json +pinset import +``` + +`detect` 不联网、也不写文件。`import --no-install` 会写入 Pinset 配置和精确锁,但不下载运行时。扫描范围从工作目录到最近的 Git 仓库根目录,并且不会修改或删除来源文件。 + 查看可用版本与已安装版本: ```sh diff --git a/crates/pinset-core/Cargo.toml b/crates/pinset-core/Cargo.toml index 0d0483c..5b5bc2b 100644 --- a/crates/pinset-core/Cargo.toml +++ b/crates/pinset-core/Cargo.toml @@ -44,6 +44,7 @@ npm-metadata = [ "dep:serde_json", ] project-write = ["dep:atomic-write-file", "dep:tempfile"] +project-discovery = ["dep:json5", "dep:serde_json", "dep:serde_yaml"] state-write = ["dep:atomic-write-file"] sources = [ "dep:atomic-write-file", @@ -55,6 +56,7 @@ atomic-write-file = { workspace = true, optional = true } base64 = { workspace = true, optional = true } flate2 = { workspace = true, optional = true } hex = { workspace = true, optional = true } +json5 = { workspace = true, optional = true } fs4 = { workspace = true, optional = true } reqwest = { workspace = true, optional = true } p256 = { workspace = true, optional = true } @@ -63,6 +65,7 @@ same-file.workspace = true semver = { workspace = true, optional = true } serde.workspace = true serde_json = { workspace = true, optional = true } +serde_yaml = { workspace = true, optional = true } sha2 = { workspace = true, optional = true } tar = { workspace = true, optional = true } tempfile = { workspace = true, optional = true } diff --git a/crates/pinset-core/src/config.rs b/crates/pinset-core/src/config.rs index 4d67b25..1cd02fc 100644 --- a/crates/pinset-core/src/config.rs +++ b/crates/pinset-core/src/config.rs @@ -6,6 +6,8 @@ use std::{ #[cfg(feature = "project-write")] use std::io::Write; +#[cfg(all(feature = "project-write", feature = "lockfile"))] +use std::sync::Mutex; #[cfg(feature = "project-write")] use atomic_write_file::AtomicWriteFile; @@ -13,10 +15,15 @@ use serde::{Deserialize, Serialize}; use crate::{Error, Result}; +#[cfg(all(feature = "project-write", feature = "lockfile"))] +use crate::{Lockfile, lockfile_path, save_lockfile, validate_lock_matches_tools}; + pub const PROJECT_CONFIG_FILENAME: &str = "pinset.toml"; pub const PROJECT_CONFIG_SCHEMA: u32 = 2; #[cfg(feature = "project-write")] const MINIMAL_PROJECT_CONFIG: &[u8] = b"schema = 2\n\n[tools]\n"; +#[cfg(all(feature = "project-write", feature = "lockfile"))] +static PROJECT_STATE_WRITE_LOCK: Mutex<()> = Mutex::new(()); #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] @@ -136,6 +143,19 @@ pub fn save_project_config(path: &Path, config: &ProjectConfig) -> Result<()> { }) } +#[cfg(all(feature = "project-write", feature = "lockfile"))] +pub fn save_project_state(path: &Path, config: &ProjectConfig, lockfile: &Lockfile) -> Result<()> { + validate_lock_matches_tools(lockfile, &config.tools, path)?; + let _guard = PROJECT_STATE_WRITE_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + + // Commit the lock first. If the second atomic write is interrupted, the previous + // selection remains active and lock-dependent operations fail until this is retried. + save_lockfile(&lockfile_path(path), lockfile)?; + save_project_config(path, config) +} + #[cfg(test)] mod tests { use std::fs; @@ -286,4 +306,28 @@ mod tests { Some("24.0.0") ); } + + #[cfg(all(feature = "project-write", feature = "lockfile"))] + #[test] + fn saves_a_validated_project_config_and_lock_pair() { + let root = tempdir().expect("project"); + let config_path = root.path().join(PROJECT_CONFIG_FILENAME); + let config = ProjectConfig { + schema: PROJECT_CONFIG_SCHEMA, + tools: BTreeMap::new(), + }; + let lockfile = Lockfile { + schema: crate::LOCKFILE_SCHEMA, + generated_by: "pinset test".to_owned(), + tools: Vec::new(), + }; + + save_project_state(&config_path, &config, &lockfile).expect("save project state"); + + assert_eq!(load_project_config(&config_path).expect("config"), config); + assert_eq!( + crate::load_lockfile(&lockfile_path(&config_path)).expect("lock"), + lockfile + ); + } } diff --git a/crates/pinset-core/src/lib.rs b/crates/pinset-core/src/lib.rs index eba1555..a346e02 100644 --- a/crates/pinset-core/src/lib.rs +++ b/crates/pinset-core/src/lib.rs @@ -55,6 +55,8 @@ mod node_trust; mod npm_metadata; #[cfg(all(feature = "installer", feature = "npm-metadata"))] mod npm_runtime; +#[cfg(feature = "project-discovery")] +mod project_discovery; #[cfg(feature = "python-metadata")] mod python_metadata; #[cfg(feature = "python-provider")] @@ -81,6 +83,8 @@ mod source_config; mod target; mod user_settings; +#[cfg(all(feature = "project-write", feature = "lockfile"))] +pub use config::save_project_state; pub use config::{ PROJECT_CONFIG_FILENAME, PROJECT_CONFIG_SCHEMA, ProjectConfig, find_optional_project_config, find_project_config, load_project_config, @@ -181,6 +185,10 @@ pub use npm_metadata::{ }; #[cfg(all(feature = "installer", feature = "npm-metadata"))] pub use npm_runtime::install_locked_npm_tool; +#[cfg(feature = "project-discovery")] +pub use project_discovery::{ + DiscoveryFinding, DiscoveryKind, DiscoveryReport, DiscoveryStatus, scan_project_sources, +}; #[cfg(feature = "python-metadata")] pub use python_metadata::{PythonMetadataClient, PythonRelease}; #[cfg(feature = "python-provider")] diff --git a/crates/pinset-core/src/project_discovery.rs b/crates/pinset-core/src/project_discovery.rs new file mode 100644 index 0000000..7a796d0 --- /dev/null +++ b/crates/pinset-core/src/project_discovery.rs @@ -0,0 +1,1421 @@ +use std::{ + collections::{BTreeMap, BTreeSet, HashSet}, + fs, + io::{self, ErrorKind}, + path::{Path, PathBuf}, +}; + +use serde::Serialize; + +use crate::{PROJECT_CONFIG_FILENAME, runtime_provider}; + +const MAX_SOURCE_BYTES: u64 = 1024 * 1024; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum DiscoveryKind { + Selection, + Constraint, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum DiscoveryStatus { + Ready, + Informational, + Ignored, + Unsupported, + Conflict, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct DiscoveryFinding { + pub tool: String, + pub source: String, + pub field: Option, + pub raw: String, + pub normalized: Option, + pub kind: DiscoveryKind, + pub status: DiscoveryStatus, + pub reason: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct DiscoveryReport { + pub start: PathBuf, + pub boundary: PathBuf, + pub target_config: PathBuf, + pub can_import: bool, + pub findings: Vec, +} + +pub fn scan_project_sources(start: &Path) -> io::Result { + let start = if start.is_file() { + start.parent().unwrap_or(start) + } else { + start + }; + let start = if start.is_absolute() { + start.to_path_buf() + } else { + std::env::current_dir()?.join(start) + }; + if !start.is_dir() { + return Err(io::Error::new( + ErrorKind::InvalidInput, + format!("discovery start is not a directory: {}", start.display()), + )); + } + + let ancestors = start.ancestors().map(Path::to_path_buf).collect::>(); + let mut boundary_index = None; + for (index, directory) in ancestors.iter().enumerate() { + if git_marker(directory)? { + boundary_index = Some(index); + break; + } + } + let directories = if let Some(index) = boundary_index { + ancestors[..=index].to_vec() + } else { + vec![start.clone()] + }; + let boundary = directories + .last() + .expect("discovery always scans at least the start directory") + .clone(); + let target_config = directories + .iter() + .map(|directory| directory.join(PROJECT_CONFIG_FILENAME)) + .find(|candidate| source_present(candidate)) + .unwrap_or_else(|| start.join(PROJECT_CONFIG_FILENAME)); + + let mut scanner = Scanner::new(boundary.clone()); + for directory in &directories { + scanner.scan_simple("nvmrc", &directory.join(".nvmrc"), "node", normalize_node); + scanner.scan_simple( + "node-version", + &directory.join(".node-version"), + "node", + normalize_node, + ); + scanner.scan_simple( + "bun-version", + &directory.join(".bun-version"), + "bun", + normalize_bun, + ); + scanner.scan_simple( + "go-version", + &directory.join(".go-version"), + "go", + normalize_go, + ); + scanner.scan_python_version(directory); + scanner.scan_simple( + "java-version", + &directory.join(".java-version"), + "java", + normalize_java, + ); + scanner.scan_package_json(directory); + scanner.scan_go_file(directory, "go.mod"); + scanner.scan_go_file(directory, "go.work"); + scanner.scan_fvm(directory); + scanner.scan_sdkman(directory); + scanner.scan_rust(directory); + scanner.scan_global_json(directory); + scanner.scan_tool_versions(directory); + scanner.scan_mise(directory); + scanner.scan_pyproject(directory); + scanner.scan_cargo(directory); + scanner.scan_pubspec(directory); + } + + scanner.finish(start, boundary, target_config) +} + +fn git_marker(directory: &Path) -> io::Result { + match fs::metadata(directory.join(".git")) { + Ok(metadata) => Ok(metadata.is_file() || metadata.is_dir()), + Err(error) if error.kind() == ErrorKind::NotFound => Ok(false), + Err(error) => Err(error), + } +} + +struct Scanner { + boundary: PathBuf, + seen: HashSet<&'static str>, + findings: Vec, +} + +impl Scanner { + fn new(boundary: PathBuf) -> Self { + Self { + boundary, + seen: HashSet::new(), + findings: Vec::new(), + } + } + + fn finish( + mut self, + start: PathBuf, + boundary: PathBuf, + target_config: PathBuf, + ) -> io::Result { + let mut normalized_by_tool = BTreeMap::>::new(); + for finding in &self.findings { + if finding.status == DiscoveryStatus::Ready { + if let Some(normalized) = &finding.normalized { + normalized_by_tool + .entry(finding.tool.clone()) + .or_default() + .insert(normalized.clone()); + } + } + } + let conflicts = normalized_by_tool + .into_iter() + .filter_map(|(tool, values)| (values.len() > 1).then_some(tool)) + .collect::>(); + for finding in &mut self.findings { + if finding.status == DiscoveryStatus::Ready && conflicts.contains(&finding.tool) { + finding.status = DiscoveryStatus::Conflict; + finding.reason = Some(format!( + "multiple traditional sources select different {} versions", + finding.tool + )); + } + } + self.findings.sort_by(|left, right| { + provider_order(&left.tool) + .cmp(&provider_order(&right.tool)) + .then_with(|| left.tool.cmp(&right.tool)) + .then_with(|| left.source.cmp(&right.source)) + .then_with(|| left.field.cmp(&right.field)) + }); + let has_ready = self + .findings + .iter() + .any(|finding| finding.status == DiscoveryStatus::Ready); + let blocked = self.findings.iter().any(|finding| { + matches!( + finding.status, + DiscoveryStatus::Unsupported | DiscoveryStatus::Conflict + ) + }); + Ok(DiscoveryReport { + start, + boundary, + target_config, + can_import: has_ready && !blocked, + findings: self.findings, + }) + } + + fn load(&mut self, group: &'static str, path: &Path, tool: &str) -> Option { + if self.seen.contains(group) { + return None; + } + match fs::symlink_metadata(path) { + Err(error) if error.kind() == ErrorKind::NotFound => return None, + Err(error) => { + self.seen.insert(group); + self.unsupported( + tool, + path, + None, + "", + format!("cannot inspect source: {error}"), + ); + return None; + } + Ok(_) => { + self.seen.insert(group); + } + } + match read_source(path) { + Ok(content) => Some(content), + Err(reason) => { + self.unsupported(tool, path, None, "", reason); + None + } + } + } + + fn scan_simple( + &mut self, + group: &'static str, + path: &Path, + tool: &str, + normalize: fn(&str) -> Result, + ) { + let Some(content) = self.load(group, path, tool) else { + return; + }; + match one_simple_value(&content) { + Ok(value) => self.selection(tool, path, None, &value, normalize(&value)), + Err(reason) => self.unsupported(tool, path, None, "", reason), + } + } + + fn scan_python_version(&mut self, directory: &Path) { + let path = directory.join(".python-version"); + let Some(content) = self.load("python-version", &path, "python") else { + return; + }; + let values = meaningful_lines(&content) + .flat_map(str::split_whitespace) + .collect::>(); + if values.len() != 1 { + self.unsupported( + "python", + &path, + None, + "multiple selectors", + ".python-version must contain exactly one CPython selector", + ); + return; + } + self.selection( + "python", + &path, + None, + values[0], + normalize_python(values[0]), + ); + } + + fn scan_package_json(&mut self, directory: &Path) { + let path = directory.join("package.json"); + let Some(content) = self.load("package-json", &path, "project") else { + return; + }; + let value: serde_json::Value = match serde_json::from_str(&content) { + Ok(value) => value, + Err(_) => { + self.unsupported("project", &path, None, "", "invalid JSON"); + return; + } + }; + if let Some(node) = value.pointer("/volta/node") { + if let Some(node) = node.as_str() { + self.selection( + "node", + &path, + Some("volta.node"), + node, + normalize_node(node), + ); + } else { + self.unsupported( + "node", + &path, + Some("volta.node"), + json_scalar(node), + "volta.node must be a string", + ); + } + } + if let Some(package_manager) = value.get("packageManager") { + if let Some(package_manager) = package_manager.as_str() { + self.package_manager(&path, "packageManager", package_manager); + } else { + self.unsupported( + "project", + &path, + Some("packageManager"), + json_scalar(package_manager), + "packageManager must be a string", + ); + } + } + if let Some(engines) = value.get("engines").and_then(serde_json::Value::as_object) { + for tool in ["node", "pnpm", "bun"] { + if let Some(raw) = engines.get(tool).and_then(serde_json::Value::as_str) { + self.informational(tool, &path, Some(&format!("engines.{tool}")), raw); + } + } + } + for key in ["runtime", "packageManager"] { + if let Some(entry) = value.get("devEngines").and_then(|value| value.get(key)) { + for item in json_entries(entry) { + let Some(name) = item.get("name").and_then(serde_json::Value::as_str) else { + continue; + }; + let Some(version) = item.get("version").and_then(serde_json::Value::as_str) + else { + continue; + }; + if let Some(tool) = tool_alias(name) { + self.informational( + tool, + &path, + Some(&format!("devEngines.{key}.version")), + version, + ); + } + } + } + } + } + + fn package_manager(&mut self, path: &Path, field: &str, raw: &str) { + let Some((name, version)) = raw.split_once('@') else { + self.unsupported( + "project", + path, + Some(field), + raw, + "packageManager must use @", + ); + return; + }; + let version = version.split("+sha").next().unwrap_or(version); + match name { + "pnpm" => self.selection("pnpm", path, Some(field), version, normalize_pnpm(version)), + "bun" => self.selection("bun", path, Some(field), version, normalize_bun(version)), + other => self.ignored( + other, + path, + Some(field), + raw, + "package manager is not a Pinset Provider", + ), + } + } + + fn scan_go_file(&mut self, directory: &Path, filename: &'static str) { + let path = directory.join(filename); + let group = if filename == "go.mod" { + "go-mod" + } else { + "go-work" + }; + let Some(content) = self.load(group, &path, "go") else { + return; + }; + for line in content.lines() { + let line = line.split("//").next().unwrap_or(line).trim(); + let mut fields = line.split_whitespace(); + match (fields.next(), fields.next()) { + (Some("toolchain"), Some(raw)) => { + self.selection("go", &path, Some("toolchain"), raw, normalize_go(raw)) + } + (Some("go"), Some(raw)) => self.informational("go", &path, Some("go"), raw), + _ => {} + } + } + } + + fn scan_fvm(&mut self, directory: &Path) { + if self.seen.contains("fvm") { + return; + } + let current = directory.join(".fvmrc"); + let legacy = directory.join(".fvm").join("fvm_config.json"); + let path = if source_present(¤t) { + current + } else if source_present(&legacy) { + legacy + } else { + return; + }; + let Some(content) = self.load("fvm", &path, "flutter") else { + return; + }; + let value: serde_json::Value = match serde_json::from_str(&content) { + Ok(value) => value, + Err(_) => { + self.unsupported("flutter", &path, None, "", "invalid JSON"); + return; + } + }; + let has_flavors = value.get("flavors").is_some_and(|flavors| match flavors { + serde_json::Value::Null => false, + serde_json::Value::Object(flavors) => !flavors.is_empty(), + _ => true, + }); + if has_flavors { + self.unsupported( + "flutter", + &path, + Some("flavors"), + "configured", + "FVM flavors cannot be represented by one Pinset selection", + ); + return; + } + let (field, raw) = + if let Some(raw) = value.get("flutter").and_then(serde_json::Value::as_str) { + ("flutter", raw) + } else if let Some(raw) = value + .get("flutterSdkVersion") + .and_then(serde_json::Value::as_str) + { + ("flutterSdkVersion", raw) + } else { + self.unsupported( + "flutter", + &path, + None, + "", + "FVM configuration has no string flutter version", + ); + return; + }; + self.selection("flutter", &path, Some(field), raw, normalize_flutter(raw)); + } + + fn scan_sdkman(&mut self, directory: &Path) { + let path = directory.join(".sdkmanrc"); + let Some(content) = self.load("sdkmanrc", &path, "java") else { + return; + }; + for line in meaningful_lines(&content) { + let Some((name, raw)) = line.split_once('=') else { + self.unsupported("java", &path, None, "", "invalid .sdkmanrc assignment"); + continue; + }; + let name = name.trim(); + let raw = raw.trim(); + if name == "java" { + self.selection("java", &path, Some("java"), raw, normalize_java(raw)); + } else { + self.ignored( + name, + &path, + Some(name), + raw, + "SDKMAN candidate is not imported", + ); + } + } + } + + fn scan_rust(&mut self, directory: &Path) { + if self.seen.contains("rust-toolchain") { + return; + } + let legacy = directory.join("rust-toolchain"); + let modern = directory.join("rust-toolchain.toml"); + let path = if source_present(&legacy) { + legacy + } else { + modern + }; + let Some(content) = self.load("rust-toolchain", &path, "rust") else { + return; + }; + if !content.trim_start().starts_with('[') { + match one_simple_value(&content) { + Ok(value) => self.selection("rust", &path, None, &value, normalize_rust(&value)), + Err(reason) => self.unsupported("rust", &path, None, "", reason), + } + return; + } + self.scan_rust_toml(&path, &content); + } + + fn scan_rust_toml(&mut self, path: &Path, content: &str) { + let value: toml::Value = match toml::from_str(content) { + Ok(value) => value, + Err(_) => { + self.unsupported("rust", path, None, "", "invalid TOML"); + return; + } + }; + let Some(toolchain) = value.get("toolchain").and_then(toml::Value::as_table) else { + self.unsupported("rust", path, None, "", "missing [toolchain] table"); + return; + }; + if let Some(field) = toolchain.keys().find(|field| { + !matches!( + field.as_str(), + "channel" | "components" | "profile" | "targets" | "path" + ) + }) { + self.unsupported( + "rust", + path, + Some(&format!("toolchain.{field}")), + "configured", + "unknown Rust toolchain fields cannot be imported safely", + ); + return; + } + if toolchain.contains_key("path") { + self.unsupported( + "rust", + path, + Some("toolchain.path"), + "configured", + "path toolchains are not supported", + ); + return; + } + if let Some(targets) = toolchain.get("targets") { + if !targets.as_array().is_some_and(|targets| targets.is_empty()) { + self.unsupported( + "rust", + path, + Some("toolchain.targets"), + "configured", + "extra Rust targets are not supported", + ); + return; + } + } + if let Some(profile) = toolchain.get("profile") { + if profile.as_str() != Some("default") { + self.unsupported( + "rust", + path, + Some("toolchain.profile"), + toml_scalar(profile), + "only the default Rust profile is supported", + ); + return; + } + } + if let Some(components) = toolchain.get("components") { + let valid = components.as_array().is_some_and(|components| { + components.iter().all(|component| { + component + .as_str() + .is_some_and(|component| matches!(component, "rustfmt" | "clippy")) + }) + }); + if !valid { + self.unsupported( + "rust", + path, + Some("toolchain.components"), + "configured", + "only rustfmt and clippy components are supported", + ); + return; + } + } + let Some(channel) = toolchain.get("channel").and_then(toml::Value::as_str) else { + self.unsupported( + "rust", + path, + Some("toolchain.channel"), + "", + "Rust channel must be a string", + ); + return; + }; + self.selection( + "rust", + path, + Some("toolchain.channel"), + channel, + normalize_rust(channel), + ); + } + + fn scan_global_json(&mut self, directory: &Path) { + let path = directory.join("global.json"); + let Some(content) = self.load("global-json", &path, "dotnet") else { + return; + }; + let value: serde_json::Value = match json5::from_str(&content) { + Ok(value) => value, + Err(_) => { + self.unsupported("dotnet", &path, None, "", "invalid JSONC"); + return; + } + }; + let Some(sdk) = value.get("sdk").and_then(serde_json::Value::as_object) else { + self.unsupported( + "dotnet", + &path, + Some("sdk"), + "", + "global.json has no sdk object", + ); + return; + }; + if let Some(version) = sdk.get("version") { + if let Some(version) = version.as_str() { + self.selection( + "dotnet", + &path, + Some("sdk.version"), + version, + normalize_dotnet_exact(version), + ); + } else { + self.unsupported( + "dotnet", + &path, + Some("sdk.version"), + json_scalar(version), + "sdk.version must be a string", + ); + } + } + for field in ["rollForward", "allowPrerelease"] { + if let Some(raw) = sdk.get(field) { + self.informational( + "dotnet", + &path, + Some(&format!("sdk.{field}")), + &json_scalar(raw), + ); + } + } + } + + fn scan_tool_versions(&mut self, directory: &Path) { + let path = directory.join(".tool-versions"); + let Some(content) = self.load("tool-versions", &path, "project") else { + return; + }; + for line in meaningful_lines(&content) { + let fields = line.split_whitespace().collect::>(); + if fields.is_empty() { + continue; + } + let Some(tool) = tool_alias(fields[0]) else { + self.ignored( + fields[0], + &path, + Some(fields[0]), + &fields[1..].join(" "), + "tool is not a Pinset Provider", + ); + continue; + }; + if fields.len() != 2 { + self.unsupported( + tool, + &path, + Some(fields[0]), + fields[1..].join(" "), + "supported tools must have exactly one plain version", + ); + continue; + } + self.selection( + tool, + &path, + Some(fields[0]), + fields[1], + normalize_tool(tool, fields[1]), + ); + } + } + + fn scan_mise(&mut self, directory: &Path) { + let path = directory.join("mise.toml"); + let Some(content) = self.load("mise-toml", &path, "project") else { + return; + }; + let value: toml::Value = match toml::from_str(&content) { + Ok(value) => value, + Err(_) => { + self.unsupported("project", &path, None, "", "invalid TOML"); + return; + } + }; + let Some(tools) = value.get("tools").and_then(toml::Value::as_table) else { + return; + }; + for (name, value) in tools { + let Some(tool) = tool_alias(name) else { + self.ignored( + name, + &path, + Some(&format!("tools.{name}")), + &toml_scalar(value), + "tool is not a Pinset Provider", + ); + continue; + }; + let Some(raw) = value.as_str() else { + self.unsupported( + tool, + &path, + Some(&format!("tools.{name}")), + toml_scalar(value), + "mise value must be one plain string selector", + ); + continue; + }; + self.selection( + tool, + &path, + Some(&format!("tools.{name}")), + raw, + normalize_tool(tool, raw), + ); + } + } + + fn scan_pyproject(&mut self, directory: &Path) { + let path = directory.join("pyproject.toml"); + let Some(content) = self.load("pyproject", &path, "python") else { + return; + }; + let value: toml::Value = match toml::from_str(&content) { + Ok(value) => value, + Err(_) => { + self.unsupported("python", &path, None, "", "invalid TOML"); + return; + } + }; + if let Some(raw) = value + .get("project") + .and_then(|value| value.get("requires-python")) + .and_then(toml::Value::as_str) + { + self.informational("python", &path, Some("project.requires-python"), raw); + } + } + + fn scan_cargo(&mut self, directory: &Path) { + let path = directory.join("Cargo.toml"); + let Some(content) = self.load("cargo-toml", &path, "rust") else { + return; + }; + let value: toml::Value = match toml::from_str(&content) { + Ok(value) => value, + Err(_) => { + self.unsupported("rust", &path, None, "", "invalid TOML"); + return; + } + }; + for (field, raw) in [ + ( + "package.rust-version", + value + .get("package") + .and_then(|value| value.get("rust-version")) + .and_then(toml::Value::as_str), + ), + ( + "workspace.package.rust-version", + value + .get("workspace") + .and_then(|value| value.get("package")) + .and_then(|value| value.get("rust-version")) + .and_then(toml::Value::as_str), + ), + ] { + if let Some(raw) = raw { + self.informational("rust", &path, Some(field), raw); + } + } + } + + fn scan_pubspec(&mut self, directory: &Path) { + let path = directory.join("pubspec.yaml"); + let Some(content) = self.load("pubspec", &path, "flutter") else { + return; + }; + let value: serde_yaml::Value = match serde_yaml::from_str(&content) { + Ok(value) => value, + Err(_) => { + self.unsupported("flutter", &path, None, "", "invalid YAML"); + return; + } + }; + let Some(environment) = yaml_get(&value, "environment") else { + return; + }; + for field in ["sdk", "flutter"] { + if let Some(raw) = yaml_get(environment, field).and_then(yaml_scalar) { + self.informational( + "flutter", + &path, + Some(&format!("environment.{field}")), + &raw, + ); + } + } + } + + fn selection( + &mut self, + tool: &str, + path: &Path, + field: Option<&str>, + raw: &str, + normalized: Result, + ) { + match normalized { + Ok(normalized) => self.findings.push(DiscoveryFinding { + tool: tool.to_owned(), + source: self.source(path), + field: field.map(str::to_owned), + raw: bounded_raw(raw), + normalized: Some(normalized), + kind: DiscoveryKind::Selection, + status: DiscoveryStatus::Ready, + reason: None, + }), + Err(reason) => self.unsupported(tool, path, field, raw, reason), + } + } + + fn informational(&mut self, tool: &str, path: &Path, field: Option<&str>, raw: &str) { + self.findings.push(DiscoveryFinding { + tool: tool.to_owned(), + source: self.source(path), + field: field.map(str::to_owned), + raw: bounded_raw(raw), + normalized: None, + kind: DiscoveryKind::Constraint, + status: DiscoveryStatus::Informational, + reason: Some("version constraint is reported but not imported".to_owned()), + }); + } + + fn ignored(&mut self, tool: &str, path: &Path, field: Option<&str>, raw: &str, reason: &str) { + self.findings.push(DiscoveryFinding { + tool: tool.to_owned(), + source: self.source(path), + field: field.map(str::to_owned), + raw: bounded_raw(raw), + normalized: None, + kind: DiscoveryKind::Selection, + status: DiscoveryStatus::Ignored, + reason: Some(reason.to_owned()), + }); + } + + fn unsupported( + &mut self, + tool: &str, + path: &Path, + field: Option<&str>, + raw: impl AsRef, + reason: impl Into, + ) { + self.findings.push(DiscoveryFinding { + tool: tool.to_owned(), + source: self.source(path), + field: field.map(str::to_owned), + raw: bounded_raw(raw.as_ref()), + normalized: None, + kind: DiscoveryKind::Selection, + status: DiscoveryStatus::Unsupported, + reason: Some(reason.into()), + }); + } + + fn source(&self, path: &Path) -> String { + path.strip_prefix(&self.boundary) + .unwrap_or(path) + .to_string_lossy() + .replace('\\', "/") + } +} + +fn read_source(path: &Path) -> Result { + let metadata = fs::symlink_metadata(path).map_err(|error| error.to_string())?; + if metadata.file_type().is_symlink() { + return Err("symbolic-link sources are not allowed".to_owned()); + } + if !metadata.is_file() { + return Err("source is not a regular file".to_owned()); + } + if metadata.len() > MAX_SOURCE_BYTES { + return Err(format!("source exceeds {MAX_SOURCE_BYTES} bytes")); + } + let bytes = fs::read(path).map_err(|error| error.to_string())?; + let content = String::from_utf8(bytes).map_err(|_| "source is not valid UTF-8".to_owned())?; + Ok(content + .strip_prefix('\u{feff}') + .unwrap_or(&content) + .to_owned()) +} + +fn bounded_raw(raw: &str) -> String { + const MAX_RAW_CHARS: usize = 160; + let raw = raw.trim(); + let mut characters = raw.chars(); + let value = characters.by_ref().take(MAX_RAW_CHARS).collect::(); + if characters.next().is_some() { + format!("{value}…") + } else { + value + } +} + +fn source_present(path: &Path) -> bool { + match fs::symlink_metadata(path) { + Ok(_) => true, + Err(error) => error.kind() != ErrorKind::NotFound, + } +} + +fn one_simple_value(content: &str) -> Result { + let values = meaningful_lines(content).collect::>(); + if values.len() != 1 { + return Err("source must contain exactly one version selector".to_owned()); + } + let fields = values[0].split_whitespace().collect::>(); + if fields.len() != 1 { + return Err("version selector must not contain whitespace".to_owned()); + } + Ok(fields[0].to_owned()) +} + +fn meaningful_lines(content: &str) -> impl Iterator { + content.lines().filter_map(|line| { + let line = line.split('#').next().unwrap_or(line).trim(); + (!line.is_empty()).then_some(line) + }) +} + +fn normalize_tool(tool: &str, raw: &str) -> Result { + match tool { + "node" => normalize_node(raw), + "pnpm" => normalize_pnpm(raw), + "bun" => normalize_bun(raw), + "go" => normalize_go(raw), + "flutter" => normalize_flutter(raw), + "python" => normalize_python(raw), + "java" => normalize_java(raw), + "rust" => normalize_rust(raw), + "dotnet" => normalize_dotnet(raw), + _ => Err(format!("unsupported Pinset Provider {tool}")), + } +} + +fn normalize_node(raw: &str) -> Result { + let value = raw.trim().to_ascii_lowercase(); + let value = match value.as_str() { + "node" => "current", + "lts/*" => "lts", + value if value.starts_with("lts/") => { + return Err("named Node.js LTS channels cannot be mapped safely".to_owned()); + } + value => value, + }; + normalize_numeric_or( + value.strip_prefix('v').unwrap_or(value), + &["current", "lts"], + ) +} + +fn normalize_pnpm(raw: &str) -> Result { + normalize_numeric_or(&raw.trim().to_ascii_lowercase(), &["latest", "current"]) +} + +fn normalize_bun(raw: &str) -> Result { + normalize_pnpm(raw) +} + +fn normalize_go(raw: &str) -> Result { + let value = raw.trim().to_ascii_lowercase(); + let value = value.strip_prefix("go").unwrap_or(&value); + normalize_numeric_or(value, &["latest", "current"]) +} + +fn normalize_flutter(raw: &str) -> Result { + let value = raw.trim().to_ascii_lowercase(); + let value = if value == "stable" { "current" } else { &value }; + normalize_numeric_or(value, &["latest", "current"]) +} + +fn normalize_python(raw: &str) -> Result { + let value = raw.trim().to_ascii_lowercase(); + if value == "system" || value.contains("pypy") || value.contains('/') || value.contains(':') { + return Err("only one CPython selector can be imported".to_owned()); + } + if let Some((version, build)) = value.split_once('+') { + if numeric_parts(version, 3, 3) + && build.len() == 8 + && build.bytes().all(|byte| byte.is_ascii_digit()) + { + return Ok(value); + } + return Err("invalid CPython distribution selector".to_owned()); + } + normalize_numeric_or(&value, &["latest", "current"]) +} + +fn normalize_java(raw: &str) -> Result { + let value = raw.trim().to_ascii_lowercase(); + let value = if let Some(value) = value.strip_suffix("-tem") { + value + } else if value.rsplit_once('-').is_some() { + return Err("only Temurin SDKMAN Java versions can be imported".to_owned()); + } else { + &value + }; + if matches!(value, "latest" | "current" | "lts") { + return Ok(value.to_owned()); + } + let (version, build) = value.split_once('+').unwrap_or((value, "")); + if numeric_parts(version, 1, 3) + && (build.is_empty() || build.bytes().all(|byte| byte.is_ascii_digit())) + { + Ok(value.to_owned()) + } else { + Err("Java selector must be stable numeric, lts, current, or Temurin -tem".to_owned()) + } +} + +fn normalize_rust(raw: &str) -> Result { + let value = raw.trim().to_ascii_lowercase(); + if value.contains("nightly") || value.contains("beta") || value.contains('-') { + return Err("only stable Rust channels can be imported".to_owned()); + } + normalize_numeric_or(&value, &["latest", "current", "stable"]) +} + +fn normalize_dotnet(raw: &str) -> Result { + normalize_numeric_or( + &raw.trim().to_ascii_lowercase(), + &["latest", "current", "lts"], + ) +} + +fn normalize_dotnet_exact(raw: &str) -> Result { + let value = raw.trim(); + if numeric_parts(value, 3, 3) { + Ok(value.to_owned()) + } else { + Err("global.json sdk.version must be one exact stable x.y.z SDK version".to_owned()) + } +} + +fn normalize_numeric_or(raw: &str, words: &[&str]) -> Result { + if words.contains(&raw) || numeric_parts(raw, 1, 3) { + Ok(raw.to_owned()) + } else { + Err("selector cannot be mapped safely".to_owned()) + } +} + +fn numeric_parts(value: &str, minimum: usize, maximum: usize) -> bool { + let parts = value.split('.').collect::>(); + (minimum..=maximum).contains(&parts.len()) + && parts.iter().all(|part| { + !part.is_empty() + && part.bytes().all(|byte| byte.is_ascii_digit()) + && (part == &"0" || !part.starts_with('0')) + }) +} + +fn tool_alias(name: &str) -> Option<&'static str> { + let tool = match name.trim().to_ascii_lowercase().as_str() { + "node" | "nodejs" => "node", + "pnpm" => "pnpm", + "bun" => "bun", + "go" | "golang" => "go", + "flutter" => "flutter", + "python" => "python", + "java" => "java", + "rust" => "rust", + "dotnet" | "dotnet-core" => "dotnet", + _ => return None, + }; + runtime_provider(tool).map(|provider| provider.tool) +} + +fn provider_order(tool: &str) -> usize { + crate::runtime_providers() + .iter() + .position(|provider| provider.tool == tool) + .unwrap_or(usize::MAX) +} + +fn json_entries(value: &serde_json::Value) -> Vec<&serde_json::Map> { + match value { + serde_json::Value::Object(object) => vec![object], + serde_json::Value::Array(values) => values + .iter() + .filter_map(serde_json::Value::as_object) + .collect(), + _ => Vec::new(), + } +} + +fn json_scalar(value: &serde_json::Value) -> String { + value + .as_str() + .map(str::to_owned) + .unwrap_or_else(|| value.to_string()) +} + +fn toml_scalar(value: &toml::Value) -> String { + value + .as_str() + .map(str::to_owned) + .unwrap_or_else(|| value.to_string()) +} + +fn yaml_get<'a>(value: &'a serde_yaml::Value, key: &str) -> Option<&'a serde_yaml::Value> { + value + .as_mapping()? + .get(serde_yaml::Value::String(key.to_owned())) +} + +fn yaml_scalar(value: &serde_yaml::Value) -> Option { + match value { + serde_yaml::Value::String(value) => Some(value.clone()), + serde_yaml::Value::Number(value) => Some(value.to_string()), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use std::fs; + + use tempfile::tempdir; + + use super::*; + + #[test] + fn scans_all_provider_sources_and_reports_constraints() { + let root = tempdir().expect("root"); + fs::create_dir(root.path().join(".git")).expect("git marker"); + fs::write( + root.path().join(".nvmrc"), + "\u{feff}# project Node\nv24.1.0\n", + ) + .expect("nvmrc"); + fs::write(root.path().join(".bun-version"), "1.2.3\n").expect("bun"); + fs::write(root.path().join(".go-version"), "go1.25.1\n").expect("go"); + fs::write(root.path().join(".python-version"), "3.14\n").expect("python"); + fs::write(root.path().join(".java-version"), "21.0.7-tem\n").expect("java"); + fs::write(root.path().join("rust-toolchain"), "stable\n").expect("rust"); + fs::write( + root.path().join("global.json"), + "{ // comment\n sdk: { version: '10.0.100', rollForward: 'latestPatch' } }", + ) + .expect("global json"); + fs::write(root.path().join(".fvmrc"), r#"{"flutter":"3.35.0"}"#).expect("fvm"); + fs::write( + root.path().join("package.json"), + r#"{"volta":{"node":"24.1.0"},"packageManager":"pnpm@10.2.0","engines":{"node":">=22"}}"#, + ) + .expect("package"); + fs::write( + root.path().join("go.mod"), + "module example.test/app\n\ngo 1.25\ntoolchain go1.25.1\n", + ) + .expect("go mod"); + fs::write( + root.path().join(".sdkmanrc"), + "java=21.0.7-tem\nkotlin=2.2.0\n", + ) + .expect("sdkman"); + fs::write( + root.path().join("pyproject.toml"), + "[project]\nname = \"example\"\nrequires-python = \">=3.12\"\n", + ) + .expect("pyproject"); + fs::write( + root.path().join("Cargo.toml"), + "[package]\nname = \"example\"\nversion = \"0.1.0\"\nrust-version = \"1.85\"\n", + ) + .expect("cargo"); + fs::write( + root.path().join("pubspec.yaml"), + "name: example\nenvironment:\n sdk: '>=3.8.0 <4.0.0'\n flutter: '>=3.35.0'\n", + ) + .expect("pubspec"); + + let report = scan_project_sources(root.path()).expect("scan"); + assert!(report.can_import); + for tool in [ + "node", "pnpm", "bun", "go", "flutter", "python", "java", "rust", "dotnet", + ] { + assert!(report.findings.iter().any(|finding| finding.tool == tool && finding.status == DiscoveryStatus::Ready), "missing {tool}"); + } + assert!( + report + .findings + .iter() + .any(|finding| finding.kind == DiscoveryKind::Constraint) + ); + assert!( + report + .findings + .iter() + .any(|finding| finding.status == DiscoveryStatus::Ignored) + ); + } + + #[test] + fn stops_at_repository_root_and_marks_conflicts() { + let root = tempdir().expect("root"); + let repository = root.path().join("repo"); + let nested = repository.join("packages").join("app"); + fs::create_dir_all(repository.join(".git")).expect("git marker"); + fs::create_dir_all(&nested).expect("nested"); + fs::write(root.path().join(".tool-versions"), "node 18.0.0\n").expect("outside"); + fs::write(repository.join(".node-version"), "22.0.0\n").expect("node version"); + fs::write(nested.join(".nvmrc"), "24.0.0\n").expect("nvmrc"); + + let report = scan_project_sources(&nested).expect("scan"); + assert_eq!(report.boundary, repository); + assert!(!report.can_import); + assert_eq!( + report + .findings + .iter() + .filter(|finding| finding.status == DiscoveryStatus::Conflict) + .count(), + 2 + ); + assert!( + report + .findings + .iter() + .all(|finding| finding.raw != "18.0.0") + ); + } + + #[test] + fn blocks_unsafe_and_unrepresentable_sources() { + let root = tempdir().expect("root"); + fs::create_dir(root.path().join(".git")).expect("git marker"); + fs::write(root.path().join(".python-version"), "3.13\n3.14\n").expect("python"); + fs::write( + root.path().join(".fvmrc"), + r#"{"flutter":"3.35.0","flavors":{"prod":"3.35.0"}}"#, + ) + .expect("fvm"); + fs::write( + root.path().join("rust-toolchain.toml"), + "[toolchain]\nchannel = \"nightly\"\ntargets = [\"wasm32-unknown-unknown\"]\n", + ) + .expect("rust"); + + let report = scan_project_sources(root.path()).expect("scan"); + assert!(!report.can_import); + assert_eq!( + report + .findings + .iter() + .filter(|finding| finding.status == DiscoveryStatus::Unsupported) + .count(), + 3 + ); + } + + #[test] + fn without_repository_marker_scans_only_start_directory() { + let root = tempdir().expect("root"); + let nested = root.path().join("nested"); + fs::create_dir(&nested).expect("nested"); + fs::write(root.path().join(".nvmrc"), "22.0.0\n").expect("outside"); + fs::write(nested.join(".bun-version"), "1.2.3\n").expect("inside"); + + let report = scan_project_sources(&nested).expect("scan"); + assert_eq!(report.start, report.boundary); + assert_eq!(report.findings.len(), 1); + assert_eq!(report.findings[0].tool, "bun"); + } + + #[test] + fn imports_plain_universal_aliases_and_blocks_complex_supported_values() { + let root = tempdir().expect("root"); + fs::write( + root.path().join(".tool-versions"), + "nodejs 24.0.0\ngolang 1.25.1\ndotnet-core 10.0.100\nruby 3.4.0\n", + ) + .expect("tool versions"); + fs::write( + root.path().join("mise.toml"), + "[tools]\npnpm = \"10.2.0\"\npython = [\"3.13\", \"3.14\"]\n", + ) + .expect("mise"); + + let report = scan_project_sources(root.path()).expect("scan"); + assert!(!report.can_import); + for tool in ["node", "go", "dotnet", "pnpm"] { + assert!(report.findings.iter().any(|finding| { + finding.tool == tool && finding.status == DiscoveryStatus::Ready + })); + } + assert!(report.findings.iter().any(|finding| { + finding.tool == "ruby" && finding.status == DiscoveryStatus::Ignored + })); + assert!(report.findings.iter().any(|finding| { + finding.tool == "python" && finding.status == DiscoveryStatus::Unsupported + })); + } + + #[test] + fn accepts_toml_in_the_legacy_rust_toolchain_filename() { + let root = tempdir().expect("root"); + fs::write( + root.path().join("rust-toolchain"), + "[toolchain]\nchannel = \"stable\"\nprofile = \"default\"\ncomponents = [\"rustfmt\", \"clippy\"]\ntargets = []\n", + ) + .expect("rust toolchain"); + + let report = scan_project_sources(root.path()).expect("scan"); + assert!(report.can_import); + assert_eq!(report.findings.len(), 1); + assert_eq!(report.findings[0].normalized.as_deref(), Some("stable")); + assert_eq!(report.findings[0].status, DiscoveryStatus::Ready); + } + + #[test] + fn rejects_non_utf8_and_oversized_sources_without_exposing_contents() { + let root = tempdir().expect("root"); + fs::create_dir(root.path().join(".git")).expect("git marker"); + fs::write(root.path().join(".nvmrc"), [0xff, 0xfe]).expect("non utf8"); + fs::write( + root.path().join(".bun-version"), + vec![b'1'; MAX_SOURCE_BYTES as usize + 1], + ) + .expect("oversized"); + + let report = scan_project_sources(root.path()).expect("scan"); + assert!(!report.can_import); + assert_eq!(report.findings.len(), 2); + assert!(report.findings.iter().all(|finding| { + finding.status == DiscoveryStatus::Unsupported && finding.raw.is_empty() + })); + } + + #[test] + fn malformed_structured_sources_do_not_echo_file_contents() { + let root = tempdir().expect("root"); + fs::write( + root.path().join("package.json"), + "{\"private-token\":\"must-not-be-reported\"", + ) + .expect("malformed package json"); + + let report = scan_project_sources(root.path()).expect("scan"); + assert_eq!(report.findings.len(), 1); + assert_eq!(report.findings[0].status, DiscoveryStatus::Unsupported); + assert!(report.findings[0].raw.is_empty()); + assert_eq!(report.findings[0].reason.as_deref(), Some("invalid JSON")); + } + + #[cfg(unix)] + #[test] + fn rejects_symbolic_link_sources() { + use std::os::unix::fs::symlink; + + let root = tempdir().expect("root"); + fs::write(root.path().join("version.txt"), "24.0.0\n").expect("target"); + symlink(root.path().join("version.txt"), root.path().join(".nvmrc")).expect("link"); + + let report = scan_project_sources(root.path()).expect("scan"); + assert_eq!(report.findings.len(), 1); + assert_eq!(report.findings[0].status, DiscoveryStatus::Unsupported); + assert!( + report.findings[0] + .reason + .as_deref() + .is_some_and(|reason| reason.contains("symbolic-link")) + ); + } +} diff --git a/crates/pinset/Cargo.toml b/crates/pinset/Cargo.toml index 6246ad8..2816c5c 100644 --- a/crates/pinset/Cargo.toml +++ b/crates/pinset/Cargo.toml @@ -12,7 +12,7 @@ path = "src/main.rs" [dependencies] clap.workspace = true -pinset-core = { path = "../pinset-core", features = ["dotnet-metadata", "flutter-metadata", "go-metadata", "installer", "java-metadata", "node-metadata", "npm-metadata", "project-write", "python-metadata", "rust-metadata", "sources", "state-write"] } +pinset-core = { path = "../pinset-core", features = ["dotnet-metadata", "flutter-metadata", "go-metadata", "installer", "java-metadata", "node-metadata", "npm-metadata", "project-discovery", "project-write", "python-metadata", "rust-metadata", "sources", "state-write"] } serde.workspace = true serde_json.workspace = true terminal_size.workspace = true diff --git a/crates/pinset/src/i18n.rs b/crates/pinset/src/i18n.rs index 4434fbf..c19d97c 100644 --- a/crates/pinset/src/i18n.rs +++ b/crates/pinset/src/i18n.rs @@ -245,7 +245,7 @@ impl Catalog { pub fn top_level_help(self) -> &'static str { match self.language { Language::English => { - "Pinset manages predictable runtime versions.\n\nUsage: pinset [--lang ] \n\nCommands:\n init Create project configuration\n global Show or set the global default\n use Select and lock a project version\n unset Clear a project or global selection\n install Install a locked or explicit version\n uninstall Safely uninstall an exact version\n prune Remove unused managed versions\n outdated Check selected versions for updates\n current Show the effective selection\n list List installed or available versions\n cache Inspect, verify or clean the download cache\n which Show the resolved command path\n exec Run with the selected version\n doctor Diagnose configuration and PATH\n venv Manage the project Python environment\n shim Repair or migrate command shims\n activate Enable provider command routing in a shell\n completions Generate shell completion\n source Manage download sources\n\nRun `pinset --help` for command details." + "Pinset manages predictable runtime versions.\n\nUsage: pinset [--lang ] \n\nCommands:\n init Create project configuration\n detect Detect traditional version files\n import Import traditional version selections\n global Show or set the global default\n use Select and lock a project version\n unset Clear a project or global selection\n install Install a locked or explicit version\n uninstall Safely uninstall an exact version\n prune Remove unused managed versions\n outdated Check selected versions for updates\n current Show the effective selection\n list List installed or available versions\n cache Inspect, verify or clean the download cache\n which Show the resolved command path\n exec Run with the selected version\n doctor Diagnose configuration and PATH\n venv Manage the project Python environment\n shim Repair or migrate command shims\n activate Enable provider command routing in a shell\n completions Generate shell completion\n source Manage download sources\n\nRun `pinset --help` for command details." } Language::SimplifiedChinese => { "Pinset 用于统一管理可复现的运行时版本。\n\n用法:pinset [--lang ] <命令>\n\n执行 `pinset <命令> --help` 查看命令详情。" @@ -259,6 +259,12 @@ impl Catalog { } match command { Some("init") => "创建项目配置。\n\n用法:pinset init", + Some("detect") => { + "只读扫描仓库边界内的传统运行时版本配置;不联网、不写文件。\n\n用法:pinset detect [--cwd <目录>] [--json]" + } + Some("import") => { + "将可安全映射的传统运行时版本选择导入 Pinset 配置和锁文件。\n\n用法:pinset import [--cwd <目录>] [--force] [--no-install]" + } Some("global") => { "查看或设置项目之外使用的全局默认运行时。\n\n用法:pinset global [node@lts|pnpm@11|bun@1.3|go@1.25|python@3.14|flutter@3.47|java@lts|rust@stable|dotnet@lts] [--no-install]" } @@ -314,7 +320,7 @@ impl Catalog { "管理并测试本机下载源。\n\n用法:pinset source [参数...]" } _ => { - "Pinset 用于统一管理可复现的运行时版本。\n\n用法:pinset [--lang ] <命令>\n\n命令:\n init 创建项目配置\n global 查看或设置全局默认版本\n use 选择并锁定项目版本\n unset 清除项目或全局选择\n install 安装锁定或指定版本\n uninstall 安全卸载精确版本\n prune 清理未引用的受管版本\n outdated 检查已选版本更新\n current 显示当前生效选择\n list 列出已安装或可用版本\n cache 统计、验证或清理下载缓存\n which 显示实际命令路径\n exec 使用当前选择执行命令\n doctor 诊断配置与 PATH\n venv 管理项目 Python 虚拟环境\n shim 管理和迁移命令 shim\n activate 为当前 Shell 启用命令路由\n completions 生成 Shell 命令补全\n source 管理下载源\n\n执行 `pinset --lang zh-CN <命令> --help` 查看详情。" + "Pinset 用于统一管理可复现的运行时版本。\n\n用法:pinset [--lang ] <命令>\n\n命令:\n init 创建项目配置\n detect 检测传统版本配置\n import 导入传统版本选择\n global 查看或设置全局默认版本\n use 选择并锁定项目版本\n unset 清除项目或全局选择\n install 安装锁定或指定版本\n uninstall 安全卸载精确版本\n prune 清理未引用的受管版本\n outdated 检查已选版本更新\n current 显示当前生效选择\n list 列出已安装或可用版本\n cache 统计、验证或清理下载缓存\n which 显示实际命令路径\n exec 使用当前选择执行命令\n doctor 诊断配置与 PATH\n venv 管理项目 Python 虚拟环境\n shim 管理和迁移命令 shim\n activate 为当前 Shell 启用命令路由\n completions 生成 Shell 命令补全\n source 管理下载源\n\n执行 `pinset --lang zh-CN <命令> --help` 查看详情。" } } } diff --git a/crates/pinset/src/main.rs b/crates/pinset/src/main.rs index 8a19f01..39decb6 100644 --- a/crates/pinset/src/main.rs +++ b/crates/pinset/src/main.rs @@ -17,11 +17,12 @@ use std::ffi::OsStr; use clap::{Parser, Subcommand, ValueEnum, error::ErrorKind}; use pinset_core::{ - ArtifactIntegrity, DotnetMetadataClient, DownloadProgressEvent, Error, FlutterMetadataClient, - GlobalConfig, GoMetadataClient, InstallLimits, Installer, JavaMetadataClient, LockedTool, - Lockfile, NodeMetadataClient, NpmMetadataClient, PythonMetadataClient, RuntimeInstallKind, - RuntimeMetadataKind, RustMetadataClient, SUPPORTED_SOURCE_PROVIDERS, ShimInstallMethod, - SourceView, clean_download_cache, command_tool, create_project_config, + ArtifactIntegrity, DiscoveryReport, DiscoveryStatus, DotnetMetadataClient, + DownloadProgressEvent, Error, FlutterMetadataClient, GlobalConfig, GoMetadataClient, + InstallLimits, Installer, JavaMetadataClient, LockedTool, Lockfile, NodeMetadataClient, + NpmMetadataClient, PROJECT_CONFIG_SCHEMA, ProjectConfig, PythonMetadataClient, + RuntimeInstallKind, RuntimeMetadataKind, RustMetadataClient, SUPPORTED_SOURCE_PROVIDERS, + ShimInstallMethod, SourceView, clean_download_cache, command_tool, create_project_config, create_project_python_environment, current_target_for_tool, download_cache_info, ensure_shims, find_optional_project_config, find_project_config, global_config_path, global_lockfile_path, import_download_cache, import_download_cache_with_integrity, install_locked_dotnet, @@ -35,13 +36,13 @@ use pinset_core::{ repair_download_cache, resolve_command, resolve_project_python_command, resolve_tool_selection, runtime_command_candidates, runtime_command_directory, runtime_environment_for_install, runtime_provider, save_global_config, save_global_state, save_lockfile, save_project_config, - save_source_config, save_user_settings, selected_runtime_environment, source_config_path, - uninstall_node_version, uninstall_tool_version, user_settings_path, - validate_exact_dotnet_version, validate_exact_flutter_version, validate_exact_go_version, - validate_exact_java_version, validate_exact_node_version, validate_exact_npm_tool_version, - validate_exact_python_version, validate_exact_rust_version, validate_lock_matches_selection, - validate_lock_matches_tool, validate_lock_matches_tools, validate_managed_runtime_invocation, - verify_download_cache, + save_project_state, save_source_config, save_user_settings, scan_project_sources, + selected_runtime_environment, source_config_path, uninstall_node_version, + uninstall_tool_version, user_settings_path, validate_exact_dotnet_version, + validate_exact_flutter_version, validate_exact_go_version, validate_exact_java_version, + validate_exact_node_version, validate_exact_npm_tool_version, validate_exact_python_version, + validate_exact_rust_version, validate_lock_matches_selection, validate_lock_matches_tool, + validate_lock_matches_tools, validate_managed_runtime_invocation, verify_download_cache, }; use serde::Serialize; use terminal_size::{Width, terminal_size_of}; @@ -67,6 +68,27 @@ struct Cli { enum Commands { /// Create a minimal pinset.toml in the current directory. Init, + /// Detect traditional runtime version files without network or writes. + Detect { + /// Directory from which repository-bounded discovery starts. + #[arg(long)] + cwd: Option, + /// Emit a stable machine-readable report. + #[arg(long)] + json: bool, + }, + /// Import traditional runtime selections into pinset.toml and pinset.lock. + Import { + /// Directory from which repository-bounded discovery starts. + #[arg(long)] + cwd: Option, + /// Replace conflicting versions already selected by Pinset. + #[arg(long)] + force: bool, + /// Write configuration and lock metadata without installing runtimes. + #[arg(long)] + no_install: bool, + }, /// Show or set a default runtime version used outside projects. Global { /// Selection such as node@lts, pnpm@11, bun@1.3, go@1.25, python@3.14, java@21, rust@stable or dotnet@lts. @@ -380,6 +402,7 @@ impl Cli { impl Commands { fn json_command(&self) -> Option<&'static str> { match self { + Self::Detect { json: true, .. } => Some("detect"), Self::Which { json: true, .. } => Some("which"), Self::Current { json: true, .. } => Some("current"), Self::List { json: true, .. } => Some("list"), @@ -479,7 +502,15 @@ fn requested_json_command(arguments: &[OsString]) -> Option { let top_level = values.iter().position(|value| { matches!( value.as_ref(), - "which" | "current" | "list" | "outdated" | "uninstall" | "prune" | "doctor" | "cache" + "detect" + | "which" + | "current" + | "list" + | "outdated" + | "uninstall" + | "prune" + | "doctor" + | "cache" ) }); let Some(index) = top_level else { @@ -501,6 +532,9 @@ fn requested_json_command(arguments: &[OsString]) -> Option { } fn json_error(error: &(dyn std::error::Error + 'static)) -> (&'static str, serde_json::Value) { + if error.downcast_ref::().is_some() { + return ("io_error", serde_json::json!({})); + } let Some(error) = error.downcast_ref::() else { return ("internal_error", serde_json::json!({})); }; @@ -776,6 +810,19 @@ fn run(cli: Cli, catalog: Catalog) -> Result> { let path = create_project_config(&env::current_dir()?)?; println!("{}", catalog.created(&path)); } + Commands::Detect { cwd, json } => { + let report = scan_project_sources(&effective_cwd(cwd)?)?; + if json { + print_json_success("detect", report)?; + } else { + print_discovery_report(&report, catalog); + } + } + Commands::Import { + cwd, + force, + no_install, + } => run_project_import(&effective_cwd(cwd)?, force, no_install, catalog)?, Commands::Global { selection, no_install, @@ -1649,7 +1696,7 @@ fn run_cache(command: CacheCommands, catalog: Catalog) -> Result<(), Box String { else case "$command" in global) values="__SELECTIONS__ --no-install --lang --help" ;; + detect) values="--cwd --json --lang --help" ;; + import) values="--cwd --force --no-install --lang --help" ;; use) values="__SELECTIONS__ --no-install --global --lang --help" ;; install) values="__SELECTIONS__ --locked --global --cwd --lang --help" ;; uninstall) values="__SELECTIONS__ --force --cwd --dry-run --json --lang --help" ;; @@ -1715,6 +1764,8 @@ _pinset_completion() { else case "$command" in global) values="__SELECTIONS__ --no-install --lang --help" ;; + detect) values="--cwd --json --lang --help" ;; + import) values="--cwd --force --no-install --lang --help" ;; use) values="__SELECTIONS__ --no-install --global --lang --help" ;; install) values="__SELECTIONS__ --locked --global --cwd --lang --help" ;; uninstall) values="__SELECTIONS__ --force --cwd --dry-run --json --lang --help" ;; @@ -1746,7 +1797,9 @@ complete -c pinset -f -n '__fish_seen_subcommand_from venv' -a '__VENV_COMMANDS_ complete -c pinset -f -n '__fish_seen_subcommand_from shim' -a '__SHIM_COMMANDS__ __PROVIDERS__' complete -c pinset -f -n '__fish_seen_subcommand_from activate completions' -a '__SHELLS__' complete -c pinset -f -n '__fish_seen_subcommand_from source' -a '__SOURCE_COMMANDS__ __SOURCE_PROVIDERS__' -complete -c pinset -f -n '__fish_seen_subcommand_from which current list outdated uninstall prune doctor cache' -a '--json' +complete -c pinset -f -n '__fish_seen_subcommand_from detect which current list outdated uninstall prune doctor cache' -a '--json' +complete -c pinset -f -n '__fish_seen_subcommand_from detect import' -a '--cwd' +complete -c pinset -f -n '__fish_seen_subcommand_from import' -a '--force --no-install' complete -c pinset -f -n '__fish_seen_subcommand_from uninstall prune cache' -a '--dry-run' complete -c pinset -f -a '--help --lang'"# } @@ -1757,6 +1810,8 @@ complete -c pinset -f -a '--help --lang'"# $command = if ($elements.Count -gt 1) { $elements[1] } else { '' } $values = switch ($command) { 'global' { '__SELECTIONS__ --no-install --lang --help' -split ' ' } + 'detect' { '--cwd --json --lang --help' -split ' ' } + 'import' { '--cwd --force --no-install --lang --help' -split ' ' } 'use' { '__SELECTIONS__ --no-install --global --lang --help' -split ' ' } 'install' { '__SELECTIONS__ --locked --global --cwd --lang --help' -split ' ' } 'uninstall' { '__SELECTIONS__ --force --cwd --dry-run --json --lang --help' -split ' ' } @@ -1922,9 +1977,7 @@ fn select_tool( lockfile.generated_by = format!("pinset {}", env!("CARGO_PKG_VERSION")); lockfile.upsert_tool(locked_tool.clone()); project.set_tool(&tool, &version); - validate_lock_matches_tools(&lockfile, &project.tools, &config_path)?; - save_lockfile(&lock_path, &lockfile)?; - save_project_config(&config_path, &project)?; + save_project_state(&config_path, &project, &lockfile)?; ("project", lock_path) }; if tool == "node" { @@ -1954,6 +2007,336 @@ fn select_tool( Ok(()) } +fn print_discovery_report(report: &DiscoveryReport, catalog: Catalog) { + match catalog.language() { + Language::English => { + println!("traditional configuration scan"); + println!("start: {}", report.start.display()); + println!("boundary: {}", report.boundary.display()); + println!("target config: {}", report.target_config.display()); + } + Language::SimplifiedChinese => { + println!("传统版本配置扫描"); + println!("起始目录:{}", report.start.display()); + println!("扫描边界:{}", report.boundary.display()); + println!("目标配置:{}", report.target_config.display()); + } + } + if report.findings.is_empty() { + println!( + "{}", + match catalog.language() { + Language::English => "no traditional runtime configuration found", + Language::SimplifiedChinese => "未发现传统运行时配置", + } + ); + } + for finding in &report.findings { + let field = finding + .field + .as_deref() + .map(|field| format!("#{field}")) + .unwrap_or_default(); + let value = finding.normalized.as_deref().unwrap_or(&finding.raw); + let status = discovery_status_name(finding.status, catalog.language()); + print!( + "[{status}] {} {} <- {}{}", + finding.tool, value, finding.source, field + ); + if let Some(reason) = &finding.reason { + print!( + " ({})", + localized_discovery_reason(reason, catalog.language()) + ); + } + println!(); + } + println!( + "{}: {}", + match catalog.language() { + Language::English => "importable", + Language::SimplifiedChinese => "可导入", + }, + match (catalog.language(), report.can_import) { + (Language::English, true) => "yes", + (Language::English, false) => "no", + (Language::SimplifiedChinese, true) => "是", + (Language::SimplifiedChinese, false) => "否", + } + ); +} + +fn localized_discovery_reason(reason: &str, language: Language) -> String { + if language == Language::English { + return reason.to_owned(); + } + let translated = match reason { + "version constraint is reported but not imported" => "版本范围仅报告,不参与导入", + "symbolic-link sources are not allowed" => "不允许使用符号链接来源", + "source is not a regular file" => "来源不是普通文件", + "source is not valid UTF-8" => "来源不是有效的 UTF-8 文本", + "source must contain exactly one version selector" => "来源必须只包含一个版本选择器", + "version selector must not contain whitespace" => "版本选择器不能包含空白", + ".python-version must contain exactly one CPython selector" => { + ".python-version 必须只包含一个 CPython 选择器" + } + "only one CPython selector can be imported" => "只能导入一个 CPython 选择器", + "invalid CPython distribution selector" => "CPython 发行版选择器无效", + "volta.node must be a string" => "volta.node 必须是字符串", + "packageManager must be a string" => "packageManager 必须是字符串", + "packageManager must use @" => "packageManager 必须使用 <名称>@<版本>", + "package manager is not a Pinset Provider" => "该包管理器不是 Pinset Provider", + "FVM flavors cannot be represented by one Pinset selection" => { + "FVM flavors 无法表示为一个 Pinset 选择" + } + "FVM configuration has no string flutter version" => { + "FVM 配置中没有字符串类型的 Flutter 版本" + } + "invalid .sdkmanrc assignment" => ".sdkmanrc 赋值格式无效", + "SDKMAN candidate is not imported" => "该 SDKMAN candidate 不参与导入", + "missing [toolchain] table" => "缺少 [toolchain] 表", + "unknown Rust toolchain fields cannot be imported safely" => { + "未知 Rust toolchain 字段无法安全导入" + } + "path toolchains are not supported" => "不支持 path toolchain", + "extra Rust targets are not supported" => "不支持额外 Rust target", + "only the default Rust profile is supported" => "仅支持 Rust default profile", + "only rustfmt and clippy components are supported" => "仅支持 rustfmt 和 clippy 组件", + "Rust channel must be a string" => "Rust channel 必须是字符串", + "sdk.version must be a string" => "sdk.version 必须是字符串", + "tool is not a Pinset Provider" => "该工具不是 Pinset Provider", + "supported tools must have exactly one plain version" => "受支持工具必须只有一个普通版本值", + "mise value must be one plain string selector" => "mise 值必须是单个普通字符串选择器", + "only Temurin SDKMAN Java versions can be imported" => { + "只能导入 SDKMAN 的 Temurin Java 版本" + } + "Java selector must be stable numeric, lts, current, or Temurin -tem" => { + "Java 选择器必须是稳定数字版本、lts、current 或 Temurin -tem" + } + "only stable Rust channels can be imported" => "只能导入 Rust stable channel", + "global.json sdk.version must be one exact stable x.y.z SDK version" => { + "global.json sdk.version 必须是精确稳定的 x.y.z SDK 版本" + } + "selector cannot be mapped safely" => "选择器无法安全映射", + "invalid JSON" => "JSON 格式无效", + "invalid JSONC" => "JSONC 格式无效", + "invalid TOML" => "TOML 格式无效", + "invalid YAML" => "YAML 格式无效", + _ if reason.starts_with("multiple traditional sources") => "多个传统来源选择了不同版本", + _ if reason.starts_with("cannot inspect source:") => "无法检查来源文件", + _ if reason.starts_with("source exceeds ") => "来源文件超过 1 MiB 限制", + _ if reason.starts_with("unsupported Pinset Provider ") => "Pinset Provider 不受支持", + _ => return format!("无法安全导入:{reason}"), + }; + translated.to_owned() +} + +fn discovery_status_name(status: DiscoveryStatus, language: Language) -> &'static str { + match (language, status) { + (Language::English, DiscoveryStatus::Ready) => "ready", + (Language::English, DiscoveryStatus::Informational) => "informational", + (Language::English, DiscoveryStatus::Ignored) => "ignored", + (Language::English, DiscoveryStatus::Unsupported) => "unsupported", + (Language::English, DiscoveryStatus::Conflict) => "conflict", + (Language::SimplifiedChinese, DiscoveryStatus::Ready) => "可导入", + (Language::SimplifiedChinese, DiscoveryStatus::Informational) => "仅报告", + (Language::SimplifiedChinese, DiscoveryStatus::Ignored) => "已忽略", + (Language::SimplifiedChinese, DiscoveryStatus::Unsupported) => "不支持", + (Language::SimplifiedChinese, DiscoveryStatus::Conflict) => "冲突", + } +} + +fn run_project_import( + cwd: &Path, + force: bool, + no_install: bool, + catalog: Catalog, +) -> Result<(), Box> { + let report = scan_project_sources(cwd)?; + if !report.can_import { + print_discovery_report(&report, catalog); + return Err(match catalog.language() { + Language::English => { + "traditional configuration has no safe importable selection or contains blockers" + .into() + } + Language::SimplifiedChinese => { + "传统配置中没有可安全导入的版本选择,或存在阻断项".into() + } + }); + } + + let config_path = report.target_config.clone(); + let config_exists = match fs::symlink_metadata(&config_path) { + Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => { + return Err(match catalog.language() { + Language::English => format!( + "refusing to import into unsafe project configuration path {}", + config_path.display() + ) + .into(), + Language::SimplifiedChinese => { + format!("拒绝导入到不安全的项目配置路径 {}", config_path.display()).into() + } + }); + } + Ok(_) => true, + Err(error) if error.kind() == io::ErrorKind::NotFound => false, + Err(error) => return Err(error.into()), + }; + let mut project = if config_exists { + load_project_config(&config_path)? + } else { + ProjectConfig { + schema: PROJECT_CONFIG_SCHEMA, + tools: BTreeMap::new(), + } + }; + let lock_path = lockfile_path(&config_path); + let existing_lockfile = match fs::symlink_metadata(&lock_path) { + Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => { + return Err(match catalog.language() { + Language::English => format!( + "refusing to import with unsafe lock path {}", + lock_path.display() + ) + .into(), + Language::SimplifiedChinese => { + format!("拒绝使用不安全的锁文件路径 {} 导入", lock_path.display()).into() + } + }); + } + Ok(_) => Some(load_lockfile(&lock_path)?), + Err(error) if error.kind() == io::ErrorKind::NotFound => None, + Err(error) => return Err(error.into()), + }; + match &existing_lockfile { + Some(lockfile) => validate_lock_matches_tools(lockfile, &project.tools, &config_path)?, + None if !project.tools.is_empty() => { + return Err(match catalog.language() { + Language::English => format!( + "existing project configuration {} has selections but no pinset.lock", + config_path.display() + ) + .into(), + Language::SimplifiedChinese => format!( + "现有项目配置 {} 包含版本选择,但缺少 pinset.lock", + config_path.display() + ) + .into(), + }); + } + None => {} + } + + let selections = report + .findings + .iter() + .filter(|finding| finding.status == DiscoveryStatus::Ready) + .filter_map(|finding| { + finding + .normalized + .as_ref() + .map(|selector| (finding.tool.clone(), selector.clone())) + }) + .collect::>(); + let mut resolved = Vec::with_capacity(selections.len()); + for (tool, selector) in selections { + let locked_tool = resolve_locked_tool(&tool, &selector)?; + resolved.push((tool, selector, locked_tool)); + } + + if !force { + if let Some((tool, existing, imported)) = import_replacement_conflict(&project, &resolved) { + return Err(match catalog.language() { + Language::English => format!( + "{} already selects {tool}@{existing}; importing {tool}@{imported} requires --force", + config_path.display(), + ) + .into(), + Language::SimplifiedChinese => format!( + "{} 已选择 {tool}@{existing};导入 {tool}@{imported} 需要 --force", + config_path.display(), + ) + .into(), + }); + } + } + + let mut lockfile = existing_lockfile.unwrap_or_else(new_lockfile); + lockfile.generated_by = format!("pinset {}", env!("CARGO_PKG_VERSION")); + for (tool, selector, locked_tool) in &resolved { + if selector != &locked_tool.version { + println!( + "{tool}@{selector} resolved to {tool}@{}", + locked_tool.version + ); + } + project.set_tool(tool, &locked_tool.version); + lockfile.upsert_tool(locked_tool.clone()); + } + save_project_state(&config_path, &project, &lockfile)?; + + match catalog.language() { + Language::English => println!( + "imported {} runtime selection(s) into {}; lock {}", + resolved.len(), + config_path.display(), + lock_path.display() + ), + Language::SimplifiedChinese => println!( + "已将 {} 个运行时选择导入 {};锁文件 {}", + resolved.len(), + config_path.display(), + lock_path.display() + ), + } + + if no_install { + let home = pinset_home()?; + for (tool, _, _) in &resolved { + if let Err(error) = register_provider_commands(&home, tool, catalog) { + eprintln!( + "{}", + catalog.shim_auto_registration_failed(&error.to_string()) + ); + } + } + return Ok(()); + } + + if let Err(error) = install_project(&report.start, catalog) { + let localized = catalog.command_error(error.as_ref()); + let detail = localized + .strip_prefix("error: ") + .or_else(|| localized.strip_prefix("错误:")) + .unwrap_or(&localized); + return Err(match catalog.language() { + Language::English => format!( + "{detail}; project state was saved successfully, retry with `pinset install --locked`" + ) + .into(), + Language::SimplifiedChinese => format!( + "{detail};项目配置和锁文件已成功保存,请运行 `pinset install --locked` 重试" + ) + .into(), + }); + } + Ok(()) +} + +fn import_replacement_conflict( + project: &ProjectConfig, + resolved: &[(String, String, LockedTool)], +) -> Option<(String, String, String)> { + resolved.iter().find_map(|(tool, _, locked_tool)| { + project.tools.get(tool).and_then(|existing| { + (existing != &locked_tool.version) + .then(|| (tool.clone(), existing.clone(), locked_tool.version.clone())) + }) + }) +} + fn install_tool_selection( selection: &str, catalog: Catalog, @@ -2086,8 +2469,10 @@ fn requested_help_command(arguments: &[OsString]) -> Option> { } fn command_from_arguments(arguments: &[OsString]) -> Option<&str> { - const COMMANDS: [&str; 19] = [ + const COMMANDS: [&str; 21] = [ "init", + "detect", + "import", "global", "use", "unset", @@ -4130,7 +4515,9 @@ mod tests { ActivationShell::Powershell, ] { let script = completion_script(shell); - for expected in ["pinset", "node@", "dotnet", "verify", "recreate", "--json"] { + for expected in [ + "pinset", "detect", "import", "node@", "dotnet", "verify", "recreate", "--json", + ] { assert!( script.contains(expected), "completion for {shell:?} omitted {expected}" @@ -4145,6 +4532,34 @@ mod tests { } } + #[test] + fn import_replacement_check_is_limited_to_discovered_tools() { + let project = ProjectConfig { + schema: PROJECT_CONFIG_SCHEMA, + tools: BTreeMap::from([ + ("go".to_owned(), "1.24.0".to_owned()), + ("node".to_owned(), "22.0.0".to_owned()), + ]), + }; + let node = LockedTool { + name: "node".to_owned(), + requested: "24.0.0".to_owned(), + version: "24.0.0".to_owned(), + provider: "nodejs-official".to_owned(), + metadata: BTreeMap::new(), + artifacts: Vec::new(), + }; + + assert_eq!( + import_replacement_conflict( + &project, + &[("node".to_owned(), "24.0.0".to_owned(), node)] + ), + Some(("node".to_owned(), "22.0.0".to_owned(), "24.0.0".to_owned())) + ); + assert_eq!(project.tools["go"], "1.24.0"); + } + #[test] fn recommends_activation_for_the_current_shell() { assert_eq!( diff --git a/crates/pinset/tests/discovery_cli.rs b/crates/pinset/tests/discovery_cli.rs new file mode 100644 index 0000000..57882de --- /dev/null +++ b/crates/pinset/tests/discovery_cli.rs @@ -0,0 +1,186 @@ +use std::{ + fs, + path::Path, + process::{Command, Output}, +}; + +use serde_json::Value; +use tempfile::tempdir; + +#[test] +fn detect_emits_a_stable_json_report_without_writes() { + let root = tempdir().expect("root"); + let project = root.path().join("project"); + let home = root.path().join("home"); + fs::create_dir_all(project.join(".git")).expect("project"); + fs::write(project.join(".nvmrc"), "lts/*\n").expect("nvmrc"); + fs::write( + project.join("package.json"), + r#"{"packageManager":"pnpm@10.2.0","engines":{"node":">=22"}}"#, + ) + .expect("package json"); + + let output = pinset(&project, &home, &["detect", "--json"]); + assert_success(&output); + let value: Value = serde_json::from_slice(&output.stdout).expect("JSON report"); + assert_eq!(value["schema"], 1); + assert_eq!(value["command"], "detect"); + assert_eq!(value["ok"], true); + assert_eq!(value["data"]["can_import"], true); + assert_eq!( + value["data"]["target_config"], + project.join("pinset.toml").display().to_string() + ); + let findings = value["data"]["findings"].as_array().expect("findings"); + assert!(findings.iter().any(|finding| { + finding["tool"] == "node" + && finding["source"] == ".nvmrc" + && finding["normalized"] == "lts" + && finding["status"] == "ready" + })); + assert!(findings.iter().any(|finding| { + finding["field"] == "engines.node" && finding["status"] == "informational" + })); + assert!(!project.join("pinset.toml").exists()); + assert!(!project.join("pinset.lock").exists()); + assert!(!home.exists()); +} + +#[test] +fn detect_localizes_human_output() { + let root = tempdir().expect("root"); + let project = root.path().join("project"); + let home = root.path().join("home"); + fs::create_dir(&project).expect("project"); + fs::write(project.join(".bun-version"), "1.2.3\n").expect("bun"); + fs::write( + project.join("package.json"), + r#"{"engines":{"node":">=22"}}"#, + ) + .expect("package json"); + + let output = Command::new(env!("CARGO_BIN_EXE_pinset")) + .current_dir(&project) + .env("PINSET_HOME", &home) + .env("PINSET_LANG", "zh-CN") + .args(["detect"]) + .output() + .expect("run pinset"); + assert_success(&output); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("传统版本配置扫描"), "stdout: {stdout}"); + assert!(stdout.contains("[可导入] bun 1.2.3"), "stdout: {stdout}"); + assert!( + stdout.contains("可导入: 是") || stdout.contains("可导入:是"), + "stdout: {stdout}" + ); + assert!( + stdout.contains("版本范围仅报告,不参与导入"), + "stdout: {stdout}" + ); +} + +#[test] +fn import_conflict_fails_before_network_or_writes() { + let root = tempdir().expect("root"); + let project = root.path().join("project"); + let home = root.path().join("home"); + fs::create_dir_all(project.join(".git")).expect("project"); + fs::write(project.join(".nvmrc"), "22.0.0\n").expect("nvmrc"); + fs::write(project.join(".node-version"), "24.0.0\n").expect("node version"); + + let output = pinset(&project, &home, &["import", "--no-install"]); + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stdout).contains("[conflict]")); + assert!(!project.join("pinset.toml").exists()); + assert!(!project.join("pinset.lock").exists()); + assert!(!home.exists()); +} + +#[test] +fn import_requires_a_valid_existing_pinset_state_before_resolution() { + let root = tempdir().expect("root"); + let project = root.path().join("project"); + let home = root.path().join("home"); + fs::create_dir(&project).expect("project"); + fs::write(project.join(".nvmrc"), "24.0.0\n").expect("nvmrc"); + let config = "schema = 2\n\n[tools]\nnode = \"22.0.0\"\n"; + fs::write(project.join("pinset.toml"), config).expect("config"); + + let output = pinset(&project, &home, &["import", "--force", "--no-install"]); + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stderr).contains("no pinset.lock")); + assert_eq!( + fs::read_to_string(project.join("pinset.toml")).expect("config"), + config + ); + assert!(!project.join("pinset.lock").exists()); + assert!(!home.exists()); +} + +#[test] +fn import_with_only_constraints_is_a_zero_write_error() { + let root = tempdir().expect("root"); + let project = root.path().join("project"); + let home = root.path().join("home"); + fs::create_dir(&project).expect("project"); + fs::write( + project.join("package.json"), + r#"{"engines":{"node":">=22"}}"#, + ) + .expect("package json"); + + let output = pinset(&project, &home, &["import", "--no-install"]); + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stdout).contains("[informational]")); + assert!(!project.join("pinset.toml").exists()); + assert!(!project.join("pinset.lock").exists()); + assert!(!home.exists()); +} + +#[test] +fn detect_errors_keep_the_schema_one_failure_envelope() { + let root = tempdir().expect("root"); + let project = root.path().join("project"); + let home = root.path().join("home"); + fs::create_dir(&project).expect("project"); + let missing = project.join("missing"); + + let output = pinset( + &project, + &home, + &[ + "detect", + "--cwd", + missing.to_str().expect("UTF-8 path"), + "--json", + ], + ); + assert!(!output.status.success()); + let value: Value = serde_json::from_slice(&output.stdout).expect("JSON error"); + assert_eq!(value["schema"], 1); + assert_eq!(value["command"], "detect"); + assert_eq!(value["ok"], false); + assert_eq!(value["error"]["code"], "io_error"); + assert!(output.stderr.is_empty()); + assert!(!home.exists()); +} + +fn pinset(project: &Path, home: &Path, arguments: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_pinset")) + .current_dir(project) + .env("PINSET_HOME", home) + .env("PINSET_LANG", "en") + .args(arguments) + .output() + .expect("run pinset") +} + +fn assert_success(output: &Output) { + assert!( + output.status.success(), + "stdout: {}\nstderr: {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} diff --git a/crates/pinset/tests/flutter_cli.rs b/crates/pinset/tests/flutter_cli.rs index a15640a..89b59c3 100644 --- a/crates/pinset/tests/flutter_cli.rs +++ b/crates/pinset/tests/flutter_cli.rs @@ -87,20 +87,6 @@ fn resolves_lists_and_executes_a_managed_flutter_sdk_with_its_bundled_dart() { ); } -#[test] -fn does_not_expose_an_external_manager_import_command() { - let root = tempdir().expect("root"); - let project = root.path().join("project"); - let home = root.path().join("home"); - fs::create_dir(&project).expect("project"); - let output = pinset(&project, &home, &["import", "--dry-run"]); - assert!(!output.status.success()); - assert!(String::from_utf8_lossy(&output.stderr).contains("unrecognized subcommand 'import'")); - assert!(!project.join("pinset.toml").exists()); - assert!(!project.join("pinset.lock").exists()); - assert!(!home.exists()); -} - #[cfg(windows)] fn write_flutter_command(directory: &Path) { fs::write( diff --git a/crates/pinset/tests/mvp_cli.rs b/crates/pinset/tests/mvp_cli.rs index c5004a3..5f3a5f0 100644 --- a/crates/pinset/tests/mvp_cli.rs +++ b/crates/pinset/tests/mvp_cli.rs @@ -8,8 +8,8 @@ use std::{ use pinset_core::{ GlobalConfig, LockedArtifact, LockedArtifactFormat, Lockfile, MVP_NODE_TARGETS, NodeArchiveFormat, ProjectConfig, SourceConfig, current_target, global_config_path, - global_lockfile_path, plan_node_artifact, save_global_config, save_global_state, save_lockfile, - save_project_config, + global_lockfile_path, plan_node_artifact, runtime_providers, save_global_config, + save_global_state, save_lockfile, save_project_config, }; use tempfile::tempdir; @@ -776,9 +776,11 @@ fn create_fake_system_node(directory: &Path) -> PathBuf { "@echo off\r\nif \"%1\"==\"exit23\" exit /b 23\r\necho system:%*\r\necho source=%PINSET_SELECTION_SOURCE%\r\n", ) .expect("fake system node"); - for command in [ - "npm", "npx", "corepack", "pnpm", "bun", "bunx", "go", "gofmt", "flutter", "dart", - ] { + for command in runtime_providers() + .iter() + .flat_map(|provider| provider.commands.iter().copied()) + .filter(|command| *command != "node") + { fs::write( directory.join(format!("{command}.cmd")), format!("@echo off\r\necho fake {command}\r\n"), @@ -803,9 +805,11 @@ fn create_fake_system_node(directory: &Path) -> PathBuf { .permissions(); permissions.set_mode(0o755); fs::set_permissions(&executable, permissions).expect("fake system node permissions"); - for command in [ - "npm", "npx", "corepack", "pnpm", "bun", "bunx", "go", "gofmt", "flutter", "dart", - ] { + for command in runtime_providers() + .iter() + .flat_map(|provider| provider.commands.iter().copied()) + .filter(|command| *command != "node") + { let command_path = directory.join(command); fs::write( &command_path, diff --git a/docs/commands.md b/docs/commands.md index 07e64f5..3655762 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -2,7 +2,7 @@ [English](commands.md) | [简体中文](commands.zh-CN.md) · [README](../README.md) -This document describes the Pinset v1.0 command-line contract. Run `pinset --help` for the exact parser help shipped with your binary. +This document describes the Pinset v1.1 command-line contract. Run `pinset --help` for the exact parser help shipped with your binary. ## Conventions @@ -58,6 +58,34 @@ The tables below repeat exceptional behavior where it matters. Otherwise the com | Exit | `0` success; `2` if the file cannot be safely created. | | Key errors | Existing configuration, unsafe path, or filesystem permission failure. | +### `detect` + +| Field | Description | +| --- | --- | +| Purpose | Read traditional project version files and report selections, constraints, ignored tools, unsupported values, and conflicts. | +| Syntax and arguments | `pinset detect [--cwd ] [--json]`. Discovery stops at the nearest `.git` file/directory; without one it scans only the start directory. | +| Modifies state | **No.** It does not use the network, create Pinset state, execute third-party tools, or modify source files. | +| Example | `pinset detect --cwd ./app --json` | +| JSON | **Yes.** Data contains `start`, `boundary`, `target_config`, `can_import`, and stable Provider-ordered `findings`. | +| Exit | `0` when the local scan completes, including reports with conflicts or no importable selection; `2` only when discovery itself cannot start. | +| Key errors | Missing/inaccessible start directory. Unsafe, malformed, or unrepresentable source files are report findings rather than command errors. | + +Recognized selection sources include `.nvmrc`, `.node-version`, `.bun-version`, `.go-version`, `.python-version`, `.java-version`, `.sdkmanrc`, `rust-toolchain(.toml)`, `global.json`, `.fvmrc`, legacy FVM project JSON, `.tool-versions`, `mise.toml`, and unambiguous fields in `package.json`, `go.mod`, and `go.work`. Version ranges from package manifests are informational only. Symlinks, non-files, non-UTF-8 sources, and files larger than 1 MiB are rejected in the report. + +### `import` + +| Field | Description | +| --- | --- | +| Purpose | Re-scan and import every safe traditional selection into schema-2 `pinset.toml` and `pinset.lock`. | +| Syntax and arguments | `pinset import [--cwd ] [--force] [--no-install]`. `--force` replaces only discovered tools already selected at another exact version. | +| Modifies state | **Yes.** Resolves metadata, writes lock then config atomically, and installs all project selections by default. `--no-install` skips runtime archives and Python `.venv`, but still resolves and locks metadata. | +| Example | `pinset import --no-install` | +| JSON | No. | +| Exit | `0` after a complete import/install; `2` for no selection, blockers, invalid existing Pinset state, resolution/write failure, or installation failure. | +| Key errors | Conflicting sources, unsupported values, missing/mismatched existing lock, version replacement without `--force`, or unavailable Provider metadata. | + +Import never reads installed state from another runtime manager, executes manager tasks/hooks, or deletes legacy files. If installation fails after the state commit, the valid config and lock remain and `pinset install --locked` resumes installation. + ### `global` | Field | Description | diff --git a/docs/commands.zh-CN.md b/docs/commands.zh-CN.md index a766d68..1fd31e4 100644 --- a/docs/commands.zh-CN.md +++ b/docs/commands.zh-CN.md @@ -2,7 +2,7 @@ [English](commands.md) | [简体中文](commands.zh-CN.md) · [README](../README.zh-CN.md) -本文档描述 Pinset v1.0 命令行协议。可以运行 `pinset --help` 查看当前二进制附带的精确参数帮助。 +本文档描述 Pinset v1.1 命令行协议。可以运行 `pinset --help` 查看当前二进制附带的精确参数帮助。 ## 通用约定 @@ -58,6 +58,34 @@ | 退出码 | 成功为 `0`;无法安全创建文件为 `2`。 | | 关键错误 | 配置已存在、不安全路径或文件系统权限失败。 | +### `detect` + +| 字段 | 说明 | +| --- | --- | +| 用途 | 读取传统项目版本文件,报告版本选择、范围约束、忽略的工具、不支持值和冲突。 | +| 语法与参数 | `pinset detect [--cwd <目录>] [--json]`。扫描在最近的 `.git` 文件/目录处停止;不存在 Git 标记时只扫描起始目录。 | +| 修改状态 | **否。** 不联网、不创建 Pinset 状态、不执行第三方工具,也不修改来源文件。 | +| 示例 | `pinset detect --cwd ./app --json` | +| JSON | **支持。** `data` 包含 `start`、`boundary`、`target_config`、`can_import` 以及按 Provider 稳定排序的 `findings`。 | +| 退出码 | 本地扫描完成为 `0`,即使报告含冲突或没有可导入选择;只有无法开始扫描时返回 `2`。 | +| 关键错误 | 起始目录不存在或不可访问。不安全、畸形、不可表示的来源文件会作为报告项返回,而不是命令错误。 | + +可导入来源包括 `.nvmrc`、`.node-version`、`.bun-version`、`.go-version`、`.python-version`、`.java-version`、`.sdkmanrc`、`rust-toolchain(.toml)`、`global.json`、`.fvmrc`、旧版 FVM 项目 JSON、`.tool-versions`、`mise.toml`,以及 `package.json`、`go.mod`、`go.work` 中无歧义的字段。包清单中的版本范围只报告、不导入。符号链接、非普通文件、非 UTF-8 来源和超过 1 MiB 的文件会在报告中被拒绝。 + +### `import` + +| 字段 | 说明 | +| --- | --- | +| 用途 | 重新扫描,并把所有可安全映射的传统选择导入 schema 2 的 `pinset.toml` 与 `pinset.lock`。 | +| 语法与参数 | `pinset import [--cwd <目录>] [--force] [--no-install]`。`--force` 只替换本次发现且现有精确版本不同的工具。 | +| 修改状态 | **是。** 解析元数据,按锁文件优先、配置随后顺序原子写入,并默认安装项目全部选择。`--no-install` 跳过运行时归档和 Python `.venv`,但仍解析并锁定元数据。 | +| 示例 | `pinset import --no-install` | +| JSON | 不支持。 | +| 退出码 | 完整导入/安装后为 `0`;没有选择、存在阻断项、现有 Pinset 状态无效、解析/写入失败或安装失败为 `2`。 | +| 关键错误 | 来源冲突、不支持值、现有锁缺失/不匹配、未带 `--force` 的版本替换,或 Provider 元数据不可用。 | + +导入不会读取其他运行时管理器的安装状态,不执行管理器任务或 hook,也不删除传统文件。若状态提交后的安装失败,有效配置与锁会保留,可运行 `pinset install --locked` 继续安装。 + ### `global` | 字段 | 说明 | diff --git a/install.sh b/install.sh index ef90392..9714fab 100644 --- a/install.sh +++ b/install.sh @@ -3,7 +3,7 @@ set -eu REPOSITORY="Future-Element/pinset" -DEFAULT_VERSION="1.0.0" +DEFAULT_VERSION="1.1.0" VERSION="${PINSET_VERSION:-$DEFAULT_VERSION}" INSTALL_DIR="${PINSET_INSTALL_DIR:-}" TEMP_ROOT="" @@ -18,7 +18,7 @@ Usage: install.sh [--version VERSION] [--install-dir DIRECTORY] Options: - --version VERSION Install an exact release, for example 1.0.0. + --version VERSION Install an exact release, for example 1.1.0. Default: the recommended release embedded in this script. --install-dir DIRECTORY Install binaries here. Default: $HOME/.local/bin. -h, --help Show this help. diff --git a/scripts/tests/ubuntu_runtime_acceptance.sh b/scripts/tests/ubuntu_runtime_acceptance.sh index 8d8e6da..fc9fce0 100644 --- a/scripts/tests/ubuntu_runtime_acceptance.sh +++ b/scripts/tests/ubuntu_runtime_acceptance.sh @@ -314,6 +314,35 @@ if [[ "$SKIP_FLUTTER_RUNTIME" == "0" ]]; then grep -F "refusing to run \`flutter $mutation\` against a Pinset-managed Flutter SDK" flutter-mutation.txt done fi + +GLOBAL_PYTHON_DISTRIBUTION="$("$PINSET_BIN" --lang en current python | sed -n 's/^python \([^ ]*\) installed.*/\1/p')" +test -n "$GLOBAL_PYTHON_DISTRIBUTION" +mkdir traditional-import +cd traditional-import +cat > .tool-versions <> .tool-versions +fi +printf '{"engines":{"node":">=%s"}}\n' "$GLOBAL_VERSION" > package.json +"$PINSET_BIN" detect --json | tee detect.json +python3 -c 'import json; report=json.load(open("detect.json", encoding="utf-8")); assert report["schema"] == 1 and report["command"] == "detect" and report["ok"] and report["data"]["can_import"]' +"$PINSET_BIN" import +test -f pinset.toml +test -f pinset.lock +"$PINSET_BIN" --lang en current node | grep -F "Node.js $GLOBAL_VERSION" +"$PINSET_BIN" which node | grep -F "$PINSET_HOME/installs/node/$GLOBAL_VERSION/" +test -f .venv/.pinset-venv.toml +"$PINSET_BIN" exec -- python -c 'import os; print(os.environ["VIRTUAL_ENV"])' | grep -F '/traditional-import/.venv' +cd "$TEST_ROOT" "$PINSET_BIN" cache clean mkdir project diff --git a/scripts/tests/windows_runtime_acceptance.ps1 b/scripts/tests/windows_runtime_acceptance.ps1 index 6a14acb..7d6f394 100644 --- a/scripts/tests/windows_runtime_acceptance.ps1 +++ b/scripts/tests/windows_runtime_acceptance.ps1 @@ -405,6 +405,52 @@ Console.WriteLine($"DOTNET_ROOT={Environment.GetEnvironmentVariable("DOTNET_ROOT } } } + + $GlobalPythonCurrent = ((& $Pinset --lang en current python) | Out-String).Trim() + $GlobalPythonDistributionMatch = [regex]::Match($GlobalPythonCurrent, '^python ([^ ]+) installed') + if (-not $GlobalPythonDistributionMatch.Success) { + throw "global Python selection could not be read: '$GlobalPythonCurrent'" + } + $TraditionalImport = Join-Path $TestRoot 'traditional-import' + New-Item -ItemType Directory -Path $TraditionalImport | Out-Null + Set-Location $TraditionalImport + $ToolVersions = @( + "node $GlobalVersion" + "pnpm $GlobalPnpmVersion" + "bun $BunVersion" + "go $GlobalGoVersion" + "python $($GlobalPythonDistributionMatch.Groups[1].Value)" + "java $GlobalJavaVersion" + "rust $GlobalRustVersion" + "dotnet $GlobalDotnetVersion" + ) + if (-not $SkipFlutterRuntime) { + $ToolVersions += "flutter $GlobalFlutterVersion" + } + Set-Content -LiteralPath (Join-Path $TraditionalImport '.tool-versions') -Value $ToolVersions + Set-Content -LiteralPath (Join-Path $TraditionalImport 'package.json') -Value "{`"engines`":{`"node`":`">=$GlobalVersion`"}}" -NoNewline + $DetectReport = ((& $Pinset detect --json) | Out-String) | ConvertFrom-Json + if ($DetectReport.schema -ne 1 -or $DetectReport.command -ne 'detect' -or + -not $DetectReport.ok -or -not $DetectReport.data.can_import) { + throw 'traditional configuration detect report was not importable' + } + & $Pinset import + if (-not (Test-Path -LiteralPath (Join-Path $TraditionalImport 'pinset.toml') -PathType Leaf) -or + -not (Test-Path -LiteralPath (Join-Path $TraditionalImport 'pinset.lock') -PathType Leaf)) { + throw 'traditional import did not create Pinset project state' + } + if (((& $Pinset --lang en current node) | Out-String).Trim() -notmatch "Node.js $([regex]::Escape($GlobalVersion))") { + throw 'traditional import did not select Node.js' + } + if (((& $Pinset which node) | Out-String).Trim() -notmatch [regex]::Escape("installs\node\$GlobalVersion")) { + throw 'traditional import did not resolve the managed Node.js command' + } + $TraditionalVenv = Join-Path $TraditionalImport '.venv' + if (-not (Test-Path -LiteralPath (Join-Path $TraditionalVenv '.pinset-venv.toml') -PathType Leaf)) { + throw 'traditional import did not create the Python environment' + } + Assert-ExactOutput $TraditionalVenv (& $Pinset exec -- python -c "import os; print(os.environ['VIRTUAL_ENV'])") 'traditional import Python environment' + Set-Location $TestRoot & $Pinset cache clean $Project = Join-Path $TestRoot 'project'