From b463841d37e6b8c7e3dfbabb84c4662b55e5e1b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Cs=C3=A1rdi?= Date: Wed, 2 Sep 2026 16:11:10 +0200 Subject: [PATCH 01/19] Implement proj lock & sync --- Cargo.lock | 60 +++++++ Cargo.toml | 2 + src/args.rs | 19 +- src/help-generated.in | 20 +-- src/help/pkg-deps.md | 2 +- src/help/pkg-install.md | 2 +- src/help/pkg-tree.md | 2 +- src/help/proj-deploy.md | 10 -- src/help/proj-deps.md | 2 +- src/help/{proj-solve.md => proj-lock.md} | 14 +- src/help/proj-sync.md | 11 ++ src/help/proj-tree.md | 2 +- src/help/proj.md | 11 +- src/install.rs | 2 +- src/lib.rs | 1 + src/main.rs | 1 + src/pkg/deps.rs | 2 +- src/pkg/install.rs | 12 +- src/pkg/tree.rs | 2 +- src/proj.rs | 89 ++++++---- src/rproj.rs | 89 ++++++++++ src/shim/main.rs | 6 +- src/shim_format.rs | 211 +++++++++++++++++++++-- src/windows/mod.rs | 18 ++ 24 files changed, 486 insertions(+), 104 deletions(-) delete mode 100644 src/help/proj-deploy.md rename src/help/{proj-solve.md => proj-lock.md} (84%) create mode 100644 src/help/proj-sync.md create mode 100644 src/rproj.rs diff --git a/Cargo.lock b/Cargo.lock index 63ecf09c..60013e42 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1848,6 +1848,7 @@ dependencies = [ "tar", "tempfile", "tokio", + "toml", "version-ranges", "whoami", "winreg 0.10.1", @@ -2068,6 +2069,15 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -2401,6 +2411,47 @@ dependencies = [ "tracing", ] +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "tower-service" version = "0.3.2" @@ -2906,6 +2957,15 @@ version = "0.52.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32b752e52a2da0ddfbdbcc6fceadfeede4c939ed16d13e648833a61dfb611ed8" +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + [[package]] name = "winreg" version = "0.10.1" diff --git a/Cargo.toml b/Cargo.toml index 5ccdcd0c..9c7df70e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -97,6 +97,8 @@ serde = { version = "1.0", features = ["derive"] } serde_derive = "1.0" serde_json = "1.0" serde_yaml = "0.9" +# rproj.lock (multi-target project lockfile) +toml = "0.8" # hash url to get target path, for macOS installers sha2 = "0.9.8" # expand ~ in paths, e.g. in R_LIBS env vars, env vars for RStudio diff --git a/src/args.rs b/src/args.rs index 392041a7..893de619 100644 --- a/src/args.rs +++ b/src/args.rs @@ -1396,9 +1396,9 @@ pub fn rig_app() -> Command { ), ) .subcommand( - Command::new("solve") - .about(ABOUT_PROJ_SOLVE) - .long_about(HELP_PROJ_SOLVE) + Command::new("lock") + .about(ABOUT_PROJ_LOCK) + .long_about(HELP_PROJ_LOCK) .display_order(0) .arg( Arg::new("input") @@ -1466,17 +1466,20 @@ pub fn rig_app() -> Command { ), ) .subcommand( - Command::new("deploy") - .about(ABOUT_PROJ_DEPLOY) - .long_about(HELP_PROJ_DEPLOY) + Command::new("sync") + .about(ABOUT_PROJ_SYNC) + .long_about(HELP_PROJ_SYNC) .display_order(0) .arg( Arg::new("library") - .help("Library path where packages should be installed") + .help( + "Library path where packages should be installed \ + (default: .rvenv/lib)", + ) .long("library") .short('l') .num_args(1) - .required(true), + .required(false), ) .arg( Arg::new("r-binary") diff --git a/src/help-generated.in b/src/help-generated.in index 2e096d6a..ccfbd239 100644 --- a/src/help-generated.in +++ b/src/help-generated.in @@ -37,17 +37,17 @@ const HELP_LIST: &str = "\u{1b}[1m\u{1b}[34mDescription:\u{1b}[39m\u{1b}[22m\n const ABOUT_PKG_AVAILABLE: &str = "List packages available in the R package repositories"; const HELP_PKG_AVAILABLE: &str = "\u{1b}[1m\u{1b}[34mDescription:\u{1b}[39m\u{1b}[22m\n List the packages available from the configured package repositories,\n ordered by name. For each package rig shows its version and its number of\n hard dependencies (\u{1b}[32mDepends\u{1b}[39m, \u{1b}[32mImports\u{1b}[39m and \u{1b}[32mLinkingTo\u{1b}[39m, excluding R and\n the base packages). A header line reports the total number of packages and\n the R version and package type they were resolved for.\n\n By default rig uses the default R version and the current platform;\n override these with \u{1b}[32m--r-version\u{1b}[39m, \u{1b}[32m--platform\u{1b}[39m and \u{1b}[32m--pkg-type\u{1b}[39m (e.g.\n \u{1b}[32msource\u{1b}[39m or \u{1b}[32mbinary\u{1b}[39m).\n\n Use \u{1b}[32m--json\u{1b}[39m to print the full listing as JSON, including the complete\n dependency lists for every package. See\n \u{1b}[32mrig pkg info\u{1b}[39m for a detailed view of a single package,\n and \u{1b}[32mrig pkg info --versions\u{1b}[39m to list all versions of a package."; const ABOUT_PKG_DEPS: &str = "Dependencies of a package in the repositories"; -const HELP_PKG_DEPS: &str = "\u{1b}[1m\u{1b}[34mDescription:\u{1b}[39m\u{1b}[22m\n Show what a package needs, in a table: every package it depends on, the\n version of that package currently on CRAN, the dependency type\n (\u{1b}[32mDepends\u{1b}[39m, \u{1b}[32mImports\u{1b}[39m, \u{1b}[32mLinkingTo\u{1b}[39m) and the version requirement, if it\n has one.\n\n By default the dependencies of the latest version of the package are\n shown; use \u{1b}[32m--version\u{1b}[39m to ask about a specific one, including versions\n that CRAN has archived. Use \u{1b}[32m--json\u{1b}[39m for machine readable output.\n\n\u{1b}[1m\u{1b}[34mDependency types:\u{1b}[39m\u{1b}[22m\n By default rig lists the hard dependencies only: \u{1b}[32mDepends\u{1b}[39m, \u{1b}[32mImports\u{1b}[39m\n and \u{1b}[32mLinkingTo\u{1b}[39m, i.e. the packages that need to be installed to use the\n package. \u{1b}[32m--dev\u{1b}[39m adds the soft dependencies, \u{1b}[32mSuggests\u{1b}[39m and \u{1b}[32mEnhances\u{1b}[39m,\n which are typically only needed to run the tests, build the vignettes or\n use some optional feature.\n\n R itself and the base packages, e.g. \u{1b}[32mutils\u{1b}[39m or \u{1b}[32mstats\u{1b}[39m, are listed if\n the package depends on them, with their version requirement, but without\n a version of their own, as they are part of R.\n\n\u{1b}[1m\u{1b}[34mRecursive dependencies:\u{1b}[39m\u{1b}[22m\n \u{1b}[32m--recursive\u{1b}[39m (\u{1b}[32m-r\u{1b}[39m) shows the whole dependency closure: not only the\n packages the package needs directly, but also the packages \u{1b}[3mthose\u{1b}[23m need,\n and so on. Each package appears once, with the \u{1b}[32mDepth\u{1b}[39m column giving its\n distance from the queried package, and the \u{1b}[32mNeeded by\u{1b}[39m column naming the\n packages that pull it in.\n\n \u{1b}[32mrig pkg tree\u{1b}[39m shows the same closure as a tree, which\n makes it easier to see how a package is pulled in, at the price of a\n longer listing.\n\n A recursive listing only ever follows hard dependencies, also below a\n soft dependency added by \u{1b}[32m--dev\u{1b}[39m, so \u{1b}[32m--dev --recursive\u{1b}[39m means the\n package's own dev dependencies plus everything they need to be\n installed.\n\n rig follows the dependencies of the \u{1b}[3mlatest\u{1b}[23m version of every package in\n the tree, so a version requirement that would force an older version,\n with different dependencies, is not taken into account. Use\n \u{1b}[32mrig proj solve\u{1b}[39m for a resolution that is consistent across\n versions."; +const HELP_PKG_DEPS: &str = "\u{1b}[1m\u{1b}[34mDescription:\u{1b}[39m\u{1b}[22m\n Show what a package needs, in a table: every package it depends on, the\n version of that package currently on CRAN, the dependency type\n (\u{1b}[32mDepends\u{1b}[39m, \u{1b}[32mImports\u{1b}[39m, \u{1b}[32mLinkingTo\u{1b}[39m) and the version requirement, if it\n has one.\n\n By default the dependencies of the latest version of the package are\n shown; use \u{1b}[32m--version\u{1b}[39m to ask about a specific one, including versions\n that CRAN has archived. Use \u{1b}[32m--json\u{1b}[39m for machine readable output.\n\n\u{1b}[1m\u{1b}[34mDependency types:\u{1b}[39m\u{1b}[22m\n By default rig lists the hard dependencies only: \u{1b}[32mDepends\u{1b}[39m, \u{1b}[32mImports\u{1b}[39m\n and \u{1b}[32mLinkingTo\u{1b}[39m, i.e. the packages that need to be installed to use the\n package. \u{1b}[32m--dev\u{1b}[39m adds the soft dependencies, \u{1b}[32mSuggests\u{1b}[39m and \u{1b}[32mEnhances\u{1b}[39m,\n which are typically only needed to run the tests, build the vignettes or\n use some optional feature.\n\n R itself and the base packages, e.g. \u{1b}[32mutils\u{1b}[39m or \u{1b}[32mstats\u{1b}[39m, are listed if\n the package depends on them, with their version requirement, but without\n a version of their own, as they are part of R.\n\n\u{1b}[1m\u{1b}[34mRecursive dependencies:\u{1b}[39m\u{1b}[22m\n \u{1b}[32m--recursive\u{1b}[39m (\u{1b}[32m-r\u{1b}[39m) shows the whole dependency closure: not only the\n packages the package needs directly, but also the packages \u{1b}[3mthose\u{1b}[23m need,\n and so on. Each package appears once, with the \u{1b}[32mDepth\u{1b}[39m column giving its\n distance from the queried package, and the \u{1b}[32mNeeded by\u{1b}[39m column naming the\n packages that pull it in.\n\n \u{1b}[32mrig pkg tree\u{1b}[39m shows the same closure as a tree, which\n makes it easier to see how a package is pulled in, at the price of a\n longer listing.\n\n A recursive listing only ever follows hard dependencies, also below a\n soft dependency added by \u{1b}[32m--dev\u{1b}[39m, so \u{1b}[32m--dev --recursive\u{1b}[39m means the\n package's own dev dependencies plus everything they need to be\n installed.\n\n rig follows the dependencies of the \u{1b}[3mlatest\u{1b}[23m version of every package in\n the tree, so a version requirement that would force an older version,\n with different dependencies, is not taken into account. Use\n \u{1b}[32mrig proj lock\u{1b}[39m for a resolution that is consistent across\n versions."; const ABOUT_PKG_INFO: &str = "Information about a package in the repositories"; const HELP_PKG_INFO: &str = "\u{1b}[1m\u{1b}[34mDescription:\u{1b}[39m\u{1b}[22m\n Show information about a package on CRAN, from its \u{1b}[32mDESCRIPTION\u{1b}[39m file.\n\n By default the latest available version is shown; use \u{1b}[32m--version\u{1b}[39m to\n select a specific one, including versions that CRAN has archived. Use\n \u{1b}[32m--json\u{1b}[39m to print all \u{1b}[32mDESCRIPTION\u{1b}[39m fields.\n\n If CRAN has archived the package, i.e. removed it from the current\n repository, rig shows the date it was archived, next to the publication\n date of the version. \u{1b}[32m--json\u{1b}[39m reports it as an extra \u{1b}[32mArchived\u{1b}[39m field.\n\n\u{1b}[1m\u{1b}[34mREADME of a package:\u{1b}[39m\u{1b}[22m\n \u{1b}[32m--readme\u{1b}[39m prints the README of the package, instead of its metadata,\n exactly as the repository stores it, i.e. not rendered and not paged. It\n works together with \u{1b}[32m--version\u{1b}[39m, to get the README of an older version,\n but not with \u{1b}[32m--versions\u{1b}[39m.\n\n \u{1b}[32m--readme --json\u{1b}[39m prints an object with the \u{1b}[32mpackage\u{1b}[39m and \u{1b}[32mversion\u{1b}[39m the\n README belongs to, the \u{1b}[32mreadme\u{1b}[39m itself, and the \u{1b}[32mformat\u{1b}[39m it is written\n in. The format is the one the repository reports, e.g. \u{1b}[32mmd\u{1b}[39m for markdown\n or \u{1b}[32mtxt\u{1b}[39m for plain text.\n\n A package without a README is not an error. \u{1b}[32m--readme\u{1b}[39m then prints\n nothing, and \u{1b}[32m--readme --json\u{1b}[39m prints \u{1b}[32mnull\u{1b}[39m for both \u{1b}[32mreadme\u{1b}[39m and\n \u{1b}[32mformat\u{1b}[39m.\n\n\u{1b}[1m\u{1b}[34mAll versions of a package:\u{1b}[39m\u{1b}[22m\n \u{1b}[32m--versions\u{1b}[39m lists all versions of the package ever published on CRAN,\n oldest first, instead of the details of a single version. For each version\n rig shows its publication date, its R version requirement and its number\n of hard dependencies (\u{1b}[32mDepends\u{1b}[39m, \u{1b}[32mImports\u{1b}[39m and \u{1b}[32mLinkingTo\u{1b}[39m, excluding R\n and the base packages); the latest version is marked. It cannot be\n combined with \u{1b}[32m--version\u{1b}[39m.\n\n For a package CRAN has archived, i.e. removed from the current\n repository, the header also shows the date it was archived. This applies\n to the package as a whole, so all of its versions are archived.\n\n \u{1b}[32m--versions --json\u{1b}[39m prints the full \u{1b}[32mDESCRIPTION\u{1b}[39m of every version, each\n with an extra \u{1b}[32mArchived\u{1b}[39m field for an archived package."; const ABOUT_PKG_INSTALL: &str = "Install packages from the repositories"; -const HELP_PKG_INSTALL: &str = "\u{1b}[1m\u{1b}[34mDescription:\u{1b}[39m\u{1b}[22m\n Install one or more R packages, and everything they need, into an R package\n library.\n\n rig pkg install cli glue\n\n ✓ Solved dependencies\n 2 of 2 packages to install (R 4.4.1, main: /Users/gaborcsardi/Library/R/arm64/4.4/library)\n Package Version Type Action Reason\n cli 3.6.3 binary install not installed\n glue 1.8.0 binary install not installed\n ✓ Installed 2 packages (R 4.4.1, main: /Users/gaborcsardi/Library/R/arm64/4.4/library)\n\n rig resolves the whole dependency tree first, the same way\n \u{1b}[32mrig proj solve\u{1b}[39m does for a project, so a package is only\n installed if every package it needs can be installed with it, at versions\n that work together. \u{1b}[32m--dry-run\u{1b}[39m runs the resolution and reports what it\n would install, without installing anything.\n\n Package names are case sensitive, as they are in R. Naming the same package\n twice is not an error, it is installed once.\n\n\u{1b}[1m\u{1b}[34mDev dependencies:\u{1b}[39m\u{1b}[22m\n By default rig installs the hard dependencies only: \u{1b}[32mDepends\u{1b}[39m, \u{1b}[32mImports\u{1b}[39m\n and \u{1b}[32mLinkingTo\u{1b}[39m, i.e. the packages that need to be installed to use the\n package. \u{1b}[32m--dev\u{1b}[39m also installs the soft dependencies, \u{1b}[32mSuggests\u{1b}[39m and\n \u{1b}[32mEnhances\u{1b}[39m, which are typically only needed to run the tests, build the\n vignettes or use some optional feature.\n\n \u{1b}[32m--dev\u{1b}[39m applies to the packages named on the command line only. A dev\n dependency is installed with everything \u{1b}[3mit\u{1b}[23m needs to be installed, but not\n with its own dev dependencies, so \u{1b}[32m--dev\u{1b}[39m does not grow without bounds.\n\n A package often suggests packages that are not in the repositories rig\n installs from, e.g. Bioconductor packages. Those cannot be installed, and\n by default rig reports them and installs nothing.\n \u{1b}[32m--ignore-unavailable\u{1b}[39m installs the rest of the dev dependencies instead,\n and names the ones it skipped. It only applies to dev dependencies: a hard\n dependency that is not available is always an error, and so is a dev\n dependency that exists but has no version that fits.\n\n\u{1b}[1m\u{1b}[34mBinary and source packages:\u{1b}[39m\u{1b}[22m\n A binary package is a package that has already been built for your platform\n and R version. Installing one is unpacking it into the library, so rig does\n that itself and never starts R.\n\n A package with no binary build is installed from its source tarball, with\n \u{1b}[32mR CMD INSTALL\u{1b}[39m, which does start R, and needs whatever that package needs\n to compile. The output of the compilation goes into a log file per package,\n in a \u{1b}[32m_logs\u{1b}[39m directory inside the library, and rig points at the log when an\n installation fails.\n\n \u{1b}[32m--platform\u{1b}[39m installs for a platform other than this machine's, and\n \u{1b}[32m--platform source\u{1b}[39m installs source packages only. \u{1b}[32m--prefer-binary\u{1b}[39m trades\n a newer version for an older one that has a binary build, which is useful\n when compiling is expensive; it takes the number of versions to look back\n through, e.g. \u{1b}[32m--prefer-binary=5\u{1b}[39m, and defaults to 3.\n\n\u{1b}[1m\u{1b}[34mPackages rig builds itself:\u{1b}[39m\u{1b}[22m\n Compiling a package produces exactly what a repository would have served as\n a binary package, so rig keeps it: a source install is archived into the\n cache, with the same file name \u{1b}[32mR CMD INSTALL --build\u{1b}[39m would have given it,\n and installing that package again unpacks the archive instead of compiling\n it a second time. That covers another library, another project, and\n \u{1b}[32m--reinstall\u{1b}[39m, which reinstalls a package but does not recompile it.\n\n A cache entry belongs to one platform, one R minor version, one source\n tarball, one set of versions of the packages it is compiled against, and one\n set of \u{1b}[32m~/.R/Makevars\u{1b}[39m files. Change any of those and the package is\n compiled again. What it does \u{1b}[3mnot\u{1b}[23m cover is your compiler, the system\n libraries the package found when it was configured, and the arguments it was\n configured with; a machine whose toolchain changed under it can hold an entry\n that no longer matches, and the way out is to delete it. \u{1b}[32mrig system dirs --cache\u{1b}[39m says where the cache is.\n\n \u{1b}[32m--no-cache\u{1b}[39m turns all of this off for one run: rig then downloads the\n repository metadata and the package files again, compiles every source\n package rather than unpacking one it built earlier, and adds nothing to the\n cache. See \u{1b}[32mrig config\u{1b}[39m.\n\n\u{1b}[1m\u{1b}[34mWhat gets skipped:\u{1b}[39m\u{1b}[22m\n rig does not install a package that is already installed and up to date, so\n running the same command twice does nothing the second time.\n\n Being up to date is more than having the right version number. A repository\n can publish several builds of one version, and a package with compiled code\n only works with the versions of the packages it was compiled against — an R\n that loads a package built against a different one can crash rather than\n complain. So rig keeps track of which build each package it installs came\n from, and what that build was compiled against, and reinstalls a package\n whose build is no longer the one the resolution picked.\n\n That check cascades: replacing a package also replaces the packages that\n were compiled against it, and the packages compiled against those.\n\n rig only knows this about packages it installed itself, so a package that R,\n pak or renv installed is always reinstalled rather than assumed to match.\n \u{1b}[32m--reinstall\u{1b}[39m installs everything in the resolution regardless.\n\n\u{1b}[1m\u{1b}[34mWhich library:\u{1b}[39m\u{1b}[22m\n By default rig installs into the default library of the default R version,\n i.e. the library that \u{1b}[32mrig library default\u{1b}[39m reports, and the\n one R installs packages into.\n\n \u{1b}[32m--library\u{1b}[39m (\u{1b}[32m-l\u{1b}[39m) selects another library. It takes either the name of a\n library of the R version, as \u{1b}[32mrig library list\u{1b}[39m prints them,\n or the path of a library directory:\n\n rig pkg install --library myproject cli\n rig pkg install --library /usr/lib/R/site-library cli\n\n A path is used as it is, and is created if it does not exist yet, so it does\n not need to belong to an R version rig manages.\n\n \u{1b}[32m--r-version\u{1b}[39m (\u{1b}[32m-r\u{1b}[39m) selects the library of another R version, instead of\n the default one, as it does for the \u{1b}[32mrig library\u{1b}[39m commands.\n It has no effect on which library \u{1b}[32m--library\u{1b}[39m names when that is a path, but\n it still decides which binary packages fit, and which \u{1b}[32mR\u{1b}[39m installs a source\n package.\n\n In admin mode the site and system libraries of\n an R installation belong to the administrator, so installing into them needs\n \u{1b}[32msudo\u{1b}[39m (an administrator account on Windows). Your own user library never\n does."; +const HELP_PKG_INSTALL: &str = "\u{1b}[1m\u{1b}[34mDescription:\u{1b}[39m\u{1b}[22m\n Install one or more R packages, and everything they need, into an R package\n library.\n\n rig pkg install cli glue\n\n ✓ Solved dependencies\n 2 of 2 packages to install (R 4.4.1, main: /Users/gaborcsardi/Library/R/arm64/4.4/library)\n Package Version Type Action Reason\n cli 3.6.3 binary install not installed\n glue 1.8.0 binary install not installed\n ✓ Installed 2 packages (R 4.4.1, main: /Users/gaborcsardi/Library/R/arm64/4.4/library)\n\n rig resolves the whole dependency tree first, the same way\n \u{1b}[32mrig proj lock\u{1b}[39m does for a project, so a package is only\n installed if every package it needs can be installed with it, at versions\n that work together. \u{1b}[32m--dry-run\u{1b}[39m runs the resolution and reports what it\n would install, without installing anything.\n\n Package names are case sensitive, as they are in R. Naming the same package\n twice is not an error, it is installed once.\n\n\u{1b}[1m\u{1b}[34mDev dependencies:\u{1b}[39m\u{1b}[22m\n By default rig installs the hard dependencies only: \u{1b}[32mDepends\u{1b}[39m, \u{1b}[32mImports\u{1b}[39m\n and \u{1b}[32mLinkingTo\u{1b}[39m, i.e. the packages that need to be installed to use the\n package. \u{1b}[32m--dev\u{1b}[39m also installs the soft dependencies, \u{1b}[32mSuggests\u{1b}[39m and\n \u{1b}[32mEnhances\u{1b}[39m, which are typically only needed to run the tests, build the\n vignettes or use some optional feature.\n\n \u{1b}[32m--dev\u{1b}[39m applies to the packages named on the command line only. A dev\n dependency is installed with everything \u{1b}[3mit\u{1b}[23m needs to be installed, but not\n with its own dev dependencies, so \u{1b}[32m--dev\u{1b}[39m does not grow without bounds.\n\n A package often suggests packages that are not in the repositories rig\n installs from, e.g. Bioconductor packages. Those cannot be installed, and\n by default rig reports them and installs nothing.\n \u{1b}[32m--ignore-unavailable\u{1b}[39m installs the rest of the dev dependencies instead,\n and names the ones it skipped. It only applies to dev dependencies: a hard\n dependency that is not available is always an error, and so is a dev\n dependency that exists but has no version that fits.\n\n\u{1b}[1m\u{1b}[34mBinary and source packages:\u{1b}[39m\u{1b}[22m\n A binary package is a package that has already been built for your platform\n and R version. Installing one is unpacking it into the library, so rig does\n that itself and never starts R.\n\n A package with no binary build is installed from its source tarball, with\n \u{1b}[32mR CMD INSTALL\u{1b}[39m, which does start R, and needs whatever that package needs\n to compile. The output of the compilation goes into a log file per package,\n in a \u{1b}[32m_logs\u{1b}[39m directory inside the library, and rig points at the log when an\n installation fails.\n\n \u{1b}[32m--platform\u{1b}[39m installs for a platform other than this machine's, and\n \u{1b}[32m--platform source\u{1b}[39m installs source packages only. \u{1b}[32m--prefer-binary\u{1b}[39m trades\n a newer version for an older one that has a binary build, which is useful\n when compiling is expensive; it takes the number of versions to look back\n through, e.g. \u{1b}[32m--prefer-binary=5\u{1b}[39m, and defaults to 3.\n\n\u{1b}[1m\u{1b}[34mPackages rig builds itself:\u{1b}[39m\u{1b}[22m\n Compiling a package produces exactly what a repository would have served as\n a binary package, so rig keeps it: a source install is archived into the\n cache, with the same file name \u{1b}[32mR CMD INSTALL --build\u{1b}[39m would have given it,\n and installing that package again unpacks the archive instead of compiling\n it a second time. That covers another library, another project, and\n \u{1b}[32m--reinstall\u{1b}[39m, which reinstalls a package but does not recompile it.\n\n A cache entry belongs to one platform, one R minor version, one source\n tarball, one set of versions of the packages it is compiled against, and one\n set of \u{1b}[32m~/.R/Makevars\u{1b}[39m files. Change any of those and the package is\n compiled again. What it does \u{1b}[3mnot\u{1b}[23m cover is your compiler, the system\n libraries the package found when it was configured, and the arguments it was\n configured with; a machine whose toolchain changed under it can hold an entry\n that no longer matches, and the way out is to delete it. \u{1b}[32mrig system dirs --cache\u{1b}[39m says where the cache is.\n\n \u{1b}[32m--no-cache\u{1b}[39m turns all of this off for one run: rig then downloads the\n repository metadata and the package files again, compiles every source\n package rather than unpacking one it built earlier, and adds nothing to the\n cache. See \u{1b}[32mrig config\u{1b}[39m.\n\n\u{1b}[1m\u{1b}[34mWhat gets skipped:\u{1b}[39m\u{1b}[22m\n rig does not install a package that is already installed and up to date, so\n running the same command twice does nothing the second time.\n\n Being up to date is more than having the right version number. A repository\n can publish several builds of one version, and a package with compiled code\n only works with the versions of the packages it was compiled against — an R\n that loads a package built against a different one can crash rather than\n complain. So rig keeps track of which build each package it installs came\n from, and what that build was compiled against, and reinstalls a package\n whose build is no longer the one the resolution picked.\n\n That check cascades: replacing a package also replaces the packages that\n were compiled against it, and the packages compiled against those.\n\n rig only knows this about packages it installed itself, so a package that R,\n pak or renv installed is always reinstalled rather than assumed to match.\n \u{1b}[32m--reinstall\u{1b}[39m installs everything in the resolution regardless.\n\n\u{1b}[1m\u{1b}[34mWhich library:\u{1b}[39m\u{1b}[22m\n By default rig installs into the default library of the default R version,\n i.e. the library that \u{1b}[32mrig library default\u{1b}[39m reports, and the\n one R installs packages into.\n\n \u{1b}[32m--library\u{1b}[39m (\u{1b}[32m-l\u{1b}[39m) selects another library. It takes either the name of a\n library of the R version, as \u{1b}[32mrig library list\u{1b}[39m prints them,\n or the path of a library directory:\n\n rig pkg install --library myproject cli\n rig pkg install --library /usr/lib/R/site-library cli\n\n A path is used as it is, and is created if it does not exist yet, so it does\n not need to belong to an R version rig manages.\n\n \u{1b}[32m--r-version\u{1b}[39m (\u{1b}[32m-r\u{1b}[39m) selects the library of another R version, instead of\n the default one, as it does for the \u{1b}[32mrig library\u{1b}[39m commands.\n It has no effect on which library \u{1b}[32m--library\u{1b}[39m names when that is a path, but\n it still decides which binary packages fit, and which \u{1b}[32mR\u{1b}[39m installs a source\n package.\n\n In admin mode the site and system libraries of\n an R installation belong to the administrator, so installing into them needs\n \u{1b}[32msudo\u{1b}[39m (an administrator account on Windows). Your own user library never\n does."; const ABOUT_PKG_LIST: &str = "Packages installed in a library"; const HELP_PKG_LIST: &str = "\u{1b}[1m\u{1b}[34mDescription:\u{1b}[39m\u{1b}[22m\n List the packages installed in an R package library, without starting R.\n\n 312 packages (R 4.4.1, main: /Users/gaborcsardi/Library/R/arm64/4.4/library)\n\n Package Version Built Platform Source\n -----------------------------------------------------------------------------\n cli 3.6.3 4.4.0 aarch64-apple-darwin20 CRAN\n glue 1.8.0 4.4.1 aarch64-apple-darwin20 CRAN\n asciicast 2.3.1.9000 4.4.1 aarch64-apple-darwin20 github::r-lib/asciicast\n mypkg 0.0.1 4.4.1 - -\n\n The first line names the number of packages and the library they were found\n in. Each line below it names a package, its version, the R version it was\n built for, the platform it was built for, and where it was installed from.\n\n \u{1b}[32mPlatform\u{1b}[39m is empty for a package installed from source. \u{1b}[32mSource\u{1b}[39m is the\n repository the package came from, e.g. \u{1b}[32mCRAN\u{1b}[39m, and for a package installed\n from somewhere else it names that place instead, in the package reference\n syntax pak uses: \u{1b}[32mgithub::/\u{1b}[39m for a GitHub install, \u{1b}[32mgit::\u{1b}[39m\n for a git one, and so on. It is empty for a package installed from a local\n directory, as \u{1b}[32mR CMD INSTALL\u{1b}[39m and \u{1b}[32mdevtools::install()\u{1b}[39m do, because such a\n package records nothing about where its source was.\n\n A field the package's \u{1b}[32mDESCRIPTION\u{1b}[39m does not have is shown as \u{1b}[32m-\u{1b}[39m. Use\n \u{1b}[32m--json\u{1b}[39m for machine readable output, which reports the repository or remote\n type as \u{1b}[32msource\u{1b}[39m and the remote itself as \u{1b}[32mremote\u{1b}[39m, separately.\n\n This subcommand and \u{1b}[32mrig pkg remove\u{1b}[39m read an \u{1b}[3minstalled\u{1b}[23m\n library; the others, e.g. \u{1b}[32mrig pkg available\u{1b}[39m, read the\n package repositories that packages are installed \u{1b}[3mfrom\u{1b}[23m.\n\n\u{1b}[1m\u{1b}[34mWhich library:\u{1b}[39m\u{1b}[22m\n By default rig lists the default library of the default R version, i.e. the\n library that \u{1b}[32mrig library default\u{1b}[39m reports, and the one R\n installs packages into.\n\n \u{1b}[32m--library\u{1b}[39m (\u{1b}[32m-l\u{1b}[39m) selects another library. It takes either the name of a\n library of the R version, as \u{1b}[32mrig library list\u{1b}[39m prints them, or\n the path of a library directory:\n\n rig pkg list --library myproject\n rig pkg list --library /usr/lib/R/site-library\n\n A path is used as it is, so it does not need to belong to an R version rig\n manages, and rig does not need an R version at all to list it.\n\n \u{1b}[32m--r-version\u{1b}[39m (\u{1b}[32m-r\u{1b}[39m) lists the library of another R version, instead of the\n default one, as it does for the \u{1b}[32mrig library\u{1b}[39m commands. It has\n no effect when \u{1b}[32m--library\u{1b}[39m is a path.\n\n Subdirectories that are not packages are left out: rig's own libraries of a\n main library, and the leftovers of an interrupted installation."; const ABOUT_PKG_REMOVE: &str = "Remove packages from a library"; const HELP_PKG_REMOVE: &str = "\u{1b}[1m\u{1b}[34mDescription:\u{1b}[39m\u{1b}[22m\n Delete one or more installed packages from an R package library, without\n starting R.\n\n rig pkg remove cli glue\n\n ▶ Removing cli 3.6.3 from /Users/gaborcsardi/Library/R/arm64/4.4/library/cli...\n ▶ Removing glue 1.8.0 from /Users/gaborcsardi/Library/R/arm64/4.4/library/glue...\n ✓ Removed 2 packages (R 4.4.1, main: /Users/gaborcsardi/Library/R/arm64/4.4/library)\n\n Removing a package deletes its directory in the library, which is what\n \u{1b}[32mR CMD REMOVE\u{1b}[39m and \u{1b}[32mremove.packages()\u{1b}[39m do as well.\n\n Package names are case sensitive, as they are in R, and every package named\n must be installed in the library: if one of them is not, then rig removes\n none of them. Naming the same package twice is not an error, it is removed\n once.\n\n rig does not check whether another installed package needs the one being\n removed. Use \u{1b}[32mrig pkg list\u{1b}[39m to see what is installed, and\n \u{1b}[32m--json\u{1b}[39m for machine readable output about what was removed.\n\n The base packages that ship with R (\u{1b}[32mbase\u{1b}[39m, \u{1b}[32mstats\u{1b}[39m, \u{1b}[32mutils\u{1b}[39m, ...) are part\n of the R installation, and R does not work without them, so rig refuses to\n remove them unless \u{1b}[32m--force\u{1b}[39m is also given.\n\n\u{1b}[1m\u{1b}[34mWhich library:\u{1b}[39m\u{1b}[22m\n By default rig removes the packages from the default library of the default R\n version, i.e. the library that \u{1b}[32mrig library default\u{1b}[39m reports,\n and the one R installs packages into.\n\n \u{1b}[32m--library\u{1b}[39m (\u{1b}[32m-l\u{1b}[39m) selects another library. It takes either the name of a\n library of the R version, as \u{1b}[32mrig library list\u{1b}[39m prints them, or\n the path of a library directory:\n\n rig pkg remove --library myproject cli\n rig pkg remove --library /usr/lib/R/site-library cli\n\n A path is used as it is, so it does not need to belong to an R version rig\n manages, and rig does not need an R version at all to remove packages from\n it.\n\n \u{1b}[32m--r-version\u{1b}[39m (\u{1b}[32m-r\u{1b}[39m) selects the library of another R version, instead of the\n default one, as it does for the \u{1b}[32mrig library\u{1b}[39m commands. It has\n no effect when \u{1b}[32m--library\u{1b}[39m is a path.\n\n In admin mode the site and system libraries of\n an R installation belong to the administrator, so removing a package from\n them needs \u{1b}[32msudo\u{1b}[39m (an administrator account on Windows). Your own user\n library never does. To remove a whole library, with all the packages in it,\n use \u{1b}[32mrig library rm\u{1b}[39m instead."; const ABOUT_PKG_TREE: &str = "Dependency tree of a package in the repositories"; -const HELP_PKG_TREE: &str = "\u{1b}[1m\u{1b}[34mDescription:\u{1b}[39m\u{1b}[22m\n Show everything a package needs, directly or indirectly, as a tree: the same\n closure \u{1b}[32mrig pkg deps --recursive\u{1b}[39m lists in a flat table, laid\n out by the shape of the dependency graph.\n\n dplyr 1.1.4 — 13 direct, 30 total\n ├── R (>= 3.5.0) [D]\n ├── cli 3.6.4 (>= 3.4.0)\n │ ├── R (>= 3.4) [D]\n │ └── utils\n ├── lifecycle 1.0.4 (>= 1.0.3)\n │ ├── cli 3.6.4 (>= 3.4.0) (*)\n │ └── rlang 1.1.6 (>= 1.1.0)\n │ └── R (>= 3.5.0) [D]\n └── vctrs 0.6.5 (>= 0.6.4)\n └── cpp11 0.5.2 [L]\n [Suggests]\n └── tidyr 1.3.1 (>= 1.3.0)\n └── cpp11 0.5.2 (>= 0.4.0) [L] (*)\n\n The first line names the package version, how many dependencies it has\n directly and how many distinct packages there are in the whole tree. Each line\n below names a package, the version currently on CRAN, and the version\n requirement it is needed with, if it has one.\n\n \u{1b}[32m--version\u{1b}[39m asks about a specific version, including versions CRAN has\n archived. \u{1b}[32m--json\u{1b}[39m gives machine readable output, as one nested object.\n \u{1b}[32mrig proj tree\u{1b}[39m shows the same tree for the\n dependencies a project declares.\n\n\u{1b}[1m\u{1b}[34mReading the tree:\u{1b}[39m\u{1b}[22m\n A package that several others need is expanded only once, under its first\n occurrence; later occurrences are a single line marked \u{1b}[32m(*)\u{1b}[39m, meaning \"its\n dependencies are above\". This is also what makes dependency cycles end on\n their own.\n\n A mark at the end of a line says how the package is needed; \u{1b}[32mImports\u{1b}[39m is the\n common case and is not marked.\n\n - \u{1b}[32m[D]\u{1b}[39m — a \u{1b}[32mDepends\u{1b}[39m, so the package is \u{1b}[3mattached\u{1b}[23m, not merely loaded.\n - \u{1b}[32m[L]\u{1b}[39m — a \u{1b}[32mLinkingTo\u{1b}[39m, so this package is compiled against it.\n - \u{1b}[32m[DL]\u{1b}[39m — both.\n\n Dependencies are listed with R first, then grouped by dependency type, in the\n order R lists the fields in, and by name within a type. R and the base\n packages, e.g. \u{1b}[32mutils\u{1b}[39m, are shown with their version requirement but without a\n version of their own, as they are part of R; \u{1b}[32m--no-base\u{1b}[39m leaves them out\n altogether. A package that is not in the repositories is shown with \u{1b}[32m?\u{1b}[39m for\n its version.\n\n By default rig follows the hard dependencies only. \u{1b}[32m--dev\u{1b}[39m adds \u{1b}[32mSuggests\u{1b}[39m and\n \u{1b}[32mEnhances\u{1b}[39m, in their own \u{1b}[32m[Suggests]\u{1b}[39m and \u{1b}[32m[Enhances]\u{1b}[39m sections. As in\n \u{1b}[32mrig pkg deps\u{1b}[39m, \u{1b}[32m--dev\u{1b}[39m applies to the queried package only, so these sections\n only ever appear at the top of the tree.\n\n rig follows the dependencies of the \u{1b}[3mlatest\u{1b}[23m version of every package in the\n tree, so a version requirement that would force an older version, with\n different dependencies, is not taken into account. Use\n \u{1b}[32mrig proj solve\u{1b}[39m for a resolution that is consistent across\n versions.\n\n\u{1b}[1m\u{1b}[34mInverting the tree:\u{1b}[39m\u{1b}[22m\n \u{1b}[32m--why \u{1b}[39m (alias \u{1b}[32m--explain\u{1b}[39m) inverts the tree: the named package is\n the root and the tree grows towards the packages that need it, down to the\n queried package, which becomes a leaf.\n\n glue 1.8.1 — 4 direct dependents, 5 total\n ├── dplyr 1.2.1 (needs >= 1.3.2)\n ├── pillar 1.11.1\n │ └── dplyr 1.2.1 (needs >= 1.9.0)\n └── vctrs 0.7.3\n ├── dplyr 1.2.1 (needs >= 0.7.1)\n └── pillar 1.11.1 (needs >= 0.5.0) (*)\n\n Each line says how \u{1b}[3mthat\u{1b}[23m package needs the one \u{1b}[1mabove\u{1b}[22m it, hence \u{1b}[32mneeds\u{1b}[39m;\n the \u{1b}[32m[D]\u{1b}[39m, \u{1b}[32m[L]\u{1b}[39m, \u{1b}[32m[S]\u{1b}[39m and \u{1b}[32m[E]\u{1b}[39m marks describe the same edge. \u{1b}[32m[S]\u{1b}[39m and \u{1b}[32m[E]\u{1b}[39m\n take the place of the \u{1b}[32m[Suggests]\u{1b}[39m and \u{1b}[32m[Enhances]\u{1b}[39m sections, which in an\n inverted tree would be one line deep inside it.\n\n \u{1b}[32m--why\u{1b}[39m searches the tree only, not the repositories, so \u{1b}[32m--version\u{1b}[39m, \u{1b}[32m--dev\u{1b}[39m\n and \u{1b}[32m--no-base\u{1b}[39m apply as above, and a package that is not in the tree is an\n error."; +const HELP_PKG_TREE: &str = "\u{1b}[1m\u{1b}[34mDescription:\u{1b}[39m\u{1b}[22m\n Show everything a package needs, directly or indirectly, as a tree: the same\n closure \u{1b}[32mrig pkg deps --recursive\u{1b}[39m lists in a flat table, laid\n out by the shape of the dependency graph.\n\n dplyr 1.1.4 — 13 direct, 30 total\n ├── R (>= 3.5.0) [D]\n ├── cli 3.6.4 (>= 3.4.0)\n │ ├── R (>= 3.4) [D]\n │ └── utils\n ├── lifecycle 1.0.4 (>= 1.0.3)\n │ ├── cli 3.6.4 (>= 3.4.0) (*)\n │ └── rlang 1.1.6 (>= 1.1.0)\n │ └── R (>= 3.5.0) [D]\n └── vctrs 0.6.5 (>= 0.6.4)\n └── cpp11 0.5.2 [L]\n [Suggests]\n └── tidyr 1.3.1 (>= 1.3.0)\n └── cpp11 0.5.2 (>= 0.4.0) [L] (*)\n\n The first line names the package version, how many dependencies it has\n directly and how many distinct packages there are in the whole tree. Each line\n below names a package, the version currently on CRAN, and the version\n requirement it is needed with, if it has one.\n\n \u{1b}[32m--version\u{1b}[39m asks about a specific version, including versions CRAN has\n archived. \u{1b}[32m--json\u{1b}[39m gives machine readable output, as one nested object.\n \u{1b}[32mrig proj tree\u{1b}[39m shows the same tree for the\n dependencies a project declares.\n\n\u{1b}[1m\u{1b}[34mReading the tree:\u{1b}[39m\u{1b}[22m\n A package that several others need is expanded only once, under its first\n occurrence; later occurrences are a single line marked \u{1b}[32m(*)\u{1b}[39m, meaning \"its\n dependencies are above\". This is also what makes dependency cycles end on\n their own.\n\n A mark at the end of a line says how the package is needed; \u{1b}[32mImports\u{1b}[39m is the\n common case and is not marked.\n\n - \u{1b}[32m[D]\u{1b}[39m — a \u{1b}[32mDepends\u{1b}[39m, so the package is \u{1b}[3mattached\u{1b}[23m, not merely loaded.\n - \u{1b}[32m[L]\u{1b}[39m — a \u{1b}[32mLinkingTo\u{1b}[39m, so this package is compiled against it.\n - \u{1b}[32m[DL]\u{1b}[39m — both.\n\n Dependencies are listed with R first, then grouped by dependency type, in the\n order R lists the fields in, and by name within a type. R and the base\n packages, e.g. \u{1b}[32mutils\u{1b}[39m, are shown with their version requirement but without a\n version of their own, as they are part of R; \u{1b}[32m--no-base\u{1b}[39m leaves them out\n altogether. A package that is not in the repositories is shown with \u{1b}[32m?\u{1b}[39m for\n its version.\n\n By default rig follows the hard dependencies only. \u{1b}[32m--dev\u{1b}[39m adds \u{1b}[32mSuggests\u{1b}[39m and\n \u{1b}[32mEnhances\u{1b}[39m, in their own \u{1b}[32m[Suggests]\u{1b}[39m and \u{1b}[32m[Enhances]\u{1b}[39m sections. As in\n \u{1b}[32mrig pkg deps\u{1b}[39m, \u{1b}[32m--dev\u{1b}[39m applies to the queried package only, so these sections\n only ever appear at the top of the tree.\n\n rig follows the dependencies of the \u{1b}[3mlatest\u{1b}[23m version of every package in the\n tree, so a version requirement that would force an older version, with\n different dependencies, is not taken into account. Use\n \u{1b}[32mrig proj lock\u{1b}[39m for a resolution that is consistent across\n versions.\n\n\u{1b}[1m\u{1b}[34mInverting the tree:\u{1b}[39m\u{1b}[22m\n \u{1b}[32m--why \u{1b}[39m (alias \u{1b}[32m--explain\u{1b}[39m) inverts the tree: the named package is\n the root and the tree grows towards the packages that need it, down to the\n queried package, which becomes a leaf.\n\n glue 1.8.1 — 4 direct dependents, 5 total\n ├── dplyr 1.2.1 (needs >= 1.3.2)\n ├── pillar 1.11.1\n │ └── dplyr 1.2.1 (needs >= 1.9.0)\n └── vctrs 0.7.3\n ├── dplyr 1.2.1 (needs >= 0.7.1)\n └── pillar 1.11.1 (needs >= 0.5.0) (*)\n\n Each line says how \u{1b}[3mthat\u{1b}[23m package needs the one \u{1b}[1mabove\u{1b}[22m it, hence \u{1b}[32mneeds\u{1b}[39m;\n the \u{1b}[32m[D]\u{1b}[39m, \u{1b}[32m[L]\u{1b}[39m, \u{1b}[32m[S]\u{1b}[39m and \u{1b}[32m[E]\u{1b}[39m marks describe the same edge. \u{1b}[32m[S]\u{1b}[39m and \u{1b}[32m[E]\u{1b}[39m\n take the place of the \u{1b}[32m[Suggests]\u{1b}[39m and \u{1b}[32m[Enhances]\u{1b}[39m sections, which in an\n inverted tree would be one line deep inside it.\n\n \u{1b}[32m--why\u{1b}[39m searches the tree only, not the repositories, so \u{1b}[32m--version\u{1b}[39m, \u{1b}[32m--dev\u{1b}[39m\n and \u{1b}[32m--no-base\u{1b}[39m apply as above, and a package that is not in the tree is an\n error."; const ABOUT_PKG: &str = "Manage R packages (experimental)"; const HELP_PKG: &str = "\u{1b}[1m\u{1b}[34mDescription:\u{1b}[39m\u{1b}[22m\n Look up R packages, in the package repositories rig configures for your R\n installations and in the libraries they are installed into, and install\n them, mostly without starting R.\n\n \u{1b}[32mrig pkg available\u{1b}[39m lists every package the\n repositories offer, \u{1b}[32mrig pkg info\u{1b}[39m shows the\n \u{1b}[32mDESCRIPTION\u{1b}[39m of one package, or, with \u{1b}[32m--versions\u{1b}[39m, all of its versions,\n \u{1b}[32mrig pkg deps\u{1b}[39m lists the packages one package needs,\n directly or, with \u{1b}[32m--recursive\u{1b}[39m, transitively, and\n \u{1b}[32mrig pkg tree\u{1b}[39m shows those transitive dependencies as a\n tree instead of a table.\n\n \u{1b}[32mrig pkg install\u{1b}[39m,\n \u{1b}[32mrig pkg list\u{1b}[39m and\n \u{1b}[32mrig pkg remove\u{1b}[39m are the subcommands that work on a\n package library instead of the repositories: they install packages and\n their dependencies into it, list the packages that are actually installed,\n and delete some of them.\n\n The repositories themselves are managed by \u{1b}[32mrig repos\u{1b}[39m, the\n libraries by \u{1b}[32mrig library\u{1b}[39m."; const ABOUT_PPM_BUILDS: &str = "List the published builds of a package"; @@ -62,16 +62,16 @@ const ABOUT_PPM_URL: &str = "Print the Posit Package Manager URL"; const HELP_PPM_URL: &str = "\u{1b}[1m\u{1b}[34mDescription:\u{1b}[39m\u{1b}[22m\n Print the base URL of the Posit Package Manager instance the other\n \u{1b}[32mrig ppm\u{1b}[39m commands report on, and nothing else, so it can be\n used directly in a script:\n\n curl \"$(rig ppm url)/__api__/repos\"\n\n This is \u{1b}[32mhttps://packagemanager.posit.co\u{1b}[39m unless the\n \u{1b}[32mPACKAGEMANAGER_ADDRESS\u{1b}[39m environment variable is set, in which case it is\n that, with any trailing slash removed.\n\n The \u{1b}[32mRIG_PPM_STATUS_URL\u{1b}[39m environment variable overrides the URL of the\n status document alone, and takes precedence over \u{1b}[32mPACKAGEMANAGER_ADDRESS\u{1b}[39m\n for that one document. If you set it, this command still prints the base\n URL, while \u{1b}[32mrig ppm status\u{1b}[39m reports on the\n instance \u{1b}[32mRIG_PPM_STATUS_URL\u{1b}[39m names and shows which URL that was."; const ABOUT_PPM: &str = "Query Posit Package Manager (experimental)"; const HELP_PPM: &str = "\u{1b}[1m\u{1b}[34mDescription:\u{1b}[39m\u{1b}[22m\n Ask Posit Package Manager (P3M) what it offers: which platforms and R\n versions it builds binary packages for, and which builds exist for a\n package. Nothing here changes anything on your machine.\n\n \u{1b}[32mrig ppm platforms\u{1b}[39m and\n \u{1b}[32mrig ppm r-versions\u{1b}[39m list the build targets and R\n versions, \u{1b}[32mrig ppm status\u{1b}[39m shows P3M's whole status\n report, \u{1b}[32mrig ppm builds\u{1b}[39m lists the published builds of\n one package, and \u{1b}[32mrig ppm url\u{1b}[39m prints the URL rig is\n talking to.\n\n This is about P3M itself. To manage the repositories configured for your R\n installations, including P3M ones, use \u{1b}[32mrig repos\u{1b}[39m; to look up\n package metadata in those repositories, use \u{1b}[32mrig pkg\u{1b}[39m.\n\n\u{1b}[1m\u{1b}[34mWhich server:\u{1b}[39m\u{1b}[22m\n By default rig reports on the public instance,\n \u{1b}[32mhttps://packagemanager.posit.co\u{1b}[39m. Set the \u{1b}[32mPACKAGEMANAGER_ADDRESS\u{1b}[39m\n environment variable to the base URL of your own P3M instance to report on\n that instead. \u{1b}[32mrig ppm url\u{1b}[39m prints whichever one is in effect.\n\n One command is different: \u{1b}[32mrig ppm builds\u{1b}[39m reads a package build index that\n rig publishes itself, derived from P3M, because P3M has no endpoint that\n lists a package's builds. That index always comes from rig's own host, and\n \u{1b}[32mPACKAGEMANAGER_ADDRESS\u{1b}[39m does not redirect it."; -const ABOUT_PROJ_DEPLOY: &str = "Deploy project dependencies"; -const HELP_PROJ_DEPLOY: &str = "\u{1b}[1m\u{1b}[34mDescription:\u{1b}[39m\u{1b}[22m\n Install the resolved dependencies of an R project into a package library.\n\n rig solves the project dependencies and installs them into the library\n given by \u{1b}[32m--library\u{1b}[39m. Use \u{1b}[32m--r-binary\u{1b}[39m to select which R to build against\n (default: \u{1b}[32mR\u{1b}[39m) and \u{1b}[32m--max-concurrent\u{1b}[39m to limit the number of simultaneous\n installations (default: 4)."; const ABOUT_PROJ_DEPS: &str = "Show project dependencies"; -const HELP_PROJ_DEPS: &str = "\u{1b}[1m\u{1b}[34mDescription:\u{1b}[39m\u{1b}[22m\n Show the dependencies of an R project, in a table: every package the\n project depends on, the dependency type (\u{1b}[32mDepends\u{1b}[39m, \u{1b}[32mImports\u{1b}[39m,\n \u{1b}[32mLinkingTo\u{1b}[39m) and the version requirement, if it has one.\n\n By default rig reads the project manifest (e.g. \u{1b}[32mDESCRIPTION\u{1b}[39m) in the\n current directory; use \u{1b}[32m--input\u{1b}[39m to point to a different file. Add \u{1b}[32m--dev\u{1b}[39m\n to include development dependencies. Use \u{1b}[32m--json\u{1b}[39m for machine readable\n output.\n\n The plain listing only reads the manifest, so it needs neither R nor the\n package repositories.\n\n\u{1b}[1m\u{1b}[34mRecursive dependencies:\u{1b}[39m\u{1b}[22m\n \u{1b}[32m--recursive\u{1b}[39m (\u{1b}[32m-r\u{1b}[39m) shows the whole dependency closure: not only the\n packages the project needs directly, but also the packages \u{1b}[3mthose\u{1b}[23m need,\n and so on. Each package appears once, with the version currently on CRAN,\n the \u{1b}[32mDepth\u{1b}[39m column giving its distance from the project, and the\n \u{1b}[32mNeeded by\u{1b}[39m column naming the packages that pull it in. This needs the\n package metadata of the repositories, which rig downloads if it does not\n have it yet.\n\n \u{1b}[32mrig proj tree\u{1b}[39m shows the same closure as a tree, laid\n out by the shape of the dependency graph, so you can see \u{1b}[3mhow\u{1b}[23m each\n package is pulled in and not only \u{1b}[3mthat\u{1b}[23m it is.\n\n A recursive listing only ever follows hard dependencies, also below a\n development dependency added by \u{1b}[32m--dev\u{1b}[39m, so \u{1b}[32m--dev --recursive\u{1b}[39m means the\n project's own dev dependencies plus everything they need to be installed.\n\n rig follows the dependencies of the \u{1b}[3mlatest\u{1b}[23m version of every package in\n the closure, so a version requirement that would force an older version,\n with different dependencies, is not taken into account. Use\n \u{1b}[32mrig proj solve\u{1b}[39m for a resolution that is consistent\n across versions."; -const ABOUT_PROJ_SOLVE: &str = "Solve project dependencies"; -const HELP_PROJ_SOLVE: &str = "\u{1b}[1m\u{1b}[34mDescription:\u{1b}[39m\u{1b}[22m\n Resolve the dependencies of an R project to a concrete set of package\n versions.\n\n rig reads the project manifest (e.g. \u{1b}[32mDESCRIPTION\u{1b}[39m; override with\n \u{1b}[32m--input\u{1b}[39m) and uses its built-in solver to find a compatible set of\n package versions from the configured repositories, without running R.\n\n Use \u{1b}[32m--r-version\u{1b}[39m to solve for a specific R version, \u{1b}[32m--dev\u{1b}[39m to include\n development dependencies, and \u{1b}[32m--renv\u{1b}[39m to write the result as an\n \u{1b}[32mrenv.lock\u{1b}[39m file.\n\n\u{1b}[1m\u{1b}[34mSource and binary packages:\u{1b}[39m\u{1b}[22m\n The solver considers binary packages as well as source packages, and\n prefers a binary build when one is available for the same version. Which\n artifact each package is installed from is part of what the solve decides,\n because a binary is only usable together with the exact versions of its\n \u{1b}[32mLinkingTo\u{1b}[39m dependencies that it was compiled against. If those versions\n conflict with the rest of the project, rig picks another build of that\n package, or falls back to its source tarball.\n\n By default a binary build never changes \u{1b}[3mwhich version\u{1b}[23m rig picks: the\n newest suitable version wins, and a binary of it is used if there is one.\n Pass \u{1b}[32m--prefer-binary\u{1b}[39m to let an older version win instead, when the newest\n one has no binary but an older one does — typically because a version was\n released so recently that it has not been built yet. Only the three newest\n versions of a package are considered; \u{1b}[32m--prefer-binary=5\u{1b}[39m considers five.\n Versions held back this way are marked in the output.\n\n Trading a version away for a binary is not free: the binary pins its\n \u{1b}[32mLinkingTo\u{1b}[39m dependencies to the versions it was compiled against, and those\n dependencies then prefer their own binaries in turn, so a whole project can\n end up on older versions.\n\n By default rig solves for the machine it runs on. Use \u{1b}[32m--platform\u{1b}[39m to solve\n for a different one, e.g. to write a lockfile on macOS for a Linux\n deployment:\n\n rig proj solve --platform ubuntu-24.04\n\n \u{1b}[32m--platform source\u{1b}[39m solves for source packages only, and does not download\n any binary package metadata. rig also falls back to source packages when\n there are no binaries for a platform at all. There is then nothing for\n \u{1b}[32m--prefer-binary\u{1b}[39m to prefer, and rig ignores it.\n\n rig keeps the repository metadata and the binary package indices it solves\n from in its cache, and refreshes them once a day. \u{1b}[32m--no-cache\u{1b}[39m downloads\n them again instead, and writes nothing to the cache, which is the way to\n solve against a package that was published minutes ago. It is a good deal\n slower, because the metadata it re-downloads is large. See\n \u{1b}[32mrig config\u{1b}[39m.\n\n The \u{1b}[32mpkg.lock\u{1b}[39m file records, for every package, whether it is a source or a\n binary package and the URL it is downloaded from. It also records where the\n file is cached, which is per \u{1b}[3mbuild\u{1b}[23m rather than per version: a repository\n can offer several binaries of one version for one platform and R version,\n and they are cached side by side."; +const HELP_PROJ_DEPS: &str = "\u{1b}[1m\u{1b}[34mDescription:\u{1b}[39m\u{1b}[22m\n Show the dependencies of an R project, in a table: every package the\n project depends on, the dependency type (\u{1b}[32mDepends\u{1b}[39m, \u{1b}[32mImports\u{1b}[39m,\n \u{1b}[32mLinkingTo\u{1b}[39m) and the version requirement, if it has one.\n\n By default rig reads the project manifest (e.g. \u{1b}[32mDESCRIPTION\u{1b}[39m) in the\n current directory; use \u{1b}[32m--input\u{1b}[39m to point to a different file. Add \u{1b}[32m--dev\u{1b}[39m\n to include development dependencies. Use \u{1b}[32m--json\u{1b}[39m for machine readable\n output.\n\n The plain listing only reads the manifest, so it needs neither R nor the\n package repositories.\n\n\u{1b}[1m\u{1b}[34mRecursive dependencies:\u{1b}[39m\u{1b}[22m\n \u{1b}[32m--recursive\u{1b}[39m (\u{1b}[32m-r\u{1b}[39m) shows the whole dependency closure: not only the\n packages the project needs directly, but also the packages \u{1b}[3mthose\u{1b}[23m need,\n and so on. Each package appears once, with the version currently on CRAN,\n the \u{1b}[32mDepth\u{1b}[39m column giving its distance from the project, and the\n \u{1b}[32mNeeded by\u{1b}[39m column naming the packages that pull it in. This needs the\n package metadata of the repositories, which rig downloads if it does not\n have it yet.\n\n \u{1b}[32mrig proj tree\u{1b}[39m shows the same closure as a tree, laid\n out by the shape of the dependency graph, so you can see \u{1b}[3mhow\u{1b}[23m each\n package is pulled in and not only \u{1b}[3mthat\u{1b}[23m it is.\n\n A recursive listing only ever follows hard dependencies, also below a\n development dependency added by \u{1b}[32m--dev\u{1b}[39m, so \u{1b}[32m--dev --recursive\u{1b}[39m means the\n project's own dev dependencies plus everything they need to be installed.\n\n rig follows the dependencies of the \u{1b}[3mlatest\u{1b}[23m version of every package in\n the closure, so a version requirement that would force an older version,\n with different dependencies, is not taken into account. Use\n \u{1b}[32mrig proj lock\u{1b}[39m for a resolution that is consistent\n across versions."; +const ABOUT_PROJ_LOCK: &str = "Resolve project dependencies and write rproj.lock"; +const HELP_PROJ_LOCK: &str = "\u{1b}[1m\u{1b}[34mDescription:\u{1b}[39m\u{1b}[22m\n Resolve the dependencies of an R project to a concrete set of package\n versions, and write the result to \u{1b}[32mrproj.lock\u{1b}[39m.\n\n rig reads the project manifest (e.g. \u{1b}[32mDESCRIPTION\u{1b}[39m; override with\n \u{1b}[32m--input\u{1b}[39m) and uses its built-in solver to find a compatible set of\n package versions from the configured repositories, without running R.\n\n Use \u{1b}[32m--r-version\u{1b}[39m to solve for a specific R version, \u{1b}[32m--dev\u{1b}[39m to include\n development dependencies, and \u{1b}[32m--renv\u{1b}[39m to also write the result as an\n \u{1b}[32mrenv.lock\u{1b}[39m file.\n\n \u{1b}[32mrproj.lock\u{1b}[39m currently records a single \u{1b}[32m(R version, platform)\u{1b}[39m target;\n \u{1b}[32mrig proj sync\u{1b}[39m installs that target. Solving for several\n targets in one lockfile is planned but not implemented yet.\n\n\u{1b}[1m\u{1b}[34mSource and binary packages:\u{1b}[39m\u{1b}[22m\n The solver considers binary packages as well as source packages, and\n prefers a binary build when one is available for the same version. Which\n artifact each package is installed from is part of what the solve decides,\n because a binary is only usable together with the exact versions of its\n \u{1b}[32mLinkingTo\u{1b}[39m dependencies that it was compiled against. If those versions\n conflict with the rest of the project, rig picks another build of that\n package, or falls back to its source tarball.\n\n By default a binary build never changes \u{1b}[3mwhich version\u{1b}[23m rig picks: the\n newest suitable version wins, and a binary of it is used if there is one.\n Pass \u{1b}[32m--prefer-binary\u{1b}[39m to let an older version win instead, when the newest\n one has no binary but an older one does — typically because a version was\n released so recently that it has not been built yet. Only the three newest\n versions of a package are considered; \u{1b}[32m--prefer-binary=5\u{1b}[39m considers five.\n Versions held back this way are marked in the output.\n\n Trading a version away for a binary is not free: the binary pins its\n \u{1b}[32mLinkingTo\u{1b}[39m dependencies to the versions it was compiled against, and those\n dependencies then prefer their own binaries in turn, so a whole project can\n end up on older versions.\n\n By default rig solves for the machine it runs on. Use \u{1b}[32m--platform\u{1b}[39m to solve\n for a different one, e.g. to write a lockfile on macOS for a Linux\n deployment:\n\n rig proj lock --platform ubuntu-24.04\n\n \u{1b}[32m--platform source\u{1b}[39m solves for source packages only, and does not download\n any binary package metadata. rig also falls back to source packages when\n there are no binaries for a platform at all. There is then nothing for\n \u{1b}[32m--prefer-binary\u{1b}[39m to prefer, and rig ignores it.\n\n rig keeps the repository metadata and the binary package indices it solves\n from in its cache, and refreshes them once a day. \u{1b}[32m--no-cache\u{1b}[39m downloads\n them again instead, and writes nothing to the cache, which is the way to\n solve against a package that was published minutes ago. It is a good deal\n slower, because the metadata it re-downloads is large. See\n \u{1b}[32mrig config\u{1b}[39m.\n\n The \u{1b}[32mrproj.lock\u{1b}[39m file records, for every package, whether it is a source or a\n binary package and the URL it is downloaded from. It also records where the\n file is cached, which is per \u{1b}[3mbuild\u{1b}[23m rather than per version: a repository\n can offer several binaries of one version for one platform and R version,\n and they are cached side by side."; +const ABOUT_PROJ_SYNC: &str = "Install the dependencies rproj.lock resolved"; +const HELP_PROJ_SYNC: &str = "\u{1b}[1m\u{1b}[34mDescription:\u{1b}[39m\u{1b}[22m\n Install the resolved dependencies of an R project into a package library.\n\n rig reads \u{1b}[32mrproj.lock\u{1b}[39m (written by \u{1b}[32mrig proj lock\u{1b}[39m) and\n installs its packages into the library given by \u{1b}[32m--library\u{1b}[39m (default:\n \u{1b}[32m.rvenv/lib\u{1b}[39m). Use \u{1b}[32m--r-binary\u{1b}[39m to select which R to build against\n (default: \u{1b}[32mR\u{1b}[39m) and \u{1b}[32m--max-concurrent\u{1b}[39m to limit the number of simultaneous\n installations (default: 4)."; const ABOUT_PROJ_TREE: &str = "Dependency tree of a project"; -const HELP_PROJ_TREE: &str = "\u{1b}[1m\u{1b}[34mDescription:\u{1b}[39m\u{1b}[22m\n Show everything an R project needs, directly or indirectly, as a tree: the\n same closure \u{1b}[32mrig proj deps --recursive\u{1b}[39m lists in a flat\n table, laid out by the shape of the dependency graph.\n\n myproject 0.1.0 — 3 direct, 24 total\n ├── R (>= 4.1) [D]\n ├── cli 3.6.4\n │ ├── R (>= 3.4) [D]\n │ └── utils\n └── dplyr 1.1.4 (>= 1.1.0)\n ├── cli 3.6.4 (>= 3.4.0) (*)\n └── vctrs 0.6.5 (>= 0.6.4)\n └── cpp11 0.5.2 [L]\n [Suggests]\n └── testthat 3.2.3 (>= 3.1.5)\n\n The first line names the project and its version, how many dependencies it\n declares directly and how many distinct packages there are in the whole tree.\n Each line below names a package, the version currently in the repositories,\n and the version requirement it is needed with, if it has one.\n\n By default rig reads the project manifest (e.g. \u{1b}[32mDESCRIPTION\u{1b}[39m) in the current\n directory; use \u{1b}[32m--input\u{1b}[39m to point to a different file. Unlike\n \u{1b}[32mrig proj deps\u{1b}[39m, the tree needs the package metadata of the\n repositories, which rig downloads if it does not have it yet. It does not need\n R. \u{1b}[32m--json\u{1b}[39m gives machine readable output, as one nested object.\n\n\u{1b}[1m\u{1b}[34mReading the tree:\u{1b}[39m\u{1b}[22m\n A package that several others need is expanded only once, under its first\n occurrence; later occurrences are marked \u{1b}[32m(*)\u{1b}[39m, meaning \"its dependencies are\n above\". \u{1b}[32m--dev\u{1b}[39m adds the project's development dependencies, in their own\n \u{1b}[32m[Suggests]\u{1b}[39m and \u{1b}[32m[Enhances]\u{1b}[39m sections; \u{1b}[32m--no-base\u{1b}[39m leaves out R and the base\n packages. Among the hard dependencies, \u{1b}[32mImports\u{1b}[39m is not marked, \u{1b}[32m[D]\u{1b}[39m is a\n \u{1b}[32mDepends\u{1b}[39m, \u{1b}[32m[L]\u{1b}[39m a \u{1b}[32mLinkingTo\u{1b}[39m, \u{1b}[32m[DL]\u{1b}[39m both.\n\n \u{1b}[32m--why \u{1b}[39m (alias \u{1b}[32m--explain\u{1b}[39m) inverts the tree, so that the named\n package is the root and the tree grows towards the packages that need it, down\n to the project itself. Each line then says how \u{1b}[3mthat\u{1b}[23m package needs the one\n above it, hence \u{1b}[32mneeds\u{1b}[39m.\n\n \u{1b}[32mrig pkg tree\u{1b}[39m, which shows the same tree for a package\n in the repositories, describes all of this in full.\n\n rig follows the dependencies of the \u{1b}[3mlatest\u{1b}[23m version of every package in the\n tree, so a version requirement that would force an older version, with\n different dependencies, is not taken into account. Use\n \u{1b}[32mrig proj solve\u{1b}[39m for a resolution that is consistent across\n versions."; +const HELP_PROJ_TREE: &str = "\u{1b}[1m\u{1b}[34mDescription:\u{1b}[39m\u{1b}[22m\n Show everything an R project needs, directly or indirectly, as a tree: the\n same closure \u{1b}[32mrig proj deps --recursive\u{1b}[39m lists in a flat\n table, laid out by the shape of the dependency graph.\n\n myproject 0.1.0 — 3 direct, 24 total\n ├── R (>= 4.1) [D]\n ├── cli 3.6.4\n │ ├── R (>= 3.4) [D]\n │ └── utils\n └── dplyr 1.1.4 (>= 1.1.0)\n ├── cli 3.6.4 (>= 3.4.0) (*)\n └── vctrs 0.6.5 (>= 0.6.4)\n └── cpp11 0.5.2 [L]\n [Suggests]\n └── testthat 3.2.3 (>= 3.1.5)\n\n The first line names the project and its version, how many dependencies it\n declares directly and how many distinct packages there are in the whole tree.\n Each line below names a package, the version currently in the repositories,\n and the version requirement it is needed with, if it has one.\n\n By default rig reads the project manifest (e.g. \u{1b}[32mDESCRIPTION\u{1b}[39m) in the current\n directory; use \u{1b}[32m--input\u{1b}[39m to point to a different file. Unlike\n \u{1b}[32mrig proj deps\u{1b}[39m, the tree needs the package metadata of the\n repositories, which rig downloads if it does not have it yet. It does not need\n R. \u{1b}[32m--json\u{1b}[39m gives machine readable output, as one nested object.\n\n\u{1b}[1m\u{1b}[34mReading the tree:\u{1b}[39m\u{1b}[22m\n A package that several others need is expanded only once, under its first\n occurrence; later occurrences are marked \u{1b}[32m(*)\u{1b}[39m, meaning \"its dependencies are\n above\". \u{1b}[32m--dev\u{1b}[39m adds the project's development dependencies, in their own\n \u{1b}[32m[Suggests]\u{1b}[39m and \u{1b}[32m[Enhances]\u{1b}[39m sections; \u{1b}[32m--no-base\u{1b}[39m leaves out R and the base\n packages. Among the hard dependencies, \u{1b}[32mImports\u{1b}[39m is not marked, \u{1b}[32m[D]\u{1b}[39m is a\n \u{1b}[32mDepends\u{1b}[39m, \u{1b}[32m[L]\u{1b}[39m a \u{1b}[32mLinkingTo\u{1b}[39m, \u{1b}[32m[DL]\u{1b}[39m both.\n\n \u{1b}[32m--why \u{1b}[39m (alias \u{1b}[32m--explain\u{1b}[39m) inverts the tree, so that the named\n package is the root and the tree grows towards the packages that need it, down\n to the project itself. Each line then says how \u{1b}[3mthat\u{1b}[23m package needs the one\n above it, hence \u{1b}[32mneeds\u{1b}[39m.\n\n \u{1b}[32mrig pkg tree\u{1b}[39m, which shows the same tree for a package\n in the repositories, describes all of this in full.\n\n rig follows the dependencies of the \u{1b}[3mlatest\u{1b}[23m version of every package in the\n tree, so a version requirement that would force an older version, with\n different dependencies, is not taken into account. Use\n \u{1b}[32mrig proj lock\u{1b}[39m for a resolution that is consistent across\n versions."; const ABOUT_PROJ: &str = "Manage R projects (experimental)"; -const HELP_PROJ: &str = "\u{1b}[1m\u{1b}[34mDescription:\u{1b}[39m\u{1b}[22m\n Manage R projects (experimental).\n\n A project is a directory with a package manifest, typically a\n \u{1b}[32mDESCRIPTION\u{1b}[39m file, that declares the R packages the project depends on.\n \u{1b}[32mrig proj\u{1b}[39m resolves those dependencies against the configured package\n repositories and can install them into a project library.\n\n \u{1b}[32mrig proj deps\u{1b}[39m shows the direct and recursive dependencies of the\n project.\n \u{1b}[32mrig proj tree\u{1b}[39m shows the recursive dependencies as a tree, so you can\n see how each package is pulled in.\n \u{1b}[32mrig proj solve\u{1b}[39m resolves the full dependency tree to a concrete set of\n package versions, and can write the result to an \u{1b}[32mrenv.lock\u{1b}[39m file.\n \u{1b}[32mrig proj deploy\u{1b}[39m installs the resolved dependencies into a package\n library.\n\n Dependencies are resolved with rig's built-in solver, so R does not need\n to be running for \u{1b}[32mrig proj deps\u{1b}[39m, \u{1b}[32mrig proj tree\u{1b}[39m and \u{1b}[32mrig proj solve\u{1b}[39m.\n\n \u{1b}[32mrig proj\u{1b}[39m is currently experimental, and might change in future\n versions. Feedback is appreciated."; +const HELP_PROJ: &str = "\u{1b}[1m\u{1b}[34mDescription:\u{1b}[39m\u{1b}[22m\n Manage R projects (experimental).\n\n A project is a directory with a package manifest, typically a\n \u{1b}[32mDESCRIPTION\u{1b}[39m file, that declares the R packages the project depends on.\n \u{1b}[32mrig proj\u{1b}[39m resolves those dependencies against the configured package\n repositories and can install them into a project library.\n\n \u{1b}[32mrig proj deps\u{1b}[39m shows the direct and recursive dependencies of the\n project.\n \u{1b}[32mrig proj tree\u{1b}[39m shows the recursive dependencies as a tree, so you can\n see how each package is pulled in.\n \u{1b}[32mrig proj lock\u{1b}[39m resolves the full dependency tree to a concrete set of\n package versions, writes the result to \u{1b}[32mrproj.lock\u{1b}[39m, and can also write\n an \u{1b}[32mrenv.lock\u{1b}[39m file.\n \u{1b}[32mrig proj sync\u{1b}[39m installs the dependencies \u{1b}[32mrproj.lock\u{1b}[39m resolved into a\n package library.\n\n Dependencies are resolved with rig's built-in solver, so R does not need\n to be running for \u{1b}[32mrig proj deps\u{1b}[39m, \u{1b}[32mrig proj tree\u{1b}[39m and \u{1b}[32mrig proj lock\u{1b}[39m.\n\n \u{1b}[32mrig proj\u{1b}[39m is currently experimental, and might change in future\n versions. Feedback is appreciated."; const ABOUT_REPOS_AVAILABLE: &str = "List available R package repositories"; const HELP_REPOS_AVAILABLE: &str = "\u{1b}[1m\u{1b}[34mDescription:\u{1b}[39m\u{1b}[22m\n List the package repositories that rig knows about and can set up.\n\n These are the repositories you can enable with \u{1b}[32m--with-repos\u{1b}[39m when running\n \u{1b}[32mrig add\u{1b}[39m or \u{1b}[32mrig repos setup\u{1b}[39m.\n\n Without arguments rig prints one row per repository: its name, whether it is\n part of the default repository set, and its title.\n\n Pass a repository name to see its description and its URLs, together with the\n platforms, architectures and R versions each URL applies to. Repository names\n are matched case insensitively.\n\n\u{1b}[1m\u{1b}[34mExamples:\u{1b}[39m\u{1b}[22m\n # List all repositories rig knows about\n rig repos available\n\n # Show the URLs of one repository\n rig repos available P3M"; const ABOUT_REPOS_LIST: &str = "List configured R package repositories"; diff --git a/src/help/pkg-deps.md b/src/help/pkg-deps.md index 2432f6be..bd4cb419 100644 --- a/src/help/pkg-deps.md +++ b/src/help/pkg-deps.md @@ -43,5 +43,5 @@ installed. rig follows the dependencies of the *latest* version of every package in the tree, so a version requirement that would force an older version, with different dependencies, is not taken into account. Use -[`rig proj solve`](proj.qmd) for a resolution that is consistent across +[`rig proj lock`](proj.qmd) for a resolution that is consistent across versions. diff --git a/src/help/pkg-install.md b/src/help/pkg-install.md index cf3d33f6..51f2c066 100644 --- a/src/help/pkg-install.md +++ b/src/help/pkg-install.md @@ -19,7 +19,7 @@ glue 1.8.0 binary install not installed ``` rig resolves the whole dependency tree first, the same way -[`rig proj solve`](proj.qmd) does for a project, so a package is only +[`rig proj lock`](proj.qmd) does for a project, so a package is only installed if every package it needs can be installed with it, at versions that work together. `--dry-run` runs the resolution and reports what it would install, without installing anything. diff --git a/src/help/pkg-tree.md b/src/help/pkg-tree.md index aff8889b..2c981719 100644 --- a/src/help/pkg-tree.md +++ b/src/help/pkg-tree.md @@ -62,7 +62,7 @@ only ever appear at the top of the tree. rig follows the dependencies of the *latest* version of every package in the tree, so a version requirement that would force an older version, with different dependencies, is not taken into account. Use -[`rig proj solve`](proj.qmd) for a resolution that is consistent across +[`rig proj lock`](proj.qmd) for a resolution that is consistent across versions. ## Inverting the tree diff --git a/src/help/proj-deploy.md b/src/help/proj-deploy.md deleted file mode 100644 index 458bfe28..00000000 --- a/src/help/proj-deploy.md +++ /dev/null @@ -1,10 +0,0 @@ -Deploy project dependencies - -## Description - -Install the resolved dependencies of an R project into a package library. - -rig solves the project dependencies and installs them into the library -given by `--library`. Use `--r-binary` to select which R to build against -(default: `R`) and `--max-concurrent` to limit the number of simultaneous -installations (default: 4). diff --git a/src/help/proj-deps.md b/src/help/proj-deps.md index 761440df..24f2fbef 100644 --- a/src/help/proj-deps.md +++ b/src/help/proj-deps.md @@ -35,5 +35,5 @@ project's own dev dependencies plus everything they need to be installed. rig follows the dependencies of the *latest* version of every package in the closure, so a version requirement that would force an older version, with different dependencies, is not taken into account. Use -[`rig proj solve`](#rig-proj-solve) for a resolution that is consistent +[`rig proj lock`](#rig-proj-lock) for a resolution that is consistent across versions. diff --git a/src/help/proj-solve.md b/src/help/proj-lock.md similarity index 84% rename from src/help/proj-solve.md rename to src/help/proj-lock.md index cda76b2d..770c3f3b 100644 --- a/src/help/proj-solve.md +++ b/src/help/proj-lock.md @@ -1,18 +1,22 @@ -Solve project dependencies +Resolve project dependencies and write rproj.lock ## Description Resolve the dependencies of an R project to a concrete set of package -versions. +versions, and write the result to `rproj.lock`. rig reads the project manifest (e.g. `DESCRIPTION`; override with `--input`) and uses its built-in solver to find a compatible set of package versions from the configured repositories, without running R. Use `--r-version` to solve for a specific R version, `--dev` to include -development dependencies, and `--renv` to write the result as an +development dependencies, and `--renv` to also write the result as an `renv.lock` file. +`rproj.lock` currently records a single `(R version, platform)` target; +[`rig proj sync`](#rig-proj-sync) installs that target. Solving for several +targets in one lockfile is planned but not implemented yet. + ## Source and binary packages The solver considers binary packages as well as source packages, and @@ -41,7 +45,7 @@ for a different one, e.g. to write a lockfile on macOS for a Linux deployment: ```sh -rig proj solve --platform ubuntu-24.04 +rig proj lock --platform ubuntu-24.04 ``` `--platform source` solves for source packages only, and does not download @@ -56,7 +60,7 @@ solve against a package that was published minutes ago. It is a good deal slower, because the metadata it re-downloads is large. See [`rig config`](config.qmd). -The `pkg.lock` file records, for every package, whether it is a source or a +The `rproj.lock` file records, for every package, whether it is a source or a binary package and the URL it is downloaded from. It also records where the file is cached, which is per *build* rather than per version: a repository can offer several binaries of one version for one platform and R version, diff --git a/src/help/proj-sync.md b/src/help/proj-sync.md new file mode 100644 index 00000000..93eef57f --- /dev/null +++ b/src/help/proj-sync.md @@ -0,0 +1,11 @@ +Install the dependencies rproj.lock resolved + +## Description + +Install the resolved dependencies of an R project into a package library. + +rig reads `rproj.lock` (written by [`rig proj lock`](#rig-proj-lock)) and +installs its packages into the library given by `--library` (default: +`.rvenv/lib`). Use `--r-binary` to select which R to build against +(default: `R`) and `--max-concurrent` to limit the number of simultaneous +installations (default: 4). diff --git a/src/help/proj-tree.md b/src/help/proj-tree.md index 44ef8dae..df5391c6 100644 --- a/src/help/proj-tree.md +++ b/src/help/proj-tree.md @@ -51,5 +51,5 @@ in the repositories, describes all of this in full. rig follows the dependencies of the *latest* version of every package in the tree, so a version requirement that would force an older version, with different dependencies, is not taken into account. Use -[`rig proj solve`](#rig-proj-solve) for a resolution that is consistent across +[`rig proj lock`](#rig-proj-lock) for a resolution that is consistent across versions. diff --git a/src/help/proj.md b/src/help/proj.md index 3ab651cc..d211bc8b 100644 --- a/src/help/proj.md +++ b/src/help/proj.md @@ -13,13 +13,14 @@ repositories and can install them into a project library. project. `rig proj tree` shows the recursive dependencies as a tree, so you can see how each package is pulled in. -`rig proj solve` resolves the full dependency tree to a concrete set of -package versions, and can write the result to an `renv.lock` file. -`rig proj deploy` installs the resolved dependencies into a package -library. +`rig proj lock` resolves the full dependency tree to a concrete set of +package versions, writes the result to `rproj.lock`, and can also write +an `renv.lock` file. +`rig proj sync` installs the dependencies `rproj.lock` resolved into a +package library. Dependencies are resolved with rig's built-in solver, so R does not need -to be running for `rig proj deps`, `rig proj tree` and `rig proj solve`. +to be running for `rig proj deps`, `rig proj tree` and `rig proj lock`. `rig proj` is currently experimental, and might change in future versions. Feedback is appreciated. diff --git a/src/install.rs b/src/install.rs index 3780a3ff..0c954b24 100644 --- a/src/install.rs +++ b/src/install.rs @@ -689,7 +689,7 @@ where /// Install a set of packages into a library, with a progress bar, and return how /// many went in. /// -/// The synchronous entry point both `rig pkg install` and `rig proj deploy` use: +/// The synchronous entry point both `rig pkg install` and `rig proj sync` use: /// it owns the tokio runtime and the progress bar, so that the callers only have /// to decide *what* to install. pub fn install_packages( diff --git a/src/lib.rs b/src/lib.rs index 245f6489..4824b5a8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -37,6 +37,7 @@ mod renv; mod repos; mod repositories; mod resolve; +mod rproj; mod run; mod rversion; mod solver; diff --git a/src/main.rs b/src/main.rs index 631a4d41..1fa4b19c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -59,6 +59,7 @@ mod renv; mod repos; mod repositories; mod resolve; +mod rproj; mod run; mod rversion; mod solver; diff --git a/src/pkg/deps.rs b/src/pkg/deps.rs index 691c930c..520816fb 100644 --- a/src/pkg/deps.rs +++ b/src/pkg/deps.rs @@ -126,7 +126,7 @@ fn direct_deps( /// requirement that would force an older version — with different dependencies /// — is not honored. That is the same approximation /// [`crate::solver::RPackageRegistry::prefetch_binaries`] makes; a full, -/// version-consistent resolution is what `rig proj solve` is for. +/// version-consistent resolution is what `rig proj lock` is for. fn recursive_deps( loader: &dyn PackageVersionLoader, package: &str, diff --git a/src/pkg/install.rs b/src/pkg/install.rs index eaccb4e5..30917d42 100644 --- a/src/pkg/install.rs +++ b/src/pkg/install.rs @@ -3,7 +3,7 @@ //! The command is a solve followed by a download followed by an unpack. The //! solve is the same one [`crate::proj`] runs for a project, with the packages //! named on the command line standing in for a `DESCRIPTION`; the download and -//! the install are the same code `rig proj deploy` uses. What is specific to +//! the install are the same code `rig proj sync` uses. What is specific to //! this command is the third step in between: deciding which of the solved //! packages actually have to be installed, because the library already holds //! the rest. @@ -54,7 +54,7 @@ use crate::solver::{is_base_package, PackageVersionLoader}; use super::deps::root_package; use super::list::{read_installed, resolve_library, InstalledPackage, ResolvedLibrary}; -/// How many packages are installed at once. The same default `rig proj deploy` +/// How many packages are installed at once. The same default `rig proj sync` /// uses. const MAX_CONCURRENT: usize = 8; @@ -150,13 +150,7 @@ pub fn sc_pkg_install( bail!("{}", library_error(&lib, err)); } - let to_download = PakLockfile { - lockfile_version: lockfile.lockfile_version, - os: lockfile.os.clone(), - r_version: lockfile.r_version.clone(), - platform: lockfile.platform.clone(), - packages: todo.iter().map(|p| (*p).clone()).collect(), - }; + let to_download: Vec = todo.iter().map(|p| (*p).clone()).collect(); download_lockfile_packages(&to_download)?; let cache_dir = get_cache_dir()?; diff --git a/src/pkg/tree.rs b/src/pkg/tree.rs index 09d6a11c..0110108a 100644 --- a/src/pkg/tree.rs +++ b/src/pkg/tree.rs @@ -146,7 +146,7 @@ struct DepTree { /// /// Like [`super::deps`], the tree is taken over the newest version of each /// package, so a version requirement that would force an older version — with -/// different dependencies — is not honored. `rig proj solve` is what a full, +/// different dependencies — is not honored. `rig proj lock` is what a full, /// version-consistent resolution is for. fn dep_tree( loader: &dyn PackageVersionLoader, diff --git a/src/proj.rs b/src/proj.rs index 194211a7..99f6cb59 100644 --- a/src/proj.rs +++ b/src/proj.rs @@ -31,6 +31,7 @@ use crate::platform::{detect_platform, parse_platform_string}; use crate::renv::*; use crate::repos::binaries::loader::{BinaryTarget, P3mBinaryLoader}; use crate::repos::*; +use crate::rproj::{RprojLock, RprojLockTarget, RPROJ_LOCK_VERSION}; use crate::solver::*; use crate::utils::create_parent_dir_if_needed; @@ -55,8 +56,8 @@ pub fn sc_proj(args: &ArgMatches, mainargs: &ArgMatches) -> Result<(), Box sc_proj_deps(s, args, mainargs), Some(("tree", s)) => sc_proj_tree(s, args, mainargs), - Some(("solve", s)) => sc_proj_solve(s, args, mainargs), - Some(("deploy", s)) => sc_proj_deploy(s, args, mainargs), + Some(("lock", s)) => sc_proj_lock(s, args, mainargs), + Some(("sync", s)) => sc_proj_sync(s, args, mainargs), _ => Ok(()), // unreachable } } @@ -383,7 +384,7 @@ fn solution_to_sorted_vec( vec } -fn sc_proj_solve( +fn sc_proj_lock( args: &ArgMatches, _libargs: &ArgMatches, _mainargs: &ArgMatches, @@ -439,10 +440,22 @@ fn sc_proj_solve( info!("Written renv lockfile to renv.lock"); } + // Single-target for now: one `(r_version, platform)` entry. The matrix + // form (solving for several targets into one `rproj.lock`) is follow-up + // work; `sc_proj_sync` already reads `targets[0]` unconditionally to + // match. let lockfile = PakLockfile::from_solution(®istry, &solution); - fs::write("pkg.lock", serde_json::to_string_pretty(&lockfile)?)?; - OUTPUT.success("Written package lockfile to pkg.lock"); - info!("Written package lockfile to pkg.lock"); + let rproj_lock = RprojLock { + version: RPROJ_LOCK_VERSION, + targets: vec![RprojLockTarget { + r_version: lockfile.r_version.clone(), + platform: lockfile.platform.clone(), + packages: lockfile.packages.clone(), + }], + }; + fs::write("rproj.lock", toml::to_string_pretty(&rproj_lock)?)?; + OUTPUT.success("Written project lockfile to rproj.lock"); + info!("Written project lockfile to rproj.lock"); let sorted_solution = solution_to_sorted_vec(&solution); let mut tab: Table = Table::new("{:<} {:<} {:<} {:<}"); @@ -476,27 +489,32 @@ fn sc_proj_solve( Ok(()) } -fn sc_proj_deploy( +fn sc_proj_sync( args: &ArgMatches, _libargs: &ArgMatches, _mainargs: &ArgMatches, ) -> Result<(), Box> { - // First, download all packages + // Read the lockfile to get package information. Single-target for now: + // always the first (and, today, only) entry `rig proj lock` wrote; the + // "pick the entry matching this machine, hard error if none match" logic + // for a real multi-target `rproj.lock` is follow-up work. + let lock_content = fs::read_to_string("rproj.lock")?; + let lock: RprojLock = toml::from_str(&lock_content)?; + let target = lock.targets.first().ok_or("rproj.lock has no targets")?; + + // Download all packages OUTPUT.status("Downloading packages"); info!("Downloading packages"); - proj_download()?; - - // Read the lockfile to get package information - let lockfile_content = fs::read_to_string("pkg.lock")?; - let lockfile: PakLockfile = serde_json::from_str(&lockfile_content)?; + download_lockfile_packages(&target.packages)?; // Get cache directory where packages were downloaded let cache_dir = get_cache_dir()?; - // Get library path - required argument + // Library path: --library, or the project venv's library by default let library_path = PathBuf::from( args.get_one::("library") - .ok_or("--library argument is required")?, + .map(|s| s.as_str()) + .unwrap_or(".rvenv/lib"), ); // Ensure library directory exists @@ -508,10 +526,10 @@ fn sc_proj_deploy( .map(|s| s.as_str()) .unwrap_or("R"); - // Build Vec from lockfile - let built = BuiltCache::new(&lockfile.r_version, r_binary); + // Build Vec from the target's package list + let built = BuiltCache::new(&target.r_version, r_binary); let mut packages: Vec = Vec::new(); - for pkg in &lockfile.packages { + for pkg in &target.packages { packages.push(lockfile_package_info(pkg, &cache_dir, built.as_ref())); } @@ -578,23 +596,31 @@ pub(crate) fn lockfile_package_info( /// This will be different for CRAN and CRAN-like repositories. pub(crate) const PACKAGE_FILE_TTL: Duration = Duration::MAX; +/// Read `pkg.lock` (the pak-compatible JSON lockfile `rig pkg install` +/// writes/reads) and download everything it names. Used by the hidden `rig +/// test download-lockfile` diagnostic; unrelated to `rproj.lock` / `rig proj +/// sync`, which call [`download_lockfile_packages`] directly instead. pub fn proj_download() -> Result<(), Box> { let lockfile_content = fs::read_to_string("pkg.lock")?; let lockfile: PakLockfile = serde_json::from_str(&lockfile_content)?; - download_lockfile_packages(&lockfile) + download_lockfile_packages(&lockfile.packages) } /// Download every package a lockfile names into the package cache. /// -/// Split out from [`proj_download`] so that `rig pkg install`, which solves in -/// memory and never writes a lockfile, can use it too. -pub(crate) fn download_lockfile_packages(lockfile: &PakLockfile) -> Result<(), Box> { +/// Takes a plain package slice, not a whole lockfile, so any caller with a +/// `Vec` can use it directly — `rig pkg install`, which +/// solves in memory and never writes a lockfile, and `rig proj sync`, which +/// reads one target's packages out of `rproj.lock`. +pub(crate) fn download_lockfile_packages( + packages: &[PakLockfilePackage], +) -> Result<(), Box> { // Get cache directory let cache_dir = get_cache_dir()?; // Build download list: (sources, target_path) for each package let mut downloads: Vec<(Vec, PathBuf)> = Vec::new(); - for pkg in &lockfile.packages { + for pkg in packages { let target_path = cache_dir.join("packages").join(&pkg.target); create_parent_dir_if_needed(&target_path)?; downloads.push((pkg.sources.clone(), target_path)); @@ -629,10 +655,10 @@ pub(crate) fn download_lockfile_packages(lockfile: &PakLockfile) -> Result<(), B Ok((downloaded, _etag)) => { if *downloaded { success_count.set(success_count.get() + 1); - overall_pb.println(format!("✓ Downloaded: {}", lockfile.packages[idx].package)); + overall_pb.println(format!("✓ Downloaded: {}", packages[idx].package)); } else { cached_count.set(cached_count.get() + 1); - overall_pb.println(format!("✓ Cached: {}", lockfile.packages[idx].package)); + overall_pb.println(format!("✓ Cached: {}", packages[idx].package)); } overall_pb.inc(1); } @@ -647,17 +673,10 @@ pub(crate) fn download_lockfile_packages(lockfile: &PakLockfile) -> Result<(), B if let Some((idx, err)) = error.into_inner() { OUTPUT.error(&format!( "Failed to download {}: {}", - lockfile.packages[idx].package, err + packages[idx].package, err )); - error!( - "Failed to download {}: {}", - lockfile.packages[idx].package, err - ); - bail!( - "Failed to download {}: {}", - lockfile.packages[idx].package, - err - ); + error!("Failed to download {}: {}", packages[idx].package, err); + bail!("Failed to download {}: {}", packages[idx].package, err); } overall_pb.finish_with_message(format!( diff --git a/src/rproj.rs b/src/rproj.rs new file mode 100644 index 00000000..f3920de3 --- /dev/null +++ b/src/rproj.rs @@ -0,0 +1,89 @@ +// `rproj.lock`: the multi-target project lockfile written by `rig proj lock` +// and read by `rig proj sync`. TOML, unlike the JSON `pkg.lock` written by +// `rig pkg install` (`src/pak.rs`), which stays as-is because it mirrors the R +// `pak` package's own lockfile schema for interop with `pak::lockfile_*()`. +// +// `rproj.lock` is not interop with anything external; it is rig's own format, +// designed to hold the solve for *several* `(R version, platform)` targets in +// one file — e.g. solving once on a laptop for both macOS and a Linux +// deployment target. Each target's package list reuses `PakLockfilePackage` +// as-is (verified it round-trips cleanly through the `toml` crate, table +// fields and all), so a target's dependency data is exactly what `pkg.lock` +// would have recorded for that one target, just nested under it instead of +// being the whole file. +// +// For now (first implementation slice) `rig proj lock` only ever writes one +// target, and `rig proj sync` always installs `targets[0]`; the multi-target +// solve loop and the "pick the entry matching this machine" logic in `sync` +// are follow-up work. + +use serde::{Deserialize, Serialize}; + +use crate::pak::PakLockfilePackage; + +pub const RPROJ_LOCK_VERSION: usize = 1; + +#[derive(Serialize, Deserialize, Debug)] +pub struct RprojLock { + pub version: usize, + pub targets: Vec, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct RprojLockTarget { + pub r_version: String, + pub platform: String, + pub packages: Vec, +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + fn sample_package() -> PakLockfilePackage { + PakLockfilePackage { + r#ref: "cli".to_string(), + package: "cli".to_string(), + version: "3.6.0".to_string(), + r#type: "standard".to_string(), + direct: true, + binary: true, + dependencies: vec!["rlang".to_string()], + vignettes: false, + metadata: HashMap::from([("RemoteSha".to_string(), "abc123".to_string())]), + sources: vec!["https://example.com/cli.tgz".to_string()], + target: "cli.tgz".to_string(), + platform: "aarch64-apple-darwin".to_string(), + rversion: "4.6".to_string(), + directpkg: true, + license: "MIT".to_string(), + dep_types: vec!["Imports".to_string()], + params: vec![], + install_args: "".to_string(), + sysreqs: "".to_string(), + } + } + + #[test] + fn roundtrips_through_toml() { + let lock = RprojLock { + version: RPROJ_LOCK_VERSION, + targets: vec![RprojLockTarget { + r_version: "4.6".to_string(), + platform: "aarch64-apple-darwin".to_string(), + packages: vec![sample_package()], + }], + }; + let text = toml::to_string_pretty(&lock).unwrap(); + let parsed: RprojLock = toml::from_str(&text).unwrap(); + assert_eq!(parsed.version, 1); + assert_eq!(parsed.targets.len(), 1); + assert_eq!(parsed.targets[0].r_version, "4.6"); + assert_eq!(parsed.targets[0].packages[0].r#ref, "cli"); + assert_eq!( + parsed.targets[0].packages[0].metadata.get("RemoteSha"), + Some(&"abc123".to_string()) + ); + } +} diff --git a/src/shim/main.rs b/src/shim/main.rs index d1b00bbc..636ff693 100644 --- a/src/shim/main.rs +++ b/src/shim/main.rs @@ -36,7 +36,11 @@ fn main() { }; let args: Vec<_> = env::args_os().skip(1).collect(); - match Command::new(&footer.target).args(&args).status() { + match Command::new(&footer.target) + .args(&args) + .envs(footer.env.iter().map(|(k, v)| (k.as_str(), v.as_str()))) + .status() + { Ok(status) => exit(status.code().unwrap_or(1)), Err(e) => { eprintln!("rig shim: failed to run {}: {}", footer.target, e); diff --git a/src/shim_format.rs b/src/shim_format.rs index 197291e2..1c17548d 100644 --- a/src/shim_format.rs +++ b/src/shim_format.rs @@ -1,9 +1,25 @@ // Binary format for the Windows `.exe` quick-link shims. A shim file is the // bundled `rig-shim.exe` template, byte-for-byte, followed by a footer that -// tells the shim which real executable to forward to: +// tells the shim which real executable to forward to. Two footer versions +// exist, distinguished by their trailing 8-byte magic (always the last 8 +// bytes of the file, regardless of version): // +// V1 (`RIGSHIM1`, no env vars — used by the ordinary quick links): // [ template bytes ][ target path, UTF-8 ][ marker, UTF-8 (may be empty) ] -// [ target_len: u32 LE ][ marker_len: u32 LE ][ MAGIC: 8 bytes ] +// [ target_len: u32 LE ][ marker_len: u32 LE ][ MAGIC_V1: 8 bytes ] +// +// V2 (`RIGSHIM2`, adds a baked-in env-var block — used by `.rvenv\bin` +// shims, which need to force `R_LIBS_USER`/`R_LIBS_SITE`/`R_REPOSITORIES`/ +// `RVENV` before exec-ing the real R, the same thing the Unix `.rvenv/bin/R` +// wrapper script does with `export`): +// [ template bytes ][ target path, UTF-8 ][ marker, UTF-8 (may be empty) ] +// [ env block, see below ] +// [ target_len: u32 LE ][ marker_len: u32 LE ][ env_len: u32 LE ] +// [ MAGIC_V2: 8 bytes ] +// +// Env block: zero or more entries back-to-back, each +// [ key_len: u16 LE ][ key, UTF-8 ][ val_len: u16 LE ][ val, UTF-8 ] +// parsed by consuming entries until `env_len` bytes are used up. // // `target` is the absolute path of the real `R.exe`/`Rscript.exe` to run. // `marker` is only non-empty for the default-version links (`R.exe`, @@ -16,13 +32,17 @@ use std::fs::File; use std::io::{Read, Seek, SeekFrom}; use std::path::Path; -pub const MAGIC: &[u8; 8] = b"RIGSHIM1"; -const TRAILER_LEN: u64 = 4 + 4 + 8; +pub const MAGIC_V1: &[u8; 8] = b"RIGSHIM1"; +pub const MAGIC_V2: &[u8; 8] = b"RIGSHIM2"; +const TRAILER_LEN_V1: u64 = 4 + 4 + 8; +const TRAILER_LEN_V2: u64 = 4 + 4 + 4 + 8; pub struct ShimFooter { pub target: String, #[allow(dead_code)] // only read by the `rig` binary, not the shim itself pub marker: String, + #[allow(dead_code)] // read by src/shim/main.rs; not yet by the `rig` binary + pub env: Vec<(String, String)>, } #[allow(dead_code)] // only used by the `rig` binary, not the shim itself @@ -33,33 +53,134 @@ pub fn build_shim_bytes(template: &[u8], target: &str, marker: &str) -> Vec out.extend_from_slice(marker.as_bytes()); out.extend_from_slice(&(target.len() as u32).to_le_bytes()); out.extend_from_slice(&(marker.len() as u32).to_le_bytes()); - out.extend_from_slice(MAGIC); + out.extend_from_slice(MAGIC_V1); out } +// Same as `build_shim_bytes`, but also bakes in a list of environment +// variables the shim will set (via `.envs(...)`, so they override whatever +// the launching process already has) before forwarding to `target`. +#[allow(dead_code)] // only used by the `rig` binary, not the shim itself +pub fn build_shim_bytes_env( + template: &[u8], + target: &str, + marker: &str, + envs: &[(String, String)], +) -> Vec { + let mut env_block = Vec::new(); + for (k, v) in envs { + env_block.extend_from_slice(&(k.len() as u16).to_le_bytes()); + env_block.extend_from_slice(k.as_bytes()); + env_block.extend_from_slice(&(v.len() as u16).to_le_bytes()); + env_block.extend_from_slice(v.as_bytes()); + } + let mut out = + Vec::with_capacity(template.len() + target.len() + marker.len() + env_block.len() + 20); + out.extend_from_slice(template); + out.extend_from_slice(target.as_bytes()); + out.extend_from_slice(marker.as_bytes()); + out.extend_from_slice(&env_block); + out.extend_from_slice(&(target.len() as u32).to_le_bytes()); + out.extend_from_slice(&(marker.len() as u32).to_le_bytes()); + out.extend_from_slice(&(env_block.len() as u32).to_le_bytes()); + out.extend_from_slice(MAGIC_V2); + out +} + +fn parse_env_block(mut data: &[u8]) -> Option> { + let mut envs = Vec::new(); + while !data.is_empty() { + if data.len() < 2 { + return None; + } + let klen = u16::from_le_bytes(data[0..2].try_into().unwrap()) as usize; + data = &data[2..]; + if data.len() < klen + 2 { + return None; + } + let key = String::from_utf8(data[..klen].to_vec()).ok()?; + data = &data[klen..]; + let vlen = u16::from_le_bytes(data[0..2].try_into().unwrap()) as usize; + data = &data[2..]; + if data.len() < vlen { + return None; + } + let val = String::from_utf8(data[..vlen].to_vec()).ok()?; + data = &data[vlen..]; + envs.push((key, val)); + } + Some(envs) +} + pub fn read_shim_footer(path: &Path) -> Result, Box> { let mut f = File::open(path)?; let file_len = f.metadata()?.len(); - if file_len < TRAILER_LEN { + if file_len < 8 { return Ok(None); } - let mut trailer = [0u8; TRAILER_LEN as usize]; - f.seek(SeekFrom::End(-(TRAILER_LEN as i64)))?; - f.read_exact(&mut trailer)?; + let mut magic = [0u8; 8]; + f.seek(SeekFrom::End(-8))?; + f.read_exact(&mut magic)?; - if &trailer[8..16] != MAGIC.as_slice() { + if magic == *MAGIC_V2 { + if file_len < TRAILER_LEN_V2 { + return Ok(None); + } + let mut trailer = [0u8; TRAILER_LEN_V2 as usize]; + f.seek(SeekFrom::End(-(TRAILER_LEN_V2 as i64)))?; + f.read_exact(&mut trailer)?; + + let target_len = u32::from_le_bytes(trailer[0..4].try_into().unwrap()) as u64; + let marker_len = u32::from_le_bytes(trailer[4..8].try_into().unwrap()) as u64; + let env_len = u32::from_le_bytes(trailer[8..12].try_into().unwrap()) as u64; + let data_len = target_len + marker_len + env_len; + if file_len < TRAILER_LEN_V2 + data_len { + return Ok(None); + } + + let mut data = vec![0u8; data_len as usize]; + f.seek(SeekFrom::Start(file_len - TRAILER_LEN_V2 - data_len))?; + f.read_exact(&mut data)?; + + let (target_bytes, rest) = data.split_at(target_len as usize); + let (marker_bytes, env_bytes) = rest.split_at(marker_len as usize); + let target = match String::from_utf8(target_bytes.to_vec()) { + Ok(s) => s, + Err(_) => return Ok(None), + }; + let marker = match String::from_utf8(marker_bytes.to_vec()) { + Ok(s) => s, + Err(_) => return Ok(None), + }; + let env = match parse_env_block(env_bytes) { + Some(e) => e, + None => return Ok(None), + }; + return Ok(Some(ShimFooter { + target, + marker, + env, + })); + } + + if magic != *MAGIC_V1 || file_len < TRAILER_LEN_V1 { return Ok(None); } + + let mut trailer = [0u8; TRAILER_LEN_V1 as usize]; + f.seek(SeekFrom::End(-(TRAILER_LEN_V1 as i64)))?; + f.read_exact(&mut trailer)?; + let target_len = u32::from_le_bytes(trailer[0..4].try_into().unwrap()) as u64; let marker_len = u32::from_le_bytes(trailer[4..8].try_into().unwrap()) as u64; let data_len = target_len + marker_len; - if file_len < TRAILER_LEN + data_len { + if file_len < TRAILER_LEN_V1 + data_len { return Ok(None); } let mut data = vec![0u8; data_len as usize]; - f.seek(SeekFrom::Start(file_len - TRAILER_LEN - data_len))?; + f.seek(SeekFrom::Start(file_len - TRAILER_LEN_V1 - data_len))?; f.read_exact(&mut data)?; let (target_bytes, marker_bytes) = data.split_at(target_len as usize); @@ -71,5 +192,69 @@ pub fn read_shim_footer(path: &Path) -> Result, Box s, Err(_) => return Ok(None), }; - Ok(Some(ShimFooter { target, marker })) + Ok(Some(ShimFooter { + target, + marker, + env: Vec::new(), + })) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + #[test] + fn v1_roundtrip_unaffected() { + let template = b"FAKE_TEMPLATE_BYTES"; + let bytes = build_shim_bytes(template, "C:\\R\\bin\\R.exe", "4.6"); + let dir = std::env::temp_dir(); + let path = dir.join("rig_shim_test_v1.bin"); + std::fs::File::create(&path) + .unwrap() + .write_all(&bytes) + .unwrap(); + let footer = read_shim_footer(&path).unwrap().unwrap(); + assert_eq!(footer.target, "C:\\R\\bin\\R.exe"); + assert_eq!(footer.marker, "4.6"); + assert!(footer.env.is_empty()); + std::fs::remove_file(&path).unwrap(); + } + + #[test] + fn v2_roundtrip_with_env() { + let template = b"FAKE_TEMPLATE_BYTES"; + let envs = vec![ + ( + "R_LIBS_USER".to_string(), + "C:\\proj\\.rvenv\\lib".to_string(), + ), + ("RVENV".to_string(), "C:\\proj\\.rvenv".to_string()), + ("R_LIBS_SITE".to_string(), "".to_string()), + ]; + let bytes = build_shim_bytes_env(template, "C:\\R\\bin\\R.exe", "", &envs); + let dir = std::env::temp_dir(); + let path = dir.join("rig_shim_test_v2.bin"); + std::fs::File::create(&path) + .unwrap() + .write_all(&bytes) + .unwrap(); + let footer = read_shim_footer(&path).unwrap().unwrap(); + assert_eq!(footer.target, "C:\\R\\bin\\R.exe"); + assert_eq!(footer.marker, ""); + assert_eq!(footer.env, envs); + std::fs::remove_file(&path).unwrap(); + } + + #[test] + fn rejects_garbage() { + let dir = std::env::temp_dir(); + let path = dir.join("rig_shim_test_garbage.bin"); + std::fs::File::create(&path) + .unwrap() + .write_all(b"not a shim") + .unwrap(); + assert!(read_shim_footer(&path).unwrap().is_none()); + std::fs::remove_file(&path).unwrap(); + } } diff --git a/src/windows/mod.rs b/src/windows/mod.rs index 8c723408..736a76d1 100644 --- a/src/windows/mod.rs +++ b/src/windows/mod.rs @@ -81,6 +81,24 @@ pub(crate) fn write_shim_link( Ok(()) } +// Same as `write_shim_link`, but also bakes in a list of environment +// variables for the shim to set before forwarding to `target`. Used for +// `.rvenv\bin\R.exe` / `Rscript.exe`, which need to force +// `R_LIBS_USER`/`R_LIBS_SITE`/`R_REPOSITORIES`/`RVENV`, the Windows +// equivalent of the `export`s in the Unix `.rvenv/bin/R` wrapper script. +#[allow(dead_code)] // wired up once `rig proj sync` lands on Windows +pub(crate) fn write_shim_link_env( + path: &Path, + target: &str, + marker: &str, + envs: &[(String, String)], +) -> Result<(), Box> { + let template = std::fs::read(find_shim_template()?)?; + let bytes = crate::shim_format::build_shim_bytes_env(&template, target, marker, envs); + std::fs::write(path, bytes)?; + Ok(()) +} + // Read the (target, marker) a quick-link `.exe` at `path` forwards to, or // `None` if `path` doesn't exist, isn't one of our shims, or can't be read. pub(crate) fn read_shim_link(path: &Path) -> Option<(String, String)> { From 7301a056b5c9e671e178f7442b905fd980a26ac2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Cs=C3=A1rdi?= Date: Wed, 2 Sep 2026 16:27:21 +0200 Subject: [PATCH 02/19] rig proj sync: do not reinstall the same version --- src/pkg/install.rs | 10 ++++---- src/pkg/list.rs | 12 +++++----- src/pkg/mod.rs | 4 ++-- src/proj.rs | 58 ++++++++++++++++++++++++++++++++++------------ 4 files changed, 56 insertions(+), 28 deletions(-) diff --git a/src/pkg/install.rs b/src/pkg/install.rs index 30917d42..7b99bd09 100644 --- a/src/pkg/install.rs +++ b/src/pkg/install.rs @@ -309,11 +309,11 @@ fn add_dev_deps( /// What rig decided to do about one package of the solution, and why. #[derive(Debug)] -struct Planned<'a> { - package: &'a PakLockfilePackage, - install: bool, +pub(crate) struct Planned<'a> { + pub(crate) package: &'a PakLockfilePackage, + pub(crate) install: bool, /// Why it is being installed, or why it is not. Reported, never acted on. - reason: String, + pub(crate) reason: String, } /// Which of the solved packages have to be installed into the library. @@ -329,7 +329,7 @@ struct Planned<'a> { /// invalidates whatever was compiled against *those*. The coupling is /// `LinkingTo` only: an `Imports` dependency being replaced changes nothing /// about how its dependents were compiled. -fn plan_installs<'a>( +pub(crate) fn plan_installs<'a>( solved: &'a [PakLockfilePackage], installed: &[InstalledPackage], reinstall: bool, diff --git a/src/pkg/list.rs b/src/pkg/list.rs index 367c5724..ed77f80b 100644 --- a/src/pkg/list.rs +++ b/src/pkg/list.rs @@ -149,9 +149,9 @@ fn looks_like_path(lib: &str) -> bool { /// this is a report on what is installed, so a version rig cannot parse is /// still shown as it is. #[derive(Debug)] -pub(super) struct InstalledPackage { - pub(super) package: String, - pub(super) version: String, +pub(crate) struct InstalledPackage { + pub(crate) package: String, + pub(crate) version: String, /// The directory the package is installed in, i.e. the one holding its /// `DESCRIPTION`. Usually named after the package, but the `Package` field /// of the `DESCRIPTION` is what `package` reports, so the two can differ. @@ -167,10 +167,10 @@ pub(super) struct InstalledPackage { /// The `RemoteHash` field, i.e. which upstream CRAN artifact this package /// was installed from. Only `rig pkg install` writes it, so it is unset for /// anything installed by R, pak or renv. - pub(super) hash: Option, + pub(crate) hash: Option, /// The `RemoteLinkingToHashes` field: what the package was compiled against, /// as `(package, version, sha256)`. - pub(super) linkingto: Vec<(String, String, String)>, + pub(crate) linkingto: Vec<(String, String, String)>, } #[cfg(test)] @@ -204,7 +204,7 @@ impl InstalledPackage { /// (see [`crate::library::sc_library_get_list`]) live in the main library /// directory, and a package whose installation was interrupted has no /// `DESCRIPTION` yet. -pub(super) fn read_installed(path: &Path) -> Result, Box> { +pub(crate) fn read_installed(path: &Path) -> Result, Box> { debug!("Listing packages in {}", path.display()); let entries = match std::fs::read_dir(path) { diff --git a/src/pkg/mod.rs b/src/pkg/mod.rs index 296ffa2e..d2da9271 100644 --- a/src/pkg/mod.rs +++ b/src/pkg/mod.rs @@ -20,8 +20,8 @@ use crate::repos::cranlike_metadata::{self, repos_get_packages, ArchivedPackage} use crate::textfmt::{reflow, wrap, write_field}; pub(crate) mod deps; -mod install; -mod list; +pub(crate) mod install; +pub(crate) mod list; mod manifest; mod remove; #[cfg(test)] diff --git a/src/proj.rs b/src/proj.rs index 99f6cb59..3f3b85df 100644 --- a/src/proj.rs +++ b/src/proj.rs @@ -26,6 +26,8 @@ use crate::pak::{PakLockfile, PakLockfilePackage}; use crate::pkg::deps::{ dep_count, print_deps_json, print_deps_recursive, print_header, type_list, walk_deps, }; +use crate::pkg::install::plan_installs; +use crate::pkg::list::read_installed; use crate::pkg::tree::proj_tree; use crate::platform::{detect_platform, parse_platform_string}; use crate::renv::*; @@ -502,14 +504,6 @@ fn sc_proj_sync( let lock: RprojLock = toml::from_str(&lock_content)?; let target = lock.targets.first().ok_or("rproj.lock has no targets")?; - // Download all packages - OUTPUT.status("Downloading packages"); - info!("Downloading packages"); - download_lockfile_packages(&target.packages)?; - - // Get cache directory where packages were downloaded - let cache_dir = get_cache_dir()?; - // Library path: --library, or the project venv's library by default let library_path = PathBuf::from( args.get_one::("library") @@ -517,6 +511,38 @@ fn sc_proj_sync( .unwrap_or(".rvenv/lib"), ); + // A package already in the library, at the version and provenance the + // lockfile asks for, does not need to be downloaded or reinstalled. + let already_installed = if library_path.exists() { + read_installed(&library_path)? + } else { + vec![] + }; + let plan = plan_installs(&target.packages, &already_installed, false); + let todo: Vec<&PakLockfilePackage> = plan + .iter() + .filter(|p| p.install) + .map(|p| p.package) + .collect(); + + if todo.is_empty() { + OUTPUT.success(&format!( + "Everything is up to date in {}", + library_path.display() + )); + info!("Nothing to install in {}", library_path.display()); + return Ok(()); + } + + // Download only the packages that are actually going to be installed + OUTPUT.status("Downloading packages"); + info!("Downloading packages"); + let to_download: Vec = todo.iter().map(|p| (*p).clone()).collect(); + download_lockfile_packages(&to_download)?; + + // Get cache directory where packages were downloaded + let cache_dir = get_cache_dir()?; + // Ensure library directory exists fs::create_dir_all(&library_path)?; @@ -526,12 +552,12 @@ fn sc_proj_sync( .map(|s| s.as_str()) .unwrap_or("R"); - // Build Vec from the target's package list + // Build Vec for the packages that need installing let built = BuiltCache::new(&target.r_version, r_binary); - let mut packages: Vec = Vec::new(); - for pkg in &target.packages { - packages.push(lockfile_package_info(pkg, &cache_dir, built.as_ref())); - } + let packages: Vec = todo + .iter() + .map(|pkg| lockfile_package_info(pkg, &cache_dir, built.as_ref())) + .collect(); // Set max concurrent installations let max_concurrent = args @@ -541,13 +567,15 @@ fn sc_proj_sync( let total_packages = packages.len(); OUTPUT.status(&format!( - "Installing {} packages to {}", + "Installing {} of {} packages to {}", total_packages, + target.packages.len(), library_path.display() )); info!( - "Installing {} packages to {}", + "Installing {} of {} packages to {}", total_packages, + target.packages.len(), library_path.display() ); From 9f9ce6ec41d59fff8fb797359c55742d17101046 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Cs=C3=A1rdi?= Date: Wed, 2 Sep 2026 16:35:40 +0200 Subject: [PATCH 03/19] Fix rig proj sync - drop already installed packages from dependencies - print plan --- src/pkg/install.rs | 8 ++++---- src/proj.rs | 13 ++++++++++--- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/pkg/install.rs b/src/pkg/install.rs index 7b99bd09..66b98048 100644 --- a/src/pkg/install.rs +++ b/src/pkg/install.rs @@ -123,7 +123,7 @@ pub fn sc_pkg_install( if json { print_plan_json(&plan)?; } else { - print_plan(&lib, &plan); + print_plan(&lib.tag(), &plan); } if dry_run { @@ -462,13 +462,13 @@ fn needs_install( // Reporting /// Print the plan as a table: what is being installed, what is not, and why. -fn print_plan(lib: &ResolvedLibrary, plan: &[Planned]) { +pub(crate) fn print_plan(tag: &str, plan: &[Planned]) { let n = plan.iter().filter(|p| p.install).count(); OUTPUT.println(&format!( "{} of {} packages to install {}", n, plan.len(), - lib.tag() + tag )); let mut tab = Table::new("{:<} {:<} {:<} {:<} {:<}"); @@ -490,7 +490,7 @@ fn print_plan(lib: &ResolvedLibrary, plan: &[Planned]) { } /// Print the plan as a JSON array, one object per package of the solution. -fn print_plan_json(plan: &[Planned]) -> Result<(), Box> { +pub(crate) fn print_plan_json(plan: &[Planned]) -> Result<(), Box> { #[derive(serde::Serialize)] struct PlanEntry<'a> { package: &'a str, diff --git a/src/proj.rs b/src/proj.rs index 3f3b85df..9ad5b82b 100644 --- a/src/proj.rs +++ b/src/proj.rs @@ -1,5 +1,5 @@ use std::cell::Cell; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::error::Error; use std::fs::{self, File}; use std::path::{Path, PathBuf}; @@ -26,7 +26,7 @@ use crate::pak::{PakLockfile, PakLockfilePackage}; use crate::pkg::deps::{ dep_count, print_deps_json, print_deps_recursive, print_header, type_list, walk_deps, }; -use crate::pkg::install::plan_installs; +use crate::pkg::install::{plan_installs, print_plan}; use crate::pkg::list::read_installed; use crate::pkg::tree::proj_tree; use crate::platform::{detect_platform, parse_platform_string}; @@ -519,6 +519,7 @@ fn sc_proj_sync( vec![] }; let plan = plan_installs(&target.packages, &already_installed, false); + print_plan(&format!("({})", library_path.display()), &plan); let todo: Vec<&PakLockfilePackage> = plan .iter() .filter(|p| p.install) @@ -554,9 +555,15 @@ fn sc_proj_sync( // Build Vec for the packages that need installing let built = BuiltCache::new(&target.r_version, r_binary); + let installing: HashSet<&str> = todo.iter().map(|p| p.package.as_str()).collect(); let packages: Vec = todo .iter() - .map(|pkg| lockfile_package_info(pkg, &cache_dir, built.as_ref())) + .map(|pkg| { + let mut info = lockfile_package_info(pkg, &cache_dir, built.as_ref()); + info.dependencies + .retain(|d| installing.contains(d.as_str())); + info + }) .collect(); // Set max concurrent installations From 8104ee663e3a79ea0a1c739255c15c4f0e6053a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Cs=C3=A1rdi?= Date: Wed, 2 Sep 2026 17:01:27 +0200 Subject: [PATCH 04/19] Add 'rig proj init' --- src/args.rs | 14 ++ src/help-generated.in | 2 + src/proj.rs | 39 ++++- src/rproj.rs | 322 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 375 insertions(+), 2 deletions(-) diff --git a/src/args.rs b/src/args.rs index 893de619..c9b02ff2 100644 --- a/src/args.rs +++ b/src/args.rs @@ -1315,6 +1315,20 @@ pub fn rig_app() -> Command { .num_args(0) .required(false), ) + .subcommand( + Command::new("init") + .about(ABOUT_PROJ_INIT) + .long_about(HELP_PROJ_INIT) + .display_order(0) + .arg( + Arg::new("force") + .help("Overwrite an existing rproj.toml") + .long("force") + .short('f') + .num_args(0) + .required(false), + ), + ) .subcommand( Command::new("deps") .about(ABOUT_PROJ_DEPS) diff --git a/src/help-generated.in b/src/help-generated.in index ccfbd239..a9778216 100644 --- a/src/help-generated.in +++ b/src/help-generated.in @@ -64,6 +64,8 @@ const ABOUT_PPM: &str = "Query Posit Package Manager (experimental)"; const HELP_PPM: &str = "\u{1b}[1m\u{1b}[34mDescription:\u{1b}[39m\u{1b}[22m\n Ask Posit Package Manager (P3M) what it offers: which platforms and R\n versions it builds binary packages for, and which builds exist for a\n package. Nothing here changes anything on your machine.\n\n \u{1b}[32mrig ppm platforms\u{1b}[39m and\n \u{1b}[32mrig ppm r-versions\u{1b}[39m list the build targets and R\n versions, \u{1b}[32mrig ppm status\u{1b}[39m shows P3M's whole status\n report, \u{1b}[32mrig ppm builds\u{1b}[39m lists the published builds of\n one package, and \u{1b}[32mrig ppm url\u{1b}[39m prints the URL rig is\n talking to.\n\n This is about P3M itself. To manage the repositories configured for your R\n installations, including P3M ones, use \u{1b}[32mrig repos\u{1b}[39m; to look up\n package metadata in those repositories, use \u{1b}[32mrig pkg\u{1b}[39m.\n\n\u{1b}[1m\u{1b}[34mWhich server:\u{1b}[39m\u{1b}[22m\n By default rig reports on the public instance,\n \u{1b}[32mhttps://packagemanager.posit.co\u{1b}[39m. Set the \u{1b}[32mPACKAGEMANAGER_ADDRESS\u{1b}[39m\n environment variable to the base URL of your own P3M instance to report on\n that instead. \u{1b}[32mrig ppm url\u{1b}[39m prints whichever one is in effect.\n\n One command is different: \u{1b}[32mrig ppm builds\u{1b}[39m reads a package build index that\n rig publishes itself, derived from P3M, because P3M has no endpoint that\n lists a package's builds. That index always comes from rig's own host, and\n \u{1b}[32mPACKAGEMANAGER_ADDRESS\u{1b}[39m does not redirect it."; const ABOUT_PROJ_DEPS: &str = "Show project dependencies"; const HELP_PROJ_DEPS: &str = "\u{1b}[1m\u{1b}[34mDescription:\u{1b}[39m\u{1b}[22m\n Show the dependencies of an R project, in a table: every package the\n project depends on, the dependency type (\u{1b}[32mDepends\u{1b}[39m, \u{1b}[32mImports\u{1b}[39m,\n \u{1b}[32mLinkingTo\u{1b}[39m) and the version requirement, if it has one.\n\n By default rig reads the project manifest (e.g. \u{1b}[32mDESCRIPTION\u{1b}[39m) in the\n current directory; use \u{1b}[32m--input\u{1b}[39m to point to a different file. Add \u{1b}[32m--dev\u{1b}[39m\n to include development dependencies. Use \u{1b}[32m--json\u{1b}[39m for machine readable\n output.\n\n The plain listing only reads the manifest, so it needs neither R nor the\n package repositories.\n\n\u{1b}[1m\u{1b}[34mRecursive dependencies:\u{1b}[39m\u{1b}[22m\n \u{1b}[32m--recursive\u{1b}[39m (\u{1b}[32m-r\u{1b}[39m) shows the whole dependency closure: not only the\n packages the project needs directly, but also the packages \u{1b}[3mthose\u{1b}[23m need,\n and so on. Each package appears once, with the version currently on CRAN,\n the \u{1b}[32mDepth\u{1b}[39m column giving its distance from the project, and the\n \u{1b}[32mNeeded by\u{1b}[39m column naming the packages that pull it in. This needs the\n package metadata of the repositories, which rig downloads if it does not\n have it yet.\n\n \u{1b}[32mrig proj tree\u{1b}[39m shows the same closure as a tree, laid\n out by the shape of the dependency graph, so you can see \u{1b}[3mhow\u{1b}[23m each\n package is pulled in and not only \u{1b}[3mthat\u{1b}[23m it is.\n\n A recursive listing only ever follows hard dependencies, also below a\n development dependency added by \u{1b}[32m--dev\u{1b}[39m, so \u{1b}[32m--dev --recursive\u{1b}[39m means the\n project's own dev dependencies plus everything they need to be installed.\n\n rig follows the dependencies of the \u{1b}[3mlatest\u{1b}[23m version of every package in\n the closure, so a version requirement that would force an older version,\n with different dependencies, is not taken into account. Use\n \u{1b}[32mrig proj lock\u{1b}[39m for a resolution that is consistent\n across versions."; +const ABOUT_PROJ_INIT: &str = "Create a new project manifest"; +const HELP_PROJ_INIT: &str = "\u{1b}[1m\u{1b}[34mDescription:\u{1b}[39m\u{1b}[22m\n Write a new \u{1b}[32mrproj.toml\u{1b}[39m manifest in the current directory. This is rig's\n modern project and package file: it describes the project's metadata and its\n R and package dependencies, and can do everything a \u{1b}[32mDESCRIPTION\u{1b}[39m file can,\n plus dependency groups, optional dependencies, workspaces and declared\n scripts.\n\n \u{1b}[32mrig proj init\u{1b}[39m writes a minimal skeleton — a \u{1b}[32m[project]\u{1b}[39m table with the\n name (taken from the current directory) and version, and a \u{1b}[32m[dependencies]\u{1b}[39m\n table with a single R requirement — that you then fill in.\n\n rig refuses to overwrite an existing \u{1b}[32mrproj.toml\u{1b}[39m; pass \u{1b}[32m--force\u{1b}[39m to replace\n it."; const ABOUT_PROJ_LOCK: &str = "Resolve project dependencies and write rproj.lock"; const HELP_PROJ_LOCK: &str = "\u{1b}[1m\u{1b}[34mDescription:\u{1b}[39m\u{1b}[22m\n Resolve the dependencies of an R project to a concrete set of package\n versions, and write the result to \u{1b}[32mrproj.lock\u{1b}[39m.\n\n rig reads the project manifest (e.g. \u{1b}[32mDESCRIPTION\u{1b}[39m; override with\n \u{1b}[32m--input\u{1b}[39m) and uses its built-in solver to find a compatible set of\n package versions from the configured repositories, without running R.\n\n Use \u{1b}[32m--r-version\u{1b}[39m to solve for a specific R version, \u{1b}[32m--dev\u{1b}[39m to include\n development dependencies, and \u{1b}[32m--renv\u{1b}[39m to also write the result as an\n \u{1b}[32mrenv.lock\u{1b}[39m file.\n\n \u{1b}[32mrproj.lock\u{1b}[39m currently records a single \u{1b}[32m(R version, platform)\u{1b}[39m target;\n \u{1b}[32mrig proj sync\u{1b}[39m installs that target. Solving for several\n targets in one lockfile is planned but not implemented yet.\n\n\u{1b}[1m\u{1b}[34mSource and binary packages:\u{1b}[39m\u{1b}[22m\n The solver considers binary packages as well as source packages, and\n prefers a binary build when one is available for the same version. Which\n artifact each package is installed from is part of what the solve decides,\n because a binary is only usable together with the exact versions of its\n \u{1b}[32mLinkingTo\u{1b}[39m dependencies that it was compiled against. If those versions\n conflict with the rest of the project, rig picks another build of that\n package, or falls back to its source tarball.\n\n By default a binary build never changes \u{1b}[3mwhich version\u{1b}[23m rig picks: the\n newest suitable version wins, and a binary of it is used if there is one.\n Pass \u{1b}[32m--prefer-binary\u{1b}[39m to let an older version win instead, when the newest\n one has no binary but an older one does — typically because a version was\n released so recently that it has not been built yet. Only the three newest\n versions of a package are considered; \u{1b}[32m--prefer-binary=5\u{1b}[39m considers five.\n Versions held back this way are marked in the output.\n\n Trading a version away for a binary is not free: the binary pins its\n \u{1b}[32mLinkingTo\u{1b}[39m dependencies to the versions it was compiled against, and those\n dependencies then prefer their own binaries in turn, so a whole project can\n end up on older versions.\n\n By default rig solves for the machine it runs on. Use \u{1b}[32m--platform\u{1b}[39m to solve\n for a different one, e.g. to write a lockfile on macOS for a Linux\n deployment:\n\n rig proj lock --platform ubuntu-24.04\n\n \u{1b}[32m--platform source\u{1b}[39m solves for source packages only, and does not download\n any binary package metadata. rig also falls back to source packages when\n there are no binaries for a platform at all. There is then nothing for\n \u{1b}[32m--prefer-binary\u{1b}[39m to prefer, and rig ignores it.\n\n rig keeps the repository metadata and the binary package indices it solves\n from in its cache, and refreshes them once a day. \u{1b}[32m--no-cache\u{1b}[39m downloads\n them again instead, and writes nothing to the cache, which is the way to\n solve against a package that was published minutes ago. It is a good deal\n slower, because the metadata it re-downloads is large. See\n \u{1b}[32mrig config\u{1b}[39m.\n\n The \u{1b}[32mrproj.lock\u{1b}[39m file records, for every package, whether it is a source or a\n binary package and the URL it is downloaded from. It also records where the\n file is cached, which is per \u{1b}[3mbuild\u{1b}[23m rather than per version: a repository\n can offer several binaries of one version for one platform and R version,\n and they are cached side by side."; const ABOUT_PROJ_SYNC: &str = "Install the dependencies rproj.lock resolved"; diff --git a/src/proj.rs b/src/proj.rs index 9ad5b82b..57648c9b 100644 --- a/src/proj.rs +++ b/src/proj.rs @@ -33,7 +33,7 @@ use crate::platform::{detect_platform, parse_platform_string}; use crate::renv::*; use crate::repos::binaries::loader::{BinaryTarget, P3mBinaryLoader}; use crate::repos::*; -use crate::rproj::{RprojLock, RprojLockTarget, RPROJ_LOCK_VERSION}; +use crate::rproj::{Rproj, RprojLock, RprojLockTarget, RPROJ_LOCK_VERSION, RPROJ_MANIFEST_FILE}; use crate::solver::*; use crate::utils::create_parent_dir_if_needed; @@ -56,6 +56,7 @@ pub const BASE_PKGS: &[&str] = &[ pub fn sc_proj(args: &ArgMatches, mainargs: &ArgMatches) -> Result<(), Box> { match args.subcommand() { + Some(("init", s)) => sc_proj_init(s, args, mainargs), Some(("deps", s)) => sc_proj_deps(s, args, mainargs), Some(("tree", s)) => sc_proj_tree(s, args, mainargs), Some(("lock", s)) => sc_proj_lock(s, args, mainargs), @@ -64,12 +65,46 @@ pub fn sc_proj(args: &ArgMatches, mainargs: &ArgMatches) -> Result<(), Box Result<(), Box> { + let path = Path::new(RPROJ_MANIFEST_FILE); + if path.exists() && !args.get_flag("force") { + OUTPUT.error(&format!( + "{} already exists, use --force to overwrite", + RPROJ_MANIFEST_FILE + )); + error!("{} already exists", RPROJ_MANIFEST_FILE); + bail!("{} already exists", RPROJ_MANIFEST_FILE); + } + + // Project name defaults to the current directory's name. + let name = std::env::current_dir() + .ok() + .and_then(|d| d.file_name().map(|n| n.to_string_lossy().into_owned())) + .filter(|n| !n.is_empty()) + .unwrap_or_else(|| "myproject".to_string()); + + let manifest = Rproj::minimal(&name); + fs::write(path, toml::to_string_pretty(&manifest)?)?; + OUTPUT.success(&format!("Created {}", RPROJ_MANIFEST_FILE)); + info!("Created {}", RPROJ_MANIFEST_FILE); + Ok(()) +} + /// Read the project's manifest, e.g. its `DESCRIPTION` file, and return it as a /// package, with the soft dependencies dropped unless `dev`. fn proj_read_deps(input: &str, dev: bool) -> Result> { OUTPUT.status(&format!("Reading dependencies from {}", input)); info!("Reading dependencies from {}", input); - let df: File = File::open(input)?; + let df: File = File::open(input).map_err(|e| { + OUTPUT.error(&format!("Cannot read {}: {}", input, e)); + error!("Cannot read {}: {}", input, e); + e + })?; let desc = Deb822::from_reader(df)?; if desc.is_empty() { diff --git a/src/rproj.rs b/src/rproj.rs index f3920de3..b0e2a429 100644 --- a/src/rproj.rs +++ b/src/rproj.rs @@ -17,12 +17,227 @@ // solve loop and the "pick the entry matching this machine" logic in `sync` // are follow-up work. +use std::collections::BTreeMap; + use serde::{Deserialize, Serialize}; use crate::pak::PakLockfilePackage; pub const RPROJ_LOCK_VERSION: usize = 1; +// `rproj.toml`: the project/package manifest (see the design doc). This is the +// *requirements* file a human edits, as opposed to `rproj.lock` (the solved +// output above). rig owns the schema; a `DESCRIPTION` can be generated from it +// (follow-up work). For now the model round-trips through TOML and backs +// `rig proj init`. +pub const RPROJ_MANIFEST_FILE: &str = "rproj.toml"; + +/// A parsed `rproj.toml` manifest. +/// +/// Key ordering is not significant, so dependency tables use `BTreeMap` (they +/// round-trip deterministically, sorted). All top-level fields are tables or +/// arrays-of-tables, so TOML's "values before tables" rule is never at risk. +#[derive(Serialize, Deserialize, Debug, Default, PartialEq)] +pub struct Rproj { + pub project: Project, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub dependencies: BTreeMap, + #[serde( + rename = "linking-dependencies", + default, + skip_serializing_if = "BTreeMap::is_empty" + )] + pub linking_dependencies: BTreeMap, + #[serde( + rename = "optional-dependencies", + default, + skip_serializing_if = "BTreeMap::is_empty" + )] + pub optional_dependencies: BTreeMap>, + #[serde( + rename = "dependency-groups", + default, + skip_serializing_if = "BTreeMap::is_empty" + )] + pub dependency_groups: BTreeMap, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub repository: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub build: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub bin: Vec, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub config: BTreeMap, + // `[description]` escape hatch: raw DESCRIPTION fields with no structured + // home (e.g. `License_is_FOSS`), passed through verbatim. + #[serde(default, skip_serializing_if = "toml::Table::is_empty")] + pub description: toml::Table, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace: Option, +} + +/// `[project]` — identity/metadata. Scalar fields serialize before `urls` +/// (a sub-table), keeping TOML happy. +#[derive(Serialize, Deserialize, Debug, Default, PartialEq)] +pub struct Project { + pub name: String, + pub version: String, + // `Type:` in DESCRIPTION. Manifest default is "project" (not built). + #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")] + pub type_: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub title: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub license: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub keywords: Option>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub authors: Vec, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub urls: BTreeMap, +} + +/// One `authors = [...]` entry; generates a `person()` in `Authors@R`. +#[derive(Serialize, Deserialize, Debug, Default, PartialEq)] +pub struct Author { + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub email: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub roles: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub orcid: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ror: Option, +} + +/// A dependency value: either a bare version string (`"^1.2"`) or a table with +/// a source/flags. Untagged so both spellings parse. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[serde(untagged)] +pub enum Dependency { + Version(String), + Detailed(DepTable), +} + +/// The table form of a dependency (`{ version = ..., git = ..., attach = ... }`). +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq)] +pub struct DepTable { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub version: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub repository: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub git: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub branch: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tag: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rev: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub url: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub hash: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub path: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub attach: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enhances: Option, + #[serde( + rename = "vignette-builder", + default, + skip_serializing_if = "Option::is_none" + )] + pub vignette_builder: Option, +} + +/// A `[dependency-groups.]` entry: package specs plus an optional +/// `include-groups` list that pulls in other groups. +#[derive(Serialize, Deserialize, Debug, Default, PartialEq)] +pub struct Group { + #[serde( + rename = "include-groups", + default, + skip_serializing_if = "Vec::is_empty" + )] + pub include_groups: Vec, + #[serde(flatten)] + pub dependencies: BTreeMap, +} + +/// One `[[repository]]`. Array order is precedence (first = highest). +#[derive(Serialize, Deserialize, Debug, Default, PartialEq)] +pub struct Repository { + pub name: String, + pub url: String, +} + +/// `[build]` — package build flags. +#[derive(Serialize, Deserialize, Debug, Default, PartialEq)] +pub struct Build { + #[serde( + rename = "byte-compile", + default, + skip_serializing_if = "Option::is_none" + )] + pub byte_compile: Option, + #[serde( + rename = "needs-compilation", + default, + skip_serializing_if = "Option::is_none" + )] + pub needs_compilation: Option, + #[serde(rename = "lazy-data", default, skip_serializing_if = "Option::is_none")] + pub lazy_data: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub biarch: Option, +} + +/// One `[[bin]]` — a named entry-point script run via `rig run `. +#[derive(Serialize, Deserialize, Debug, Default, PartialEq)] +pub struct Bin { + pub name: String, + pub path: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, +} + +/// `[workspace]` — a cargo-style monorepo of member manifests. +#[derive(Serialize, Deserialize, Debug, Default, PartialEq)] +pub struct Workspace { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub members: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub exclude: Vec, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub dependencies: BTreeMap, +} + +impl Rproj { + /// The minimal skeleton written by `rig proj init`: a `[project]` with the + /// given name and a single R dependency. + pub fn minimal(name: &str) -> Self { + let mut dependencies = BTreeMap::new(); + dependencies.insert("R".to_string(), Dependency::Version(">= 4.1".to_string())); + Rproj { + project: Project { + name: name.to_string(), + version: "0.1.0".to_string(), + type_: Some("project".to_string()), + ..Default::default() + }, + dependencies, + ..Default::default() + } + } +} + #[derive(Serialize, Deserialize, Debug)] pub struct RprojLock { pub version: usize, @@ -65,6 +280,113 @@ mod tests { } } + fn dep(v: &str) -> Dependency { + Dependency::Version(v.to_string()) + } + + #[test] + fn minimal_manifest_serializes_expected() { + let text = toml::to_string_pretty(&Rproj::minimal("mypkg")).unwrap(); + assert_eq!( + text, + "[project]\n\ + name = \"mypkg\"\n\ + version = \"0.1.0\"\n\ + type = \"project\"\n\ + \n\ + [dependencies]\n\ + R = \">= 4.1\"\n" + ); + // and it parses back to the same value + let parsed: Rproj = toml::from_str(&text).unwrap(); + assert_eq!(parsed, Rproj::minimal("mypkg")); + } + + #[test] + fn full_manifest_roundtrips_through_toml() { + let mut m = Rproj::minimal("mypkg"); + m.project.type_ = Some("package".to_string()); + m.project.title = Some("A Modern Thing".to_string()); + m.project.license = Some("MIT + file LICENSE".to_string()); + m.project.keywords = Some(vec!["cli".to_string()]); + m.project.authors = vec![Author { + name: "Gábor Csárdi".to_string(), + email: Some("gabor@posit.co".to_string()), + roles: vec!["aut".to_string(), "cre".to_string()], + orcid: Some("0000-0001-7098-9676".to_string()), + ror: None, + }]; + m.project + .urls + .insert("homepage".to_string(), "https://example.org".to_string()); + + m.dependencies.insert("cli".to_string(), dep(">= 3.6.5")); + m.dependencies.insert( + "ts".to_string(), + Dependency::Detailed(DepTable { + git: Some("https://github.com/gaborcsardi/ts".to_string()), + branch: Some("main".to_string()), + ..Default::default() + }), + ); + m.linking_dependencies + .insert("Rcpp".to_string(), dep(">= 1.0")); + + let mut viz = BTreeMap::new(); + viz.insert("ggplot2".to_string(), dep("*")); + m.optional_dependencies.insert("viz".to_string(), viz); + + let mut test_deps = BTreeMap::new(); + test_deps.insert("testthat".to_string(), dep(">= 3.0")); + m.dependency_groups.insert( + "test".to_string(), + Group { + include_groups: vec![], + dependencies: test_deps, + }, + ); + m.dependency_groups.insert( + "dev".to_string(), + Group { + include_groups: vec!["test".to_string()], + dependencies: BTreeMap::from([("lintr".to_string(), dep("*"))]), + }, + ); + + m.repository = vec![Repository { + name: "CRAN".to_string(), + url: "https://cran.r-project.org".to_string(), + }]; + m.build = Some(Build { + byte_compile: Some(true), + needs_compilation: Some(true), + lazy_data: None, + biarch: None, + }); + m.bin = vec![Bin { + name: "report".to_string(), + path: "scripts/report.R".to_string(), + description: Some("Build the report".to_string()), + }]; + m.config.insert( + "testthat".to_string(), + toml::Table::from_iter([("edition".to_string(), toml::Value::Integer(3))]), + ); + m.description.insert( + "License_is_FOSS".to_string(), + toml::Value::String("yes".to_string()), + ); + m.workspace = Some(Workspace { + members: vec!["packages/*".to_string()], + exclude: vec![], + dependencies: BTreeMap::from([("cli".to_string(), dep(">= 3.6.5"))]), + }); + + let text = toml::to_string_pretty(&m).unwrap(); + let parsed: Rproj = toml::from_str(&text).unwrap(); + assert_eq!(parsed, m); + } + #[test] fn roundtrips_through_toml() { let lock = RprojLock { From f62eb027c6df219e2977059b1704fa4f1d50f505 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Cs=C3=A1rdi?= Date: Wed, 2 Sep 2026 17:32:35 +0200 Subject: [PATCH 05/19] Add rig project import To import a DESCRIPTION file. --- src/args.rs | 14 +++ src/help-generated.in | 4 +- src/help/proj-import.md | 21 +++++ src/help/proj-init.md | 16 ++++ src/help/proj.md | 2 + src/proj.rs | 39 ++++++++ src/rproj.rs | 203 ++++++++++++++++++++++++++++++++++++++++ 7 files changed, 298 insertions(+), 1 deletion(-) create mode 100644 src/help/proj-import.md create mode 100644 src/help/proj-init.md diff --git a/src/args.rs b/src/args.rs index c9b02ff2..ecb718b7 100644 --- a/src/args.rs +++ b/src/args.rs @@ -1329,6 +1329,20 @@ pub fn rig_app() -> Command { .required(false), ), ) + .subcommand( + Command::new("import") + .about(ABOUT_PROJ_IMPORT) + .long_about(HELP_PROJ_IMPORT) + .display_order(0) + .arg( + Arg::new("input") + .help("DESCRIPTION file to import (e.g. DESCRIPTION)") + .long("input") + .short('i') + .num_args(1) + .required(false), + ), + ) .subcommand( Command::new("deps") .about(ABOUT_PROJ_DEPS) diff --git a/src/help-generated.in b/src/help-generated.in index a9778216..b29a4f79 100644 --- a/src/help-generated.in +++ b/src/help-generated.in @@ -64,6 +64,8 @@ const ABOUT_PPM: &str = "Query Posit Package Manager (experimental)"; const HELP_PPM: &str = "\u{1b}[1m\u{1b}[34mDescription:\u{1b}[39m\u{1b}[22m\n Ask Posit Package Manager (P3M) what it offers: which platforms and R\n versions it builds binary packages for, and which builds exist for a\n package. Nothing here changes anything on your machine.\n\n \u{1b}[32mrig ppm platforms\u{1b}[39m and\n \u{1b}[32mrig ppm r-versions\u{1b}[39m list the build targets and R\n versions, \u{1b}[32mrig ppm status\u{1b}[39m shows P3M's whole status\n report, \u{1b}[32mrig ppm builds\u{1b}[39m lists the published builds of\n one package, and \u{1b}[32mrig ppm url\u{1b}[39m prints the URL rig is\n talking to.\n\n This is about P3M itself. To manage the repositories configured for your R\n installations, including P3M ones, use \u{1b}[32mrig repos\u{1b}[39m; to look up\n package metadata in those repositories, use \u{1b}[32mrig pkg\u{1b}[39m.\n\n\u{1b}[1m\u{1b}[34mWhich server:\u{1b}[39m\u{1b}[22m\n By default rig reports on the public instance,\n \u{1b}[32mhttps://packagemanager.posit.co\u{1b}[39m. Set the \u{1b}[32mPACKAGEMANAGER_ADDRESS\u{1b}[39m\n environment variable to the base URL of your own P3M instance to report on\n that instead. \u{1b}[32mrig ppm url\u{1b}[39m prints whichever one is in effect.\n\n One command is different: \u{1b}[32mrig ppm builds\u{1b}[39m reads a package build index that\n rig publishes itself, derived from P3M, because P3M has no endpoint that\n lists a package's builds. That index always comes from rig's own host, and\n \u{1b}[32mPACKAGEMANAGER_ADDRESS\u{1b}[39m does not redirect it."; const ABOUT_PROJ_DEPS: &str = "Show project dependencies"; const HELP_PROJ_DEPS: &str = "\u{1b}[1m\u{1b}[34mDescription:\u{1b}[39m\u{1b}[22m\n Show the dependencies of an R project, in a table: every package the\n project depends on, the dependency type (\u{1b}[32mDepends\u{1b}[39m, \u{1b}[32mImports\u{1b}[39m,\n \u{1b}[32mLinkingTo\u{1b}[39m) and the version requirement, if it has one.\n\n By default rig reads the project manifest (e.g. \u{1b}[32mDESCRIPTION\u{1b}[39m) in the\n current directory; use \u{1b}[32m--input\u{1b}[39m to point to a different file. Add \u{1b}[32m--dev\u{1b}[39m\n to include development dependencies. Use \u{1b}[32m--json\u{1b}[39m for machine readable\n output.\n\n The plain listing only reads the manifest, so it needs neither R nor the\n package repositories.\n\n\u{1b}[1m\u{1b}[34mRecursive dependencies:\u{1b}[39m\u{1b}[22m\n \u{1b}[32m--recursive\u{1b}[39m (\u{1b}[32m-r\u{1b}[39m) shows the whole dependency closure: not only the\n packages the project needs directly, but also the packages \u{1b}[3mthose\u{1b}[23m need,\n and so on. Each package appears once, with the version currently on CRAN,\n the \u{1b}[32mDepth\u{1b}[39m column giving its distance from the project, and the\n \u{1b}[32mNeeded by\u{1b}[39m column naming the packages that pull it in. This needs the\n package metadata of the repositories, which rig downloads if it does not\n have it yet.\n\n \u{1b}[32mrig proj tree\u{1b}[39m shows the same closure as a tree, laid\n out by the shape of the dependency graph, so you can see \u{1b}[3mhow\u{1b}[23m each\n package is pulled in and not only \u{1b}[3mthat\u{1b}[23m it is.\n\n A recursive listing only ever follows hard dependencies, also below a\n development dependency added by \u{1b}[32m--dev\u{1b}[39m, so \u{1b}[32m--dev --recursive\u{1b}[39m means the\n project's own dev dependencies plus everything they need to be installed.\n\n rig follows the dependencies of the \u{1b}[3mlatest\u{1b}[23m version of every package in\n the closure, so a version requirement that would force an older version,\n with different dependencies, is not taken into account. Use\n \u{1b}[32mrig proj lock\u{1b}[39m for a resolution that is consistent\n across versions."; +const ABOUT_PROJ_IMPORT: &str = "Import a DESCRIPTION file's dependencies into rproj.toml"; +const HELP_PROJ_IMPORT: &str = "\u{1b}[1m\u{1b}[34mDescription:\u{1b}[39m\u{1b}[22m\n Read a \u{1b}[32mDESCRIPTION\u{1b}[39m file and merge its dependencies into \u{1b}[32mrproj.toml\u{1b}[39m, rig's\n project and package manifest. If \u{1b}[32mrproj.toml\u{1b}[39m does not exist yet, it is\n created first, named after the DESCRIPTION file's \u{1b}[32mPackage:\u{1b}[39m field.\n\n \u{1b}[32mDepends\u{1b}[39m and \u{1b}[32mImports\u{1b}[39m land in the \u{1b}[32m[dependencies]\u{1b}[39m table (\u{1b}[32mDepends\u{1b}[39m\n packages are marked to attach on load); \u{1b}[32mLinkingTo\u{1b}[39m also lands in\n \u{1b}[32m[linking-dependencies]\u{1b}[39m. \u{1b}[32mSuggests\u{1b}[39m is imported into\n \u{1b}[32m[dependency-groups.test]\u{1b}[39m and \u{1b}[32mEnhances\u{1b}[39m into\n \u{1b}[32m[dependency-groups.enhances]\u{1b}[39m.\n\n By default rig reads \u{1b}[32mDESCRIPTION\u{1b}[39m in the current directory; use \u{1b}[32m--input\u{1b}[39m\n to point to a different file.\n\n Importing a package already listed in \u{1b}[32mrproj.toml\u{1b}[39m overwrites its entry\n with the version requirement from the DESCRIPTION file. Because\n \u{1b}[32mrproj.toml\u{1b}[39m is rewritten in full, any comments or custom formatting in an\n existing file are not preserved."; const ABOUT_PROJ_INIT: &str = "Create a new project manifest"; const HELP_PROJ_INIT: &str = "\u{1b}[1m\u{1b}[34mDescription:\u{1b}[39m\u{1b}[22m\n Write a new \u{1b}[32mrproj.toml\u{1b}[39m manifest in the current directory. This is rig's\n modern project and package file: it describes the project's metadata and its\n R and package dependencies, and can do everything a \u{1b}[32mDESCRIPTION\u{1b}[39m file can,\n plus dependency groups, optional dependencies, workspaces and declared\n scripts.\n\n \u{1b}[32mrig proj init\u{1b}[39m writes a minimal skeleton — a \u{1b}[32m[project]\u{1b}[39m table with the\n name (taken from the current directory) and version, and a \u{1b}[32m[dependencies]\u{1b}[39m\n table with a single R requirement — that you then fill in.\n\n rig refuses to overwrite an existing \u{1b}[32mrproj.toml\u{1b}[39m; pass \u{1b}[32m--force\u{1b}[39m to replace\n it."; const ABOUT_PROJ_LOCK: &str = "Resolve project dependencies and write rproj.lock"; @@ -73,7 +75,7 @@ const HELP_PROJ_SYNC: &str = "\u{1b}[1m\u{1b}[34mDescription:\u{1b}[39m\u{1b}[22 const ABOUT_PROJ_TREE: &str = "Dependency tree of a project"; const HELP_PROJ_TREE: &str = "\u{1b}[1m\u{1b}[34mDescription:\u{1b}[39m\u{1b}[22m\n Show everything an R project needs, directly or indirectly, as a tree: the\n same closure \u{1b}[32mrig proj deps --recursive\u{1b}[39m lists in a flat\n table, laid out by the shape of the dependency graph.\n\n myproject 0.1.0 — 3 direct, 24 total\n ├── R (>= 4.1) [D]\n ├── cli 3.6.4\n │ ├── R (>= 3.4) [D]\n │ └── utils\n └── dplyr 1.1.4 (>= 1.1.0)\n ├── cli 3.6.4 (>= 3.4.0) (*)\n └── vctrs 0.6.5 (>= 0.6.4)\n └── cpp11 0.5.2 [L]\n [Suggests]\n └── testthat 3.2.3 (>= 3.1.5)\n\n The first line names the project and its version, how many dependencies it\n declares directly and how many distinct packages there are in the whole tree.\n Each line below names a package, the version currently in the repositories,\n and the version requirement it is needed with, if it has one.\n\n By default rig reads the project manifest (e.g. \u{1b}[32mDESCRIPTION\u{1b}[39m) in the current\n directory; use \u{1b}[32m--input\u{1b}[39m to point to a different file. Unlike\n \u{1b}[32mrig proj deps\u{1b}[39m, the tree needs the package metadata of the\n repositories, which rig downloads if it does not have it yet. It does not need\n R. \u{1b}[32m--json\u{1b}[39m gives machine readable output, as one nested object.\n\n\u{1b}[1m\u{1b}[34mReading the tree:\u{1b}[39m\u{1b}[22m\n A package that several others need is expanded only once, under its first\n occurrence; later occurrences are marked \u{1b}[32m(*)\u{1b}[39m, meaning \"its dependencies are\n above\". \u{1b}[32m--dev\u{1b}[39m adds the project's development dependencies, in their own\n \u{1b}[32m[Suggests]\u{1b}[39m and \u{1b}[32m[Enhances]\u{1b}[39m sections; \u{1b}[32m--no-base\u{1b}[39m leaves out R and the base\n packages. Among the hard dependencies, \u{1b}[32mImports\u{1b}[39m is not marked, \u{1b}[32m[D]\u{1b}[39m is a\n \u{1b}[32mDepends\u{1b}[39m, \u{1b}[32m[L]\u{1b}[39m a \u{1b}[32mLinkingTo\u{1b}[39m, \u{1b}[32m[DL]\u{1b}[39m both.\n\n \u{1b}[32m--why \u{1b}[39m (alias \u{1b}[32m--explain\u{1b}[39m) inverts the tree, so that the named\n package is the root and the tree grows towards the packages that need it, down\n to the project itself. Each line then says how \u{1b}[3mthat\u{1b}[23m package needs the one\n above it, hence \u{1b}[32mneeds\u{1b}[39m.\n\n \u{1b}[32mrig pkg tree\u{1b}[39m, which shows the same tree for a package\n in the repositories, describes all of this in full.\n\n rig follows the dependencies of the \u{1b}[3mlatest\u{1b}[23m version of every package in the\n tree, so a version requirement that would force an older version, with\n different dependencies, is not taken into account. Use\n \u{1b}[32mrig proj lock\u{1b}[39m for a resolution that is consistent across\n versions."; const ABOUT_PROJ: &str = "Manage R projects (experimental)"; -const HELP_PROJ: &str = "\u{1b}[1m\u{1b}[34mDescription:\u{1b}[39m\u{1b}[22m\n Manage R projects (experimental).\n\n A project is a directory with a package manifest, typically a\n \u{1b}[32mDESCRIPTION\u{1b}[39m file, that declares the R packages the project depends on.\n \u{1b}[32mrig proj\u{1b}[39m resolves those dependencies against the configured package\n repositories and can install them into a project library.\n\n \u{1b}[32mrig proj deps\u{1b}[39m shows the direct and recursive dependencies of the\n project.\n \u{1b}[32mrig proj tree\u{1b}[39m shows the recursive dependencies as a tree, so you can\n see how each package is pulled in.\n \u{1b}[32mrig proj lock\u{1b}[39m resolves the full dependency tree to a concrete set of\n package versions, writes the result to \u{1b}[32mrproj.lock\u{1b}[39m, and can also write\n an \u{1b}[32mrenv.lock\u{1b}[39m file.\n \u{1b}[32mrig proj sync\u{1b}[39m installs the dependencies \u{1b}[32mrproj.lock\u{1b}[39m resolved into a\n package library.\n\n Dependencies are resolved with rig's built-in solver, so R does not need\n to be running for \u{1b}[32mrig proj deps\u{1b}[39m, \u{1b}[32mrig proj tree\u{1b}[39m and \u{1b}[32mrig proj lock\u{1b}[39m.\n\n \u{1b}[32mrig proj\u{1b}[39m is currently experimental, and might change in future\n versions. Feedback is appreciated."; +const HELP_PROJ: &str = "\u{1b}[1m\u{1b}[34mDescription:\u{1b}[39m\u{1b}[22m\n Manage R projects (experimental).\n\n A project is a directory with a package manifest, typically a\n \u{1b}[32mDESCRIPTION\u{1b}[39m file, that declares the R packages the project depends on.\n \u{1b}[32mrig proj\u{1b}[39m resolves those dependencies against the configured package\n repositories and can install them into a project library.\n\n \u{1b}[32mrig proj import\u{1b}[39m reads a \u{1b}[32mDESCRIPTION\u{1b}[39m file and merges its dependencies\n into \u{1b}[32mrproj.toml\u{1b}[39m, creating it first if needed.\n \u{1b}[32mrig proj deps\u{1b}[39m shows the direct and recursive dependencies of the\n project.\n \u{1b}[32mrig proj tree\u{1b}[39m shows the recursive dependencies as a tree, so you can\n see how each package is pulled in.\n \u{1b}[32mrig proj lock\u{1b}[39m resolves the full dependency tree to a concrete set of\n package versions, writes the result to \u{1b}[32mrproj.lock\u{1b}[39m, and can also write\n an \u{1b}[32mrenv.lock\u{1b}[39m file.\n \u{1b}[32mrig proj sync\u{1b}[39m installs the dependencies \u{1b}[32mrproj.lock\u{1b}[39m resolved into a\n package library.\n\n Dependencies are resolved with rig's built-in solver, so R does not need\n to be running for \u{1b}[32mrig proj deps\u{1b}[39m, \u{1b}[32mrig proj tree\u{1b}[39m and \u{1b}[32mrig proj lock\u{1b}[39m.\n\n \u{1b}[32mrig proj\u{1b}[39m is currently experimental, and might change in future\n versions. Feedback is appreciated."; const ABOUT_REPOS_AVAILABLE: &str = "List available R package repositories"; const HELP_REPOS_AVAILABLE: &str = "\u{1b}[1m\u{1b}[34mDescription:\u{1b}[39m\u{1b}[22m\n List the package repositories that rig knows about and can set up.\n\n These are the repositories you can enable with \u{1b}[32m--with-repos\u{1b}[39m when running\n \u{1b}[32mrig add\u{1b}[39m or \u{1b}[32mrig repos setup\u{1b}[39m.\n\n Without arguments rig prints one row per repository: its name, whether it is\n part of the default repository set, and its title.\n\n Pass a repository name to see its description and its URLs, together with the\n platforms, architectures and R versions each URL applies to. Repository names\n are matched case insensitively.\n\n\u{1b}[1m\u{1b}[34mExamples:\u{1b}[39m\u{1b}[22m\n # List all repositories rig knows about\n rig repos available\n\n # Show the URLs of one repository\n rig repos available P3M"; const ABOUT_REPOS_LIST: &str = "List configured R package repositories"; diff --git a/src/help/proj-import.md b/src/help/proj-import.md new file mode 100644 index 00000000..072e8ab6 --- /dev/null +++ b/src/help/proj-import.md @@ -0,0 +1,21 @@ +Import a DESCRIPTION file's dependencies into rproj.toml + +## Description + +Read a `DESCRIPTION` file and merge its dependencies into `rproj.toml`, rig's +project and package manifest. If `rproj.toml` does not exist yet, it is +created first, named after the DESCRIPTION file's `Package:` field. + +`Depends` and `Imports` land in the `[dependencies]` table (`Depends` +packages are marked to attach on load); `LinkingTo` also lands in +`[linking-dependencies]`. `Suggests` is imported into +`[dependency-groups.test]` and `Enhances` into +`[dependency-groups.enhances]`. + +By default rig reads `DESCRIPTION` in the current directory; use `--input` +to point to a different file. + +Importing a package already listed in `rproj.toml` overwrites its entry +with the version requirement from the DESCRIPTION file. Because +`rproj.toml` is rewritten in full, any comments or custom formatting in an +existing file are not preserved. diff --git a/src/help/proj-init.md b/src/help/proj-init.md new file mode 100644 index 00000000..f86ad500 --- /dev/null +++ b/src/help/proj-init.md @@ -0,0 +1,16 @@ +Create a new project manifest + +## Description + +Write a new `rproj.toml` manifest in the current directory. This is rig's +modern project and package file: it describes the project's metadata and its +R and package dependencies, and can do everything a `DESCRIPTION` file can, +plus dependency groups, optional dependencies, workspaces and declared +scripts. + +`rig proj init` writes a minimal skeleton — a `[project]` table with the +name (taken from the current directory) and version, and a `[dependencies]` +table with a single R requirement — that you then fill in. + +rig refuses to overwrite an existing `rproj.toml`; pass `--force` to replace +it. diff --git a/src/help/proj.md b/src/help/proj.md index d211bc8b..a9cd12e9 100644 --- a/src/help/proj.md +++ b/src/help/proj.md @@ -9,6 +9,8 @@ A project is a directory with a package manifest, typically a `rig proj` resolves those dependencies against the configured package repositories and can install them into a project library. +`rig proj import` reads a `DESCRIPTION` file and merges its dependencies +into `rproj.toml`, creating it first if needed. `rig proj deps` shows the direct and recursive dependencies of the project. `rig proj tree` shows the recursive dependencies as a tree, so you can diff --git a/src/proj.rs b/src/proj.rs index 57648c9b..205dd34d 100644 --- a/src/proj.rs +++ b/src/proj.rs @@ -57,6 +57,7 @@ pub const BASE_PKGS: &[&str] = &[ pub fn sc_proj(args: &ArgMatches, mainargs: &ArgMatches) -> Result<(), Box> { match args.subcommand() { Some(("init", s)) => sc_proj_init(s, args, mainargs), + Some(("import", s)) => sc_proj_import(s, args, mainargs), Some(("deps", s)) => sc_proj_deps(s, args, mainargs), Some(("tree", s)) => sc_proj_tree(s, args, mainargs), Some(("lock", s)) => sc_proj_lock(s, args, mainargs), @@ -95,6 +96,44 @@ fn sc_proj_init( Ok(()) } +/// Import a `DESCRIPTION` file's dependencies into `rproj.toml`, creating a +/// minimal manifest first if none exists yet. +fn sc_proj_import( + args: &ArgMatches, + _projargs: &ArgMatches, + _mainargs: &ArgMatches, +) -> Result<(), Box> { + let default_input = "DESCRIPTION".to_string(); + let input: &String = args.get_one::("input").unwrap_or(&default_input); + let pkg = proj_read_deps(input, true)?; + + let path = Path::new(RPROJ_MANIFEST_FILE); + let mut manifest = if path.exists() { + toml::from_str::(&fs::read_to_string(path)?)? + } else { + OUTPUT.status(&format!( + "{} does not exist, creating a new one", + RPROJ_MANIFEST_FILE + )); + info!("{} does not exist, creating a new one", RPROJ_MANIFEST_FILE); + Rproj::minimal(&pkg.name) + }; + + let count = pkg.dependencies.dependencies.len(); + manifest.merge_description(&pkg); + fs::write(path, toml::to_string_pretty(&manifest)?)?; + + OUTPUT.success(&format!( + "Imported {} dependencies from {} into {}", + count, input, RPROJ_MANIFEST_FILE + )); + info!( + "Imported {} dependencies from {} into {}", + count, input, RPROJ_MANIFEST_FILE + ); + Ok(()) +} + /// Read the project's manifest, e.g. its `DESCRIPTION` file, and return it as a /// package, with the soft dependencies dropped unless `dev`. fn proj_read_deps(input: &str, dev: bool) -> Result> { diff --git a/src/rproj.rs b/src/rproj.rs index b0e2a429..42cca88a 100644 --- a/src/rproj.rs +++ b/src/rproj.rs @@ -21,6 +21,7 @@ use std::collections::BTreeMap; use serde::{Deserialize, Serialize}; +use crate::dcf::{Package as DcfPackage, RDepType, VersionConstraint}; use crate::pak::PakLockfilePackage; pub const RPROJ_LOCK_VERSION: usize = 1; @@ -236,6 +237,71 @@ impl Rproj { ..Default::default() } } + + /// Merge a DESCRIPTION-derived `Package`'s dependencies into this + /// manifest, upserting entries (an existing entry for the same package + /// name is overwritten). `Depends`/`Imports` land in `[dependencies]` + /// (`Depends` marked with `attach = true`, except for `R` itself, which + /// stays a plain version string); `LinkingTo` also lands in + /// `[linking-dependencies]` (a package can be in both tables at once, + /// e.g. `Rcpp` in both `Imports` and `LinkingTo`); `Suggests`/`Enhances` + /// land in `[dependency-groups.test]` / `[dependency-groups.enhances]`. + pub fn merge_description(&mut self, pkg: &DcfPackage) { + for dep in pkg.dependencies.dependencies.iter() { + let version_str = format_constraints(&dep.constraints); + let hard = dep.types.contains(&RDepType::Depends) + || dep.types.contains(&RDepType::Imports) + || dep.types.contains(&RDepType::LinkingTo); + + if dep.types.contains(&RDepType::Depends) || dep.types.contains(&RDepType::Imports) { + let value = if dep.name != "R" && dep.types.contains(&RDepType::Depends) { + Dependency::Detailed(DepTable { + version: Some(version_str.clone()), + attach: Some(true), + ..Default::default() + }) + } else { + Dependency::Version(version_str.clone()) + }; + self.dependencies.insert(dep.name.clone(), value); + } + + if dep.types.contains(&RDepType::LinkingTo) { + self.linking_dependencies + .insert(dep.name.clone(), Dependency::Version(version_str.clone())); + } + + if !hard { + if dep.types.contains(&RDepType::Suggests) { + self.dependency_groups + .entry("test".to_string()) + .or_default() + .dependencies + .insert(dep.name.clone(), Dependency::Version(version_str.clone())); + } + if dep.types.contains(&RDepType::Enhances) { + self.dependency_groups + .entry("enhances".to_string()) + .or_default() + .dependencies + .insert(dep.name.clone(), Dependency::Version(version_str.clone())); + } + } + } + } +} + +/// Format a dependency's version constraints as an `rproj.toml` version +/// string, e.g. `">= 1.0, < 2.0"`, or `"*"` if there are none. +fn format_constraints(constraints: &[VersionConstraint]) -> String { + if constraints.is_empty() { + return "*".to_string(); + } + constraints + .iter() + .map(|c| format!("{} {}", c.constraint_type, c.version)) + .collect::>() + .join(", ") } #[derive(Serialize, Deserialize, Debug)] @@ -254,8 +320,32 @@ pub struct RprojLockTarget { #[cfg(test)] mod tests { use super::*; + use crate::dcf::{DepVersionSpec, RPackageVersion, VersionConstraintType}; use std::collections::HashMap; + fn spec(name: &str, types: &[RDepType], constraints: Vec) -> DepVersionSpec { + DepVersionSpec { + name: name.to_string(), + types: types.to_vec(), + constraints, + } + } + + fn constraint(op: VersionConstraintType, version: &str) -> VersionConstraint { + VersionConstraint { + constraint_type: op, + version: RPackageVersion::from_str(version).unwrap(), + } + } + + fn package(deps: Vec) -> DcfPackage { + DcfPackage::from_crandb( + "mypkg".to_string(), + RPackageVersion::from_str("1.0.0").unwrap(), + deps, + ) + } + fn sample_package() -> PakLockfilePackage { PakLockfilePackage { r#ref: "cli".to_string(), @@ -408,4 +498,117 @@ mod tests { Some(&"abc123".to_string()) ); } + + #[test] + fn merge_description_imports_go_to_dependencies() { + let mut m = Rproj::minimal("mypkg"); + m.dependencies.clear(); + let pkg = package(vec![spec( + "cli", + &[RDepType::Imports], + vec![constraint(VersionConstraintType::GreaterOrEqual, "3.6.5")], + )]); + m.merge_description(&pkg); + assert_eq!(m.dependencies.get("cli"), Some(&dep(">= 3.6.5"))); + } + + #[test] + fn merge_description_depends_sets_attach() { + let mut m = Rproj::minimal("mypkg"); + m.dependencies.clear(); + let pkg = package(vec![spec("crayon", &[RDepType::Depends], vec![])]); + m.merge_description(&pkg); + assert_eq!( + m.dependencies.get("crayon"), + Some(&Dependency::Detailed(DepTable { + version: Some("*".to_string()), + attach: Some(true), + ..Default::default() + })) + ); + } + + #[test] + fn merge_description_r_depends_stays_plain() { + let mut m = Rproj::minimal("mypkg"); + m.dependencies.clear(); + let pkg = package(vec![spec( + "R", + &[RDepType::Depends], + vec![constraint(VersionConstraintType::GreaterOrEqual, "4.1")], + )]); + m.merge_description(&pkg); + assert_eq!(m.dependencies.get("R"), Some(&dep(">= 4.1"))); + } + + #[test] + fn merge_description_linkingto_lands_in_both_tables() { + let mut m = Rproj::minimal("mypkg"); + m.dependencies.clear(); + let pkg = package(vec![spec( + "Rcpp", + &[RDepType::Imports, RDepType::LinkingTo], + vec![constraint(VersionConstraintType::GreaterOrEqual, "1.0")], + )]); + m.merge_description(&pkg); + assert_eq!(m.dependencies.get("Rcpp"), Some(&dep(">= 1.0"))); + assert_eq!(m.linking_dependencies.get("Rcpp"), Some(&dep(">= 1.0"))); + } + + #[test] + fn merge_description_suggests_and_enhances_go_to_groups() { + let mut m = Rproj::minimal("mypkg"); + m.dependencies.clear(); + let pkg = package(vec![ + spec("testthat", &[RDepType::Suggests], vec![]), + spec("otherpkg", &[RDepType::Enhances], vec![]), + ]); + m.merge_description(&pkg); + assert_eq!( + m.dependency_groups + .get("test") + .unwrap() + .dependencies + .get("testthat"), + Some(&dep("*")) + ); + assert_eq!( + m.dependency_groups + .get("enhances") + .unwrap() + .dependencies + .get("otherpkg"), + Some(&dep("*")) + ); + assert!(m.dependencies.is_empty()); + } + + #[test] + fn merge_description_upserts_existing_entry() { + let mut m = Rproj::minimal("mypkg"); + m.dependencies.insert("cli".to_string(), dep(">= 1.0")); + let pkg = package(vec![spec( + "cli", + &[RDepType::Imports], + vec![constraint(VersionConstraintType::GreaterOrEqual, "3.6.5")], + )]); + m.merge_description(&pkg); + assert_eq!(m.dependencies.get("cli"), Some(&dep(">= 3.6.5"))); + } + + #[test] + fn merge_description_multiple_constraints_join_with_comma() { + let mut m = Rproj::minimal("mypkg"); + m.dependencies.clear(); + let pkg = package(vec![spec( + "cli", + &[RDepType::Imports], + vec![ + constraint(VersionConstraintType::GreaterOrEqual, "1.0"), + constraint(VersionConstraintType::Less, "2.0"), + ], + )]); + m.merge_description(&pkg); + assert_eq!(m.dependencies.get("cli"), Some(&dep(">= 1.0, << 2.0"))); + } } From d2d0a1ffa6b9a956a3feb87275a59be03f6e7743 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Cs=C3=A1rdi?= Date: Wed, 2 Sep 2026 17:58:39 +0200 Subject: [PATCH 06/19] rig proj lock/deps/tree work off rproj.toml now Instead of DESCRIPTION. --- src/args.rs | 24 ---- src/help-generated.in | 6 +- src/help/proj-deps.md | 7 +- src/help/proj-lock.md | 6 +- src/help/proj-tree.md | 3 +- src/proj.rs | 74 +++++++---- src/rproj.rs | 292 +++++++++++++++++++++++++++++++++++++++++- 7 files changed, 353 insertions(+), 59 deletions(-) diff --git a/src/args.rs b/src/args.rs index ecb718b7..4f8b7d78 100644 --- a/src/args.rs +++ b/src/args.rs @@ -1348,14 +1348,6 @@ pub fn rig_app() -> Command { .about(ABOUT_PROJ_DEPS) .long_about(HELP_PROJ_DEPS) .display_order(0) - .arg( - Arg::new("input") - .help("Project file to solve (e.g. DESCRIPTION)") - .long("input") - .short('i') - .num_args(1) - .required(false), - ) .arg( Arg::new("recursive") .help("Show recursive (transitive) dependencies") @@ -1384,14 +1376,6 @@ pub fn rig_app() -> Command { .about(ABOUT_PROJ_TREE) .long_about(HELP_PROJ_TREE) .display_order(0) - .arg( - Arg::new("input") - .help("Project file to solve (e.g. DESCRIPTION)") - .long("input") - .short('i') - .num_args(1) - .required(false), - ) .arg( Arg::new("dev") .help("Include dev (development) dependencies") @@ -1428,14 +1412,6 @@ pub fn rig_app() -> Command { .about(ABOUT_PROJ_LOCK) .long_about(HELP_PROJ_LOCK) .display_order(0) - .arg( - Arg::new("input") - .help("Project file to solve (e.g. DESCRIPTION)") - .long("input") - .short('i') - .num_args(1) - .required(false), - ) .arg( Arg::new("renv") .help("Output and renv.lock file") diff --git a/src/help-generated.in b/src/help-generated.in index b29a4f79..c42e7a4c 100644 --- a/src/help-generated.in +++ b/src/help-generated.in @@ -63,17 +63,17 @@ const HELP_PPM_URL: &str = "\u{1b}[1m\u{1b}[34mDescription:\u{1b}[39m\u{1b}[22m\ const ABOUT_PPM: &str = "Query Posit Package Manager (experimental)"; const HELP_PPM: &str = "\u{1b}[1m\u{1b}[34mDescription:\u{1b}[39m\u{1b}[22m\n Ask Posit Package Manager (P3M) what it offers: which platforms and R\n versions it builds binary packages for, and which builds exist for a\n package. Nothing here changes anything on your machine.\n\n \u{1b}[32mrig ppm platforms\u{1b}[39m and\n \u{1b}[32mrig ppm r-versions\u{1b}[39m list the build targets and R\n versions, \u{1b}[32mrig ppm status\u{1b}[39m shows P3M's whole status\n report, \u{1b}[32mrig ppm builds\u{1b}[39m lists the published builds of\n one package, and \u{1b}[32mrig ppm url\u{1b}[39m prints the URL rig is\n talking to.\n\n This is about P3M itself. To manage the repositories configured for your R\n installations, including P3M ones, use \u{1b}[32mrig repos\u{1b}[39m; to look up\n package metadata in those repositories, use \u{1b}[32mrig pkg\u{1b}[39m.\n\n\u{1b}[1m\u{1b}[34mWhich server:\u{1b}[39m\u{1b}[22m\n By default rig reports on the public instance,\n \u{1b}[32mhttps://packagemanager.posit.co\u{1b}[39m. Set the \u{1b}[32mPACKAGEMANAGER_ADDRESS\u{1b}[39m\n environment variable to the base URL of your own P3M instance to report on\n that instead. \u{1b}[32mrig ppm url\u{1b}[39m prints whichever one is in effect.\n\n One command is different: \u{1b}[32mrig ppm builds\u{1b}[39m reads a package build index that\n rig publishes itself, derived from P3M, because P3M has no endpoint that\n lists a package's builds. That index always comes from rig's own host, and\n \u{1b}[32mPACKAGEMANAGER_ADDRESS\u{1b}[39m does not redirect it."; const ABOUT_PROJ_DEPS: &str = "Show project dependencies"; -const HELP_PROJ_DEPS: &str = "\u{1b}[1m\u{1b}[34mDescription:\u{1b}[39m\u{1b}[22m\n Show the dependencies of an R project, in a table: every package the\n project depends on, the dependency type (\u{1b}[32mDepends\u{1b}[39m, \u{1b}[32mImports\u{1b}[39m,\n \u{1b}[32mLinkingTo\u{1b}[39m) and the version requirement, if it has one.\n\n By default rig reads the project manifest (e.g. \u{1b}[32mDESCRIPTION\u{1b}[39m) in the\n current directory; use \u{1b}[32m--input\u{1b}[39m to point to a different file. Add \u{1b}[32m--dev\u{1b}[39m\n to include development dependencies. Use \u{1b}[32m--json\u{1b}[39m for machine readable\n output.\n\n The plain listing only reads the manifest, so it needs neither R nor the\n package repositories.\n\n\u{1b}[1m\u{1b}[34mRecursive dependencies:\u{1b}[39m\u{1b}[22m\n \u{1b}[32m--recursive\u{1b}[39m (\u{1b}[32m-r\u{1b}[39m) shows the whole dependency closure: not only the\n packages the project needs directly, but also the packages \u{1b}[3mthose\u{1b}[23m need,\n and so on. Each package appears once, with the version currently on CRAN,\n the \u{1b}[32mDepth\u{1b}[39m column giving its distance from the project, and the\n \u{1b}[32mNeeded by\u{1b}[39m column naming the packages that pull it in. This needs the\n package metadata of the repositories, which rig downloads if it does not\n have it yet.\n\n \u{1b}[32mrig proj tree\u{1b}[39m shows the same closure as a tree, laid\n out by the shape of the dependency graph, so you can see \u{1b}[3mhow\u{1b}[23m each\n package is pulled in and not only \u{1b}[3mthat\u{1b}[23m it is.\n\n A recursive listing only ever follows hard dependencies, also below a\n development dependency added by \u{1b}[32m--dev\u{1b}[39m, so \u{1b}[32m--dev --recursive\u{1b}[39m means the\n project's own dev dependencies plus everything they need to be installed.\n\n rig follows the dependencies of the \u{1b}[3mlatest\u{1b}[23m version of every package in\n the closure, so a version requirement that would force an older version,\n with different dependencies, is not taken into account. Use\n \u{1b}[32mrig proj lock\u{1b}[39m for a resolution that is consistent\n across versions."; +const HELP_PROJ_DEPS: &str = "\u{1b}[1m\u{1b}[34mDescription:\u{1b}[39m\u{1b}[22m\n Show the dependencies of an R project, in a table: every package the\n project depends on, the dependency type (\u{1b}[32mDepends\u{1b}[39m, \u{1b}[32mImports\u{1b}[39m,\n \u{1b}[32mLinkingTo\u{1b}[39m) and the version requirement, if it has one.\n\n rig reads the project manifest, \u{1b}[32mrproj.toml\u{1b}[39m, in the current directory. Add\n \u{1b}[32m--dev\u{1b}[39m to include development dependencies. Use \u{1b}[32m--json\u{1b}[39m for machine\n readable output.\n\n The plain listing only reads the manifest, so it needs neither R nor the\n package repositories.\n\n\u{1b}[1m\u{1b}[34mRecursive dependencies:\u{1b}[39m\u{1b}[22m\n \u{1b}[32m--recursive\u{1b}[39m (\u{1b}[32m-r\u{1b}[39m) shows the whole dependency closure: not only the\n packages the project needs directly, but also the packages \u{1b}[3mthose\u{1b}[23m need,\n and so on. Each package appears once, with the version currently on CRAN,\n the \u{1b}[32mDepth\u{1b}[39m column giving its distance from the project, and the\n \u{1b}[32mNeeded by\u{1b}[39m column naming the packages that pull it in. This needs the\n package metadata of the repositories, which rig downloads if it does not\n have it yet.\n\n \u{1b}[32mrig proj tree\u{1b}[39m shows the same closure as a tree, laid\n out by the shape of the dependency graph, so you can see \u{1b}[3mhow\u{1b}[23m each\n package is pulled in and not only \u{1b}[3mthat\u{1b}[23m it is.\n\n A recursive listing only ever follows hard dependencies, also below a\n development dependency added by \u{1b}[32m--dev\u{1b}[39m, so \u{1b}[32m--dev --recursive\u{1b}[39m means the\n project's own dev dependencies plus everything they need to be installed.\n\n rig follows the dependencies of the \u{1b}[3mlatest\u{1b}[23m version of every package in\n the closure, so a version requirement that would force an older version,\n with different dependencies, is not taken into account. Use\n \u{1b}[32mrig proj lock\u{1b}[39m for a resolution that is consistent\n across versions."; const ABOUT_PROJ_IMPORT: &str = "Import a DESCRIPTION file's dependencies into rproj.toml"; const HELP_PROJ_IMPORT: &str = "\u{1b}[1m\u{1b}[34mDescription:\u{1b}[39m\u{1b}[22m\n Read a \u{1b}[32mDESCRIPTION\u{1b}[39m file and merge its dependencies into \u{1b}[32mrproj.toml\u{1b}[39m, rig's\n project and package manifest. If \u{1b}[32mrproj.toml\u{1b}[39m does not exist yet, it is\n created first, named after the DESCRIPTION file's \u{1b}[32mPackage:\u{1b}[39m field.\n\n \u{1b}[32mDepends\u{1b}[39m and \u{1b}[32mImports\u{1b}[39m land in the \u{1b}[32m[dependencies]\u{1b}[39m table (\u{1b}[32mDepends\u{1b}[39m\n packages are marked to attach on load); \u{1b}[32mLinkingTo\u{1b}[39m also lands in\n \u{1b}[32m[linking-dependencies]\u{1b}[39m. \u{1b}[32mSuggests\u{1b}[39m is imported into\n \u{1b}[32m[dependency-groups.test]\u{1b}[39m and \u{1b}[32mEnhances\u{1b}[39m into\n \u{1b}[32m[dependency-groups.enhances]\u{1b}[39m.\n\n By default rig reads \u{1b}[32mDESCRIPTION\u{1b}[39m in the current directory; use \u{1b}[32m--input\u{1b}[39m\n to point to a different file.\n\n Importing a package already listed in \u{1b}[32mrproj.toml\u{1b}[39m overwrites its entry\n with the version requirement from the DESCRIPTION file. Because\n \u{1b}[32mrproj.toml\u{1b}[39m is rewritten in full, any comments or custom formatting in an\n existing file are not preserved."; const ABOUT_PROJ_INIT: &str = "Create a new project manifest"; const HELP_PROJ_INIT: &str = "\u{1b}[1m\u{1b}[34mDescription:\u{1b}[39m\u{1b}[22m\n Write a new \u{1b}[32mrproj.toml\u{1b}[39m manifest in the current directory. This is rig's\n modern project and package file: it describes the project's metadata and its\n R and package dependencies, and can do everything a \u{1b}[32mDESCRIPTION\u{1b}[39m file can,\n plus dependency groups, optional dependencies, workspaces and declared\n scripts.\n\n \u{1b}[32mrig proj init\u{1b}[39m writes a minimal skeleton — a \u{1b}[32m[project]\u{1b}[39m table with the\n name (taken from the current directory) and version, and a \u{1b}[32m[dependencies]\u{1b}[39m\n table with a single R requirement — that you then fill in.\n\n rig refuses to overwrite an existing \u{1b}[32mrproj.toml\u{1b}[39m; pass \u{1b}[32m--force\u{1b}[39m to replace\n it."; const ABOUT_PROJ_LOCK: &str = "Resolve project dependencies and write rproj.lock"; -const HELP_PROJ_LOCK: &str = "\u{1b}[1m\u{1b}[34mDescription:\u{1b}[39m\u{1b}[22m\n Resolve the dependencies of an R project to a concrete set of package\n versions, and write the result to \u{1b}[32mrproj.lock\u{1b}[39m.\n\n rig reads the project manifest (e.g. \u{1b}[32mDESCRIPTION\u{1b}[39m; override with\n \u{1b}[32m--input\u{1b}[39m) and uses its built-in solver to find a compatible set of\n package versions from the configured repositories, without running R.\n\n Use \u{1b}[32m--r-version\u{1b}[39m to solve for a specific R version, \u{1b}[32m--dev\u{1b}[39m to include\n development dependencies, and \u{1b}[32m--renv\u{1b}[39m to also write the result as an\n \u{1b}[32mrenv.lock\u{1b}[39m file.\n\n \u{1b}[32mrproj.lock\u{1b}[39m currently records a single \u{1b}[32m(R version, platform)\u{1b}[39m target;\n \u{1b}[32mrig proj sync\u{1b}[39m installs that target. Solving for several\n targets in one lockfile is planned but not implemented yet.\n\n\u{1b}[1m\u{1b}[34mSource and binary packages:\u{1b}[39m\u{1b}[22m\n The solver considers binary packages as well as source packages, and\n prefers a binary build when one is available for the same version. Which\n artifact each package is installed from is part of what the solve decides,\n because a binary is only usable together with the exact versions of its\n \u{1b}[32mLinkingTo\u{1b}[39m dependencies that it was compiled against. If those versions\n conflict with the rest of the project, rig picks another build of that\n package, or falls back to its source tarball.\n\n By default a binary build never changes \u{1b}[3mwhich version\u{1b}[23m rig picks: the\n newest suitable version wins, and a binary of it is used if there is one.\n Pass \u{1b}[32m--prefer-binary\u{1b}[39m to let an older version win instead, when the newest\n one has no binary but an older one does — typically because a version was\n released so recently that it has not been built yet. Only the three newest\n versions of a package are considered; \u{1b}[32m--prefer-binary=5\u{1b}[39m considers five.\n Versions held back this way are marked in the output.\n\n Trading a version away for a binary is not free: the binary pins its\n \u{1b}[32mLinkingTo\u{1b}[39m dependencies to the versions it was compiled against, and those\n dependencies then prefer their own binaries in turn, so a whole project can\n end up on older versions.\n\n By default rig solves for the machine it runs on. Use \u{1b}[32m--platform\u{1b}[39m to solve\n for a different one, e.g. to write a lockfile on macOS for a Linux\n deployment:\n\n rig proj lock --platform ubuntu-24.04\n\n \u{1b}[32m--platform source\u{1b}[39m solves for source packages only, and does not download\n any binary package metadata. rig also falls back to source packages when\n there are no binaries for a platform at all. There is then nothing for\n \u{1b}[32m--prefer-binary\u{1b}[39m to prefer, and rig ignores it.\n\n rig keeps the repository metadata and the binary package indices it solves\n from in its cache, and refreshes them once a day. \u{1b}[32m--no-cache\u{1b}[39m downloads\n them again instead, and writes nothing to the cache, which is the way to\n solve against a package that was published minutes ago. It is a good deal\n slower, because the metadata it re-downloads is large. See\n \u{1b}[32mrig config\u{1b}[39m.\n\n The \u{1b}[32mrproj.lock\u{1b}[39m file records, for every package, whether it is a source or a\n binary package and the URL it is downloaded from. It also records where the\n file is cached, which is per \u{1b}[3mbuild\u{1b}[23m rather than per version: a repository\n can offer several binaries of one version for one platform and R version,\n and they are cached side by side."; +const HELP_PROJ_LOCK: &str = "\u{1b}[1m\u{1b}[34mDescription:\u{1b}[39m\u{1b}[22m\n Resolve the dependencies of an R project to a concrete set of package\n versions, and write the result to \u{1b}[32mrproj.lock\u{1b}[39m.\n\n rig reads the project manifest, \u{1b}[32mrproj.toml\u{1b}[39m, in the current directory, and\n uses its built-in solver to find a compatible set of package versions from\n the configured repositories, without running R.\n\n Use \u{1b}[32m--r-version\u{1b}[39m to solve for a specific R version, \u{1b}[32m--dev\u{1b}[39m to include\n development dependencies, and \u{1b}[32m--renv\u{1b}[39m to also write the result as an\n \u{1b}[32mrenv.lock\u{1b}[39m file.\n\n \u{1b}[32mrproj.lock\u{1b}[39m currently records a single \u{1b}[32m(R version, platform)\u{1b}[39m target;\n \u{1b}[32mrig proj sync\u{1b}[39m installs that target. Solving for several\n targets in one lockfile is planned but not implemented yet.\n\n\u{1b}[1m\u{1b}[34mSource and binary packages:\u{1b}[39m\u{1b}[22m\n The solver considers binary packages as well as source packages, and\n prefers a binary build when one is available for the same version. Which\n artifact each package is installed from is part of what the solve decides,\n because a binary is only usable together with the exact versions of its\n \u{1b}[32mLinkingTo\u{1b}[39m dependencies that it was compiled against. If those versions\n conflict with the rest of the project, rig picks another build of that\n package, or falls back to its source tarball.\n\n By default a binary build never changes \u{1b}[3mwhich version\u{1b}[23m rig picks: the\n newest suitable version wins, and a binary of it is used if there is one.\n Pass \u{1b}[32m--prefer-binary\u{1b}[39m to let an older version win instead, when the newest\n one has no binary but an older one does — typically because a version was\n released so recently that it has not been built yet. Only the three newest\n versions of a package are considered; \u{1b}[32m--prefer-binary=5\u{1b}[39m considers five.\n Versions held back this way are marked in the output.\n\n Trading a version away for a binary is not free: the binary pins its\n \u{1b}[32mLinkingTo\u{1b}[39m dependencies to the versions it was compiled against, and those\n dependencies then prefer their own binaries in turn, so a whole project can\n end up on older versions.\n\n By default rig solves for the machine it runs on. Use \u{1b}[32m--platform\u{1b}[39m to solve\n for a different one, e.g. to write a lockfile on macOS for a Linux\n deployment:\n\n rig proj lock --platform ubuntu-24.04\n\n \u{1b}[32m--platform source\u{1b}[39m solves for source packages only, and does not download\n any binary package metadata. rig also falls back to source packages when\n there are no binaries for a platform at all. There is then nothing for\n \u{1b}[32m--prefer-binary\u{1b}[39m to prefer, and rig ignores it.\n\n rig keeps the repository metadata and the binary package indices it solves\n from in its cache, and refreshes them once a day. \u{1b}[32m--no-cache\u{1b}[39m downloads\n them again instead, and writes nothing to the cache, which is the way to\n solve against a package that was published minutes ago. It is a good deal\n slower, because the metadata it re-downloads is large. See\n \u{1b}[32mrig config\u{1b}[39m.\n\n The \u{1b}[32mrproj.lock\u{1b}[39m file records, for every package, whether it is a source or a\n binary package and the URL it is downloaded from. It also records where the\n file is cached, which is per \u{1b}[3mbuild\u{1b}[23m rather than per version: a repository\n can offer several binaries of one version for one platform and R version,\n and they are cached side by side."; const ABOUT_PROJ_SYNC: &str = "Install the dependencies rproj.lock resolved"; const HELP_PROJ_SYNC: &str = "\u{1b}[1m\u{1b}[34mDescription:\u{1b}[39m\u{1b}[22m\n Install the resolved dependencies of an R project into a package library.\n\n rig reads \u{1b}[32mrproj.lock\u{1b}[39m (written by \u{1b}[32mrig proj lock\u{1b}[39m) and\n installs its packages into the library given by \u{1b}[32m--library\u{1b}[39m (default:\n \u{1b}[32m.rvenv/lib\u{1b}[39m). Use \u{1b}[32m--r-binary\u{1b}[39m to select which R to build against\n (default: \u{1b}[32mR\u{1b}[39m) and \u{1b}[32m--max-concurrent\u{1b}[39m to limit the number of simultaneous\n installations (default: 4)."; const ABOUT_PROJ_TREE: &str = "Dependency tree of a project"; -const HELP_PROJ_TREE: &str = "\u{1b}[1m\u{1b}[34mDescription:\u{1b}[39m\u{1b}[22m\n Show everything an R project needs, directly or indirectly, as a tree: the\n same closure \u{1b}[32mrig proj deps --recursive\u{1b}[39m lists in a flat\n table, laid out by the shape of the dependency graph.\n\n myproject 0.1.0 — 3 direct, 24 total\n ├── R (>= 4.1) [D]\n ├── cli 3.6.4\n │ ├── R (>= 3.4) [D]\n │ └── utils\n └── dplyr 1.1.4 (>= 1.1.0)\n ├── cli 3.6.4 (>= 3.4.0) (*)\n └── vctrs 0.6.5 (>= 0.6.4)\n └── cpp11 0.5.2 [L]\n [Suggests]\n └── testthat 3.2.3 (>= 3.1.5)\n\n The first line names the project and its version, how many dependencies it\n declares directly and how many distinct packages there are in the whole tree.\n Each line below names a package, the version currently in the repositories,\n and the version requirement it is needed with, if it has one.\n\n By default rig reads the project manifest (e.g. \u{1b}[32mDESCRIPTION\u{1b}[39m) in the current\n directory; use \u{1b}[32m--input\u{1b}[39m to point to a different file. Unlike\n \u{1b}[32mrig proj deps\u{1b}[39m, the tree needs the package metadata of the\n repositories, which rig downloads if it does not have it yet. It does not need\n R. \u{1b}[32m--json\u{1b}[39m gives machine readable output, as one nested object.\n\n\u{1b}[1m\u{1b}[34mReading the tree:\u{1b}[39m\u{1b}[22m\n A package that several others need is expanded only once, under its first\n occurrence; later occurrences are marked \u{1b}[32m(*)\u{1b}[39m, meaning \"its dependencies are\n above\". \u{1b}[32m--dev\u{1b}[39m adds the project's development dependencies, in their own\n \u{1b}[32m[Suggests]\u{1b}[39m and \u{1b}[32m[Enhances]\u{1b}[39m sections; \u{1b}[32m--no-base\u{1b}[39m leaves out R and the base\n packages. Among the hard dependencies, \u{1b}[32mImports\u{1b}[39m is not marked, \u{1b}[32m[D]\u{1b}[39m is a\n \u{1b}[32mDepends\u{1b}[39m, \u{1b}[32m[L]\u{1b}[39m a \u{1b}[32mLinkingTo\u{1b}[39m, \u{1b}[32m[DL]\u{1b}[39m both.\n\n \u{1b}[32m--why \u{1b}[39m (alias \u{1b}[32m--explain\u{1b}[39m) inverts the tree, so that the named\n package is the root and the tree grows towards the packages that need it, down\n to the project itself. Each line then says how \u{1b}[3mthat\u{1b}[23m package needs the one\n above it, hence \u{1b}[32mneeds\u{1b}[39m.\n\n \u{1b}[32mrig pkg tree\u{1b}[39m, which shows the same tree for a package\n in the repositories, describes all of this in full.\n\n rig follows the dependencies of the \u{1b}[3mlatest\u{1b}[23m version of every package in the\n tree, so a version requirement that would force an older version, with\n different dependencies, is not taken into account. Use\n \u{1b}[32mrig proj lock\u{1b}[39m for a resolution that is consistent across\n versions."; +const HELP_PROJ_TREE: &str = "\u{1b}[1m\u{1b}[34mDescription:\u{1b}[39m\u{1b}[22m\n Show everything an R project needs, directly or indirectly, as a tree: the\n same closure \u{1b}[32mrig proj deps --recursive\u{1b}[39m lists in a flat\n table, laid out by the shape of the dependency graph.\n\n myproject 0.1.0 — 3 direct, 24 total\n ├── R (>= 4.1) [D]\n ├── cli 3.6.4\n │ ├── R (>= 3.4) [D]\n │ └── utils\n └── dplyr 1.1.4 (>= 1.1.0)\n ├── cli 3.6.4 (>= 3.4.0) (*)\n └── vctrs 0.6.5 (>= 0.6.4)\n └── cpp11 0.5.2 [L]\n [Suggests]\n └── testthat 3.2.3 (>= 3.1.5)\n\n The first line names the project and its version, how many dependencies it\n declares directly and how many distinct packages there are in the whole tree.\n Each line below names a package, the version currently in the repositories,\n and the version requirement it is needed with, if it has one.\n\n rig reads the project manifest, \u{1b}[32mrproj.toml\u{1b}[39m, in the current directory. Unlike\n \u{1b}[32mrig proj deps\u{1b}[39m, the tree needs the package metadata of the\n repositories, which rig downloads if it does not have it yet. It does not need\n R. \u{1b}[32m--json\u{1b}[39m gives machine readable output, as one nested object.\n\n\u{1b}[1m\u{1b}[34mReading the tree:\u{1b}[39m\u{1b}[22m\n A package that several others need is expanded only once, under its first\n occurrence; later occurrences are marked \u{1b}[32m(*)\u{1b}[39m, meaning \"its dependencies are\n above\". \u{1b}[32m--dev\u{1b}[39m adds the project's development dependencies, in their own\n \u{1b}[32m[Suggests]\u{1b}[39m and \u{1b}[32m[Enhances]\u{1b}[39m sections; \u{1b}[32m--no-base\u{1b}[39m leaves out R and the base\n packages. Among the hard dependencies, \u{1b}[32mImports\u{1b}[39m is not marked, \u{1b}[32m[D]\u{1b}[39m is a\n \u{1b}[32mDepends\u{1b}[39m, \u{1b}[32m[L]\u{1b}[39m a \u{1b}[32mLinkingTo\u{1b}[39m, \u{1b}[32m[DL]\u{1b}[39m both.\n\n \u{1b}[32m--why \u{1b}[39m (alias \u{1b}[32m--explain\u{1b}[39m) inverts the tree, so that the named\n package is the root and the tree grows towards the packages that need it, down\n to the project itself. Each line then says how \u{1b}[3mthat\u{1b}[23m package needs the one\n above it, hence \u{1b}[32mneeds\u{1b}[39m.\n\n \u{1b}[32mrig pkg tree\u{1b}[39m, which shows the same tree for a package\n in the repositories, describes all of this in full.\n\n rig follows the dependencies of the \u{1b}[3mlatest\u{1b}[23m version of every package in the\n tree, so a version requirement that would force an older version, with\n different dependencies, is not taken into account. Use\n \u{1b}[32mrig proj lock\u{1b}[39m for a resolution that is consistent across\n versions."; const ABOUT_PROJ: &str = "Manage R projects (experimental)"; const HELP_PROJ: &str = "\u{1b}[1m\u{1b}[34mDescription:\u{1b}[39m\u{1b}[22m\n Manage R projects (experimental).\n\n A project is a directory with a package manifest, typically a\n \u{1b}[32mDESCRIPTION\u{1b}[39m file, that declares the R packages the project depends on.\n \u{1b}[32mrig proj\u{1b}[39m resolves those dependencies against the configured package\n repositories and can install them into a project library.\n\n \u{1b}[32mrig proj import\u{1b}[39m reads a \u{1b}[32mDESCRIPTION\u{1b}[39m file and merges its dependencies\n into \u{1b}[32mrproj.toml\u{1b}[39m, creating it first if needed.\n \u{1b}[32mrig proj deps\u{1b}[39m shows the direct and recursive dependencies of the\n project.\n \u{1b}[32mrig proj tree\u{1b}[39m shows the recursive dependencies as a tree, so you can\n see how each package is pulled in.\n \u{1b}[32mrig proj lock\u{1b}[39m resolves the full dependency tree to a concrete set of\n package versions, writes the result to \u{1b}[32mrproj.lock\u{1b}[39m, and can also write\n an \u{1b}[32mrenv.lock\u{1b}[39m file.\n \u{1b}[32mrig proj sync\u{1b}[39m installs the dependencies \u{1b}[32mrproj.lock\u{1b}[39m resolved into a\n package library.\n\n Dependencies are resolved with rig's built-in solver, so R does not need\n to be running for \u{1b}[32mrig proj deps\u{1b}[39m, \u{1b}[32mrig proj tree\u{1b}[39m and \u{1b}[32mrig proj lock\u{1b}[39m.\n\n \u{1b}[32mrig proj\u{1b}[39m is currently experimental, and might change in future\n versions. Feedback is appreciated."; const ABOUT_REPOS_AVAILABLE: &str = "List available R package repositories"; diff --git a/src/help/proj-deps.md b/src/help/proj-deps.md index 24f2fbef..d010b717 100644 --- a/src/help/proj-deps.md +++ b/src/help/proj-deps.md @@ -6,10 +6,9 @@ Show the dependencies of an R project, in a table: every package the project depends on, the dependency type (`Depends`, `Imports`, `LinkingTo`) and the version requirement, if it has one. -By default rig reads the project manifest (e.g. `DESCRIPTION`) in the -current directory; use `--input` to point to a different file. Add `--dev` -to include development dependencies. Use `--json` for machine readable -output. +rig reads the project manifest, `rproj.toml`, in the current directory. Add +`--dev` to include development dependencies. Use `--json` for machine +readable output. The plain listing only reads the manifest, so it needs neither R nor the package repositories. diff --git a/src/help/proj-lock.md b/src/help/proj-lock.md index 770c3f3b..cd657316 100644 --- a/src/help/proj-lock.md +++ b/src/help/proj-lock.md @@ -5,9 +5,9 @@ Resolve project dependencies and write rproj.lock Resolve the dependencies of an R project to a concrete set of package versions, and write the result to `rproj.lock`. -rig reads the project manifest (e.g. `DESCRIPTION`; override with -`--input`) and uses its built-in solver to find a compatible set of -package versions from the configured repositories, without running R. +rig reads the project manifest, `rproj.toml`, in the current directory, and +uses its built-in solver to find a compatible set of package versions from +the configured repositories, without running R. Use `--r-version` to solve for a specific R version, `--dev` to include development dependencies, and `--renv` to also write the result as an diff --git a/src/help/proj-tree.md b/src/help/proj-tree.md index df5391c6..bb66b913 100644 --- a/src/help/proj-tree.md +++ b/src/help/proj-tree.md @@ -25,8 +25,7 @@ declares directly and how many distinct packages there are in the whole tree. Each line below names a package, the version currently in the repositories, and the version requirement it is needed with, if it has one. -By default rig reads the project manifest (e.g. `DESCRIPTION`) in the current -directory; use `--input` to point to a different file. Unlike +rig reads the project manifest, `rproj.toml`, in the current directory. Unlike [`rig proj deps`](#rig-proj-deps), the tree needs the package metadata of the repositories, which rig downloads if it does not have it yet. It does not need R. `--json` gives machine readable output, as one nested object. diff --git a/src/proj.rs b/src/proj.rs index 205dd34d..707b7eff 100644 --- a/src/proj.rs +++ b/src/proj.rs @@ -175,7 +175,38 @@ fn proj_read_deps(input: &str, dev: bool) -> Result> { Ok(package) } -/// Parse dependencies from DESCRIPTION file and print them out +/// Read the project's `rproj.toml` manifest and return its name, version and +/// dependencies, with the soft dependencies dropped unless `dev`. +fn proj_read_manifest_deps( + dev: bool, +) -> Result<(String, RPackageVersion, PackageDependencies), Box> { + let path = Path::new(RPROJ_MANIFEST_FILE); + if !path.exists() { + OUTPUT.error(&format!( + "{} not found, run `rig proj init` first", + RPROJ_MANIFEST_FILE + )); + error!("{} not found", RPROJ_MANIFEST_FILE); + bail!("{} not found", RPROJ_MANIFEST_FILE); + } + + OUTPUT.status(&format!( + "Reading dependencies from {}", + RPROJ_MANIFEST_FILE + )); + info!("Reading dependencies from {}", RPROJ_MANIFEST_FILE); + let manifest: Rproj = toml::from_str(&fs::read_to_string(path)?).map_err(|e| { + OUTPUT.error(&format!("Cannot parse {}: {}", RPROJ_MANIFEST_FILE, e)); + error!("Cannot parse {}: {}", RPROJ_MANIFEST_FILE, e); + e + })?; + + let deps = manifest.to_dep_version_specs(dev)?; + let version = RPackageVersion::from_str(&manifest.project.version)?; + Ok((manifest.project.name, version, deps)) +} + +/// Parse dependencies from the project manifest and print them out fn sc_proj_deps( args: &ArgMatches, projargs: &ArgMatches, @@ -183,15 +214,13 @@ fn sc_proj_deps( ) -> Result<(), Box> { let dev = args.get_flag("dev"); let json = args.get_flag("json") || projargs.get_flag("json") || mainargs.get_flag("json"); - let default_input = "DESCRIPTION".to_string(); - let input: &String = args.get_one::("input").unwrap_or(&default_input); - let pkg = proj_read_deps(input, dev)?; + let (name, version, pkg_deps) = proj_read_manifest_deps(dev)?; if args.get_flag("recursive") { - return proj_deps_recursive(&pkg, json); + return proj_deps_recursive(&name, &version, &pkg_deps, json); } - let mut deps = pkg.dependencies.dependencies.clone(); + let mut deps = pkg_deps.dependencies.clone(); // Sort by dependency type first, then by package name deps.sort_by(|a, b| { @@ -247,7 +276,7 @@ fn sc_proj_deps( } println!("]"); } else { - print_header(&pkg.name, &pkg.version, &dep_count(deps.len()), false); + print_header(&name, &version, &dep_count(deps.len()), false); if deps.is_empty() { return Ok(()); } @@ -276,17 +305,22 @@ fn sc_proj_deps( /// The transitive dependency closure of a project, in the same table /// `rig pkg deps --recursive` prints. /// -/// The soft dependencies were already dropped by [`proj_read_deps`] unless -/// `--dev` was given, so the walk takes the manifest's dependencies as they -/// are; below the project itself it only ever follows hard dependencies. -fn proj_deps_recursive(pkg: &Package, json: bool) -> Result<(), Box> { +/// The soft dependencies were already dropped by [`proj_read_manifest_deps`] +/// unless `--dev` was given, so the walk takes the manifest's dependencies as +/// they are; below the project itself it only ever follows hard dependencies. +fn proj_deps_recursive( + name: &str, + version: &RPackageVersion, + deps: &PackageDependencies, + json: bool, +) -> Result<(), Box> { let loader = DbSourcePackageLoader::new()?; - let (rows, num_direct) = walk_deps(&loader, &pkg.name, &pkg.dependencies.dependencies, true); + let (rows, num_direct) = walk_deps(&loader, name, &deps.dependencies, true); if json { print_deps_json(&rows, true)?; } else { - print_deps_recursive(&pkg.name, &pkg.version, num_direct, &rows); + print_deps_recursive(name, version, num_direct, &rows); } Ok(()) @@ -310,14 +344,12 @@ fn sc_proj_tree( let no_base = args.get_flag("no-base"); let why = args.get_one::("why").map(|s| s.as_str()); let json = args.get_flag("json") || projargs.get_flag("json") || mainargs.get_flag("json"); - let default_input = "DESCRIPTION".to_string(); - let input: &String = args.get_one::("input").unwrap_or(&default_input); - let pkg = proj_read_deps(input, dev)?; + let (name, version, pkg_deps) = proj_read_manifest_deps(dev)?; proj_tree( - &pkg.name, - &pkg.version, - &pkg.dependencies.dependencies, + &name, + &version, + &pkg_deps.dependencies, dev, no_base, why, @@ -480,9 +512,7 @@ fn sc_proj_lock( // Do this first, to report local errors early let dev = args.get_flag("dev"); - let default_input = "DESCRIPTION".to_string(); - let input: &String = args.get_one::("input").unwrap_or(&default_input); - let mut pkg_deps = proj_read_deps(input, dev)?.dependencies; + let (_name, _version, mut pkg_deps) = proj_read_manifest_deps(dev)?; if args.get_flag("renv") { pkg_deps.dependencies.push(DepVersionSpec { diff --git a/src/rproj.rs b/src/rproj.rs index 42cca88a..09040a0d 100644 --- a/src/rproj.rs +++ b/src/rproj.rs @@ -18,10 +18,14 @@ // are follow-up work. use std::collections::BTreeMap; +use std::error::Error; use serde::{Deserialize, Serialize}; -use crate::dcf::{Package as DcfPackage, RDepType, VersionConstraint}; +use crate::dcf::{ + DepVersionSpec, Package as DcfPackage, PackageDependencies, RDepType, VersionConstraint, + DEP_TYPES_SOFT, +}; use crate::pak::PakLockfilePackage; pub const RPROJ_LOCK_VERSION: usize = 1; @@ -289,6 +293,95 @@ impl Rproj { } } } + + /// The manifest's dependencies as the solver's [`PackageDependencies`], the + /// inverse of [`Rproj::merge_description`]: `[dependencies]` becomes + /// `Depends` (entries marked `attach = true`, and `R` itself) or `Imports`, + /// `[linking-dependencies]` becomes `LinkingTo`, and the `test` / `enhances` + /// dependency groups become `Suggests` / `Enhances`. Other groups have no + /// DESCRIPTION dependency type to map to and are left out. + /// + /// Soft dependencies are dropped unless `dev`; a package that is also a hard + /// dependency stays, because it needs to be installed either way. + pub fn to_dep_version_specs(&self, dev: bool) -> Result> { + let mut deps: Vec = Vec::new(); + + for (name, dep) in self.dependencies.iter() { + let dep_type = if name == "R" || dep_attach(dep) { + RDepType::Depends + } else { + RDepType::Imports + }; + deps.push(dep_spec(name, dep, dep_type)?); + } + + for (name, dep) in self.linking_dependencies.iter() { + deps.push(dep_spec(name, dep, RDepType::LinkingTo)?); + } + + for (group, dep_type) in [ + ("test", RDepType::Suggests), + ("enhances", RDepType::Enhances), + ] { + if let Some(group) = self.dependency_groups.get(group) { + for (name, dep) in group.dependencies.iter() { + deps.push(dep_spec(name, dep, dep_type.clone())?); + } + } + } + + let mut pkg_deps = PackageDependencies { dependencies: deps }; + pkg_deps.simplify(); + + if !dev { + pkg_deps + .dependencies + .retain(|dep| !dep.types.iter().all(|t| DEP_TYPES_SOFT.contains(t))); + } + + Ok(pkg_deps) + } +} + +/// Whether a dependency is attached (`Depends:` rather than `Imports:`). +fn dep_attach(dep: &Dependency) -> bool { + match dep { + Dependency::Version(_) => false, + Dependency::Detailed(t) => t.attach == Some(true), + } +} + +/// One manifest dependency entry as a solver [`DepVersionSpec`]. A dependency +/// with no version (a bare `"*"`, or a table that only names a source, e.g. +/// `git = ...`) has no constraints. +fn dep_spec( + name: &str, + dep: &Dependency, + dep_type: RDepType, +) -> Result> { + let version = match dep { + Dependency::Version(v) => Some(v.as_str()), + Dependency::Detailed(t) => t.version.as_deref(), + }; + Ok(DepVersionSpec { + name: name.to_string(), + types: vec![dep_type], + constraints: parse_constraints(version.unwrap_or("*"))?, + }) +} + +/// Parse an `rproj.toml` version string, e.g. `">= 1.0, < 2.0"`, into version +/// constraints. The inverse of [`format_constraints`]; `"*"` means no +/// constraint. +fn parse_constraints(version: &str) -> Result, Box> { + let version = version.trim(); + if version.is_empty() || version == "*" { + return Ok(vec![]); + } + version + .split(',') + .map(|c| VersionConstraint::from_str(c.trim())) + .collect() } /// Format a dependency's version constraints as an `rproj.toml` version @@ -596,6 +689,203 @@ mod tests { assert_eq!(m.dependencies.get("cli"), Some(&dep(">= 3.6.5"))); } + /// The `(types, constraint strings)` of a converted manifest's dependency, + /// or `None` if the package is not in the solver's dependency list. + fn converted<'a>( + deps: &'a PackageDependencies, + name: &str, + ) -> Option<(&'a [RDepType], Vec)> { + deps.dependencies.iter().find(|d| d.name == name).map(|d| { + ( + d.types.as_slice(), + d.constraints + .iter() + .map(|c| format!("{} {}", c.constraint_type, c.version)) + .collect(), + ) + }) + } + + #[test] + fn to_dep_version_specs_plain_entry_is_an_import() { + let mut m = Rproj::minimal("mypkg"); + m.dependencies.insert("cli".to_string(), dep(">= 3.6.5")); + let deps = m.to_dep_version_specs(false).unwrap(); + assert_eq!( + converted(&deps, "cli"), + Some((&[RDepType::Imports][..], vec![">= 3.6.5".to_string()])) + ); + } + + #[test] + fn to_dep_version_specs_attach_is_a_depends() { + let mut m = Rproj::minimal("mypkg"); + m.dependencies.insert( + "crayon".to_string(), + Dependency::Detailed(DepTable { + version: Some("*".to_string()), + attach: Some(true), + ..Default::default() + }), + ); + let deps = m.to_dep_version_specs(false).unwrap(); + assert_eq!( + converted(&deps, "crayon"), + Some((&[RDepType::Depends][..], vec![])) + ); + } + + #[test] + fn to_dep_version_specs_r_is_a_depends_with_its_constraint() { + let deps = Rproj::minimal("mypkg").to_dep_version_specs(false).unwrap(); + assert_eq!( + converted(&deps, "R"), + Some((&[RDepType::Depends][..], vec![">= 4.1".to_string()])) + ); + } + + #[test] + fn to_dep_version_specs_versionless_source_has_no_constraints() { + let mut m = Rproj::minimal("mypkg"); + m.dependencies.insert( + "ts".to_string(), + Dependency::Detailed(DepTable { + git: Some("https://github.com/gaborcsardi/ts".to_string()), + ..Default::default() + }), + ); + let deps = m.to_dep_version_specs(false).unwrap(); + assert_eq!( + converted(&deps, "ts"), + Some((&[RDepType::Imports][..], vec![])) + ); + } + + #[test] + fn to_dep_version_specs_merges_linkingto_into_one_entry() { + let mut m = Rproj::minimal("mypkg"); + m.dependencies.insert("Rcpp".to_string(), dep(">= 1.0")); + m.linking_dependencies + .insert("Rcpp".to_string(), dep(">= 1.0")); + let deps = m.to_dep_version_specs(false).unwrap(); + assert_eq!( + converted(&deps, "Rcpp"), + Some(( + &[RDepType::Imports, RDepType::LinkingTo][..], + vec![">= 1.0".to_string()] + )) + ); + } + + #[test] + fn to_dep_version_specs_multiple_constraints_split_on_comma() { + let mut m = Rproj::minimal("mypkg"); + m.dependencies + .insert("cli".to_string(), dep(">= 1.0, << 2.0")); + let deps = m.to_dep_version_specs(false).unwrap(); + assert_eq!( + converted(&deps, "cli"), + Some(( + &[RDepType::Imports][..], + vec![">= 1.0".to_string(), "<< 2.0".to_string()] + )) + ); + } + + #[test] + fn to_dep_version_specs_groups_are_soft_and_need_dev() { + let mut m = Rproj::minimal("mypkg"); + m.dependency_groups.insert( + "test".to_string(), + Group { + include_groups: vec![], + dependencies: BTreeMap::from([("testthat".to_string(), dep(">= 3.0"))]), + }, + ); + m.dependency_groups.insert( + "enhances".to_string(), + Group { + include_groups: vec![], + dependencies: BTreeMap::from([("otherpkg".to_string(), dep("*"))]), + }, + ); + // an unknown group has no DESCRIPTION dependency type, and is left out + m.dependency_groups.insert( + "docs".to_string(), + Group { + include_groups: vec![], + dependencies: BTreeMap::from([("pkgdown".to_string(), dep("*"))]), + }, + ); + + let deps = m.to_dep_version_specs(false).unwrap(); + assert_eq!(converted(&deps, "testthat"), None); + assert_eq!(converted(&deps, "otherpkg"), None); + assert_eq!(converted(&deps, "pkgdown"), None); + + let deps = m.to_dep_version_specs(true).unwrap(); + assert_eq!( + converted(&deps, "testthat"), + Some((&[RDepType::Suggests][..], vec![">= 3.0".to_string()])) + ); + assert_eq!( + converted(&deps, "otherpkg"), + Some((&[RDepType::Enhances][..], vec![])) + ); + assert_eq!(converted(&deps, "pkgdown"), None); + } + + #[test] + fn to_dep_version_specs_keeps_a_soft_dep_that_is_also_hard() { + let mut m = Rproj::minimal("mypkg"); + m.dependencies.insert("cli".to_string(), dep(">= 3.6.5")); + m.dependency_groups.insert( + "test".to_string(), + Group { + include_groups: vec![], + dependencies: BTreeMap::from([("cli".to_string(), dep("*"))]), + }, + ); + let deps = m.to_dep_version_specs(false).unwrap(); + assert_eq!( + converted(&deps, "cli"), + Some(( + &[RDepType::Imports, RDepType::Suggests][..], + vec![">= 3.6.5".to_string()] + )) + ); + } + + #[test] + fn to_dep_version_specs_round_trips_a_description() { + let mut m = Rproj::minimal("mypkg"); + m.dependencies.clear(); + let pkg = package(vec![ + spec( + "R", + &[RDepType::Depends], + vec![constraint(VersionConstraintType::GreaterOrEqual, "4.1")], + ), + spec("crayon", &[RDepType::Depends], vec![]), + spec( + "cli", + &[RDepType::Imports], + vec![constraint(VersionConstraintType::GreaterOrEqual, "3.6.5")], + ), + spec("Rcpp", &[RDepType::Imports, RDepType::LinkingTo], vec![]), + spec("testthat", &[RDepType::Suggests], vec![]), + spec("otherpkg", &[RDepType::Enhances], vec![]), + ]); + m.merge_description(&pkg); + + let deps = m.to_dep_version_specs(true).unwrap(); + let mut expected = pkg.dependencies.dependencies.clone(); + expected.sort_by(|a, b| a.name.cmp(&b.name)); + let mut got = deps.dependencies.clone(); + got.sort_by(|a, b| a.name.cmp(&b.name)); + assert_eq!(got, expected); + } + #[test] fn merge_description_multiple_constraints_join_with_comma() { let mut m = Rproj::minimal("mypkg"); From 9b863c48a73fb4c67374fce8513d83bb33aa6fc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Cs=C3=A1rdi?= Date: Wed, 2 Sep 2026 19:28:03 +0200 Subject: [PATCH 07/19] rig proj init prototype --- .gitattributes | 6 + .github/workflows/test.yaml | 1 + Cargo.lock | 51 +- Makefile | 9 + src/args.rs | 10 +- src/data/rvenv-pkg/DESCRIPTION | 13 + src/data/rvenv-pkg/LICENSE | 2 + src/data/rvenv-pkg/NAMESPACE | 2 + src/data/rvenv-pkg/R/rvenv.R | 83 ++++ src/data/rvenv-shim/SOURCE-HASH | 7 + src/data/rvenv-shim/shim-3.5.tar.gz | Bin 0 -> 4334 bytes src/data/rvenv-shim/shim-4.0.tar.gz | Bin 0 -> 4374 bytes src/data/rvenv-shim/shim-lt-3.5.tar.gz | Bin 0 -> 4337 bytes src/hardcoded.rs | 8 + src/help-generated.in | 6 +- src/help/proj-init.md | 65 ++- src/help/proj-sync.md | 20 +- src/lib.rs | 1 + src/main.rs | 1 + src/proj.rs | 135 +++++- src/rproj.rs | 36 ++ src/rvenv.rs | 545 +++++++++++++++++++++ tests/test-linux.sh | 41 ++ tests/test-macos.sh | 41 ++ tests/test-windows.sh | 41 ++ xtask/Cargo.toml | 6 + xtask/src/main.rs | 15 +- xtask/src/rvenv_shim.rs | 629 +++++++++++++++++++++++++ 28 files changed, 1725 insertions(+), 49 deletions(-) create mode 100644 src/data/rvenv-pkg/DESCRIPTION create mode 100644 src/data/rvenv-pkg/LICENSE create mode 100644 src/data/rvenv-pkg/NAMESPACE create mode 100644 src/data/rvenv-pkg/R/rvenv.R create mode 100644 src/data/rvenv-shim/SOURCE-HASH create mode 100644 src/data/rvenv-shim/shim-3.5.tar.gz create mode 100644 src/data/rvenv-shim/shim-4.0.tar.gz create mode 100644 src/data/rvenv-shim/shim-lt-3.5.tar.gz create mode 100644 src/rvenv.rs create mode 100644 xtask/src/rvenv_shim.rs diff --git a/.gitattributes b/.gitattributes index 12ae954a..69ae23c1 100644 --- a/.gitattributes +++ b/.gitattributes @@ -3,3 +3,9 @@ # checked out on. A CRLF checkout of repositories.R broke profile marker # matching on Windows (see src/hardcoded.rs). src/data/repositories.R text eol=lf + +# Same for the shim R package source: it is byte-hashed by +# `cargo xtask gen-rvenv-shim`, so a CRLF checkout would look like a source +# change and fail the CI drift check. The built packages are binary. +src/data/rvenv-pkg/** text eol=lf +src/data/rvenv-shim/*.tar.gz binary diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index ade66205..d6faf8a1 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -79,6 +79,7 @@ jobs: cargo test cargo test -p xtask cargo xtask gen-help --check + cargo xtask gen-rvenv-shim --check - name: Build rig run: | diff --git a/Cargo.lock b/Cargo.lock index 60013e42..6418526d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -194,6 +194,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bstr" version = "1.9.1" @@ -371,6 +380,16 @@ version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "csv" version = "1.4.0" @@ -441,6 +460,16 @@ dependencies = [ "generic-array", ] +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common", +] + [[package]] name = "directories" version = "4.0.1" @@ -1839,7 +1868,7 @@ dependencies = [ "serde_derive", "serde_json", "serde_yaml", - "sha2", + "sha2 0.9.9", "shellexpand", "simple-error", "static_vcruntime", @@ -2109,13 +2138,24 @@ version = "0.9.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800" dependencies = [ - "block-buffer", + "block-buffer 0.9.0", "cfg-if", "cpufeatures", - "digest", + "digest 0.9.0", "opaque-debug", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest 0.10.7", +] + [[package]] name = "shared_child" version = "1.0.0" @@ -3111,7 +3151,12 @@ dependencies = [ name = "xtask" version = "0.0.0" dependencies = [ + "flate2", "pulldown-cmark", + "serde", + "serde_json", + "sha2 0.10.9", + "tar", ] [[package]] diff --git a/Makefile b/Makefile index 3ddd5cc4..9fa866d6 100644 --- a/Makefile +++ b/Makefile @@ -307,6 +307,15 @@ fonts-asset: help: cargo xtask gen-help +# Rebuild the pre-built shim R packages in src/data/rvenv-shim (one per R +# version bracket) from the package source in src/data/rvenv-pkg. `rig proj +# init` seeds one of them into a project's .rvenv/lib/rig. Needs the R +# versions listed in xtask/src/rvenv_shim.rs and installs them with `rig add` +# if they are missing, so this is a maintainer-only task; CI only runs +# `cargo xtask gen-rvenv-shim --check`, which needs no R. +rvenv-shim: + cargo xtask gen-rvenv-shim + readme: README.md # Regenerate the command-line reference (website/reference/_commands.md) from diff --git a/src/args.rs b/src/args.rs index 4f8b7d78..a2c60bed 100644 --- a/src/args.rs +++ b/src/args.rs @@ -1322,11 +1322,19 @@ pub fn rig_app() -> Command { .display_order(0) .arg( Arg::new("force") - .help("Overwrite an existing rproj.toml") + .help("Overwrite existing project files") .long("force") .short('f') .num_args(0) .required(false), + ) + .arg( + Arg::new("r-version") + .help("R version of the project (default: the default R version)") + .long("r-version") + .short('r') + .num_args(1) + .required(false), ), ) .subcommand( diff --git a/src/data/rvenv-pkg/DESCRIPTION b/src/data/rvenv-pkg/DESCRIPTION new file mode 100644 index 00000000..4b7ec49e --- /dev/null +++ b/src/data/rvenv-pkg/DESCRIPTION @@ -0,0 +1,13 @@ +Package: rig +Title: Project Library Activation Shim +Version: 0.1.0 +Author: Gabor Csardi +Maintainer: Gabor Csardi +Description: Loaded automatically via R_DEFAULT_PACKAGES from a project's + .Renviron file. Resolves the project's relative library path to an + absolute one, so that child R processes inherit it, and warns if the + project has not been synced yet. This package is generated and installed + by rig, the R installation manager; do not edit it. +License: MIT + file LICENSE +Encoding: UTF-8 +Depends: R (>= 3.0.0) diff --git a/src/data/rvenv-pkg/LICENSE b/src/data/rvenv-pkg/LICENSE new file mode 100644 index 00000000..86486433 --- /dev/null +++ b/src/data/rvenv-pkg/LICENSE @@ -0,0 +1,2 @@ +YEAR: 2026 +COPYRIGHT HOLDER: Gabor Csardi diff --git a/src/data/rvenv-pkg/NAMESPACE b/src/data/rvenv-pkg/NAMESPACE new file mode 100644 index 00000000..67d101bf --- /dev/null +++ b/src/data/rvenv-pkg/NAMESPACE @@ -0,0 +1,2 @@ +# This package intentionally exports nothing. All of its behavior happens in +# .onLoad(), see R/rvenv.R. diff --git a/src/data/rvenv-pkg/R/rvenv.R b/src/data/rvenv-pkg/R/rvenv.R new file mode 100644 index 00000000..c7041e2a --- /dev/null +++ b/src/data/rvenv-pkg/R/rvenv.R @@ -0,0 +1,83 @@ +# This package is loaded from a project's .Renviron, via +# +# R_DEFAULT_PACKAGES=rig,datasets,utils,grDevices,graphics,stats,methods +# +# It is the "in-session activation" leg of a rig project: it makes the +# project's `.rvenv/lib` library work in R sessions that rig did not start, +# e.g. in RStudio, Positron or VS Code. We deliberately do not use a project +# `.Rprofile` for this, because that would shadow the user's own +# `~/.Rprofile` entirely. +# +# `.Renviron` sets `R_LIBS_USER` to the *relative* path `.rvenv/lib`, because +# the file is committed to version control and has to work from any clone +# location. `.onLoad()` receives an already-resolved absolute `libname`, so +# this is where the relative path becomes an absolute one. That matters for +# child processes (callr, parallel, `R CMD`, `Rscript` from a subdirectory): +# they inherit the environment variable, not our `.libPaths()` call, so a +# child started in a subdirectory would otherwise look for +# `/.rvenv/lib`. + +.onLoad <- function(libname, pkgname) { + venv <- normalizePath(dirname(libname), mustWork = FALSE) + lib <- file.path(venv, "lib") + + # Whether a parent process activated this project already. Note that this + # does *not* mean there is nothing to do: a child process started in the + # project root reads the project .Renviron itself, which sets R_LIBS_USER + # back to the relative `.rvenv/lib`, and a grandchild started from a + # subdirectory would then look for the wrong directory. So the variables + # below are re-asserted unconditionally, and only the parts that are not + # idempotent -- re-reading the user's .Renviron and warning about an + # unsynced project -- are skipped. + activated <- identical( + normalizePath(Sys.getenv("RVENV"), mustWork = FALSE), + venv + ) + + # Re-read the user's own .Renviron first: the project .Renviron shadows it + # rather than merging with it, so without this the user's variables are + # empty in this session. Our own variables are set after it, so they win. + # Not in a child process: there the parent's environment, including + # whatever it deliberately changed, is what should survive. + home_renv <- path.expand("~/.Renviron") + if (!activated && file.exists(home_renv)) readRenviron(home_renv) + + Sys.setenv( + R_LIBS_USER = lib, + # Setting this empty does not reliably disable the site library on all R + # versions, so point it at a path that does not exist. + R_LIBS_SITE = "/nonexistent/rvenv-no-site", + RVENV = venv + ) + + # `include.site` was added in R 4.2.0. + if (getRversion() >= "4.2.0") { + .libPaths(lib, include.site = FALSE) + } else { + .libPaths(lib) + } + + Sys.unsetenv("R_DEFAULT_PACKAGES") + + # One warning per project is enough; child processes are quiet. + if (activated) return(invisible()) + + # `rig proj sync` copies the lock file it installed from to + # `.rvenv/lib/.synced`. A copy rather than a hash, so that both sides only + # need to read files: base R has no sha256, and md5 would mean one more + # dependency on the rig side. + stamp <- file.path(lib, ".synced") + lock <- file.path(dirname(venv), "rproj.lock") + synced <- FALSE + if (file.exists(stamp) && file.exists(lock)) { + synced <- identical( + readLines(stamp, warn = FALSE), + readLines(lock, warn = FALSE) + ) + } + if (!synced) { + packageStartupMessage("! Project is not synced. Run: rig proj sync") + } + + invisible() +} diff --git a/src/data/rvenv-shim/SOURCE-HASH b/src/data/rvenv-shim/SOURCE-HASH new file mode 100644 index 00000000..b10a0af3 --- /dev/null +++ b/src/data/rvenv-shim/SOURCE-HASH @@ -0,0 +1,7 @@ +# Generated by `cargo xtask gen-rvenv-shim` (run `make rvenv-shim`). +# Do not edit by hand. `cargo xtask gen-rvenv-shim --check` verifies that +# the committed shim packages still match src/data/rvenv-pkg. +source = 27876fc19c83d55b4d0bac79b5e5ae119a6e3cc5bdfd6cef95bddea575636e2c +shim-lt-3.5.tar.gz = r 3.4.4, serialize 2, sha256 cb6eabe9cb6541348025a25019dfea8dfaedcb830ae35234651321ec36685fe7 +shim-3.5.tar.gz = r 3.5.3, serialize 2, sha256 4d41a108ee53bcb960358b457d4eee515b5de2ccc3ef19bfe4586684b7d2372c +shim-4.0.tar.gz = r 4.0.5, serialize 3, sha256 3a205bb92f2fb1a8b9baacd03ea51ce6a91e9a4bb7465a5a7b8645783387770c diff --git a/src/data/rvenv-shim/shim-3.5.tar.gz b/src/data/rvenv-shim/shim-3.5.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..d2cd51301c43d0e8bb9d66a59bb6ab3a3792501e GIT binary patch literal 4334 zcmVX2!t#U5JU-!fNUXwDsC`I zCS)W`U^0MM6+xD|DcoZj1mZ1uS351ABsfb5ZfRH15(8R6{!`)25Ip32kA$4J#GMfE_~wr zXR#bOADsUzM+YuTbN=@N!o2uGXLTUcJc0yaLSJv65a1K!@98D{;6#Ts_y>ZD{{V!5 z?PLf{#NmjFiAz<`UyD!0Kbyz?@czekwAbAKdIKYt_UnGoS^ASWA|gT@Awy&lVm)?R za-UI#`U_DLy+E*AoRys^%T~xYN6FBQ+oLkG7e_X7S?{Bwq+Cg7q{(T?Fo_z-Ne3nSwFP4X(J`Cw7y8$ALhaF!wvI( zNvW0QEZ>uO_BCT&Vw>UmRl|RLHKMMp)-bl{-P9SU2XJq)t+*woC)qLHqSGevj~mW) z{8C_?d!r>U|GT_tf|RS~5S?%HNLL%u;4i@Y_*bA<)aOY4(4V;fQ?dR*{Btz%-z)gf z@!!X-55!s@pJxJT@Xy8j_{UVfm`wRu4uDVQf8NLWpG&nty#P)8cR%RF1;P4Nz_5~# z>?-N^3*&9-gucPyZ)Vdi{fA#F=$cp9CLK=vW9Y|}M2-wdG^O@;nBvBY~ zQY*Sn2XXfL_*ie>^o38_MXfiFX^dtArF7>PS1;aKH`8R;R&SR?N73sU7Xu2sA64en z?s~B!a$?Lq%Yk2ByuFJqZukDtvdg)#YVpRX>K_KQPmAZ=+?g@H@$i(Q0nzm?8~Uc_ zCqey6#u}{Df;9LG@jm{0K0W^z?*DAAJ?G>5KZmV}|6agv@qbx#TX~6P7?`vyUbnj1 zd`{~uLk~spRrJ!-Mo;wcj)EJLb4T79>liz|w0e)?`}0ZW6@@RZ7{90*w%V-o$9so6 zj~?!v7Hekp*qBEPJi7376|d{u_Ul!Z&Bhz*JQWeXkUW%a`xXC|GGvY+U{yguL5*GMP1jZ=TMSo_sM^E;|B?6XEo zx9gjSTI7m!=ej=*;9u<5RL}6#Ys$LcU#^w94Lf6)UuUm-qK!#Uu3wxUQB`1gq&moL zt^3|(LEEK6b8em5HgcX-=9c)ZRKMZtR-zP_)8^kzA+!)ses#yP!rPLkC%Cxh}*q(hg; z#^Mzl$6SiIQiInQ=vx%n97+ks5_QT1xiyOt_0Dlcx8of0PhE38vB5cD^mF-1r>Ksl zeaG&TeSKuF_oMF)TIU|li)?FyA1_Niuw{kdklJQoSL^>n(b}k_{31MwXD-^hdq#V;XyoJSI%doFPD_qX-BO=bvRICg z#c4Y{o~*+&g-lKP=fcJBcacSPsBfmWzYI3{_Q!An)vSpi~?X1MFmmGVH_bn z0aH;&fr6sn;7lPI(Ley>1Qqn%WRsqQ!Uo6*BF5RyE+HX-DYWfjw=k7>ByAKWCm z7m%oo0}>bmI3h(=1dfU+J`PGyycj$3{E1ddQCT9Thir?(NW0ah5(plx`qgglz);|= zCe$c^AWp16C7_2717LD!szoJ7q(H3JLrF}cY!LO>046C(#cG0$3qVlvJr3f#Nl<|E z>li%>ZGaM|l+a; z7y)rAL>8y8p;VBa!4%&RpPE5JciY((C8Qiil_-n zIE)dAr1g}B057434=KWT_x1M;2_xqKCi#X0dWl3}Qjievfq*Xz@%0Gx=L>-#R451% zc`*SHA&8pbL@M}$6_%+tiCje~`FAF&Q1#544+sVvEHZvm;;=L3dRxzpfbh00#TN;%CI!d<2DHL=})ze5pMJQwF7Nq-9WGoqq zNdfk&8qmW~izYL!dXX$_C@XA$R2dDEJMRht`3x7eCaG8qlPf_QqGq#EBsIF)QUxle z^z{f@2)k8u53hjdCaRNhx3mp86&p&PazR%hi^C+8W70|2#K1^bV8ILze}5AU4`*61 zX!9AGwEYjq`}|LS_z^>&2cOFSJRav`{x3_D|9c6QbMijsepqMeX`74zC(azNI^KU^ zJyq$|z(wDuOCbG3eFYyIrfI~s-0GF2V$_&wE`nFY0l z7c)b0`uWLX_x1OW4qSl8&!(OE8k(18ZVN%$qB(i<1pz@PSpx!&(RZfWFTQ<(W7@p$ z7<*)FK<@VCSH^^z(OSY)&3cwQxmSjN+dBS6VtM#4LL+x!=HMZ|r9a(zv(wGSm3j7Q z>yBdsGuNb@_TPT%WL*j2y!pkjf9tDOFBtv2qG>|JrRwQvIWz2;_76tSiA>C{9jEhJ z`Jm40)S&r#Xfkivu<1Ib?iU;-FWq*}-YzL$z2f?XtGD<&=U!)+&wBDb`U~Q-dC&9q z1!WtnWfPrjJ37G7xA@PhwHMah98&3dPW*k_)~MwRr;akXZtaxTF`sp1gZt(DjH|;t zo%-FKYBqs&b~k}3-wc8ax;uKvqH@WZ#}LX8#CwUlxE}V{#wF?3&&~~=at7SEq}qA*}Q9a zQ=FXNoHVL1_xX+3G1oF%p4L9Bb$nLmblZ7J!S>0O!BZLz8J9h|w{l>3aRDRx^)4Z8Ejts{R%%rv~JUBOQ>zmoDgImRK^iGO`1vX3olO0O|YEVfzq@-<(oaLYeEe5#~myg46WGE=HPgDYOAe{JDL;OVi%bh zeqHN)#(@=+kAL@QV7;mT*N%!M$$Q6pMngU?U!G^(@vWM1Xx-Qm2Dldbr2!PZdxZ5j zC$b}&zO|X>Rq<2FYZHO&?k$OFqwccMuHbu#7hjicevn&w_QH*}hlDe4YxurX<6WJH ze0QBl@|ihwWcI@^!zxU5+6|nHDvT|W^04AAp1o!J`m2JZ)@wP|wN7W98guf$s(JQg zk)U=Hf5`Q8vo|pTYXX(VO`{s{dBcXq+P<4!BTnhdH@ADH)2QG0gmJsPf9KtYZ(p6f zPv9IE^U6;9H%=R?mOtR%aTsDUVT<5jo(2P~?ir+OVT#~I`Uif-Gq92>PJ8x=y*mca zA6eWydqtEj?__#MpvB(0$?SlP#M-x*rBN}*f$Klk&61VTsrwqUcqefvEJw3eV3Rv36HmEZ+dcb(EUqcN0XZ$8*5G3eZlq1Fn#;e zqU4+>mGwEQ*LtiM2lkHeJGXB%Q5Dxta~*T)d_exuhCAVh%!NZyc-h=5_md`O8w?{o zcjU*O+h<|U>g2TKK|b8CkRlyuV7k^d=r6_Z^Zy*^^Wan8|F}HX$N8V5;eYf7{)qp{ z&5~(}XYCuWvu4byD_+g{-YxI6nrHRNFB{~e^L!uFIAEFE8mDCYs99ocHha}bh-YST zX75)c9^bqFV5r$o_a6=&vA6GF6D?ZbVVyp#Em$6;%2t@y<8_9ug3W%b+j za65y}v|^Izq!B>841=V2m!$tp1Va31C=zxAQY4Zbj9_B?GEgdsg83NmMR-z3DhDh+ zh9p23B0)ir2#F!jhov(7izA@Z7&JPW6_1G|Qb*tg@g!2fmO)YhLiU3Y6vO}Ew{8Gu zzT%S>Ni@1e#z2Ue#+Ssnl09GKQUJz(M1}XaXX|O%@_zm@XJUT zpF0R6CIQ!nptzfg#jrr3j3+0Si>eq4_qTgP3lsyPxB{sY5J(jLzygI;G_o(khfx{6 zx_&;vV3uN5fUggm;~&H!b5OoSfS{3%ASBq+!X96WSQr(^9C76oR~KMKqtoc-WcPSP zj1g_t_;(v8-~^a-rmY3t!Gg{N3|mJVdq=t*u7T|Y;!z}tOj6?S58RI|huwhmq41vk zPp8{3-^%}VI~yyyGXIYN7I9cyM@1l$*#UuzxISLq!N5Df*Mq}-E2EQ?_y>ZBe?J(5 zEQK(H#YSb1QY>B2-TT} z3)$>jn8kz%#p$U=o?txb3RSz@ZE+pN!t4U|`^)Yvjm^&UT1?HGLhTxiZTwm;)4vCG z&6Eq5ThErKre&F%4Qk|ugCC5xoQ-vV#qUnGMe zDPQyf!vDSTZ$-C#C;n}$mB0T+0`H6e6m19yp@@jF5hT^|)4>T%{dP6PyP;8?TkF=S zmM@I(3OcD0yrHPLY_YZt+4O~Ow)*j?X;H1JV>edoT?v9hD;jsCoZg^KDQdW)evbE4 zb)p(vohjIK57_ssmo(5Ph4pUe4Py2-PqUroRQ_~!X>`f7yS{__53e&69_ITCd5lbc z+q#|WEV^!272faXm|Gt@<1pLO;oxU#U%HS!O^YIbGK!?czl}HXFGkSl53%`^{>uCR zyYbJowo}Ic2;lwkuQFPfq~AdYQ%Oqv^YAAAQJD`alzfl`@V@wG+Pxe9R<_Fc9|`;( z|M3Cm$tUTfLvLns=W-qf;*OwwAQT8Kr(__Kx$orIl#aP03t#SNral)m!Wv4%a6C zKR1WsP=Ssm007O@7iOXIyVxIyz==ovrcZOz$PmyPoD!6F)xm94+di zQ3h)r6T7&_U*0V%3PmH)=VkrhmGm!8Fq-k;-sV*sXSFq%hrYPaygQ6U%4+dgRB`{g9E~_?qvs0}M`-T$;=pHIY;jHHJ^C@Mtm;u1&EMeRHw1W%KLL zmPl6Yt~j)ezQI^y)JD$cOCg3&;#|%}`ttHk#?Lcw-tA$%os4-)*sNyF_DFr>Q>Z(p zxtP>$u5EbtVAW1w|AVU86UU~uEGqf<+}IVn^|vvna2u@YN3#~F_W7H7{Y>#G8dXv- zdaQb=Xae^f>q5Vx2KFYA_^?0T(Z75|Bu~t~E#_$4$$E5S$9K=n#b5WDY@>c#9)~VhwOLSF zpZ5L4La&|2%23zbxMc&CP8MdI14y~ftA=pLuf}XT*6r0%)L&#b$lOx+`I>60vCaFW z->v3H)*b77)StdDd*e;-RQgZrx*j!5etEzA{>I>q2d@bRIi)tP^PjC?Mt-Bg*nf0s z%hiHk2CaMy?I)2glIK3S^z&jdQscUJ>8a?%SKWKg|B^w!(yktT^J}=f^M{+koqlKd zyAK>()172|ulZIdeP!C7R)a}Bi<%PGu9&qX%un>IMz_YA`V$%?CH@3N{`Y73af0x2 z>W8WTz3=^xVZ(T*{$r;+{}~aOyj6HcF&HHZ$%=wIoD?gOVv2&b2rm!Q081A73RT zlJw#5p7>|dt>3NxT3a!c@jn7E0l<@>g2+UW6vjOPl@X~xOweznG%g;|AP|ybM9>eB zO@UTWAt#B-wxL1iVKw+B$hJ%BR1(VKT;@O7K z!j?#35DY^xLk|z7AgLr$3dIoI7oil-9Wsd!OT>+G0&+<_;6o^o!U9BwNf91F$U!iI z7-NYipcsh&5y}ZYcw0P*8?LYwgQYRD-wgNi4*^~ZMvWK*#`DAo9}E*>0EBltk3U6-cCn5(&|!D%wb z&6NW&g#9i```4rYeUJq2N1<9IiG_!#NB9sE5j+d?U=S~Zh4Er4p#pyxEbs~T4hRVb zEdNDd0gKCJ`3Em@!XL&&68tWV=V65^BqmmjzzT+v6hbjMZatyFkHckq<033~A77u~ zMfezir%$jyCnyMb25v$ zr{K?-4@o0JKtb@Lgmo40?5oZc_R0=vO4cPtSDtUuLKYhvqo{!HwAs6wP>6>m=ZQrW4({{#e z2PYeK!7S3==LS_Il8VN^ZNM&`=Pr9+Mu0yr1fIk5*%mTS=y)2`4~n7|x@ovyXkS%& zBITTC(;4!$Gx-H2MH@dU{az?1P7&KUv;9;Y&>|y#W2) z?8J=mszt8`hVDhHko5U^>n~1>boqG)hwd_Gr`e&NJJ-)!)w|qWh*0;) ztNh@aF*!%0st-2qDs@1k9u*trJ5}d)F5EL`(EV8MDa+&>%%a1tw{EGp$M1@JSSLsC zqF4DI>{!uq?v|hi!O^ZA(FeXtHA!%pX`kK`bGd%9`?bkgMHZjl(R!RT!8Nu;P5jZK zrfsfgv;0n(yUyE`al5iJqci%|pXzpXs~1>m}{#H%D&FY5)8+=~2%D z;uN0oXA79J_Gj(U$2AM?sWs#N0MDPFNeXpQYxo;D-T%Pm>7-CMwMG(2(=w?%BV?}R zireUfu};X8ah1o_<6@JZt-7m!T4kCWId5A|it!BdTif;?^((TEj!x8DoY!|pvr71D zc6X~Duc0|fn9xyHBDsENV$QZ;{g9@Doar1++1da{%W<5&XV=f3^Q@qf?Gh^xj9+tO z|I-_oj&ldf?Bu$t4q zh&a`#M^88Zu$obnEflv5nfYo=x=#A?HD(6kVYWq5ldE2r{08c$+04$WGG3#>yl$DD zrem2J6jtFx+44i-wsln2eD8=!b3DYp>ihFHPK1#<7aYioTTbsaQ^92hq*b}6rImhQYsq~?JiY<%S#=tbj$D(?Y<;%2w( zd==K&JbHim$|ChOOxj0RuwfE;qxrKO8C z#+?haGkuh1lj{^lIl|d>pBf|fI5;3Ntk#;+ht!_dEniB{HM@V)w*7MX%7+!trl&o{ zT9*%2z1Tl{)ufhH=k_qEo7TE#==n_Wzc^W2l`77!S6`e|#kN`kO^LtI)^e3B$qtJP zIdib1?yJ;VgY%O@8+Ib}^)+{L?N9FWy`~mqIKlMzsZ(oL*loJ$aC_e`x~YZd$MSMJ z=6N2{Wil5&P}y4ixt868s@HDq9*6Xw*sD_PU#J~%?6nLW*i3B;i^eXouNz%>;ti&6 zx*geAGcaoU^8?(bIc&94K`kAWLJIVi#`+pMY!{B}Ae^z$PfARl6%HRJZfxp84JTZ23 zsd~0NwOA{=Jmmmm?BJxC6^rz|{k)zm(j_6upN(;|RW-S6>YryOUtLpG=axxojm-wf zmaeF+*#2d7Y|8fP=oPh7f)}b$jKp1Ki#Oy~aE&$;7i`SQ?O7SwspXgYre3HU-bpCVCi+_#PRot$sL%dxg7(+q+?maYKKNI;X0xuF4#+RUT!muQ{Wv9V?-P Q694h|FJ7??K>$1e0Bt6C)L7-j)vRYrDM#F;?_4ba0h zOyexr(|{;0AgDZ15kc8pSz{Ch6^V+X5&=a~+z{}&fC6qP=nF9}fA>I4j=mT3pA&gG zdGnp)nYOy_ty@+1-fHT87#eQj;>vaAc>3`?XZu250(1h_mX`8g0w4b}7%bK=&jalY zI+MkO=+Gb_N`}K2F(mZA2|qY=5gd-#0SpbN@K9VtJo;hM2qY8--Y6e~u~@(k#nEUO zN2LK(XN^s&I^3e<65Gwzr zg_Dg=;bIsS(L$x-=@b`47K)*fxLmWh6c!)?07v0cF)?x|EE2_nXcPvVU>8?6w!b$o z*pKb(#dde)f-p=f1~7>1w|a_<0tnDJh$I@t2$RE55kdnTL?#tQBQk&skzcz43=t9P zqY)q)m}?}A3jr<#utcr~<`ZI3I0B>+#0hy z+>8(vfEXB)5UpY4aB@WhLjoZz0}?3?_y{5avRFwdG0Rv4rvaW2l@X)McLNZQ;RvyI zFitK4p%0Z1ZYmNX0=Y7NEV*4~^0{z+dDp)|Vpu{b!0drQDjx?C$gQGLywOlZA|rO! z$CC$4<*Nd`J)K>BxvmsfNvKqSO2X}cKhMqFhS-WoL?V#c5z4949RZ6*r_oI)Gow%u zPPVaVmNZLyU=NsdrnNcU)|}1+3~M_p8#}r+p~2Z6L`l#k6i9);J_tXu7;ppVOW`y5 zpH8=8ew6>|)2vu@MgAWI1h}#}cJe@`IM4PA;CQ-w@PNl`Zx>h2M;RSb;BN>r{(TS} zwg^LDJPJc(G)y3a{#<-6{u$QvkH7yI(<~W^_#Xu5t=H&(P+5PM9}*J64+(?e5WYGi zDPGl3bJ!|WTiqAz=ZA=dsr)p-?ws6QLGhwARl(%Rf!Upf<8mfvXBTFTAD1 z?}9IS)%%#cvDVqM4j3JwS}=;5uBz3(9Ri)%>v?Mhq`+SdAL3sqLtrda_yxlMv+>WI zX8B3{TUlBw;(rkEpW>f6J~2L1jcQZ{shfE_C5)t=8JeHPWy<2UEaneWJ(y>551Lv> z#`>BGYH2?Ysa6g-qG|O+DY@=ea@V)?lG$M#C%&h8`i1r`Wu&xE>8pgY0B5EbrkVRD zp!HGZf2cg47(#K}R_#)f0Q1M|n38hJ9;jyKdC?i*ON;XB`zm&KMdtS$vQj`Q5C9A9V9Fm^AcFIMRR%a~C& z(yMrglD|RL95uVbqlExXcowcTYPDo0K0FY*_x(g>bvW0D9eGS;)?L{i{gi-F?9`uz ze#`01c#%1)FurS2+=SCH2DTCI0Tr#AhYn@TsY+U-U!Zd>ckMdN_sEd9nIjRkY zuB(!A+EVicRo}hqdKPaU0=3k*KR?&&_`cwZcnGySc3D)h^Th*CyI*%lznyU6pEKf? zMRs*V*nH9RP28uzT2xk{l?5)IsUFM`Z`Cv ztKWuj58u*ypOihuVJklEF)L?vW>eq+ETZxC`_1hdxmH6ijhfZbu>6N|j5U3K5K9>I z^V19KugBAGk90A;S_VI8zEkvVW6s7J9%{O+rOTyu;^MrseO4R!!TYX+d){HbH1~-z zU;S!gByuUfLLE}z_d(`=U$&1cmnf%xsS42NzW*3jj8E!6(<~MB|G|LaM}=nzj!4Kt zvb^AqEQyq2xV&I3B+A1yz!r&sG>oVx%ZLKD5ROKP@{kaYB&x+^37ldGXi^DTx~G~D z#d3rwPFrBnL>->Sp()Boe@&3_PwWyJ4t+U%CjOapOZq4AZ^@jdi2p%=ApmY96+|Y4 zF@*30iHuAIB9eZC(Ktjz13wtU$)N8in+zlhGa$tANIMIQn3xzE$9#a@LX%?Q6hl(T zSsEEjIF%3wRA&>wu(h=^XVMu=BH55ioTV57cnB=+@7$k(Fljgji%IHB7)GQHnKTTK zA#Ab-vC=3I3QGWn2v8Z0p?s2$gW@DF#)8N{VyOTPizW3CZBY`!Zn>#Af{A6n*zNA? z58UO18WHe|;)~EwFu;caFflaQA`>D4z?bVGCEQ3GxC1r-Hz}c5POz~D2ui%iKy*I| z3NU{eV?d!9kYc0~DvXnsV?ZjWG?@_Q#sU$H|0+uR_p|?Xk|ga%u39LKMEaRW#1vyt zA_?;m5G6yxqC{q-3ZgTZ=gIS!?au>j-vBU=&Ec?pc>(rBXIv;H-XcUAma9U=ks=h4 z6O=Gmg2xiplNx+nInEx02z#cdwGBGak3H3v~UFfAVq~`H1a)_=x5R|jfMd#O0-6aL{y1vppk?Omqt&I`7sdM;2;B;-( znW~|q8pukoI#U&-4S&C?&oKR=vS}h@WL*oLd++DC&u_I&8nc(f>}_1XJFfiNiT2#M zahps_^Yf{!Zx0)dF+P(Ppd(V|=4{!SyFFXEYz$PPs!cM@-~PV`<}nO;I)#~2rj+hw zPA)aNvX{!5#Mf+FX=!B!RCYg%WM2$%Lh?F8lODBsoUiw)tM{^4({qc{nUA2HN3(W9~|l}_FaKRM^I|#LUD<@<`AUJU6Q-p@3ik(`f#6?^s-qVTlz+9 zJ*~O+?YYEiEAgHifjK-+YRajH)n9qc4c}+4Sr=R0D>~IzV3e3|l$V|#5Gj3|VW_RU zt$m5`(Sbz2vgesYI~`kfyoyFgoVs$7KDKKfXG6Fap8SCm?+ZMhE3hXv(NIQIg z?z@(yvNkon7ftEZm0E4|uxA$8_A zkFURta7@0qA~LZ33YhSg|5C>O&huGH5I{;>UN#;HY?^O4;=HL%QqS_s=eE!6Y&iRP z<4PUX7>5yh6^rtuLjv0NOdr`+Sl7DKqaZ*LrVUtJX`TSZShm_vFd;Y z$8#sXAZdHx6ujWfNUcoWpf)O4*HLGKGs_U%{tyny%_=MdQ?DTk^jlYUv=l5PAs|w8b4o&V@@$mAcdGlRv zJKfK|b!9An!NRkzo&?y6sNF_A&HLJ~9ZH7=CepG#pYfE?a*~eD3-00k~ z>FM|#X{FT*X>S@_G+wFG|8;nguIc%8<~~!sA9}Kk)v|6RR9apSJ~o~+0fke-nmbp{ z$QUt^a9#aRT~~Eg=H~dM_|YD$u}Gl`G%87{3Hmef+w*@3^kwk5`#;vF{7*WQso;MM z27ZtK$x07X;s+gcP}yRfdc*bpPWQI=O82L$?mRNgL*>;$=+^KQr!6+|mWzV;k_bks zK4h(9PC zYoTssLUsF->X*D3yIwfGo8jHT!c = Lazy::new(|| { data.replace("\r\n", "\n") }); +// The pre-built copies of the `rig` shim R package, one per R version +// bracket, that `rig proj init` seeds into a project's `.rvenv/lib/rig`. See +// `xtask/src/rvenv_shim.rs` for what the brackets are and how these are +// built, and `src/rvenv.rs` for which one is picked when. +pub static HC_RVENV_SHIM_LT_35: &[u8] = include_bytes!("data/rvenv-shim/shim-lt-3.5.tar.gz"); +pub static HC_RVENV_SHIM_35: &[u8] = include_bytes!("data/rvenv-shim/shim-3.5.tar.gz"); +pub static HC_RVENV_SHIM_40: &[u8] = include_bytes!("data/rvenv-shim/shim-4.0.tar.gz"); + pub struct ProfileReposMarkers { pub generic_start: String, pub current_start: String, diff --git a/src/help-generated.in b/src/help-generated.in index c42e7a4c..dbc4ca1a 100644 --- a/src/help-generated.in +++ b/src/help-generated.in @@ -66,12 +66,12 @@ const ABOUT_PROJ_DEPS: &str = "Show project dependencies"; const HELP_PROJ_DEPS: &str = "\u{1b}[1m\u{1b}[34mDescription:\u{1b}[39m\u{1b}[22m\n Show the dependencies of an R project, in a table: every package the\n project depends on, the dependency type (\u{1b}[32mDepends\u{1b}[39m, \u{1b}[32mImports\u{1b}[39m,\n \u{1b}[32mLinkingTo\u{1b}[39m) and the version requirement, if it has one.\n\n rig reads the project manifest, \u{1b}[32mrproj.toml\u{1b}[39m, in the current directory. Add\n \u{1b}[32m--dev\u{1b}[39m to include development dependencies. Use \u{1b}[32m--json\u{1b}[39m for machine\n readable output.\n\n The plain listing only reads the manifest, so it needs neither R nor the\n package repositories.\n\n\u{1b}[1m\u{1b}[34mRecursive dependencies:\u{1b}[39m\u{1b}[22m\n \u{1b}[32m--recursive\u{1b}[39m (\u{1b}[32m-r\u{1b}[39m) shows the whole dependency closure: not only the\n packages the project needs directly, but also the packages \u{1b}[3mthose\u{1b}[23m need,\n and so on. Each package appears once, with the version currently on CRAN,\n the \u{1b}[32mDepth\u{1b}[39m column giving its distance from the project, and the\n \u{1b}[32mNeeded by\u{1b}[39m column naming the packages that pull it in. This needs the\n package metadata of the repositories, which rig downloads if it does not\n have it yet.\n\n \u{1b}[32mrig proj tree\u{1b}[39m shows the same closure as a tree, laid\n out by the shape of the dependency graph, so you can see \u{1b}[3mhow\u{1b}[23m each\n package is pulled in and not only \u{1b}[3mthat\u{1b}[23m it is.\n\n A recursive listing only ever follows hard dependencies, also below a\n development dependency added by \u{1b}[32m--dev\u{1b}[39m, so \u{1b}[32m--dev --recursive\u{1b}[39m means the\n project's own dev dependencies plus everything they need to be installed.\n\n rig follows the dependencies of the \u{1b}[3mlatest\u{1b}[23m version of every package in\n the closure, so a version requirement that would force an older version,\n with different dependencies, is not taken into account. Use\n \u{1b}[32mrig proj lock\u{1b}[39m for a resolution that is consistent\n across versions."; const ABOUT_PROJ_IMPORT: &str = "Import a DESCRIPTION file's dependencies into rproj.toml"; const HELP_PROJ_IMPORT: &str = "\u{1b}[1m\u{1b}[34mDescription:\u{1b}[39m\u{1b}[22m\n Read a \u{1b}[32mDESCRIPTION\u{1b}[39m file and merge its dependencies into \u{1b}[32mrproj.toml\u{1b}[39m, rig's\n project and package manifest. If \u{1b}[32mrproj.toml\u{1b}[39m does not exist yet, it is\n created first, named after the DESCRIPTION file's \u{1b}[32mPackage:\u{1b}[39m field.\n\n \u{1b}[32mDepends\u{1b}[39m and \u{1b}[32mImports\u{1b}[39m land in the \u{1b}[32m[dependencies]\u{1b}[39m table (\u{1b}[32mDepends\u{1b}[39m\n packages are marked to attach on load); \u{1b}[32mLinkingTo\u{1b}[39m also lands in\n \u{1b}[32m[linking-dependencies]\u{1b}[39m. \u{1b}[32mSuggests\u{1b}[39m is imported into\n \u{1b}[32m[dependency-groups.test]\u{1b}[39m and \u{1b}[32mEnhances\u{1b}[39m into\n \u{1b}[32m[dependency-groups.enhances]\u{1b}[39m.\n\n By default rig reads \u{1b}[32mDESCRIPTION\u{1b}[39m in the current directory; use \u{1b}[32m--input\u{1b}[39m\n to point to a different file.\n\n Importing a package already listed in \u{1b}[32mrproj.toml\u{1b}[39m overwrites its entry\n with the version requirement from the DESCRIPTION file. Because\n \u{1b}[32mrproj.toml\u{1b}[39m is rewritten in full, any comments or custom formatting in an\n existing file are not preserved."; -const ABOUT_PROJ_INIT: &str = "Create a new project manifest"; -const HELP_PROJ_INIT: &str = "\u{1b}[1m\u{1b}[34mDescription:\u{1b}[39m\u{1b}[22m\n Write a new \u{1b}[32mrproj.toml\u{1b}[39m manifest in the current directory. This is rig's\n modern project and package file: it describes the project's metadata and its\n R and package dependencies, and can do everything a \u{1b}[32mDESCRIPTION\u{1b}[39m file can,\n plus dependency groups, optional dependencies, workspaces and declared\n scripts.\n\n \u{1b}[32mrig proj init\u{1b}[39m writes a minimal skeleton — a \u{1b}[32m[project]\u{1b}[39m table with the\n name (taken from the current directory) and version, and a \u{1b}[32m[dependencies]\u{1b}[39m\n table with a single R requirement — that you then fill in.\n\n rig refuses to overwrite an existing \u{1b}[32mrproj.toml\u{1b}[39m; pass \u{1b}[32m--force\u{1b}[39m to replace\n it."; +const ABOUT_PROJ_INIT: &str = "Create a new R project"; +const HELP_PROJ_INIT: &str = "\u{1b}[1m\u{1b}[34mDescription:\u{1b}[39m\u{1b}[22m\n Set up an R project in the current directory: the \u{1b}[32mrproj.toml\u{1b}[39m manifest, plus\n the part of the project's virtual environment (\u{1b}[32m.rvenv\u{1b}[39m) that belongs in\n version control.\n\n \u{1b}[32mrproj.toml\u{1b}[39m is rig's modern project and package file. It describes the\n project's metadata and its R and package dependencies, and can do everything\n a \u{1b}[32mDESCRIPTION\u{1b}[39m file can, plus dependency groups, optional dependencies,\n workspaces and declared scripts. \u{1b}[32mrig proj init\u{1b}[39m writes a minimal skeleton — a\n \u{1b}[32m[project]\u{1b}[39m table with the name (taken from the current directory) and\n version, and a \u{1b}[32m[dependencies]\u{1b}[39m table with a single R requirement — that you\n then fill in. Use \u{1b}[32mrig proj import\u{1b}[39m instead to take the\n dependencies from an existing \u{1b}[32mDESCRIPTION\u{1b}[39m.\n\n\u{1b}[1m\u{1b}[34mFiles:\u{1b}[39m\u{1b}[22m\n \u{1b}[32mrig proj init\u{1b}[39m creates these, and nothing else. All of them are meant to be\n committed, so that a fresh clone of the project works right away:\n\n - \u{1b}[32mrproj.toml\u{1b}[39m — the manifest. Its R requirement is \u{1b}[32m>= .\u{1b}[39m of\n the project's R version.\n - \u{1b}[32m.Renviron\u{1b}[39m — points R at the project library, \u{1b}[32m.rvenv/lib\u{1b}[39m. This is what\n makes the project work in an editor (RStudio, Positron, VS Code), which\n starts R itself.\n - \u{1b}[32m.gitignore\u{1b}[39m — a marked \u{1b}[32m# rig rvenv start\u{1b}[39m / \u{1b}[32m# rig rvenv end\u{1b}[39m block that\n ignores everything in \u{1b}[32m.rvenv\u{1b}[39m except the library directory. An existing\n \u{1b}[32m.gitignore\u{1b}[39m is \u{1b}[3mnot\u{1b}[23m replaced: rig only adds or refreshes its own block,\n and leaves the rest of the file alone.\n - \u{1b}[32m.rvenv/lib/.gitignore\u{1b}[39m — keeps the library directory itself, and the \u{1b}[32mrig\u{1b}[39m\n package in it, in version control, and ignores the installed dependencies.\n The directory has to exist in every checkout, because plain R does not\n create a missing library directory.\n - \u{1b}[32m.rvenv/lib/rig\u{1b}[39m — a small, pre-built R package that rig manages. It is not\n a dependency of your project. \u{1b}[32m.Renviron\u{1b}[39m loads it in every R session\n started in the project, where it turns the relative library path into an\n absolute one — so that R processes started from a subdirectory still use the\n project library — and warns while the project is out of sync with\n \u{1b}[32mrproj.lock\u{1b}[39m.\n\n The rest of \u{1b}[32m.rvenv\u{1b}[39m is machine-specific and is created by\n \u{1b}[32mrig proj sync\u{1b}[39m, which installs the project's dependencies\n into \u{1b}[32m.rvenv/lib\u{1b}[39m.\n\n Note that \u{1b}[32mR --vanilla\u{1b}[39m ignores \u{1b}[32m.Renviron\u{1b}[39m, and so does not use the project\n library.\n\n\u{1b}[1m\u{1b}[34mOptions:\u{1b}[39m\u{1b}[22m\n \u{1b}[32m--r-version\u{1b}[39m sets the R version the project is for. It defaults to the\n current default R version, and does not have to be installed. Both the\n manifest's R requirement and the flavor of the pre-built \u{1b}[32mrig\u{1b}[39m package depend\n on it.\n\n rig refuses to overwrite any of the files above; pass \u{1b}[32m--force\u{1b}[39m to replace\n them. \u{1b}[32m--force\u{1b}[39m still does not rewrite the whole \u{1b}[32m.gitignore\u{1b}[39m, only rig's\n block in it."; const ABOUT_PROJ_LOCK: &str = "Resolve project dependencies and write rproj.lock"; const HELP_PROJ_LOCK: &str = "\u{1b}[1m\u{1b}[34mDescription:\u{1b}[39m\u{1b}[22m\n Resolve the dependencies of an R project to a concrete set of package\n versions, and write the result to \u{1b}[32mrproj.lock\u{1b}[39m.\n\n rig reads the project manifest, \u{1b}[32mrproj.toml\u{1b}[39m, in the current directory, and\n uses its built-in solver to find a compatible set of package versions from\n the configured repositories, without running R.\n\n Use \u{1b}[32m--r-version\u{1b}[39m to solve for a specific R version, \u{1b}[32m--dev\u{1b}[39m to include\n development dependencies, and \u{1b}[32m--renv\u{1b}[39m to also write the result as an\n \u{1b}[32mrenv.lock\u{1b}[39m file.\n\n \u{1b}[32mrproj.lock\u{1b}[39m currently records a single \u{1b}[32m(R version, platform)\u{1b}[39m target;\n \u{1b}[32mrig proj sync\u{1b}[39m installs that target. Solving for several\n targets in one lockfile is planned but not implemented yet.\n\n\u{1b}[1m\u{1b}[34mSource and binary packages:\u{1b}[39m\u{1b}[22m\n The solver considers binary packages as well as source packages, and\n prefers a binary build when one is available for the same version. Which\n artifact each package is installed from is part of what the solve decides,\n because a binary is only usable together with the exact versions of its\n \u{1b}[32mLinkingTo\u{1b}[39m dependencies that it was compiled against. If those versions\n conflict with the rest of the project, rig picks another build of that\n package, or falls back to its source tarball.\n\n By default a binary build never changes \u{1b}[3mwhich version\u{1b}[23m rig picks: the\n newest suitable version wins, and a binary of it is used if there is one.\n Pass \u{1b}[32m--prefer-binary\u{1b}[39m to let an older version win instead, when the newest\n one has no binary but an older one does — typically because a version was\n released so recently that it has not been built yet. Only the three newest\n versions of a package are considered; \u{1b}[32m--prefer-binary=5\u{1b}[39m considers five.\n Versions held back this way are marked in the output.\n\n Trading a version away for a binary is not free: the binary pins its\n \u{1b}[32mLinkingTo\u{1b}[39m dependencies to the versions it was compiled against, and those\n dependencies then prefer their own binaries in turn, so a whole project can\n end up on older versions.\n\n By default rig solves for the machine it runs on. Use \u{1b}[32m--platform\u{1b}[39m to solve\n for a different one, e.g. to write a lockfile on macOS for a Linux\n deployment:\n\n rig proj lock --platform ubuntu-24.04\n\n \u{1b}[32m--platform source\u{1b}[39m solves for source packages only, and does not download\n any binary package metadata. rig also falls back to source packages when\n there are no binaries for a platform at all. There is then nothing for\n \u{1b}[32m--prefer-binary\u{1b}[39m to prefer, and rig ignores it.\n\n rig keeps the repository metadata and the binary package indices it solves\n from in its cache, and refreshes them once a day. \u{1b}[32m--no-cache\u{1b}[39m downloads\n them again instead, and writes nothing to the cache, which is the way to\n solve against a package that was published minutes ago. It is a good deal\n slower, because the metadata it re-downloads is large. See\n \u{1b}[32mrig config\u{1b}[39m.\n\n The \u{1b}[32mrproj.lock\u{1b}[39m file records, for every package, whether it is a source or a\n binary package and the URL it is downloaded from. It also records where the\n file is cached, which is per \u{1b}[3mbuild\u{1b}[23m rather than per version: a repository\n can offer several binaries of one version for one platform and R version,\n and they are cached side by side."; const ABOUT_PROJ_SYNC: &str = "Install the dependencies rproj.lock resolved"; -const HELP_PROJ_SYNC: &str = "\u{1b}[1m\u{1b}[34mDescription:\u{1b}[39m\u{1b}[22m\n Install the resolved dependencies of an R project into a package library.\n\n rig reads \u{1b}[32mrproj.lock\u{1b}[39m (written by \u{1b}[32mrig proj lock\u{1b}[39m) and\n installs its packages into the library given by \u{1b}[32m--library\u{1b}[39m (default:\n \u{1b}[32m.rvenv/lib\u{1b}[39m). Use \u{1b}[32m--r-binary\u{1b}[39m to select which R to build against\n (default: \u{1b}[32mR\u{1b}[39m) and \u{1b}[32m--max-concurrent\u{1b}[39m to limit the number of simultaneous\n installations (default: 4)."; +const HELP_PROJ_SYNC: &str = "\u{1b}[1m\u{1b}[34mDescription:\u{1b}[39m\u{1b}[22m\n Install the resolved dependencies of an R project into a package library.\n\n rig looks for the project in the current directory and its parents, reads its\n \u{1b}[32mrproj.lock\u{1b}[39m (written by \u{1b}[32mrig proj lock\u{1b}[39m) and installs the\n packages into the project library, \u{1b}[32m.rvenv/lib\u{1b}[39m. That library is created by\n \u{1b}[32mrig proj init\u{1b}[39m, together with the \u{1b}[32m.gitignore\u{1b}[39m files that\n keep it in version control, so \u{1b}[32mrig proj sync\u{1b}[39m fails if it is missing. Pass\n \u{1b}[32m--library\u{1b}[39m to install somewhere else instead.\n\n Use \u{1b}[32m--r-binary\u{1b}[39m to select which R to build against (default: \u{1b}[32mR\u{1b}[39m) and\n \u{1b}[32m--max-concurrent\u{1b}[39m to limit the number of simultaneous installations\n (default: 8).\n\n After a successful sync rig records the lock file it installed from in\n \u{1b}[32m.rvenv/lib/.synced\u{1b}[39m. The \u{1b}[32mrig\u{1b}[39m package in the project library compares the\n two, and warns in every R session while the project library does not match\n \u{1b}[32mrproj.lock\u{1b}[39m."; const ABOUT_PROJ_TREE: &str = "Dependency tree of a project"; const HELP_PROJ_TREE: &str = "\u{1b}[1m\u{1b}[34mDescription:\u{1b}[39m\u{1b}[22m\n Show everything an R project needs, directly or indirectly, as a tree: the\n same closure \u{1b}[32mrig proj deps --recursive\u{1b}[39m lists in a flat\n table, laid out by the shape of the dependency graph.\n\n myproject 0.1.0 — 3 direct, 24 total\n ├── R (>= 4.1) [D]\n ├── cli 3.6.4\n │ ├── R (>= 3.4) [D]\n │ └── utils\n └── dplyr 1.1.4 (>= 1.1.0)\n ├── cli 3.6.4 (>= 3.4.0) (*)\n └── vctrs 0.6.5 (>= 0.6.4)\n └── cpp11 0.5.2 [L]\n [Suggests]\n └── testthat 3.2.3 (>= 3.1.5)\n\n The first line names the project and its version, how many dependencies it\n declares directly and how many distinct packages there are in the whole tree.\n Each line below names a package, the version currently in the repositories,\n and the version requirement it is needed with, if it has one.\n\n rig reads the project manifest, \u{1b}[32mrproj.toml\u{1b}[39m, in the current directory. Unlike\n \u{1b}[32mrig proj deps\u{1b}[39m, the tree needs the package metadata of the\n repositories, which rig downloads if it does not have it yet. It does not need\n R. \u{1b}[32m--json\u{1b}[39m gives machine readable output, as one nested object.\n\n\u{1b}[1m\u{1b}[34mReading the tree:\u{1b}[39m\u{1b}[22m\n A package that several others need is expanded only once, under its first\n occurrence; later occurrences are marked \u{1b}[32m(*)\u{1b}[39m, meaning \"its dependencies are\n above\". \u{1b}[32m--dev\u{1b}[39m adds the project's development dependencies, in their own\n \u{1b}[32m[Suggests]\u{1b}[39m and \u{1b}[32m[Enhances]\u{1b}[39m sections; \u{1b}[32m--no-base\u{1b}[39m leaves out R and the base\n packages. Among the hard dependencies, \u{1b}[32mImports\u{1b}[39m is not marked, \u{1b}[32m[D]\u{1b}[39m is a\n \u{1b}[32mDepends\u{1b}[39m, \u{1b}[32m[L]\u{1b}[39m a \u{1b}[32mLinkingTo\u{1b}[39m, \u{1b}[32m[DL]\u{1b}[39m both.\n\n \u{1b}[32m--why \u{1b}[39m (alias \u{1b}[32m--explain\u{1b}[39m) inverts the tree, so that the named\n package is the root and the tree grows towards the packages that need it, down\n to the project itself. Each line then says how \u{1b}[3mthat\u{1b}[23m package needs the one\n above it, hence \u{1b}[32mneeds\u{1b}[39m.\n\n \u{1b}[32mrig pkg tree\u{1b}[39m, which shows the same tree for a package\n in the repositories, describes all of this in full.\n\n rig follows the dependencies of the \u{1b}[3mlatest\u{1b}[23m version of every package in the\n tree, so a version requirement that would force an older version, with\n different dependencies, is not taken into account. Use\n \u{1b}[32mrig proj lock\u{1b}[39m for a resolution that is consistent across\n versions."; const ABOUT_PROJ: &str = "Manage R projects (experimental)"; diff --git a/src/help/proj-init.md b/src/help/proj-init.md index f86ad500..fd4f57c6 100644 --- a/src/help/proj-init.md +++ b/src/help/proj-init.md @@ -1,16 +1,59 @@ -Create a new project manifest +Create a new R project ## Description -Write a new `rproj.toml` manifest in the current directory. This is rig's -modern project and package file: it describes the project's metadata and its -R and package dependencies, and can do everything a `DESCRIPTION` file can, -plus dependency groups, optional dependencies, workspaces and declared -scripts. +Set up an R project in the current directory: the `rproj.toml` manifest, plus +the part of the project's virtual environment (`.rvenv`) that belongs in +version control. -`rig proj init` writes a minimal skeleton — a `[project]` table with the -name (taken from the current directory) and version, and a `[dependencies]` -table with a single R requirement — that you then fill in. +`rproj.toml` is rig's modern project and package file. It describes the +project's metadata and its R and package dependencies, and can do everything +a `DESCRIPTION` file can, plus dependency groups, optional dependencies, +workspaces and declared scripts. `rig proj init` writes a minimal skeleton — a +`[project]` table with the name (taken from the current directory) and +version, and a `[dependencies]` table with a single R requirement — that you +then fill in. Use [`rig proj import`](#rig-proj-import) instead to take the +dependencies from an existing `DESCRIPTION`. -rig refuses to overwrite an existing `rproj.toml`; pass `--force` to replace -it. +## Files + +`rig proj init` creates these, and nothing else. All of them are meant to be +committed, so that a fresh clone of the project works right away: + +- `rproj.toml` — the manifest. Its R requirement is `>= .` of + the project's R version. +- `.Renviron` — points R at the project library, `.rvenv/lib`. This is what + makes the project work in an editor (RStudio, Positron, VS Code), which + starts R itself. +- `.gitignore` — a marked `# rig rvenv start` / `# rig rvenv end` block that + ignores everything in `.rvenv` except the library directory. An existing + `.gitignore` is *not* replaced: rig only adds or refreshes its own block, + and leaves the rest of the file alone. +- `.rvenv/lib/.gitignore` — keeps the library directory itself, and the `rig` + package in it, in version control, and ignores the installed dependencies. + The directory has to exist in every checkout, because plain R does not + create a missing library directory. +- `.rvenv/lib/rig` — a small, pre-built R package that rig manages. It is not + a dependency of your project. `.Renviron` loads it in every R session + started in the project, where it turns the relative library path into an + absolute one — so that R processes started from a subdirectory still use the + project library — and warns while the project is out of sync with + `rproj.lock`. + +The rest of `.rvenv` is machine-specific and is created by +[`rig proj sync`](#rig-proj-sync), which installs the project's dependencies +into `.rvenv/lib`. + +Note that `R --vanilla` ignores `.Renviron`, and so does not use the project +library. + +## Options + +`--r-version` sets the R version the project is for. It defaults to the +current default R version, and does not have to be installed. Both the +manifest's R requirement and the flavor of the pre-built `rig` package depend +on it. + +rig refuses to overwrite any of the files above; pass `--force` to replace +them. `--force` still does not rewrite the whole `.gitignore`, only rig's +block in it. diff --git a/src/help/proj-sync.md b/src/help/proj-sync.md index 93eef57f..0dfa4af9 100644 --- a/src/help/proj-sync.md +++ b/src/help/proj-sync.md @@ -4,8 +4,18 @@ Install the dependencies rproj.lock resolved Install the resolved dependencies of an R project into a package library. -rig reads `rproj.lock` (written by [`rig proj lock`](#rig-proj-lock)) and -installs its packages into the library given by `--library` (default: -`.rvenv/lib`). Use `--r-binary` to select which R to build against -(default: `R`) and `--max-concurrent` to limit the number of simultaneous -installations (default: 4). +rig looks for the project in the current directory and its parents, reads its +`rproj.lock` (written by [`rig proj lock`](#rig-proj-lock)) and installs the +packages into the project library, `.rvenv/lib`. That library is created by +[`rig proj init`](#rig-proj-init), together with the `.gitignore` files that +keep it in version control, so `rig proj sync` fails if it is missing. Pass +`--library` to install somewhere else instead. + +Use `--r-binary` to select which R to build against (default: `R`) and +`--max-concurrent` to limit the number of simultaneous installations +(default: 8). + +After a successful sync rig records the lock file it installed from in +`.rvenv/lib/.synced`. The `rig` package in the project library compares the +two, and warns in every R session while the project library does not match +`rproj.lock`. diff --git a/src/lib.rs b/src/lib.rs index 4824b5a8..de88e39e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -39,6 +39,7 @@ mod repositories; mod resolve; mod rproj; mod run; +mod rvenv; mod rversion; mod solver; mod textfmt; diff --git a/src/main.rs b/src/main.rs index 1fa4b19c..ed8ad4ac 100644 --- a/src/main.rs +++ b/src/main.rs @@ -61,6 +61,7 @@ mod repositories; mod resolve; mod rproj; mod run; +mod rvenv; mod rversion; mod solver; mod sysreqs; diff --git a/src/proj.rs b/src/proj.rs index 707b7eff..f1765523 100644 --- a/src/proj.rs +++ b/src/proj.rs @@ -34,6 +34,9 @@ use crate::renv::*; use crate::repos::binaries::loader::{BinaryTarget, P3mBinaryLoader}; use crate::repos::*; use crate::rproj::{Rproj, RprojLock, RprojLockTarget, RPROJ_LOCK_VERSION, RPROJ_MANIFEST_FILE}; +use crate::rvenv::{ + existing_targets, find_project_root, project_library, rvenv_init, write_sync_stamp, +}; use crate::solver::*; use crate::utils::create_parent_dir_if_needed; @@ -66,33 +69,82 @@ pub fn sc_proj(args: &ArgMatches, mainargs: &ArgMatches) -> Result<(), Box Result<(), Box> { - let path = Path::new(RPROJ_MANIFEST_FILE); - if path.exists() && !args.get_flag("force") { - OUTPUT.error(&format!( - "{} already exists, use --force to overwrite", - RPROJ_MANIFEST_FILE - )); - error!("{} already exists", RPROJ_MANIFEST_FILE); - bail!("{} already exists", RPROJ_MANIFEST_FILE); + let root = std::env::current_dir()?; + let force = args.get_flag("force"); + + // Check every file we are about to write before writing any of them, so + // that a conflict does not leave a half-created project behind, and so + // that the error can name all of them at once. + if !force { + let existing = existing_targets(&root)?; + if !existing.is_empty() { + let names: Vec = existing + .iter() + .map(|p| { + p.strip_prefix(&root) + .unwrap_or(p) + .to_string_lossy() + .into_owned() + }) + .collect(); + let msg = format!( + "{} already exist{}, use --force to overwrite", + names.join(", "), + if names.len() == 1 { "s" } else { "" } + ); + OUTPUT.error(&msg); + error!("{}", msg); + bail!("{}", msg); + } } + // The R version decides both the manifest's R requirement and which + // pre-built shim package the project gets. It does not have to be + // installed, nothing we write here refers to an R installation. + let rver = match args.get_one::("r-version") { + Some(rv) => rv.to_string(), + None => match get_default_r_version()? { + Some(rv) => rv, + None => { + OUTPUT.error("Cannot determine R version, please specify it with --r-version."); + error!("Cannot determine R version, please specify it with --r-version."); + bail!("Cannot determine R version, please specify it with --r-version.") + } + }, + }; + // Project name defaults to the current directory's name. - let name = std::env::current_dir() - .ok() - .and_then(|d| d.file_name().map(|n| n.to_string_lossy().into_owned())) + let name = root + .file_name() + .map(|n| n.to_string_lossy().into_owned()) .filter(|n| !n.is_empty()) .unwrap_or_else(|| "myproject".to_string()); - let manifest = Rproj::minimal(&name); - fs::write(path, toml::to_string_pretty(&manifest)?)?; - OUTPUT.success(&format!("Created {}", RPROJ_MANIFEST_FILE)); - info!("Created {}", RPROJ_MANIFEST_FILE); + let manifest = Rproj::minimal_for_r(&name, &rver)?; + let manifest_path = root.join(RPROJ_MANIFEST_FILE); + fs::write(&manifest_path, toml::to_string_pretty(&manifest)?)?; + + let mut created = vec![manifest_path]; + created.extend(rvenv_init(&root, &rver)?); + + for path in &created { + let name = path.strip_prefix(&root).unwrap_or(path).to_string_lossy(); + OUTPUT.success(&format!("Created {}", name)); + info!("Created {}", name); + } + OUTPUT.info(&format!( + "Project set up for R {}. Next: add dependencies to {}, \ + then run `rig proj lock` and `rig proj sync`.", + rver, RPROJ_MANIFEST_FILE + )); + Ok(()) } @@ -600,20 +652,41 @@ fn sc_proj_sync( _libargs: &ArgMatches, _mainargs: &ArgMatches, ) -> Result<(), Box> { + // The project is the nearest one at or above the current directory, so + // that `rig proj sync` works from a subdirectory, like `git` does. + let cwd = std::env::current_dir()?; + let root = find_project_root(&cwd).unwrap_or(cwd); + // Read the lockfile to get package information. Single-target for now: // always the first (and, today, only) entry `rig proj lock` wrote; the // "pick the entry matching this machine, hard error if none match" logic // for a real multi-target `rproj.lock` is follow-up work. - let lock_content = fs::read_to_string("rproj.lock")?; + let lock_path = root.join("rproj.lock"); + let lock_content = fs::read_to_string(&lock_path)?; let lock: RprojLock = toml::from_str(&lock_content)?; let target = lock.targets.first().ok_or("rproj.lock has no targets")?; - // Library path: --library, or the project venv's library by default - let library_path = PathBuf::from( - args.get_one::("library") - .map(|s| s.as_str()) - .unwrap_or(".rvenv/lib"), - ); + // Library path: --library, or the project library by default. The + // project library is created by `rig proj init`, together with the + // `.gitignore` files that keep it in version control, so do not create + // it here. + let library_path = match args.get_one::("library") { + Some(lib) => PathBuf::from(lib), + None => { + let lib = project_library(&root); + if !lib.exists() { + let msg = format!( + "No project library in {}, run `rig proj init` first \ + (or pass --library)", + lib.display() + ); + OUTPUT.error(&msg); + error!("{}", msg); + bail!("{}", msg); + } + lib + } + }; // A package already in the library, at the version and provenance the // lockfile asks for, does not need to be downloaded or reinstalled. @@ -630,7 +703,19 @@ fn sc_proj_sync( .map(|p| p.package) .collect(); + // The shim package in the project library compares this stamp to + // `rproj.lock` and warns in every R session while they differ, so it has + // to be updated even when there was nothing to install. + let stamp_lib = if library_path == project_library(&root) { + Some(library_path.clone()) + } else { + None + }; + if todo.is_empty() { + if let Some(lib) = &stamp_lib { + write_sync_stamp(lib, &lock_path)?; + } OUTPUT.success(&format!( "Everything is up to date in {}", library_path.display() @@ -692,6 +777,10 @@ fn sc_proj_sync( let installed = install_packages(packages, &library_path, r_binary, max_concurrent)?; + if let Some(lib) = &stamp_lib { + write_sync_stamp(lib, &lock_path)?; + } + OUTPUT.success(&format!( "Deployment complete, installed {} packages", installed diff --git a/src/rproj.rs b/src/rproj.rs index 09040a0d..4479332f 100644 --- a/src/rproj.rs +++ b/src/rproj.rs @@ -27,6 +27,7 @@ use crate::dcf::{ DEP_TYPES_SOFT, }; use crate::pak::PakLockfilePackage; +use crate::repos::cranlike_metadata::minor_r_version; pub const RPROJ_LOCK_VERSION: usize = 1; @@ -242,6 +243,22 @@ impl Rproj { } } + /// Same as [`Rproj::minimal`], but with the R requirement taken from the + /// R version the project is being created for. + /// + /// The requirement is `>= .`, not the patch level: a + /// project practically never means "at least this patch release", and + /// `rproj.lock` records the exact version anyway. + pub fn minimal_for_r(name: &str, r_version: &str) -> Result> { + let minor = minor_r_version(r_version)?; + let mut manifest = Rproj::minimal(name); + manifest.dependencies.insert( + "R".to_string(), + Dependency::Version(format!(">= {}", minor)), + ); + Ok(manifest) + } + /// Merge a DESCRIPTION-derived `Package`'s dependencies into this /// manifest, upserting entries (an existing entry for the same package /// name is overwritten). `Depends`/`Imports` land in `[dependencies]` @@ -485,6 +502,25 @@ mod tests { assert_eq!(parsed, Rproj::minimal("mypkg")); } + #[test] + fn minimal_manifest_records_the_projects_r_version() { + // The patch level is dropped: a project practically never means "at + // least this patch release". + let m = Rproj::minimal_for_r("mypkg", "4.6.1").unwrap(); + assert_eq!( + m.dependencies.get("R"), + Some(&Dependency::Version(">= 4.6".to_string())) + ); + // A two-part version is fine, too. + let m2 = Rproj::minimal_for_r("mypkg", "4.6").unwrap(); + assert_eq!(m2, m); + // and it round-trips + let text = toml::to_string_pretty(&m).unwrap(); + assert_eq!(toml::from_str::(&text).unwrap(), m); + + assert!(Rproj::minimal_for_r("mypkg", "devel").is_err()); + } + #[test] fn full_manifest_roundtrips_through_toml() { let mut m = Rproj::minimal("mypkg"); diff --git a/src/rvenv.rs b/src/rvenv.rs new file mode 100644 index 00000000..3123d831 --- /dev/null +++ b/src/rvenv.rs @@ -0,0 +1,545 @@ +//! The project virtual environment (`.rvenv`) layout. +//! +//! A rig project keeps its package library in `.rvenv/lib`, next to the +//! `rproj.toml` manifest and the `rproj.lock` lock file. `rig proj init` +//! creates the part of that layout which is committed to version control: +//! +//! ```text +//! project-root/ +//! rproj.toml # tracked -- manifest +//! .Renviron # tracked -- in-session activation +//! .gitignore # tracked -- /.rvenv/* + !/.rvenv/lib +//! .rvenv/ +//! lib/.gitignore # tracked -- keeps lib/ and lib/rig, ignores the rest +//! lib/rig/ # tracked -- pre-built shim package +//! lib/... # untracked -- the real dependencies, from `rig proj sync` +//! lib/.synced # untracked -- sync stamp, the lock file's md5 sum +//! ``` +//! +//! Two things here are less obvious than they look. +//! +//! `.rvenv/lib` itself is committed (as a directory containing only a +//! `.gitignore`) because vanilla R does not create a missing `R_LIBS_USER` +//! directory -- `install.packages()` against a missing one just fails. Only +//! rig's own R installations create it, from a block rig injects into +//! `Rprofile.site` (see `library_update_rprofile`), and a project has to work +//! on any R install. Note that git will not look inside an ignored directory +//! for a nested exception, so `/.rvenv/*` has to be followed by +//! `!/.rvenv/lib` -- and the same double negation is needed for `rig/` inside +//! `lib/.gitignore`. +//! +//! The shim package in `.rvenv/lib/rig` is committed pre-built, rather than +//! installed by `rig proj sync`, because its whole job is to be there +//! *before* the first sync: `.Renviron` names it in `R_DEFAULT_PACKAGES`, so +//! without it R prints its own unhelpful "package 'rig' in +//! options(\"defaultPackages\") was not found". See `src/data/rvenv-pkg` for +//! the source and `xtask/src/rvenv_shim.rs` for the build. +//! +//! Never write a file named `___default` into `.rvenv/lib`: that is the +//! sentinel of rig's own per-version library switching, in the same +//! `Rprofile.site` block. + +use std::error::Error; +use std::fs; +use std::io::Cursor; +use std::path::{Path, PathBuf}; + +use simple_error::bail; + +use crate::hardcoded::{HC_RVENV_SHIM_35, HC_RVENV_SHIM_40, HC_RVENV_SHIM_LT_35}; +use crate::rproj::RPROJ_MANIFEST_FILE; +use crate::utils::write_atomically; + +pub const RVENV_DIR: &str = ".rvenv"; +pub const RVENV_LIB_SUBDIR: &str = "lib"; +pub const RVENV_SHIM_PKG: &str = "rig"; +pub const RVENV_RENVIRON_FILE: &str = ".Renviron"; +pub const RVENV_GITIGNORE_FILE: &str = ".gitignore"; +pub const RPROJ_LOCK_FILE: &str = "rproj.lock"; + +/// The stamp file `rig proj sync` writes into the project library: a copy of +/// the lock file it installed from. The shim package compares it to +/// `rproj.lock` to decide whether to warn about an unsynced project. A copy +/// rather than a hash, so that both sides only need to read files: base R has +/// no sha256, and md5 would mean one more dependency on the rig side. +pub const RVENV_SYNC_STAMP: &str = ".synced"; + +/// Record that the project library now matches `lock_file`. +pub fn write_sync_stamp(lib: &Path, lock_file: &Path) -> Result<(), Box> { + let lock = fs::read(lock_file)?; + write_atomically(&lib.join(RVENV_SYNC_STAMP), &lock) +} + +const GITIGNORE_START: &str = "# rig rvenv start"; +const GITIGNORE_END: &str = "# rig rvenv end"; + +// -------------------------------------------------------------- file bodies -- + +/// The tracked project `.Renviron`. +/// +/// This is the "in-session activation" leg: IDEs (RStudio, Positron, VS Code) +/// start R themselves, so there is no wrapper script and no `PATH` entry to +/// hook into. A project `.Rprofile` would be the obvious alternative, but it +/// *shadows* the user's `~/.Rprofile` entirely, which is renv's most +/// complained-about behavior. A project `.Renviron` shadows `~/.Renviron` the +/// same way, but the shim package restores it with `readRenviron()`. +/// +/// `R_DEFAULT_PACKAGES` *replaces* the default package list rather than +/// prepending to it, so the whole list has to be spelled out here; leaving +/// out e.g. `stats` would silently drop it from `search()`. +fn renviron_body() -> &'static str { + "\ +# Managed by rig (rig proj init). +# +# R_LIBS_USER is deliberately relative: the `rig` package in .rvenv/lib +# re-exports it as an absolute path at load time, so child R processes +# started from a subdirectory still see the project library. +# +# R_DEFAULT_PACKAGES replaces R's default package list rather than adding to +# it, so the whole list has to be spelled out. +# +# Note that `R --vanilla` ignores this file entirely. +R_LIBS_USER=.rvenv/lib +R_DEFAULT_PACKAGES=rig,datasets,utils,grDevices,graphics,stats,methods +" +} + +/// The block rig manages in the project's root `.gitignore`. Everything in +/// `.rvenv` is machine-specific except the library directory itself. +fn root_gitignore_block() -> String { + format!( + "\ +{} +# Everything in .rvenv is machine-specific, except the library directory +# itself and the rig shim package in it. git does not look inside an ignored +# directory for a nested exception, hence the second line. +/.rvenv/* +!/.rvenv/lib +{} +", + GITIGNORE_START, GITIGNORE_END + ) +} + +/// `.rvenv/lib/.gitignore`: keep the directory and the shim package, ignore +/// the installed dependencies. +fn lib_gitignore_body() -> &'static str { + "\ +# Managed by rig (rig proj init). The library directory itself is committed, +# because vanilla R does not create a missing R_LIBS_USER. The rig shim +# package is committed so that a fresh clone works before the first +# `rig proj sync`. Everything else here is installed and not tracked. +* +!.gitignore +!rig +!rig/** +" +} + +// -------------------------------------------------------------------- paths -- + +/// `/.rvenv/lib`, the project package library. +pub fn project_library(root: &Path) -> PathBuf { + root.join(RVENV_DIR).join(RVENV_LIB_SUBDIR) +} + +/// The project root at or above `start`: the nearest directory holding an +/// `rproj.toml`, an `rproj.lock` or an `.rvenv` directory. +pub fn find_project_root(start: &Path) -> Option { + let mut dir = start; + loop { + if dir.join(RPROJ_MANIFEST_FILE).exists() + || dir.join(RPROJ_LOCK_FILE).exists() + || dir.join(RVENV_DIR).is_dir() + { + return Some(dir.to_path_buf()); + } + dir = dir.parent()?; + } +} + +/// Everything `rig proj init` writes, in write order. +pub fn init_targets(root: &Path) -> Vec { + vec![ + root.join(RPROJ_MANIFEST_FILE), + root.join(RVENV_RENVIRON_FILE), + root.join(RVENV_GITIGNORE_FILE), + project_library(root).join(RVENV_GITIGNORE_FILE), + project_library(root).join(RVENV_SHIM_PKG), + ] +} + +/// The paths from [`init_targets`] that are already there, so that the +/// caller can name all of them at once instead of failing on the first. +/// +/// The root `.gitignore` is special: rig only manages a marked block in it, +/// so an existing one that already has that block is not a conflict, it is +/// just a re-init. +pub fn existing_targets(root: &Path) -> Result, Box> { + let gitignore = root.join(RVENV_GITIGNORE_FILE); + let gitignore_is_ours = gitignore.exists() && gitignore_block(&gitignore)?.is_some(); + Ok(init_targets(root) + .into_iter() + .filter(|p| p.exists()) + .filter(|p| !(*p == gitignore && gitignore_is_ours)) + .collect()) +} + +// --------------------------------------------------------------- .gitignore -- + +/// The line range of rig's block in a `.gitignore` file, if it has one. +fn gitignore_block(path: &Path) -> Result, Box> { + let text = fs::read_to_string(path)?; + let starts: Vec = text + .lines() + .enumerate() + .filter(|(_, l)| l.trim() == GITIGNORE_START) + .map(|(i, _)| i) + .collect(); + let ends: Vec = text + .lines() + .enumerate() + .filter(|(_, l)| l.trim() == GITIGNORE_END) + .map(|(i, _)| i) + .collect(); + match (starts.len(), ends.len()) { + (0, 0) => Ok(None), + (1, 1) if starts[0] < ends[0] => Ok(Some((starts[0], ends[0]))), + _ => bail!( + "{} has a malformed `{}` / `{}` block, fix it by hand", + path.display(), + GITIGNORE_START, + GITIGNORE_END + ), + } +} + +/// Create the project's root `.gitignore`, or add rig's block to it. +/// +/// Never rewrites the whole file: an existing `.gitignore` is a file the user +/// (or `usethis`, or a git template) wrote, and clobbering it would drop +/// their ignore rules. Same fenced-block approach as +/// `library_update_rprofile` uses for `Rprofile.site`. Running this twice is +/// a no-op. +pub fn update_root_gitignore(root: &Path) -> Result<(), Box> { + let path = root.join(RVENV_GITIGNORE_FILE); + let block = root_gitignore_block(); + if !path.exists() { + write_atomically(&path, block.as_bytes())?; + return Ok(()); + } + + let text = fs::read_to_string(&path)?; + let new = match gitignore_block(&path)? { + Some((start, end)) => { + let lines: Vec<&str> = text.lines().collect(); + let mut out = lines[..start].join("\n"); + if start > 0 { + out.push('\n'); + } + out.push_str(&block); + if end + 1 < lines.len() { + out.push_str(&lines[end + 1..].join("\n")); + out.push('\n'); + } + out + } + None => { + let mut out = text; + if !out.is_empty() && !out.ends_with('\n') { + out.push('\n'); + } + if !out.is_empty() { + out.push('\n'); + } + out.push_str(&block); + out + } + }; + write_atomically(&path, new.as_bytes()) +} + +// -------------------------------------------------------------- shim package -- + +/// Which pre-built copy of the shim package an R version can load. +/// +/// R's installed-package format has two boundaries: serialization format 3 +/// (the default from R 3.6.0) cannot be read by R < 3.5.0, and a package +/// installed by R < 4.0.0 is rejected by R >= 4.0.0. See +/// `xtask/src/rvenv_shim.rs` for the measured matrix. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ShimBracket { + /// R < 3.5.0 + Lt35, + /// R >= 3.5.0, < 4.0.0 + R35, + /// R >= 4.0.0 + R40, +} + +pub fn shim_bracket(r_version: &str) -> Result> { + let mut parts = r_version.split('.'); + let major: u32 = match parts.next().map(|p| p.parse()) { + Some(Ok(v)) => v, + _ => bail!("Cannot parse R version: {}", r_version), + }; + // A version like "4.6" is fine, the minor part defaults to 0. + let minor: u32 = match parts.next().map(|p| p.parse()) { + Some(Ok(v)) => v, + Some(Err(_)) => bail!("Cannot parse R version: {}", r_version), + None => 0, + }; + Ok(if major >= 4 { + ShimBracket::R40 + } else if major == 3 && minor >= 5 { + ShimBracket::R35 + } else { + ShimBracket::Lt35 + }) +} + +fn shim_bytes(bracket: ShimBracket) -> &'static [u8] { + match bracket { + ShimBracket::Lt35 => HC_RVENV_SHIM_LT_35, + ShimBracket::R35 => HC_RVENV_SHIM_35, + ShimBracket::R40 => HC_RVENV_SHIM_40, + } +} + +/// Unpack the pre-built shim package into `/rig`. +pub fn unpack_shim_package(lib: &Path, bracket: ShimBracket) -> Result<(), Box> { + let pkg = lib.join(RVENV_SHIM_PKG); + if pkg.exists() { + fs::remove_dir_all(&pkg)?; + } + fs::create_dir_all(lib)?; + let mut ar = tar::Archive::new(flate2::read::GzDecoder::new(Cursor::new(shim_bytes( + bracket, + )))); + // The modes in these archives are synthesized by `cargo xtask + // gen-rvenv-shim`, not authored, so let the user's umask decide. + ar.set_preserve_permissions(false); + ar.set_preserve_mtime(false); + ar.set_overwrite(true); + ar.unpack(lib)?; + Ok(()) +} + +// --------------------------------------------------------------------- init -- + +/// Write the tracked part of the `.rvenv` layout, and return what was +/// written. Does not write `rproj.toml`, that is the manifest half of +/// `rig proj init`. +/// +/// The caller is expected to have run the [`existing_targets`] check first. +pub fn rvenv_init(root: &Path, r_version: &str) -> Result, Box> { + let bracket = shim_bracket(r_version)?; + let lib = project_library(root); + + let renviron = root.join(RVENV_RENVIRON_FILE); + write_atomically(&renviron, renviron_body().as_bytes())?; + + update_root_gitignore(root)?; + + fs::create_dir_all(&lib)?; + let lib_gitignore = lib.join(RVENV_GITIGNORE_FILE); + write_atomically(&lib_gitignore, lib_gitignore_body().as_bytes())?; + + unpack_shim_package(&lib, bracket)?; + + Ok(vec![ + renviron, + root.join(RVENV_GITIGNORE_FILE), + lib_gitignore, + lib.join(RVENV_SHIM_PKG), + ]) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn shim_brackets() { + assert_eq!(shim_bracket("3.0.0").unwrap(), ShimBracket::Lt35); + assert_eq!(shim_bracket("3.4.4").unwrap(), ShimBracket::Lt35); + assert_eq!(shim_bracket("3.5.0").unwrap(), ShimBracket::R35); + assert_eq!(shim_bracket("3.6.3").unwrap(), ShimBracket::R35); + assert_eq!(shim_bracket("3.9.9").unwrap(), ShimBracket::R35); + assert_eq!(shim_bracket("4.0.0").unwrap(), ShimBracket::R40); + assert_eq!(shim_bracket("4.6.1").unwrap(), ShimBracket::R40); + assert_eq!(shim_bracket("4.6").unwrap(), ShimBracket::R40); + assert_eq!(shim_bracket("5.0.0").unwrap(), ShimBracket::R40); + assert!(shim_bracket("devel").is_err()); + assert!(shim_bracket("4.x.1").is_err()); + } + + #[test] + fn renviron_body_is_what_the_shim_expects() { + let body = renviron_body(); + assert!(body.contains("\nR_LIBS_USER=.rvenv/lib\n")); + // The shim package has to come first, and the rest of R's default + // package list has to be spelled out. + assert!(body.contains( + "\nR_DEFAULT_PACKAGES=rig,datasets,utils,grDevices,graphics,stats,methods\n" + )); + } + + #[test] + fn gitignore_bodies_un_ignore_the_library() { + let root = root_gitignore_block(); + assert!(root.contains("\n/.rvenv/*\n")); + assert!(root.contains("\n!/.rvenv/lib\n")); + let lib = lib_gitignore_body(); + assert!(lib.contains("\n*\n")); + assert!(lib.contains("\n!.gitignore\n")); + assert!(lib.contains("\n!rig\n")); + assert!(lib.contains("\n!rig/**\n")); + } + + #[test] + fn gitignore_is_created_when_missing() { + let tmp = tempfile::tempdir().unwrap(); + update_root_gitignore(tmp.path()).unwrap(); + let text = fs::read_to_string(tmp.path().join(".gitignore")).unwrap(); + assert_eq!(text, root_gitignore_block()); + } + + #[test] + fn gitignore_keeps_user_rules() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join(".gitignore"); + fs::write(&path, "*.Rproj\n.Rhistory\n").unwrap(); + update_root_gitignore(tmp.path()).unwrap(); + let text = fs::read_to_string(&path).unwrap(); + assert!(text.starts_with("*.Rproj\n.Rhistory\n")); + assert!(text.contains("!/.rvenv/lib")); + } + + #[test] + fn gitignore_update_is_idempotent() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join(".gitignore"); + fs::write(&path, "*.log\n").unwrap(); + update_root_gitignore(tmp.path()).unwrap(); + let once = fs::read_to_string(&path).unwrap(); + update_root_gitignore(tmp.path()).unwrap(); + assert_eq!(fs::read_to_string(&path).unwrap(), once); + } + + #[test] + fn gitignore_block_is_replaced_in_place() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join(".gitignore"); + fs::write( + &path, + format!( + "before\n{}\nstale content\n{}\nafter\n", + GITIGNORE_START, GITIGNORE_END + ), + ) + .unwrap(); + update_root_gitignore(tmp.path()).unwrap(); + let text = fs::read_to_string(&path).unwrap(); + assert!(text.starts_with("before\n")); + assert!(text.ends_with("after\n")); + assert!(!text.contains("stale content")); + assert_eq!(text.matches(GITIGNORE_START).count(), 1); + } + + #[test] + fn gitignore_with_a_broken_block_is_an_error() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join(".gitignore"); + fs::write(&path, format!("{}\nno end marker\n", GITIGNORE_START)).unwrap(); + assert!(update_root_gitignore(tmp.path()).is_err()); + } + + #[test] + fn a_managed_gitignore_is_not_a_conflict() { + let tmp = tempfile::tempdir().unwrap(); + update_root_gitignore(tmp.path()).unwrap(); + assert!(existing_targets(tmp.path()).unwrap().is_empty()); + + let path = tmp.path().join(".gitignore"); + fs::write(&path, "*.log\n").unwrap(); + assert_eq!(existing_targets(tmp.path()).unwrap(), vec![path]); + } + + #[test] + fn find_project_root_walks_up() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("proj"); + let deep = root.join("R/sub/sub"); + fs::create_dir_all(&deep).unwrap(); + fs::write(root.join(RPROJ_MANIFEST_FILE), "").unwrap(); + assert_eq!(find_project_root(&deep).as_deref(), Some(root.as_path())); + assert_eq!(find_project_root(&root).as_deref(), Some(root.as_path())); + } + + #[test] + fn find_project_root_finds_the_lock_file_and_the_venv() { + let tmp = tempfile::tempdir().unwrap(); + let lock = tmp.path().join("lock/sub"); + fs::create_dir_all(&lock).unwrap(); + fs::write(lock.parent().unwrap().join(RPROJ_LOCK_FILE), "").unwrap(); + assert_eq!(find_project_root(&lock).unwrap(), lock.parent().unwrap()); + + let venv = tmp.path().join("venv/sub"); + fs::create_dir_all(venv.parent().unwrap().join(RVENV_DIR)).unwrap(); + fs::create_dir_all(&venv).unwrap(); + assert_eq!(find_project_root(&venv).unwrap(), venv.parent().unwrap()); + } + + #[test] + fn find_project_root_gives_up_at_the_filesystem_root() { + let tmp = tempfile::tempdir().unwrap(); + let deep = tmp.path().join("a/b/c"); + fs::create_dir_all(&deep).unwrap(); + assert_eq!(find_project_root(&deep), None); + } + + #[test] + fn every_shim_bracket_unpacks() { + for bracket in [ShimBracket::Lt35, ShimBracket::R35, ShimBracket::R40] { + let tmp = tempfile::tempdir().unwrap(); + let lib = tmp.path().join("lib"); + unpack_shim_package(&lib, bracket).unwrap(); + let desc = fs::read_to_string(lib.join("rig/DESCRIPTION")).unwrap(); + assert!(desc.contains("Package: rig"), "{:?}", bracket); + assert!(lib.join("rig/R/rig.rdb").exists(), "{:?}", bracket); + assert!(lib.join("rig/R/rig.rdx").exists(), "{:?}", bracket); + assert!(lib.join("rig/Meta/package.rds").exists(), "{:?}", bracket); + } + } + + #[test] + fn unpacking_the_shim_twice_replaces_it() { + let tmp = tempfile::tempdir().unwrap(); + let lib = tmp.path().join("lib"); + unpack_shim_package(&lib, ShimBracket::R40).unwrap(); + let stray = lib.join("rig/stray-file"); + fs::write(&stray, "x").unwrap(); + unpack_shim_package(&lib, ShimBracket::R40).unwrap(); + assert!(!stray.exists()); + assert!(lib.join("rig/DESCRIPTION").exists()); + } + + #[test] + fn rvenv_init_writes_the_tracked_layout() { + let tmp = tempfile::tempdir().unwrap(); + let written = rvenv_init(tmp.path(), "4.6.1").unwrap(); + for path in &written { + assert!(path.exists(), "{} was not written", path.display()); + } + // Everything init_targets() promises, except the manifest, which + // `rig proj init` writes itself. + let mut expected = init_targets(tmp.path()); + expected.retain(|p| !p.ends_with(RPROJ_MANIFEST_FILE)); + expected.sort(); + let mut written = written; + written.sort(); + assert_eq!(written, expected); + assert!(tmp.path().join(".rvenv/lib/rig/DESCRIPTION").exists()); + } +} diff --git a/tests/test-linux.sh b/tests/test-linux.sh index 411871c9..ced1d4b5 100644 --- a/tests/test-linux.sh +++ b/tests/test-linux.sh @@ -273,3 +273,44 @@ teardown() { uid=`stat -c "%u" "$libdir"` [[ "$uid" -eq "`id -u`" ]] } + +@test "proj init" { + cd "$BATS_TEST_TMPDIR" + rm -rf myproj && mkdir myproj && cd myproj + + # No R needs to be installed for the requested version, `rig proj init` + # does not touch an R installation. + run rig proj init -r 4.5.1 + [[ "$status" -eq 0 ]] + [[ -f rproj.toml ]] + [[ -f .Renviron ]] + [[ -f .gitignore ]] + [[ -f .rvenv/lib/.gitignore ]] + [[ -f .rvenv/lib/rig/DESCRIPTION ]] + grep -q '^name = "myproj"$' rproj.toml + grep -q '^R = ">= 4.5"$' rproj.toml + grep -q '^R_LIBS_USER=.rvenv/lib$' .Renviron + grep -q '^!/.rvenv/lib$' .gitignore + grep -q '^Package: rig$' .rvenv/lib/rig/DESCRIPTION + + # The IDE leg: a plain R session in the project picks up the shim + # package, which resolves the library path and warns about the missing + # sync. + run env -u RVENV R-4.5.1 -q -s -e 'cat(.libPaths()[1])' + [[ "$status" -eq 0 ]] + echo "$output" | grep -q "Project is not synced" + echo "$output" | grep -q "myproj/[.]rvenv/lib" + + # Refuses to overwrite, and says what is in the way + run rig proj init -r 4.5.1 + [[ "$status" -ne 0 ]] + echo "$output" | grep -q "rproj.toml" + echo "$output" | grep -q -- "--force" + + # --force keeps the user's own ignore rules, rig only manages its block + echo "*.log" >> .gitignore + run rig proj init -r 4.5.1 --force + [[ "$status" -eq 0 ]] + grep -q '^[*].log$' .gitignore + [[ "$(grep -c '^# rig rvenv start$' .gitignore)" -eq 1 ]] +} diff --git a/tests/test-macos.sh b/tests/test-macos.sh index 0634d2d0..62ee580d 100644 --- a/tests/test-macos.sh +++ b/tests/test-macos.sh @@ -328,3 +328,44 @@ teardown() { echo "$output" [[ "$status" -eq 0 ]] } + +@test "proj init" { + cd "$BATS_TEST_TMPDIR" + rm -rf myproj && mkdir myproj && cd myproj + + # No R needs to be installed for the requested version, `rig proj init` + # does not touch an R installation. + run rig proj init -r 4.1 + [[ "$status" -eq 0 ]] + [[ -f rproj.toml ]] + [[ -f .Renviron ]] + [[ -f .gitignore ]] + [[ -f .rvenv/lib/.gitignore ]] + [[ -f .rvenv/lib/rig/DESCRIPTION ]] + grep -q '^name = "myproj"$' rproj.toml + grep -q '^R = ">= 4.1"$' rproj.toml + grep -q '^R_LIBS_USER=.rvenv/lib$' .Renviron + grep -q '^!/.rvenv/lib$' .gitignore + grep -q '^Package: rig$' .rvenv/lib/rig/DESCRIPTION + + # The IDE leg: a plain R session in the project picks up the shim + # package, which resolves the library path and warns about the missing + # sync. + run env -u RVENV R-4.1 -q -s -e 'cat(.libPaths()[1])' + [[ "$status" -eq 0 ]] + echo "$output" | grep -q "Project is not synced" + echo "$output" | grep -q "myproj/[.]rvenv/lib" + + # Refuses to overwrite, and says what is in the way + run rig proj init -r 4.1 + [[ "$status" -ne 0 ]] + echo "$output" | grep -q "rproj.toml" + echo "$output" | grep -q -- "--force" + + # --force keeps the user's own ignore rules, rig only manages its block + echo "*.log" >> .gitignore + run rig proj init -r 4.1 --force + [[ "$status" -eq 0 ]] + grep -q '^[*].log$' .gitignore + [[ "$(grep -c '^# rig rvenv start$' .gitignore)" -eq 1 ]] +} diff --git a/tests/test-windows.sh b/tests/test-windows.sh index 52dfaf04..09ff456b 100644 --- a/tests/test-windows.sh +++ b/tests/test-windows.sh @@ -303,3 +303,44 @@ teardown() { @test "system make-links" { true } + +@test "proj init" { + cd "$BATS_TEST_TMPDIR" + rm -rf myproj && mkdir myproj && cd myproj + + # No R needs to be installed for the requested version, `rig proj init` + # does not touch an R installation. + run rig proj init -r 4.5.0 + [[ "$status" -eq 0 ]] + [[ -f rproj.toml ]] + [[ -f .Renviron ]] + [[ -f .gitignore ]] + [[ -f .rvenv/lib/.gitignore ]] + [[ -f .rvenv/lib/rig/DESCRIPTION ]] + grep -q '^name = "myproj"$' rproj.toml + grep -q '^R = ">= 4.5"$' rproj.toml + grep -q '^R_LIBS_USER=.rvenv/lib$' .Renviron + grep -q '^!/.rvenv/lib$' .gitignore + grep -q '^Package: rig$' .rvenv/lib/rig/DESCRIPTION + + # The IDE leg: a plain R session in the project picks up the shim + # package, which resolves the library path and warns about the missing + # sync. + run R-4.5.0.exe -q -s -e 'cat(.libPaths()[1])' + [[ "$status" -eq 0 ]] + echo "$output" | grep -q "Project is not synced" + echo "$output" | grep -q "myproj.[.]rvenv.lib" + + # Refuses to overwrite, and says what is in the way + run rig proj init -r 4.5.0 + [[ "$status" -ne 0 ]] + echo "$output" | grep -q "rproj.toml" + echo "$output" | grep -q -- "--force" + + # --force keeps the user's own ignore rules, rig only manages its block + echo "*.log" >> .gitignore + run rig proj init -r 4.5.0 --force + [[ "$status" -eq 0 ]] + grep -q '^[*].log$' .gitignore + [[ "$(grep -c '^# rig rvenv start$' .gitignore)" -eq 1 ]] +} diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml index 1cee7227..7601c66e 100644 --- a/xtask/Cargo.toml +++ b/xtask/Cargo.toml @@ -14,3 +14,9 @@ publish = false # render the markdown help text (src/help/*.md) to the committed ANSI strings # in src/help-generated.in pulldown-cmark = { version = "0.12", default-features = false } +# build the committed shim R packages in src/data/rvenv-shim (gen-rvenv-shim) +flate2 = "1.0" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +sha2 = "0.10" +tar = "0.4" diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 6a521019..b7aaef2b 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -1,8 +1,11 @@ //! Dev-only build tasks for rig. Run with `cargo xtask `. //! //! Tasks: -//! gen-help Regenerate src/help-generated.in from src/help/*.md -//! gen-help --check Verify src/help-generated.in is up to date (CI guard) +//! gen-help Regenerate src/help-generated.in from src/help/*.md +//! gen-help --check Verify src/help-generated.in is up to date (CI guard) +//! gen-rvenv-shim Rebuild the committed shim R packages in +//! src/data/rvenv-shim from src/data/rvenv-pkg +//! gen-rvenv-shim --check Verify they match the source (CI guard) //! //! The help prose for each command lives in `src/help/*.md`: the lead paragraph //! (before the first heading) is the short `about`, and the rest is the long @@ -16,6 +19,7 @@ //! explicitly via `cargo xtask`. mod render; +mod rvenv_shim; use std::fs; use std::path::PathBuf; @@ -132,9 +136,14 @@ fn main() -> ExitCode { let check = args[1..].iter().any(|a| a == "--check"); gen_help(check) } + Some("gen-rvenv-shim") => { + let check = args[1..].iter().any(|a| a == "--check"); + rvenv_shim::gen_rvenv_shim(&workspace_root(), check) + } other => { eprintln!( - "unknown task: {}\n\nUsage:\n cargo xtask gen-help [--check]", + "unknown task: {}\n\nUsage:\n cargo xtask gen-help [--check]\n \ + cargo xtask gen-rvenv-shim [--check]", other.unwrap_or("(none)") ); ExitCode::FAILURE diff --git a/xtask/src/rvenv_shim.rs b/xtask/src/rvenv_shim.rs new file mode 100644 index 00000000..fce2c863 --- /dev/null +++ b/xtask/src/rvenv_shim.rs @@ -0,0 +1,629 @@ +//! `cargo xtask gen-rvenv-shim [--check]` +//! +//! Builds the pre-built copies of the `rig` shim R package that `rig proj +//! init` seeds into a project's `.rvenv/lib/rig/`. The package source is +//! `src/data/rvenv-pkg/`; the built artifacts are committed to +//! `src/data/rvenv-shim/` and embedded into the `rig` binary with +//! `include_bytes!`. +//! +//! Why pre-built and not built on the user's machine: the whole point of the +//! shim is to work on a *fresh clone*, before anything has been installed, +//! and on an R installation rig does not manage. So it has to be committed to +//! the project, which means rig has to be able to write it without running R. +//! +//! R's installed-package format has version boundaries, so there is one +//! artifact per R version bracket (see `BRACKETS`): +//! +//! - serialization format 3 became the default in R 3.6.0 and is unreadable +//! by R < 3.5.0, hence `R_DEFAULT_SERIALIZE_VERSION=2` for the two older +//! brackets; +//! - a package installed by R < 4.0.0 refuses to load under R >= 4.0.0 +//! ("package 'rig' was installed before R 4.0.0: please re-install it"). +//! +//! Each bracket is built with the *oldest* R in it. This task needs those R +//! versions installed, and installs them with `rig add` if they are missing, +//! so it is a maintainer-only task; CI only ever runs `--check`, which needs +//! no R. +//! +//! `--check` cannot simply diff the tarballs, because `R CMD INSTALL` output +//! is not reproducible (`DESCRIPTION`'s `Built:` field carries a timestamp). +//! Instead the committed `src/data/rvenv-shim/SOURCE-HASH` manifest records a +//! hash of the *package source*, plus the hash and build R version of each +//! artifact, and `--check` verifies all of those. + +use std::collections::BTreeMap; +use std::fs; +use std::io::Read; +use std::path::{Path, PathBuf}; +use std::process::{Command, ExitCode}; + +use flate2::write::GzEncoder; +use flate2::{Compression, GzBuilder}; +use sha2::{Digest, Sha256}; + +/// The R version brackets, oldest first. `(artifact file name, R version to +/// build with, serialization format)`. +/// +/// Measured load matrix (macOS, `library(rig, lib.loc = ...)`): +/// +/// | built with | 3.4.4 | 3.5.3 | 3.6.3 | 4.0.5 | 4.1.3 | 4.6.1 | +/// |-------------------|-------|-------|-------|-------|-------|-------| +/// | 3.4.4, serialize 2| ok | ok | ok | no | no | no | +/// | 3.5.3, serialize 2| ok | ok | ok | no | no | no | +/// | 4.0.5, serialize 3| no | ok | ok | ok | ok | ok | +/// +/// So the R 4.0.0 boundary is one-directional (a package built by an older R +/// is rejected by R >= 4.0.0, not the other way round), and serialization +/// format 3 is the only thing that keeps the 4.0 artifact off R < 3.5. That +/// makes the middle bracket redundant in practice — its artifact covers +/// exactly the same R versions as the oldest one. It is kept for now because +/// it costs ~4 KB and guards against the two older R series diverging. +const BRACKETS: [(&str, &str, u32); 3] = [ + ("shim-lt-3.5.tar.gz", "3.4.4", 2), + ("shim-3.5.tar.gz", "3.5.3", 2), + ("shim-4.0.tar.gz", "4.0.5", 3), +]; + +const MANIFEST_FILE: &str = "SOURCE-HASH"; + +/// Files every artifact must contain. `R/rig` is the lazy-load loader stub, +/// `R/rig.rdb`/`R/rig.rdx` the lazy-load database itself. +const REQUIRED_ENTRIES: [&str; 6] = [ + "rig/DESCRIPTION", + "rig/NAMESPACE", + "rig/Meta/package.rds", + "rig/R/rig", + "rig/R/rig.rdb", + "rig/R/rig.rdx", +]; + +fn pkg_dir(root: &Path) -> PathBuf { + root.join("src/data/rvenv-pkg") +} + +fn shim_dir(root: &Path) -> PathBuf { + root.join("src/data/rvenv-shim") +} + +// ---------------------------------------------------------------- hashing -- + +fn sha256_hex(bytes: &[u8]) -> String { + let mut h = Sha256::new(); + h.update(bytes); + h.finalize().iter().map(|b| format!("{:02x}", b)).collect() +} + +/// Every file under `dir`, keyed by its `/`-separated path relative to `dir`. +fn read_tree(dir: &Path) -> Result>, String> { + let mut out = BTreeMap::new(); + read_tree_into(dir, dir, &mut out)?; + Ok(out) +} + +fn read_tree_into( + base: &Path, + dir: &Path, + out: &mut BTreeMap>, +) -> Result<(), String> { + let entries = fs::read_dir(dir).map_err(|e| format!("cannot read {}: {}", dir.display(), e))?; + for entry in entries { + let path = entry + .map_err(|e| format!("cannot read {}: {}", dir.display(), e))? + .path(); + let name = path.file_name().unwrap_or_default().to_string_lossy(); + // Nothing of ours starts with a dot; skipping them keeps stray + // .DS_Store files from changing the source hash. + if name.starts_with('.') { + continue; + } + if path.is_dir() { + read_tree_into(base, &path, out)?; + } else { + let rel = path + .strip_prefix(base) + .map_err(|e| e.to_string())? + .components() + .map(|c| c.as_os_str().to_string_lossy().into_owned()) + .collect::>() + .join("/"); + let bytes = + fs::read(&path).map_err(|e| format!("cannot read {}: {}", path.display(), e))?; + out.insert(rel, bytes); + } + } + Ok(()) +} + +/// A hash over the whole package source, so `--check` can tell that the +/// source changed without the artifacts being rebuilt. +fn source_hash(tree: &BTreeMap>) -> String { + let mut h = Sha256::new(); + for (path, bytes) in tree { + h.update(path.as_bytes()); + h.update([0u8]); + h.update(bytes.len().to_le_bytes()); + h.update(bytes); + } + h.finalize().iter().map(|b| format!("{:02x}", b)).collect() +} + +// -------------------------------------------------------------------- tar -- + +/// Tar + gzip `tree` deterministically: sorted entries, no timestamps, no +/// ownership, fixed modes. Two runs over the same input give the same bytes, +/// so a rebuild that changes nothing shows up as no diff. +pub fn deterministic_targz(tree: &BTreeMap>) -> Result, String> { + let mut tar = tar::Builder::new(Vec::new()); + tar.mode(tar::HeaderMode::Deterministic); + for (path, bytes) in tree { + let mut header = tar::Header::new_ustar(); + header + .set_path(path) + .map_err(|e| format!("tar path {}: {}", path, e))?; + header.set_size(bytes.len() as u64); + header.set_mode(0o644); + header.set_mtime(0); + header.set_uid(0); + header.set_gid(0); + header + .set_username("") + .map_err(|e| format!("tar user: {}", e))?; + header + .set_groupname("") + .map_err(|e| format!("tar group: {}", e))?; + header.set_entry_type(tar::EntryType::Regular); + header.set_cksum(); + tar.append(&header, &bytes[..]) + .map_err(|e| format!("tar append {}: {}", path, e))?; + } + let tarred = tar.into_inner().map_err(|e| e.to_string())?; + let gz = GzBuilder::new() + .mtime(0) + .write(Vec::new(), Compression::default()); + let mut gz: GzEncoder> = gz; + use std::io::Write; + gz.write_all(&tarred).map_err(|e| e.to_string())?; + gz.finish().map_err(|e| e.to_string()) +} + +fn untargz(bytes: &[u8]) -> Result>, String> { + let mut ar = tar::Archive::new(flate2::read::GzDecoder::new(std::io::Cursor::new(bytes))); + let mut out = BTreeMap::new(); + for entry in ar.entries().map_err(|e| e.to_string())? { + let mut entry = entry.map_err(|e| e.to_string())?; + let path = entry + .path() + .map_err(|e| e.to_string())? + .to_string_lossy() + .replace('\\', "/"); + let mut buf = Vec::new(); + entry.read_to_end(&mut buf).map_err(|e| e.to_string())?; + out.insert(path, buf); + } + Ok(out) +} + +// ------------------------------------------------------------------ rig(1) -- + +#[derive(serde::Deserialize)] +struct RigListEntry { + version: Option, + binary: Option, +} + +fn rig_binary() -> String { + std::env::var("RIG").unwrap_or_else(|_| "rig".to_string()) +} + +fn rig_list() -> Result, String> { + let out = Command::new(rig_binary()) + .args(["--json", "list"]) + .output() + .map_err(|e| format!("cannot run `{} --json list`: {}", rig_binary(), e))?; + if !out.status.success() { + return Err(format!( + "`{} --json list` failed: {}", + rig_binary(), + String::from_utf8_lossy(&out.stderr).trim() + )); + } + serde_json::from_slice(&out.stdout) + .map_err(|e| format!("cannot parse `rig list` output: {}", e)) +} + +/// The R binary for `version`, installing it with `rig add` if it is missing. +fn r_binary_for(version: &str) -> Result { + if let Some(bin) = find_r_binary(version)? { + return Ok(bin); + } + eprintln!( + "R {} is not installed, running `rig add {}`", + version, version + ); + // `--without-pak`: pak needs R >= 3.5.0, and the shim build does not use + // it. We do not check the exit status, only whether R ended up installed: + // some post-install steps can fail without that mattering here. + rig_add(version, &["--without-pak"])?; + // The R versions in the older brackets predate arm64 macOS, so on an + // Apple silicon machine they only exist as x86_64 builds (run under + // Rosetta). The installed package is pure R either way. + if cfg!(target_os = "macos") && find_r_binary(version)?.is_none() { + eprintln!("retrying with `rig add {} --arch x86_64`", version); + rig_add(version, &["--without-pak", "--arch", "x86_64"])?; + } + find_r_binary(version)?.ok_or_else(|| { + format!( + "`{} add {}` did not install R {}; install it manually and re-run", + rig_binary(), + version, + version + ) + }) +} + +fn rig_add(version: &str, extra: &[&str]) -> Result { + let status = Command::new(rig_binary()) + .args(["add", version]) + .args(extra) + .status() + .map_err(|e| format!("cannot run `{} add {}`: {}", rig_binary(), version, e))?; + Ok(status.success()) +} + +fn find_r_binary(version: &str) -> Result, String> { + Ok(rig_list()? + .into_iter() + .find(|e| e.version.as_deref() == Some(version)) + .and_then(|e| e.binary) + .map(PathBuf::from)) +} + +// ------------------------------------------------------------------ build -- + +/// `R CMD INSTALL` the package source into a throwaway library and return the +/// installed `rig/` tree, ready to be tarred. +fn build_one( + root: &Path, + r_binary: &Path, + serialize_version: u32, +) -> Result>, String> { + let lib = root.join("target/rvenv-shim/lib"); + if lib.exists() { + fs::remove_dir_all(&lib).map_err(|e| format!("cannot clean {}: {}", lib.display(), e))?; + } + fs::create_dir_all(&lib).map_err(|e| format!("cannot create {}: {}", lib.display(), e))?; + + let status = Command::new(r_binary) + .args([ + "CMD", + "INSTALL", + // Bytecode carries the compiler's own version tag, which is one + // more cross-version hazard for an artifact we ship. The shim is + // a few lines of code run once per session; it does not need it. + "--no-byte-compile", + "--no-help", + "--no-multiarch", + // Loading the package during install would fire .onLoad() and + // its side effects. + "--no-test-load", + "-l", + ]) + .arg(&lib) + .arg(pkg_dir(root)) + .env("R_DEFAULT_SERIALIZE_VERSION", serialize_version.to_string()) + .status() + .map_err(|e| format!("cannot run `{} CMD INSTALL`: {}", r_binary.display(), e))?; + if !status.success() { + return Err(format!("`{} CMD INSTALL` failed", r_binary.display())); + } + + let installed = read_tree(&lib.join("rig"))?; + let mut tree = BTreeMap::new(); + for (path, bytes) in installed { + tree.insert(format!("rig/{}", path), bytes); + } + for required in REQUIRED_ENTRIES { + if !tree.contains_key(required) { + return Err(format!("the installed package has no {}", required)); + } + } + Ok(tree) +} + +/// The R version out of `DESCRIPTION`'s `Built:` field, e.g. `Built: R 4.0.5; +/// ; 2026-09-02 10:11:12 UTC; unix` -> `4.0.5`. +pub fn built_r_version(description: &str) -> Option { + let line = description + .lines() + .find(|l| l.starts_with("Built:"))? + .trim_start_matches("Built:") + .trim(); + let field = line.split(';').next()?.trim(); + field.strip_prefix("R ").map(|v| v.trim().to_string()) +} + +// --------------------------------------------------------------- manifest -- + +#[derive(Debug, PartialEq, Eq)] +pub struct Manifest { + pub source: String, + /// `(file name, R version, serialization format, sha256)` + pub artifacts: Vec<(String, String, u32, String)>, +} + +pub fn render_manifest(m: &Manifest) -> String { + let mut out = String::from( + "# Generated by `cargo xtask gen-rvenv-shim` (run `make rvenv-shim`).\n\ + # Do not edit by hand. `cargo xtask gen-rvenv-shim --check` verifies that\n\ + # the committed shim packages still match src/data/rvenv-pkg.\n", + ); + out.push_str(&format!("source = {}\n", m.source)); + for (file, rver, serialize, hash) in &m.artifacts { + out.push_str(&format!( + "{} = r {}, serialize {}, sha256 {}\n", + file, rver, serialize, hash + )); + } + out +} + +pub fn parse_manifest(text: &str) -> Result { + let mut source = None; + let mut artifacts = Vec::new(); + for line in text.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let (key, value) = line + .split_once('=') + .ok_or_else(|| format!("malformed manifest line: {}", line))?; + let (key, value) = (key.trim(), value.trim()); + if key == "source" { + source = Some(value.to_string()); + continue; + } + let mut rver = None; + let mut serialize = None; + let mut hash = None; + for field in value.split(',') { + let field = field.trim(); + if let Some(v) = field.strip_prefix("r ") { + rver = Some(v.trim().to_string()); + } else if let Some(v) = field.strip_prefix("serialize ") { + serialize = v.trim().parse::().ok(); + } else if let Some(v) = field.strip_prefix("sha256 ") { + hash = Some(v.trim().to_string()); + } + } + match (rver, serialize, hash) { + (Some(r), Some(s), Some(h)) => artifacts.push((key.to_string(), r, s, h)), + _ => return Err(format!("malformed manifest entry for {}", key)), + } + } + Ok(Manifest { + source: source.ok_or_else(|| "manifest has no `source` line".to_string())?, + artifacts, + }) +} + +// ------------------------------------------------------------------ tasks -- + +fn gen(root: &Path) -> Result<(), String> { + let src = read_tree(&pkg_dir(root))?; + fs::create_dir_all(shim_dir(root)).map_err(|e| e.to_string())?; + + let mut artifacts = Vec::new(); + let mut payloads: Vec<(String, BTreeMap>)> = Vec::new(); + for (file, rver, serialize) in BRACKETS { + let r_binary = r_binary_for(rver)?; + eprintln!("building {} with R {} ({})", file, rver, r_binary.display()); + let tree = build_one(root, &r_binary, serialize)?; + let targz = deterministic_targz(&tree)?; + let path = shim_dir(root).join(file); + fs::write(&path, &targz).map_err(|e| format!("cannot write {}: {}", path.display(), e))?; + eprintln!("wrote {} ({} bytes)", path.display(), targz.len()); + artifacts.push(( + file.to_string(), + rver.to_string(), + serialize, + sha256_hex(&targz), + )); + payloads.push((file.to_string(), tree)); + } + + // The two pre-4.0 brackets are both serialization format 2, so they may + // well be interchangeable in practice. If the lazy-load databases come + // out identical, one of the two brackets is dead weight and can be + // dropped — but say so rather than guess. + if let (Some((a_name, a)), Some((b_name, b))) = (payloads.first(), payloads.get(1)) { + if a.get("rig/R/rig.rdb") == b.get("rig/R/rig.rdb") + && a.get("rig/R/rig.rdx") == b.get("rig/R/rig.rdx") + { + eprintln!( + "note: {} and {} have identical lazy-load databases; \ + these two brackets could be collapsed into one", + a_name, b_name + ); + } + } + + let manifest = Manifest { + source: source_hash(&src), + artifacts, + }; + let path = shim_dir(root).join(MANIFEST_FILE); + fs::write(&path, render_manifest(&manifest)) + .map_err(|e| format!("cannot write {}: {}", path.display(), e))?; + eprintln!("wrote {}", path.display()); + Ok(()) +} + +fn check(root: &Path) -> Result<(), String> { + let manifest_path = shim_dir(root).join(MANIFEST_FILE); + let text = fs::read_to_string(&manifest_path) + .map_err(|e| format!("cannot read {}: {}", manifest_path.display(), e))?; + let manifest = parse_manifest(&text)?; + + let src = read_tree(&pkg_dir(root))?; + if source_hash(&src) != manifest.source { + return Err( + "src/data/rvenv-pkg changed but the pre-built shim packages were \ + not rebuilt; run `make rvenv-shim`" + .to_string(), + ); + } + + if manifest.artifacts.len() != BRACKETS.len() { + return Err(format!( + "{} lists {} artifacts, expected {}", + MANIFEST_FILE, + manifest.artifacts.len(), + BRACKETS.len() + )); + } + + for (file, rver, serialize) in BRACKETS { + let entry = manifest + .artifacts + .iter() + .find(|(f, _, _, _)| f == file) + .ok_or_else(|| format!("{} has no entry for {}", MANIFEST_FILE, file))?; + if entry.1 != rver || entry.2 != serialize { + return Err(format!( + "{} says {} was built with R {} (serialize {}), expected R {} \ + (serialize {}); run `make rvenv-shim`", + MANIFEST_FILE, file, entry.1, entry.2, rver, serialize + )); + } + let path = shim_dir(root).join(file); + let bytes = + fs::read(&path).map_err(|e| format!("cannot read {}: {}", path.display(), e))?; + if sha256_hex(&bytes) != entry.3 { + return Err(format!( + "{} does not match its hash in {}; run `make rvenv-shim`", + path.display(), + MANIFEST_FILE + )); + } + + let tree = untargz(&bytes)?; + for required in REQUIRED_ENTRIES { + if !tree.contains_key(required) { + return Err(format!("{} has no {}", file, required)); + } + } + // This data is unpacked into the user's project, so make sure it + // cannot write anywhere else. + for path in tree.keys() { + if !path.starts_with("rig/") || path.contains("..") { + return Err(format!("{} contains an unexpected entry: {}", file, path)); + } + } + // The source hash cannot catch an artifact rebuilt from unchanged + // source with the wrong R, but `Built:` records it. + let description = tree + .get("rig/DESCRIPTION") + .map(|b| String::from_utf8_lossy(b).into_owned()) + .unwrap_or_default(); + match built_r_version(&description) { + Some(built) if built == rver => {} + Some(built) => { + return Err(format!( + "{} was built with R {}, but the {} bracket needs R {}", + file, built, file, rver + )) + } + None => return Err(format!("{} has no `Built:` field in DESCRIPTION", file)), + } + } + + eprintln!("{} shim packages are up to date", BRACKETS.len()); + Ok(()) +} + +pub fn gen_rvenv_shim(root: &Path, do_check: bool) -> ExitCode { + let res = if do_check { check(root) } else { gen(root) }; + match res { + Ok(()) => ExitCode::SUCCESS, + Err(msg) => { + eprintln!("error: {}", msg); + ExitCode::FAILURE + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tree(entries: &[(&str, &[u8])]) -> BTreeMap> { + entries + .iter() + .map(|(p, b)| (p.to_string(), b.to_vec())) + .collect() + } + + #[test] + fn targz_is_deterministic() { + let t = tree(&[("rig/DESCRIPTION", b"Package: rig\n"), ("rig/R/rig", b"x")]); + assert_eq!( + deterministic_targz(&t).unwrap(), + deterministic_targz(&t).unwrap() + ); + } + + #[test] + fn targz_round_trips() { + let t = tree(&[("rig/DESCRIPTION", b"Package: rig\n"), ("rig/R/rig", b"x")]); + assert_eq!(untargz(&deterministic_targz(&t).unwrap()).unwrap(), t); + } + + #[test] + fn source_hash_tracks_content_and_paths() { + let a = tree(&[("R/rvenv.R", b"one")]); + let b = tree(&[("R/rvenv.R", b"two")]); + let c = tree(&[("R/other.R", b"one")]); + assert_eq!(source_hash(&a), source_hash(&a.clone())); + assert_ne!(source_hash(&a), source_hash(&b)); + assert_ne!(source_hash(&a), source_hash(&c)); + } + + #[test] + fn manifest_round_trips() { + let m = Manifest { + source: "abc123".to_string(), + artifacts: vec![ + ( + "shim-lt-3.5.tar.gz".to_string(), + "3.4.4".to_string(), + 2, + "deadbeef".to_string(), + ), + ( + "shim-4.0.tar.gz".to_string(), + "4.0.5".to_string(), + 3, + "cafe".to_string(), + ), + ], + }; + assert_eq!(parse_manifest(&render_manifest(&m)).unwrap(), m); + } + + #[test] + fn manifest_rejects_garbage() { + assert!(parse_manifest("no source line here = x").is_err()); + assert!(parse_manifest("source = abc\nshim.tar.gz = r 4.0.5").is_err()); + } + + #[test] + fn built_r_version_parses_the_built_field() { + assert_eq!( + built_r_version("Package: rig\nBuilt: R 4.0.5; ; 2026-09-02 10:00:00 UTC; unix\n") + .as_deref(), + Some("4.0.5") + ); + assert_eq!(built_r_version("Package: rig\n"), None); + } +} From f64941850f459a0e1472beb73f8e6300dca05365 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Cs=C3=A1rdi?= Date: Wed, 2 Sep 2026 20:43:03 +0200 Subject: [PATCH 08/19] rig proj init default R version tweaks 1. option 2. current default 3. current R release 4. error --- src/args.rs | 2 +- src/help-generated.in | 2 +- src/help/proj-init.md | 7 +++---- src/proj.rs | 46 +++++++++++++++++++++++++++++++++++++------ 4 files changed, 45 insertions(+), 12 deletions(-) diff --git a/src/args.rs b/src/args.rs index a2c60bed..53cf6dab 100644 --- a/src/args.rs +++ b/src/args.rs @@ -1330,7 +1330,7 @@ pub fn rig_app() -> Command { ) .arg( Arg::new("r-version") - .help("R version of the project (default: the default R version)") + .help("R version of the project (default: the default R version, or the current R release)") .long("r-version") .short('r') .num_args(1) diff --git a/src/help-generated.in b/src/help-generated.in index dbc4ca1a..d0ca4790 100644 --- a/src/help-generated.in +++ b/src/help-generated.in @@ -67,7 +67,7 @@ const HELP_PROJ_DEPS: &str = "\u{1b}[1m\u{1b}[34mDescription:\u{1b}[39m\u{1b}[22 const ABOUT_PROJ_IMPORT: &str = "Import a DESCRIPTION file's dependencies into rproj.toml"; const HELP_PROJ_IMPORT: &str = "\u{1b}[1m\u{1b}[34mDescription:\u{1b}[39m\u{1b}[22m\n Read a \u{1b}[32mDESCRIPTION\u{1b}[39m file and merge its dependencies into \u{1b}[32mrproj.toml\u{1b}[39m, rig's\n project and package manifest. If \u{1b}[32mrproj.toml\u{1b}[39m does not exist yet, it is\n created first, named after the DESCRIPTION file's \u{1b}[32mPackage:\u{1b}[39m field.\n\n \u{1b}[32mDepends\u{1b}[39m and \u{1b}[32mImports\u{1b}[39m land in the \u{1b}[32m[dependencies]\u{1b}[39m table (\u{1b}[32mDepends\u{1b}[39m\n packages are marked to attach on load); \u{1b}[32mLinkingTo\u{1b}[39m also lands in\n \u{1b}[32m[linking-dependencies]\u{1b}[39m. \u{1b}[32mSuggests\u{1b}[39m is imported into\n \u{1b}[32m[dependency-groups.test]\u{1b}[39m and \u{1b}[32mEnhances\u{1b}[39m into\n \u{1b}[32m[dependency-groups.enhances]\u{1b}[39m.\n\n By default rig reads \u{1b}[32mDESCRIPTION\u{1b}[39m in the current directory; use \u{1b}[32m--input\u{1b}[39m\n to point to a different file.\n\n Importing a package already listed in \u{1b}[32mrproj.toml\u{1b}[39m overwrites its entry\n with the version requirement from the DESCRIPTION file. Because\n \u{1b}[32mrproj.toml\u{1b}[39m is rewritten in full, any comments or custom formatting in an\n existing file are not preserved."; const ABOUT_PROJ_INIT: &str = "Create a new R project"; -const HELP_PROJ_INIT: &str = "\u{1b}[1m\u{1b}[34mDescription:\u{1b}[39m\u{1b}[22m\n Set up an R project in the current directory: the \u{1b}[32mrproj.toml\u{1b}[39m manifest, plus\n the part of the project's virtual environment (\u{1b}[32m.rvenv\u{1b}[39m) that belongs in\n version control.\n\n \u{1b}[32mrproj.toml\u{1b}[39m is rig's modern project and package file. It describes the\n project's metadata and its R and package dependencies, and can do everything\n a \u{1b}[32mDESCRIPTION\u{1b}[39m file can, plus dependency groups, optional dependencies,\n workspaces and declared scripts. \u{1b}[32mrig proj init\u{1b}[39m writes a minimal skeleton — a\n \u{1b}[32m[project]\u{1b}[39m table with the name (taken from the current directory) and\n version, and a \u{1b}[32m[dependencies]\u{1b}[39m table with a single R requirement — that you\n then fill in. Use \u{1b}[32mrig proj import\u{1b}[39m instead to take the\n dependencies from an existing \u{1b}[32mDESCRIPTION\u{1b}[39m.\n\n\u{1b}[1m\u{1b}[34mFiles:\u{1b}[39m\u{1b}[22m\n \u{1b}[32mrig proj init\u{1b}[39m creates these, and nothing else. All of them are meant to be\n committed, so that a fresh clone of the project works right away:\n\n - \u{1b}[32mrproj.toml\u{1b}[39m — the manifest. Its R requirement is \u{1b}[32m>= .\u{1b}[39m of\n the project's R version.\n - \u{1b}[32m.Renviron\u{1b}[39m — points R at the project library, \u{1b}[32m.rvenv/lib\u{1b}[39m. This is what\n makes the project work in an editor (RStudio, Positron, VS Code), which\n starts R itself.\n - \u{1b}[32m.gitignore\u{1b}[39m — a marked \u{1b}[32m# rig rvenv start\u{1b}[39m / \u{1b}[32m# rig rvenv end\u{1b}[39m block that\n ignores everything in \u{1b}[32m.rvenv\u{1b}[39m except the library directory. An existing\n \u{1b}[32m.gitignore\u{1b}[39m is \u{1b}[3mnot\u{1b}[23m replaced: rig only adds or refreshes its own block,\n and leaves the rest of the file alone.\n - \u{1b}[32m.rvenv/lib/.gitignore\u{1b}[39m — keeps the library directory itself, and the \u{1b}[32mrig\u{1b}[39m\n package in it, in version control, and ignores the installed dependencies.\n The directory has to exist in every checkout, because plain R does not\n create a missing library directory.\n - \u{1b}[32m.rvenv/lib/rig\u{1b}[39m — a small, pre-built R package that rig manages. It is not\n a dependency of your project. \u{1b}[32m.Renviron\u{1b}[39m loads it in every R session\n started in the project, where it turns the relative library path into an\n absolute one — so that R processes started from a subdirectory still use the\n project library — and warns while the project is out of sync with\n \u{1b}[32mrproj.lock\u{1b}[39m.\n\n The rest of \u{1b}[32m.rvenv\u{1b}[39m is machine-specific and is created by\n \u{1b}[32mrig proj sync\u{1b}[39m, which installs the project's dependencies\n into \u{1b}[32m.rvenv/lib\u{1b}[39m.\n\n Note that \u{1b}[32mR --vanilla\u{1b}[39m ignores \u{1b}[32m.Renviron\u{1b}[39m, and so does not use the project\n library.\n\n\u{1b}[1m\u{1b}[34mOptions:\u{1b}[39m\u{1b}[22m\n \u{1b}[32m--r-version\u{1b}[39m sets the R version the project is for. It defaults to the\n current default R version, and does not have to be installed. Both the\n manifest's R requirement and the flavor of the pre-built \u{1b}[32mrig\u{1b}[39m package depend\n on it.\n\n rig refuses to overwrite any of the files above; pass \u{1b}[32m--force\u{1b}[39m to replace\n them. \u{1b}[32m--force\u{1b}[39m still does not rewrite the whole \u{1b}[32m.gitignore\u{1b}[39m, only rig's\n block in it."; +const HELP_PROJ_INIT: &str = "\u{1b}[1m\u{1b}[34mDescription:\u{1b}[39m\u{1b}[22m\n Set up an R project in the current directory: the \u{1b}[32mrproj.toml\u{1b}[39m manifest, plus\n the part of the project's virtual environment (\u{1b}[32m.rvenv\u{1b}[39m) that belongs in\n version control.\n\n \u{1b}[32mrproj.toml\u{1b}[39m is rig's modern project and package file. It describes the\n project's metadata and its R and package dependencies, and can do everything\n a \u{1b}[32mDESCRIPTION\u{1b}[39m file can, plus dependency groups, optional dependencies,\n workspaces and declared scripts. \u{1b}[32mrig proj init\u{1b}[39m writes a minimal skeleton — a\n \u{1b}[32m[project]\u{1b}[39m table with the name (taken from the current directory) and\n version, and a \u{1b}[32m[dependencies]\u{1b}[39m table with a single R requirement — that you\n then fill in. Use \u{1b}[32mrig proj import\u{1b}[39m instead to take the\n dependencies from an existing \u{1b}[32mDESCRIPTION\u{1b}[39m.\n\n\u{1b}[1m\u{1b}[34mFiles:\u{1b}[39m\u{1b}[22m\n \u{1b}[32mrig proj init\u{1b}[39m creates these, and nothing else. All of them are meant to be\n committed, so that a fresh clone of the project works right away:\n\n - \u{1b}[32mrproj.toml\u{1b}[39m — the manifest. Its R requirement is \u{1b}[32m>= .\u{1b}[39m of\n the project's R version.\n - \u{1b}[32m.Renviron\u{1b}[39m — points R at the project library, \u{1b}[32m.rvenv/lib\u{1b}[39m. This is what\n makes the project work in an editor (RStudio, Positron, VS Code), which\n starts R itself.\n - \u{1b}[32m.gitignore\u{1b}[39m — a marked \u{1b}[32m# rig rvenv start\u{1b}[39m / \u{1b}[32m# rig rvenv end\u{1b}[39m block that\n ignores everything in \u{1b}[32m.rvenv\u{1b}[39m except the library directory. An existing\n \u{1b}[32m.gitignore\u{1b}[39m is \u{1b}[3mnot\u{1b}[23m replaced: rig only adds or refreshes its own block,\n and leaves the rest of the file alone.\n - \u{1b}[32m.rvenv/lib/.gitignore\u{1b}[39m — keeps the library directory itself, and the \u{1b}[32mrig\u{1b}[39m\n package in it, in version control, and ignores the installed dependencies.\n The directory has to exist in every checkout, because plain R does not\n create a missing library directory.\n - \u{1b}[32m.rvenv/lib/rig\u{1b}[39m — a small, pre-built R package that rig manages. It is not\n a dependency of your project. \u{1b}[32m.Renviron\u{1b}[39m loads it in every R session\n started in the project, where it turns the relative library path into an\n absolute one — so that R processes started from a subdirectory still use the\n project library — and warns while the project is out of sync with\n \u{1b}[32mrproj.lock\u{1b}[39m.\n\n The rest of \u{1b}[32m.rvenv\u{1b}[39m is machine-specific and is created by\n \u{1b}[32mrig proj sync\u{1b}[39m, which installs the project's dependencies\n into \u{1b}[32m.rvenv/lib\u{1b}[39m.\n\n Note that \u{1b}[32mR --vanilla\u{1b}[39m ignores \u{1b}[32m.Renviron\u{1b}[39m, and so does not use the project\n library.\n\n\u{1b}[1m\u{1b}[34mOptions:\u{1b}[39m\u{1b}[22m\n \u{1b}[32m--r-version\u{1b}[39m sets the R version the project is for. It does not have to be\n installed. Both the manifest's R requirement and the flavor of the pre-built\n \u{1b}[32mrig\u{1b}[39m package depend on it. Without \u{1b}[32m--r-version\u{1b}[39m, rig takes the version of\n the current default R version, and if there is no R installed at all, the\n version of the current R release, which it looks up online. If that lookup\n fails too, e.g. because there is no network, \u{1b}[32mrig proj init\u{1b}[39m gives up and asks\n for \u{1b}[32m--r-version\u{1b}[39m.\n\n rig refuses to overwrite any of the files above; pass \u{1b}[32m--force\u{1b}[39m to replace\n them. \u{1b}[32m--force\u{1b}[39m still does not rewrite the whole \u{1b}[32m.gitignore\u{1b}[39m, only rig's\n block in it."; const ABOUT_PROJ_LOCK: &str = "Resolve project dependencies and write rproj.lock"; const HELP_PROJ_LOCK: &str = "\u{1b}[1m\u{1b}[34mDescription:\u{1b}[39m\u{1b}[22m\n Resolve the dependencies of an R project to a concrete set of package\n versions, and write the result to \u{1b}[32mrproj.lock\u{1b}[39m.\n\n rig reads the project manifest, \u{1b}[32mrproj.toml\u{1b}[39m, in the current directory, and\n uses its built-in solver to find a compatible set of package versions from\n the configured repositories, without running R.\n\n Use \u{1b}[32m--r-version\u{1b}[39m to solve for a specific R version, \u{1b}[32m--dev\u{1b}[39m to include\n development dependencies, and \u{1b}[32m--renv\u{1b}[39m to also write the result as an\n \u{1b}[32mrenv.lock\u{1b}[39m file.\n\n \u{1b}[32mrproj.lock\u{1b}[39m currently records a single \u{1b}[32m(R version, platform)\u{1b}[39m target;\n \u{1b}[32mrig proj sync\u{1b}[39m installs that target. Solving for several\n targets in one lockfile is planned but not implemented yet.\n\n\u{1b}[1m\u{1b}[34mSource and binary packages:\u{1b}[39m\u{1b}[22m\n The solver considers binary packages as well as source packages, and\n prefers a binary build when one is available for the same version. Which\n artifact each package is installed from is part of what the solve decides,\n because a binary is only usable together with the exact versions of its\n \u{1b}[32mLinkingTo\u{1b}[39m dependencies that it was compiled against. If those versions\n conflict with the rest of the project, rig picks another build of that\n package, or falls back to its source tarball.\n\n By default a binary build never changes \u{1b}[3mwhich version\u{1b}[23m rig picks: the\n newest suitable version wins, and a binary of it is used if there is one.\n Pass \u{1b}[32m--prefer-binary\u{1b}[39m to let an older version win instead, when the newest\n one has no binary but an older one does — typically because a version was\n released so recently that it has not been built yet. Only the three newest\n versions of a package are considered; \u{1b}[32m--prefer-binary=5\u{1b}[39m considers five.\n Versions held back this way are marked in the output.\n\n Trading a version away for a binary is not free: the binary pins its\n \u{1b}[32mLinkingTo\u{1b}[39m dependencies to the versions it was compiled against, and those\n dependencies then prefer their own binaries in turn, so a whole project can\n end up on older versions.\n\n By default rig solves for the machine it runs on. Use \u{1b}[32m--platform\u{1b}[39m to solve\n for a different one, e.g. to write a lockfile on macOS for a Linux\n deployment:\n\n rig proj lock --platform ubuntu-24.04\n\n \u{1b}[32m--platform source\u{1b}[39m solves for source packages only, and does not download\n any binary package metadata. rig also falls back to source packages when\n there are no binaries for a platform at all. There is then nothing for\n \u{1b}[32m--prefer-binary\u{1b}[39m to prefer, and rig ignores it.\n\n rig keeps the repository metadata and the binary package indices it solves\n from in its cache, and refreshes them once a day. \u{1b}[32m--no-cache\u{1b}[39m downloads\n them again instead, and writes nothing to the cache, which is the way to\n solve against a package that was published minutes ago. It is a good deal\n slower, because the metadata it re-downloads is large. See\n \u{1b}[32mrig config\u{1b}[39m.\n\n The \u{1b}[32mrproj.lock\u{1b}[39m file records, for every package, whether it is a source or a\n binary package and the URL it is downloaded from. It also records where the\n file is cached, which is per \u{1b}[3mbuild\u{1b}[23m rather than per version: a repository\n can offer several binaries of one version for one platform and R version,\n and they are cached side by side."; const ABOUT_PROJ_SYNC: &str = "Install the dependencies rproj.lock resolved"; diff --git a/src/help/proj-init.md b/src/help/proj-init.md index fd4f57c6..3ef033bc 100644 --- a/src/help/proj-init.md +++ b/src/help/proj-init.md @@ -49,10 +49,9 @@ library. ## Options -`--r-version` sets the R version the project is for. It defaults to the -current default R version, and does not have to be installed. Both the -manifest's R requirement and the flavor of the pre-built `rig` package depend -on it. +`--r-version` sets the R version the project is for. It does not have to be +installed. Defaults to the current default R version, or the current R +release if there is no default. rig refuses to overwrite any of the files above; pass `--force` to replace them. `--force` still does not rewrite the whole `.gitignore`, only rig's diff --git a/src/proj.rs b/src/proj.rs index f1765523..4a1dff1e 100644 --- a/src/proj.rs +++ b/src/proj.rs @@ -15,7 +15,7 @@ use tabular::*; use crate::built::BuiltCache; use crate::cache::get_cache_dir; -use crate::common::get_default_r_version; +use crate::common::{get_arch, get_default_r_version, get_platform}; use crate::dcf::*; use crate::download::download_multiple_first_available_with_progress; use crate::install::{ @@ -33,6 +33,7 @@ use crate::platform::{detect_platform, parse_platform_string}; use crate::renv::*; use crate::repos::binaries::loader::{BinaryTarget, P3mBinaryLoader}; use crate::repos::*; +use crate::resolve::resolve_versions; use crate::rproj::{Rproj, RprojLock, RprojLockTarget, RPROJ_LOCK_VERSION, RPROJ_MANIFEST_FILE}; use crate::rvenv::{ existing_targets, find_project_root, project_library, rvenv_init, write_sync_stamp, @@ -112,11 +113,25 @@ fn sc_proj_init( Some(rv) => rv.to_string(), None => match get_default_r_version()? { Some(rv) => rv, - None => { - OUTPUT.error("Cannot determine R version, please specify it with --r-version."); - error!("Cannot determine R version, please specify it with --r-version."); - bail!("Cannot determine R version, please specify it with --r-version.") - } + // No R installed (or no default set), so fall back to the current + // release, which needs the network. + None => match resolve_release_r_version(args) { + Some(rv) => { + OUTPUT.info(&format!( + "No default R version, using the current release (R {}).", + rv + )); + info!("No default R version, using the current release (R {})", rv); + rv + } + None => { + let msg = "Cannot determine R version. Install R with `rig add`, \ + or set the version with --r-version."; + OUTPUT.error(msg); + error!("{}", msg); + bail!("{}", msg) + } + }, }, }; @@ -148,6 +163,25 @@ fn sc_proj_init( Ok(()) } +/// Current release version or None on error. +fn resolve_release_r_version(args: &ArgMatches) -> Option { + let platform = match get_platform(args) { + Ok(p) => p, + Err(err) => { + info!("Cannot detect platform to resolve R release: {}", err); + return None; + } + }; + let arch = get_arch(&platform, args); + match resolve_versions(vec!["release".to_string()], &platform, &arch) { + Ok(vers) => vers.first().and_then(|v| v.version.clone()), + Err(err) => { + info!("Cannot resolve the current R release: {}", err); + None + } + } +} + /// Import a `DESCRIPTION` file's dependencies into `rproj.toml`, creating a /// minimal manifest first if none exists yet. fn sc_proj_import( From 21d11761c9450d3df8b50355a7604d8efa3e79df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Cs=C3=A1rdi?= Date: Wed, 2 Sep 2026 21:05:36 +0200 Subject: [PATCH 09/19] Typo fix --- src/args.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/args.rs b/src/args.rs index 53cf6dab..96216dfb 100644 --- a/src/args.rs +++ b/src/args.rs @@ -1422,7 +1422,7 @@ pub fn rig_app() -> Command { .display_order(0) .arg( Arg::new("renv") - .help("Output and renv.lock file") + .help("Output an renv.lock file") .long("renv") .num_args(0) .required(false), From b9a0443d0fc525cf76fadf82b9a17f8bfff685dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Cs=C3=A1rdi?= Date: Wed, 2 Sep 2026 21:07:31 +0200 Subject: [PATCH 10/19] rig proj sync calls rig proj lock if no lockfile --- src/help-generated.in | 4 +-- src/help/proj-sync.md | 4 ++- src/proj.rs | 77 +++++++++++++++++++++++++++++++++---------- 3 files changed, 64 insertions(+), 21 deletions(-) diff --git a/src/help-generated.in b/src/help-generated.in index d0ca4790..8828777e 100644 --- a/src/help-generated.in +++ b/src/help-generated.in @@ -67,11 +67,11 @@ const HELP_PROJ_DEPS: &str = "\u{1b}[1m\u{1b}[34mDescription:\u{1b}[39m\u{1b}[22 const ABOUT_PROJ_IMPORT: &str = "Import a DESCRIPTION file's dependencies into rproj.toml"; const HELP_PROJ_IMPORT: &str = "\u{1b}[1m\u{1b}[34mDescription:\u{1b}[39m\u{1b}[22m\n Read a \u{1b}[32mDESCRIPTION\u{1b}[39m file and merge its dependencies into \u{1b}[32mrproj.toml\u{1b}[39m, rig's\n project and package manifest. If \u{1b}[32mrproj.toml\u{1b}[39m does not exist yet, it is\n created first, named after the DESCRIPTION file's \u{1b}[32mPackage:\u{1b}[39m field.\n\n \u{1b}[32mDepends\u{1b}[39m and \u{1b}[32mImports\u{1b}[39m land in the \u{1b}[32m[dependencies]\u{1b}[39m table (\u{1b}[32mDepends\u{1b}[39m\n packages are marked to attach on load); \u{1b}[32mLinkingTo\u{1b}[39m also lands in\n \u{1b}[32m[linking-dependencies]\u{1b}[39m. \u{1b}[32mSuggests\u{1b}[39m is imported into\n \u{1b}[32m[dependency-groups.test]\u{1b}[39m and \u{1b}[32mEnhances\u{1b}[39m into\n \u{1b}[32m[dependency-groups.enhances]\u{1b}[39m.\n\n By default rig reads \u{1b}[32mDESCRIPTION\u{1b}[39m in the current directory; use \u{1b}[32m--input\u{1b}[39m\n to point to a different file.\n\n Importing a package already listed in \u{1b}[32mrproj.toml\u{1b}[39m overwrites its entry\n with the version requirement from the DESCRIPTION file. Because\n \u{1b}[32mrproj.toml\u{1b}[39m is rewritten in full, any comments or custom formatting in an\n existing file are not preserved."; const ABOUT_PROJ_INIT: &str = "Create a new R project"; -const HELP_PROJ_INIT: &str = "\u{1b}[1m\u{1b}[34mDescription:\u{1b}[39m\u{1b}[22m\n Set up an R project in the current directory: the \u{1b}[32mrproj.toml\u{1b}[39m manifest, plus\n the part of the project's virtual environment (\u{1b}[32m.rvenv\u{1b}[39m) that belongs in\n version control.\n\n \u{1b}[32mrproj.toml\u{1b}[39m is rig's modern project and package file. It describes the\n project's metadata and its R and package dependencies, and can do everything\n a \u{1b}[32mDESCRIPTION\u{1b}[39m file can, plus dependency groups, optional dependencies,\n workspaces and declared scripts. \u{1b}[32mrig proj init\u{1b}[39m writes a minimal skeleton — a\n \u{1b}[32m[project]\u{1b}[39m table with the name (taken from the current directory) and\n version, and a \u{1b}[32m[dependencies]\u{1b}[39m table with a single R requirement — that you\n then fill in. Use \u{1b}[32mrig proj import\u{1b}[39m instead to take the\n dependencies from an existing \u{1b}[32mDESCRIPTION\u{1b}[39m.\n\n\u{1b}[1m\u{1b}[34mFiles:\u{1b}[39m\u{1b}[22m\n \u{1b}[32mrig proj init\u{1b}[39m creates these, and nothing else. All of them are meant to be\n committed, so that a fresh clone of the project works right away:\n\n - \u{1b}[32mrproj.toml\u{1b}[39m — the manifest. Its R requirement is \u{1b}[32m>= .\u{1b}[39m of\n the project's R version.\n - \u{1b}[32m.Renviron\u{1b}[39m — points R at the project library, \u{1b}[32m.rvenv/lib\u{1b}[39m. This is what\n makes the project work in an editor (RStudio, Positron, VS Code), which\n starts R itself.\n - \u{1b}[32m.gitignore\u{1b}[39m — a marked \u{1b}[32m# rig rvenv start\u{1b}[39m / \u{1b}[32m# rig rvenv end\u{1b}[39m block that\n ignores everything in \u{1b}[32m.rvenv\u{1b}[39m except the library directory. An existing\n \u{1b}[32m.gitignore\u{1b}[39m is \u{1b}[3mnot\u{1b}[23m replaced: rig only adds or refreshes its own block,\n and leaves the rest of the file alone.\n - \u{1b}[32m.rvenv/lib/.gitignore\u{1b}[39m — keeps the library directory itself, and the \u{1b}[32mrig\u{1b}[39m\n package in it, in version control, and ignores the installed dependencies.\n The directory has to exist in every checkout, because plain R does not\n create a missing library directory.\n - \u{1b}[32m.rvenv/lib/rig\u{1b}[39m — a small, pre-built R package that rig manages. It is not\n a dependency of your project. \u{1b}[32m.Renviron\u{1b}[39m loads it in every R session\n started in the project, where it turns the relative library path into an\n absolute one — so that R processes started from a subdirectory still use the\n project library — and warns while the project is out of sync with\n \u{1b}[32mrproj.lock\u{1b}[39m.\n\n The rest of \u{1b}[32m.rvenv\u{1b}[39m is machine-specific and is created by\n \u{1b}[32mrig proj sync\u{1b}[39m, which installs the project's dependencies\n into \u{1b}[32m.rvenv/lib\u{1b}[39m.\n\n Note that \u{1b}[32mR --vanilla\u{1b}[39m ignores \u{1b}[32m.Renviron\u{1b}[39m, and so does not use the project\n library.\n\n\u{1b}[1m\u{1b}[34mOptions:\u{1b}[39m\u{1b}[22m\n \u{1b}[32m--r-version\u{1b}[39m sets the R version the project is for. It does not have to be\n installed. Both the manifest's R requirement and the flavor of the pre-built\n \u{1b}[32mrig\u{1b}[39m package depend on it. Without \u{1b}[32m--r-version\u{1b}[39m, rig takes the version of\n the current default R version, and if there is no R installed at all, the\n version of the current R release, which it looks up online. If that lookup\n fails too, e.g. because there is no network, \u{1b}[32mrig proj init\u{1b}[39m gives up and asks\n for \u{1b}[32m--r-version\u{1b}[39m.\n\n rig refuses to overwrite any of the files above; pass \u{1b}[32m--force\u{1b}[39m to replace\n them. \u{1b}[32m--force\u{1b}[39m still does not rewrite the whole \u{1b}[32m.gitignore\u{1b}[39m, only rig's\n block in it."; +const HELP_PROJ_INIT: &str = "\u{1b}[1m\u{1b}[34mDescription:\u{1b}[39m\u{1b}[22m\n Set up an R project in the current directory: the \u{1b}[32mrproj.toml\u{1b}[39m manifest, plus\n the part of the project's virtual environment (\u{1b}[32m.rvenv\u{1b}[39m) that belongs in\n version control.\n\n \u{1b}[32mrproj.toml\u{1b}[39m is rig's modern project and package file. It describes the\n project's metadata and its R and package dependencies, and can do everything\n a \u{1b}[32mDESCRIPTION\u{1b}[39m file can, plus dependency groups, optional dependencies,\n workspaces and declared scripts. \u{1b}[32mrig proj init\u{1b}[39m writes a minimal skeleton — a\n \u{1b}[32m[project]\u{1b}[39m table with the name (taken from the current directory) and\n version, and a \u{1b}[32m[dependencies]\u{1b}[39m table with a single R requirement — that you\n then fill in. Use \u{1b}[32mrig proj import\u{1b}[39m instead to take the\n dependencies from an existing \u{1b}[32mDESCRIPTION\u{1b}[39m.\n\n\u{1b}[1m\u{1b}[34mFiles:\u{1b}[39m\u{1b}[22m\n \u{1b}[32mrig proj init\u{1b}[39m creates these, and nothing else. All of them are meant to be\n committed, so that a fresh clone of the project works right away:\n\n - \u{1b}[32mrproj.toml\u{1b}[39m — the manifest. Its R requirement is \u{1b}[32m>= .\u{1b}[39m of\n the project's R version.\n - \u{1b}[32m.Renviron\u{1b}[39m — points R at the project library, \u{1b}[32m.rvenv/lib\u{1b}[39m. This is what\n makes the project work in an editor (RStudio, Positron, VS Code), which\n starts R itself.\n - \u{1b}[32m.gitignore\u{1b}[39m — a marked \u{1b}[32m# rig rvenv start\u{1b}[39m / \u{1b}[32m# rig rvenv end\u{1b}[39m block that\n ignores everything in \u{1b}[32m.rvenv\u{1b}[39m except the library directory. An existing\n \u{1b}[32m.gitignore\u{1b}[39m is \u{1b}[3mnot\u{1b}[23m replaced: rig only adds or refreshes its own block,\n and leaves the rest of the file alone.\n - \u{1b}[32m.rvenv/lib/.gitignore\u{1b}[39m — keeps the library directory itself, and the \u{1b}[32mrig\u{1b}[39m\n package in it, in version control, and ignores the installed dependencies.\n The directory has to exist in every checkout, because plain R does not\n create a missing library directory.\n - \u{1b}[32m.rvenv/lib/rig\u{1b}[39m — a small, pre-built R package that rig manages. It is not\n a dependency of your project. \u{1b}[32m.Renviron\u{1b}[39m loads it in every R session\n started in the project, where it turns the relative library path into an\n absolute one — so that R processes started from a subdirectory still use the\n project library — and warns while the project is out of sync with\n \u{1b}[32mrproj.lock\u{1b}[39m.\n\n The rest of \u{1b}[32m.rvenv\u{1b}[39m is machine-specific and is created by\n \u{1b}[32mrig proj sync\u{1b}[39m, which installs the project's dependencies\n into \u{1b}[32m.rvenv/lib\u{1b}[39m.\n\n Note that \u{1b}[32mR --vanilla\u{1b}[39m ignores \u{1b}[32m.Renviron\u{1b}[39m, and so does not use the project\n library.\n\n\u{1b}[1m\u{1b}[34mOptions:\u{1b}[39m\u{1b}[22m\n \u{1b}[32m--r-version\u{1b}[39m sets the R version the project is for. It does not have to be\n installed. Defaults to the current default R version, or the current R\n release if there is no default.\n\n rig refuses to overwrite any of the files above; pass \u{1b}[32m--force\u{1b}[39m to replace\n them. \u{1b}[32m--force\u{1b}[39m still does not rewrite the whole \u{1b}[32m.gitignore\u{1b}[39m, only rig's\n block in it."; const ABOUT_PROJ_LOCK: &str = "Resolve project dependencies and write rproj.lock"; const HELP_PROJ_LOCK: &str = "\u{1b}[1m\u{1b}[34mDescription:\u{1b}[39m\u{1b}[22m\n Resolve the dependencies of an R project to a concrete set of package\n versions, and write the result to \u{1b}[32mrproj.lock\u{1b}[39m.\n\n rig reads the project manifest, \u{1b}[32mrproj.toml\u{1b}[39m, in the current directory, and\n uses its built-in solver to find a compatible set of package versions from\n the configured repositories, without running R.\n\n Use \u{1b}[32m--r-version\u{1b}[39m to solve for a specific R version, \u{1b}[32m--dev\u{1b}[39m to include\n development dependencies, and \u{1b}[32m--renv\u{1b}[39m to also write the result as an\n \u{1b}[32mrenv.lock\u{1b}[39m file.\n\n \u{1b}[32mrproj.lock\u{1b}[39m currently records a single \u{1b}[32m(R version, platform)\u{1b}[39m target;\n \u{1b}[32mrig proj sync\u{1b}[39m installs that target. Solving for several\n targets in one lockfile is planned but not implemented yet.\n\n\u{1b}[1m\u{1b}[34mSource and binary packages:\u{1b}[39m\u{1b}[22m\n The solver considers binary packages as well as source packages, and\n prefers a binary build when one is available for the same version. Which\n artifact each package is installed from is part of what the solve decides,\n because a binary is only usable together with the exact versions of its\n \u{1b}[32mLinkingTo\u{1b}[39m dependencies that it was compiled against. If those versions\n conflict with the rest of the project, rig picks another build of that\n package, or falls back to its source tarball.\n\n By default a binary build never changes \u{1b}[3mwhich version\u{1b}[23m rig picks: the\n newest suitable version wins, and a binary of it is used if there is one.\n Pass \u{1b}[32m--prefer-binary\u{1b}[39m to let an older version win instead, when the newest\n one has no binary but an older one does — typically because a version was\n released so recently that it has not been built yet. Only the three newest\n versions of a package are considered; \u{1b}[32m--prefer-binary=5\u{1b}[39m considers five.\n Versions held back this way are marked in the output.\n\n Trading a version away for a binary is not free: the binary pins its\n \u{1b}[32mLinkingTo\u{1b}[39m dependencies to the versions it was compiled against, and those\n dependencies then prefer their own binaries in turn, so a whole project can\n end up on older versions.\n\n By default rig solves for the machine it runs on. Use \u{1b}[32m--platform\u{1b}[39m to solve\n for a different one, e.g. to write a lockfile on macOS for a Linux\n deployment:\n\n rig proj lock --platform ubuntu-24.04\n\n \u{1b}[32m--platform source\u{1b}[39m solves for source packages only, and does not download\n any binary package metadata. rig also falls back to source packages when\n there are no binaries for a platform at all. There is then nothing for\n \u{1b}[32m--prefer-binary\u{1b}[39m to prefer, and rig ignores it.\n\n rig keeps the repository metadata and the binary package indices it solves\n from in its cache, and refreshes them once a day. \u{1b}[32m--no-cache\u{1b}[39m downloads\n them again instead, and writes nothing to the cache, which is the way to\n solve against a package that was published minutes ago. It is a good deal\n slower, because the metadata it re-downloads is large. See\n \u{1b}[32mrig config\u{1b}[39m.\n\n The \u{1b}[32mrproj.lock\u{1b}[39m file records, for every package, whether it is a source or a\n binary package and the URL it is downloaded from. It also records where the\n file is cached, which is per \u{1b}[3mbuild\u{1b}[23m rather than per version: a repository\n can offer several binaries of one version for one platform and R version,\n and they are cached side by side."; const ABOUT_PROJ_SYNC: &str = "Install the dependencies rproj.lock resolved"; -const HELP_PROJ_SYNC: &str = "\u{1b}[1m\u{1b}[34mDescription:\u{1b}[39m\u{1b}[22m\n Install the resolved dependencies of an R project into a package library.\n\n rig looks for the project in the current directory and its parents, reads its\n \u{1b}[32mrproj.lock\u{1b}[39m (written by \u{1b}[32mrig proj lock\u{1b}[39m) and installs the\n packages into the project library, \u{1b}[32m.rvenv/lib\u{1b}[39m. That library is created by\n \u{1b}[32mrig proj init\u{1b}[39m, together with the \u{1b}[32m.gitignore\u{1b}[39m files that\n keep it in version control, so \u{1b}[32mrig proj sync\u{1b}[39m fails if it is missing. Pass\n \u{1b}[32m--library\u{1b}[39m to install somewhere else instead.\n\n Use \u{1b}[32m--r-binary\u{1b}[39m to select which R to build against (default: \u{1b}[32mR\u{1b}[39m) and\n \u{1b}[32m--max-concurrent\u{1b}[39m to limit the number of simultaneous installations\n (default: 8).\n\n After a successful sync rig records the lock file it installed from in\n \u{1b}[32m.rvenv/lib/.synced\u{1b}[39m. The \u{1b}[32mrig\u{1b}[39m package in the project library compares the\n two, and warns in every R session while the project library does not match\n \u{1b}[32mrproj.lock\u{1b}[39m."; +const HELP_PROJ_SYNC: &str = "\u{1b}[1m\u{1b}[34mDescription:\u{1b}[39m\u{1b}[22m\n Install the resolved dependencies of an R project into a package library.\n\n rig looks for the project in the current directory and its parents, reads its\n \u{1b}[32mrproj.lock\u{1b}[39m (written by \u{1b}[32mrig proj lock\u{1b}[39m) and installs the\n packages into the project library, \u{1b}[32m.rvenv/lib\u{1b}[39m. If the project has no\n \u{1b}[32mrproj.lock\u{1b}[39m yet, rig runs \u{1b}[32mrig proj lock\u{1b}[39m with its default\n options first, to create one. That library is created by\n \u{1b}[32mrig proj init\u{1b}[39m, together with the \u{1b}[32m.gitignore\u{1b}[39m files that\n keep it in version control, so \u{1b}[32mrig proj sync\u{1b}[39m fails if it is missing. Pass\n \u{1b}[32m--library\u{1b}[39m to install somewhere else instead.\n\n Use \u{1b}[32m--r-binary\u{1b}[39m to select which R to build against (default: \u{1b}[32mR\u{1b}[39m) and\n \u{1b}[32m--max-concurrent\u{1b}[39m to limit the number of simultaneous installations\n (default: 8).\n\n After a successful sync rig records the lock file it installed from in\n \u{1b}[32m.rvenv/lib/.synced\u{1b}[39m. The \u{1b}[32mrig\u{1b}[39m package in the project library compares the\n two, and warns in every R session while the project library does not match\n \u{1b}[32mrproj.lock\u{1b}[39m."; const ABOUT_PROJ_TREE: &str = "Dependency tree of a project"; const HELP_PROJ_TREE: &str = "\u{1b}[1m\u{1b}[34mDescription:\u{1b}[39m\u{1b}[22m\n Show everything an R project needs, directly or indirectly, as a tree: the\n same closure \u{1b}[32mrig proj deps --recursive\u{1b}[39m lists in a flat\n table, laid out by the shape of the dependency graph.\n\n myproject 0.1.0 — 3 direct, 24 total\n ├── R (>= 4.1) [D]\n ├── cli 3.6.4\n │ ├── R (>= 3.4) [D]\n │ └── utils\n └── dplyr 1.1.4 (>= 1.1.0)\n ├── cli 3.6.4 (>= 3.4.0) (*)\n └── vctrs 0.6.5 (>= 0.6.4)\n └── cpp11 0.5.2 [L]\n [Suggests]\n └── testthat 3.2.3 (>= 3.1.5)\n\n The first line names the project and its version, how many dependencies it\n declares directly and how many distinct packages there are in the whole tree.\n Each line below names a package, the version currently in the repositories,\n and the version requirement it is needed with, if it has one.\n\n rig reads the project manifest, \u{1b}[32mrproj.toml\u{1b}[39m, in the current directory. Unlike\n \u{1b}[32mrig proj deps\u{1b}[39m, the tree needs the package metadata of the\n repositories, which rig downloads if it does not have it yet. It does not need\n R. \u{1b}[32m--json\u{1b}[39m gives machine readable output, as one nested object.\n\n\u{1b}[1m\u{1b}[34mReading the tree:\u{1b}[39m\u{1b}[22m\n A package that several others need is expanded only once, under its first\n occurrence; later occurrences are marked \u{1b}[32m(*)\u{1b}[39m, meaning \"its dependencies are\n above\". \u{1b}[32m--dev\u{1b}[39m adds the project's development dependencies, in their own\n \u{1b}[32m[Suggests]\u{1b}[39m and \u{1b}[32m[Enhances]\u{1b}[39m sections; \u{1b}[32m--no-base\u{1b}[39m leaves out R and the base\n packages. Among the hard dependencies, \u{1b}[32mImports\u{1b}[39m is not marked, \u{1b}[32m[D]\u{1b}[39m is a\n \u{1b}[32mDepends\u{1b}[39m, \u{1b}[32m[L]\u{1b}[39m a \u{1b}[32mLinkingTo\u{1b}[39m, \u{1b}[32m[DL]\u{1b}[39m both.\n\n \u{1b}[32m--why \u{1b}[39m (alias \u{1b}[32m--explain\u{1b}[39m) inverts the tree, so that the named\n package is the root and the tree grows towards the packages that need it, down\n to the project itself. Each line then says how \u{1b}[3mthat\u{1b}[23m package needs the one\n above it, hence \u{1b}[32mneeds\u{1b}[39m.\n\n \u{1b}[32mrig pkg tree\u{1b}[39m, which shows the same tree for a package\n in the repositories, describes all of this in full.\n\n rig follows the dependencies of the \u{1b}[3mlatest\u{1b}[23m version of every package in the\n tree, so a version requirement that would force an older version, with\n different dependencies, is not taken into account. Use\n \u{1b}[32mrig proj lock\u{1b}[39m for a resolution that is consistent across\n versions."; const ABOUT_PROJ: &str = "Manage R projects (experimental)"; diff --git a/src/help/proj-sync.md b/src/help/proj-sync.md index 0dfa4af9..477a60d6 100644 --- a/src/help/proj-sync.md +++ b/src/help/proj-sync.md @@ -6,7 +6,9 @@ Install the resolved dependencies of an R project into a package library. rig looks for the project in the current directory and its parents, reads its `rproj.lock` (written by [`rig proj lock`](#rig-proj-lock)) and installs the -packages into the project library, `.rvenv/lib`. That library is created by +packages into the project library, `.rvenv/lib`. If the project has no +`rproj.lock` yet, rig runs [`rig proj lock`](#rig-proj-lock) with its default +options first, to create one. That library is created by [`rig proj init`](#rig-proj-init), together with the `.gitignore` files that keep it in version control, so `rig proj sync` fails if it is missing. Pass `--library` to install somewhere else instead. diff --git a/src/proj.rs b/src/proj.rs index 4a1dff1e..926419b5 100644 --- a/src/proj.rs +++ b/src/proj.rs @@ -37,6 +37,7 @@ use crate::resolve::resolve_versions; use crate::rproj::{Rproj, RprojLock, RprojLockTarget, RPROJ_LOCK_VERSION, RPROJ_MANIFEST_FILE}; use crate::rvenv::{ existing_targets, find_project_root, project_library, rvenv_init, write_sync_stamp, + RPROJ_LOCK_FILE, }; use crate::solver::*; use crate::utils::create_parent_dir_if_needed; @@ -262,11 +263,13 @@ fn proj_read_deps(input: &str, dev: bool) -> Result> { } /// Read the project's `rproj.toml` manifest and return its name, version and -/// dependencies, with the soft dependencies dropped unless `dev`. +/// dependencies, with the soft dependencies dropped unless `dev`. The manifest +/// is read from `root`, the project directory. fn proj_read_manifest_deps( + root: &Path, dev: bool, ) -> Result<(String, RPackageVersion, PackageDependencies), Box> { - let path = Path::new(RPROJ_MANIFEST_FILE); + let path = root.join(RPROJ_MANIFEST_FILE); if !path.exists() { OUTPUT.error(&format!( "{} not found, run `rig proj init` first", @@ -300,7 +303,7 @@ fn sc_proj_deps( ) -> Result<(), Box> { let dev = args.get_flag("dev"); let json = args.get_flag("json") || projargs.get_flag("json") || mainargs.get_flag("json"); - let (name, version, pkg_deps) = proj_read_manifest_deps(dev)?; + let (name, version, pkg_deps) = proj_read_manifest_deps(Path::new("."), dev)?; if args.get_flag("recursive") { return proj_deps_recursive(&name, &version, &pkg_deps, json); @@ -430,7 +433,7 @@ fn sc_proj_tree( let no_base = args.get_flag("no-base"); let why = args.get_one::("why").map(|s| s.as_str()); let json = args.get_flag("json") || projargs.get_flag("json") || mainargs.get_flag("json"); - let (name, version, pkg_deps) = proj_read_manifest_deps(dev)?; + let (name, version, pkg_deps) = proj_read_manifest_deps(Path::new("."), dev)?; proj_tree( &name, @@ -578,29 +581,52 @@ fn solution_to_sorted_vec( vec } +/// Everything `rig proj lock` takes from the command line. `rig proj sync` +/// builds the default set of these when it has to create the lockfile itself. +#[derive(Default)] +struct ProjLockOptions { + r_version: Option, + platform: Option, + prefer_binary: Option, + dev: bool, + renv: bool, +} + fn sc_proj_lock( args: &ArgMatches, _libargs: &ArgMatches, _mainargs: &ArgMatches, ) -> Result<(), Box> { - let rver = if args.contains_id("r-version") { - args.get_one::("r-version").unwrap().to_string() - } else { - match get_default_r_version()? { + let opts = ProjLockOptions { + r_version: args.get_one::("r-version").cloned(), + platform: args.get_one::("platform").cloned(), + prefer_binary: args.get_one::("prefer-binary").copied(), + dev: args.get_flag("dev"), + renv: args.get_flag("renv"), + }; + proj_lock(Path::new("."), &opts) +} + +/// Solve the dependencies of the project in `root` and write `rproj.lock` +/// (and `renv.lock` with `--renv`) into it. +fn proj_lock(root: &Path, opts: &ProjLockOptions) -> Result<(), Box> { + let rver = match &opts.r_version { + Some(rv) => rv.to_string(), + None => match get_default_r_version()? { Some(rv) => rv, None => { OUTPUT.error("Cannot determine R version, please specify it with --r-version."); error!("Cannot determine R version, please specify it with --r-version."); bail!("Cannot determine R version, please specify it with --r-version.") } - } + }, }; // Do this first, to report local errors early - let dev = args.get_flag("dev"); - let (_name, _version, mut pkg_deps) = proj_read_manifest_deps(dev)?; + let dev = opts.dev; + let (_name, _version, mut pkg_deps) = proj_read_manifest_deps(root, dev)?; - if args.get_flag("renv") { + if opts.renv { pkg_deps.dependencies.push(DepVersionSpec { name: "renv".to_string(), constraints: vec![], @@ -608,9 +634,9 @@ fn sc_proj_lock( }); }; - let target = proj_binary_target(args.get_one::("platform"), &rver)?; + let target = proj_binary_target(opts.platform.as_ref(), &rver)?; - let prefer_binary = args.get_one::("prefer-binary").copied(); + let prefer_binary = opts.prefer_binary; if prefer_binary.is_some() && target.is_none() { OUTPUT.warn("There are no binary packages to prefer, ignoring --prefer-binary"); info!("Ignoring --prefer-binary: solving for source packages only"); @@ -625,9 +651,9 @@ fn sc_proj_lock( OUTPUT.success("Solved dependencies"); info!("Solved dependencies"); - if args.get_flag("renv") { + if opts.renv { let renv = REnvLockfile::from_solution(®istry, &solution); - fs::write("renv.lock", serde_json::to_string_pretty(&renv)?)?; + fs::write(root.join("renv.lock"), serde_json::to_string_pretty(&renv)?)?; OUTPUT.success("Written renv lockfile to renv.lock"); info!("Written renv lockfile to renv.lock"); } @@ -645,7 +671,10 @@ fn sc_proj_lock( packages: lockfile.packages.clone(), }], }; - fs::write("rproj.lock", toml::to_string_pretty(&rproj_lock)?)?; + fs::write( + root.join(RPROJ_LOCK_FILE), + toml::to_string_pretty(&rproj_lock)?, + )?; OUTPUT.success("Written project lockfile to rproj.lock"); info!("Written project lockfile to rproj.lock"); @@ -695,7 +724,19 @@ fn sc_proj_sync( // always the first (and, today, only) entry `rig proj lock` wrote; the // "pick the entry matching this machine, hard error if none match" logic // for a real multi-target `rproj.lock` is follow-up work. - let lock_path = root.join("rproj.lock"); + // No lockfile yet, so create one first, with the default options, instead + // of erroring out. `rig proj lock` reads the project's `rproj.toml`, and + // errors out itself if there is none. + let lock_path = root.join(RPROJ_LOCK_FILE); + if !lock_path.exists() { + OUTPUT.info(&format!( + "No {}, running `rig proj lock` first", + RPROJ_LOCK_FILE + )); + info!("No {}, running `rig proj lock` first", RPROJ_LOCK_FILE); + proj_lock(&root, &ProjLockOptions::default())?; + } + let lock_content = fs::read_to_string(&lock_path)?; let lock: RprojLock = toml::from_str(&lock_content)?; let target = lock.targets.first().ok_or("rproj.lock has no targets")?; From 81a4f540c97e8c5033da476a0bf0bf5370cd3ceb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Cs=C3=A1rdi?= Date: Wed, 2 Sep 2026 21:13:34 +0200 Subject: [PATCH 11/19] rig proj lock has --no-dev, remove --dev Dev dependencies are included in the lockfile by default. --- src/args.rs | 6 +++--- src/help-generated.in | 2 +- src/help/proj-lock.md | 6 +++--- src/proj.rs | 16 ++++++++++++++-- 4 files changed, 21 insertions(+), 9 deletions(-) diff --git a/src/args.rs b/src/args.rs index 96216dfb..41396abe 100644 --- a/src/args.rs +++ b/src/args.rs @@ -1470,9 +1470,9 @@ pub fn rig_app() -> Command { .required(false), ) .arg( - Arg::new("dev") - .help("Include dev (development) dependencies") - .long("dev") + Arg::new("no-dev") + .help("Leave out dev (development) dependencies") + .long("no-dev") .num_args(0) .required(false), ), diff --git a/src/help-generated.in b/src/help-generated.in index 8828777e..3a4c2251 100644 --- a/src/help-generated.in +++ b/src/help-generated.in @@ -69,7 +69,7 @@ const HELP_PROJ_IMPORT: &str = "\u{1b}[1m\u{1b}[34mDescription:\u{1b}[39m\u{1b}[ const ABOUT_PROJ_INIT: &str = "Create a new R project"; const HELP_PROJ_INIT: &str = "\u{1b}[1m\u{1b}[34mDescription:\u{1b}[39m\u{1b}[22m\n Set up an R project in the current directory: the \u{1b}[32mrproj.toml\u{1b}[39m manifest, plus\n the part of the project's virtual environment (\u{1b}[32m.rvenv\u{1b}[39m) that belongs in\n version control.\n\n \u{1b}[32mrproj.toml\u{1b}[39m is rig's modern project and package file. It describes the\n project's metadata and its R and package dependencies, and can do everything\n a \u{1b}[32mDESCRIPTION\u{1b}[39m file can, plus dependency groups, optional dependencies,\n workspaces and declared scripts. \u{1b}[32mrig proj init\u{1b}[39m writes a minimal skeleton — a\n \u{1b}[32m[project]\u{1b}[39m table with the name (taken from the current directory) and\n version, and a \u{1b}[32m[dependencies]\u{1b}[39m table with a single R requirement — that you\n then fill in. Use \u{1b}[32mrig proj import\u{1b}[39m instead to take the\n dependencies from an existing \u{1b}[32mDESCRIPTION\u{1b}[39m.\n\n\u{1b}[1m\u{1b}[34mFiles:\u{1b}[39m\u{1b}[22m\n \u{1b}[32mrig proj init\u{1b}[39m creates these, and nothing else. All of them are meant to be\n committed, so that a fresh clone of the project works right away:\n\n - \u{1b}[32mrproj.toml\u{1b}[39m — the manifest. Its R requirement is \u{1b}[32m>= .\u{1b}[39m of\n the project's R version.\n - \u{1b}[32m.Renviron\u{1b}[39m — points R at the project library, \u{1b}[32m.rvenv/lib\u{1b}[39m. This is what\n makes the project work in an editor (RStudio, Positron, VS Code), which\n starts R itself.\n - \u{1b}[32m.gitignore\u{1b}[39m — a marked \u{1b}[32m# rig rvenv start\u{1b}[39m / \u{1b}[32m# rig rvenv end\u{1b}[39m block that\n ignores everything in \u{1b}[32m.rvenv\u{1b}[39m except the library directory. An existing\n \u{1b}[32m.gitignore\u{1b}[39m is \u{1b}[3mnot\u{1b}[23m replaced: rig only adds or refreshes its own block,\n and leaves the rest of the file alone.\n - \u{1b}[32m.rvenv/lib/.gitignore\u{1b}[39m — keeps the library directory itself, and the \u{1b}[32mrig\u{1b}[39m\n package in it, in version control, and ignores the installed dependencies.\n The directory has to exist in every checkout, because plain R does not\n create a missing library directory.\n - \u{1b}[32m.rvenv/lib/rig\u{1b}[39m — a small, pre-built R package that rig manages. It is not\n a dependency of your project. \u{1b}[32m.Renviron\u{1b}[39m loads it in every R session\n started in the project, where it turns the relative library path into an\n absolute one — so that R processes started from a subdirectory still use the\n project library — and warns while the project is out of sync with\n \u{1b}[32mrproj.lock\u{1b}[39m.\n\n The rest of \u{1b}[32m.rvenv\u{1b}[39m is machine-specific and is created by\n \u{1b}[32mrig proj sync\u{1b}[39m, which installs the project's dependencies\n into \u{1b}[32m.rvenv/lib\u{1b}[39m.\n\n Note that \u{1b}[32mR --vanilla\u{1b}[39m ignores \u{1b}[32m.Renviron\u{1b}[39m, and so does not use the project\n library.\n\n\u{1b}[1m\u{1b}[34mOptions:\u{1b}[39m\u{1b}[22m\n \u{1b}[32m--r-version\u{1b}[39m sets the R version the project is for. It does not have to be\n installed. Defaults to the current default R version, or the current R\n release if there is no default.\n\n rig refuses to overwrite any of the files above; pass \u{1b}[32m--force\u{1b}[39m to replace\n them. \u{1b}[32m--force\u{1b}[39m still does not rewrite the whole \u{1b}[32m.gitignore\u{1b}[39m, only rig's\n block in it."; const ABOUT_PROJ_LOCK: &str = "Resolve project dependencies and write rproj.lock"; -const HELP_PROJ_LOCK: &str = "\u{1b}[1m\u{1b}[34mDescription:\u{1b}[39m\u{1b}[22m\n Resolve the dependencies of an R project to a concrete set of package\n versions, and write the result to \u{1b}[32mrproj.lock\u{1b}[39m.\n\n rig reads the project manifest, \u{1b}[32mrproj.toml\u{1b}[39m, in the current directory, and\n uses its built-in solver to find a compatible set of package versions from\n the configured repositories, without running R.\n\n Use \u{1b}[32m--r-version\u{1b}[39m to solve for a specific R version, \u{1b}[32m--dev\u{1b}[39m to include\n development dependencies, and \u{1b}[32m--renv\u{1b}[39m to also write the result as an\n \u{1b}[32mrenv.lock\u{1b}[39m file.\n\n \u{1b}[32mrproj.lock\u{1b}[39m currently records a single \u{1b}[32m(R version, platform)\u{1b}[39m target;\n \u{1b}[32mrig proj sync\u{1b}[39m installs that target. Solving for several\n targets in one lockfile is planned but not implemented yet.\n\n\u{1b}[1m\u{1b}[34mSource and binary packages:\u{1b}[39m\u{1b}[22m\n The solver considers binary packages as well as source packages, and\n prefers a binary build when one is available for the same version. Which\n artifact each package is installed from is part of what the solve decides,\n because a binary is only usable together with the exact versions of its\n \u{1b}[32mLinkingTo\u{1b}[39m dependencies that it was compiled against. If those versions\n conflict with the rest of the project, rig picks another build of that\n package, or falls back to its source tarball.\n\n By default a binary build never changes \u{1b}[3mwhich version\u{1b}[23m rig picks: the\n newest suitable version wins, and a binary of it is used if there is one.\n Pass \u{1b}[32m--prefer-binary\u{1b}[39m to let an older version win instead, when the newest\n one has no binary but an older one does — typically because a version was\n released so recently that it has not been built yet. Only the three newest\n versions of a package are considered; \u{1b}[32m--prefer-binary=5\u{1b}[39m considers five.\n Versions held back this way are marked in the output.\n\n Trading a version away for a binary is not free: the binary pins its\n \u{1b}[32mLinkingTo\u{1b}[39m dependencies to the versions it was compiled against, and those\n dependencies then prefer their own binaries in turn, so a whole project can\n end up on older versions.\n\n By default rig solves for the machine it runs on. Use \u{1b}[32m--platform\u{1b}[39m to solve\n for a different one, e.g. to write a lockfile on macOS for a Linux\n deployment:\n\n rig proj lock --platform ubuntu-24.04\n\n \u{1b}[32m--platform source\u{1b}[39m solves for source packages only, and does not download\n any binary package metadata. rig also falls back to source packages when\n there are no binaries for a platform at all. There is then nothing for\n \u{1b}[32m--prefer-binary\u{1b}[39m to prefer, and rig ignores it.\n\n rig keeps the repository metadata and the binary package indices it solves\n from in its cache, and refreshes them once a day. \u{1b}[32m--no-cache\u{1b}[39m downloads\n them again instead, and writes nothing to the cache, which is the way to\n solve against a package that was published minutes ago. It is a good deal\n slower, because the metadata it re-downloads is large. See\n \u{1b}[32mrig config\u{1b}[39m.\n\n The \u{1b}[32mrproj.lock\u{1b}[39m file records, for every package, whether it is a source or a\n binary package and the URL it is downloaded from. It also records where the\n file is cached, which is per \u{1b}[3mbuild\u{1b}[23m rather than per version: a repository\n can offer several binaries of one version for one platform and R version,\n and they are cached side by side."; +const HELP_PROJ_LOCK: &str = "\u{1b}[1m\u{1b}[34mDescription:\u{1b}[39m\u{1b}[22m\n Resolve the dependencies of an R project to a concrete set of package\n versions, and write the result to \u{1b}[32mrproj.lock\u{1b}[39m.\n\n rig reads the project manifest, \u{1b}[32mrproj.toml\u{1b}[39m, in the current directory, and\n uses its built-in solver to find a compatible set of package versions from\n the configured repositories, without running R.\n\n Development dependencies are included by default. Use \u{1b}[32m--r-version\u{1b}[39m to solve\n for a specific R version, \u{1b}[32m--no-dev\u{1b}[39m to leave out development dependencies,\n and \u{1b}[32m--renv\u{1b}[39m to also write the result as an \u{1b}[32mrenv.lock\u{1b}[39m file.\n\n \u{1b}[32mrproj.lock\u{1b}[39m currently records a single \u{1b}[32m(R version, platform)\u{1b}[39m target;\n \u{1b}[32mrig proj sync\u{1b}[39m installs that target. Solving for several\n targets in one lockfile is planned but not implemented yet.\n\n\u{1b}[1m\u{1b}[34mSource and binary packages:\u{1b}[39m\u{1b}[22m\n The solver considers binary packages as well as source packages, and\n prefers a binary build when one is available for the same version. Which\n artifact each package is installed from is part of what the solve decides,\n because a binary is only usable together with the exact versions of its\n \u{1b}[32mLinkingTo\u{1b}[39m dependencies that it was compiled against. If those versions\n conflict with the rest of the project, rig picks another build of that\n package, or falls back to its source tarball.\n\n By default a binary build never changes \u{1b}[3mwhich version\u{1b}[23m rig picks: the\n newest suitable version wins, and a binary of it is used if there is one.\n Pass \u{1b}[32m--prefer-binary\u{1b}[39m to let an older version win instead, when the newest\n one has no binary but an older one does — typically because a version was\n released so recently that it has not been built yet. Only the three newest\n versions of a package are considered; \u{1b}[32m--prefer-binary=5\u{1b}[39m considers five.\n Versions held back this way are marked in the output.\n\n Trading a version away for a binary is not free: the binary pins its\n \u{1b}[32mLinkingTo\u{1b}[39m dependencies to the versions it was compiled against, and those\n dependencies then prefer their own binaries in turn, so a whole project can\n end up on older versions.\n\n By default rig solves for the machine it runs on. Use \u{1b}[32m--platform\u{1b}[39m to solve\n for a different one, e.g. to write a lockfile on macOS for a Linux\n deployment:\n\n rig proj lock --platform ubuntu-24.04\n\n \u{1b}[32m--platform source\u{1b}[39m solves for source packages only, and does not download\n any binary package metadata. rig also falls back to source packages when\n there are no binaries for a platform at all. There is then nothing for\n \u{1b}[32m--prefer-binary\u{1b}[39m to prefer, and rig ignores it.\n\n rig keeps the repository metadata and the binary package indices it solves\n from in its cache, and refreshes them once a day. \u{1b}[32m--no-cache\u{1b}[39m downloads\n them again instead, and writes nothing to the cache, which is the way to\n solve against a package that was published minutes ago. It is a good deal\n slower, because the metadata it re-downloads is large. See\n \u{1b}[32mrig config\u{1b}[39m.\n\n The \u{1b}[32mrproj.lock\u{1b}[39m file records, for every package, whether it is a source or a\n binary package and the URL it is downloaded from. It also records where the\n file is cached, which is per \u{1b}[3mbuild\u{1b}[23m rather than per version: a repository\n can offer several binaries of one version for one platform and R version,\n and they are cached side by side."; const ABOUT_PROJ_SYNC: &str = "Install the dependencies rproj.lock resolved"; const HELP_PROJ_SYNC: &str = "\u{1b}[1m\u{1b}[34mDescription:\u{1b}[39m\u{1b}[22m\n Install the resolved dependencies of an R project into a package library.\n\n rig looks for the project in the current directory and its parents, reads its\n \u{1b}[32mrproj.lock\u{1b}[39m (written by \u{1b}[32mrig proj lock\u{1b}[39m) and installs the\n packages into the project library, \u{1b}[32m.rvenv/lib\u{1b}[39m. If the project has no\n \u{1b}[32mrproj.lock\u{1b}[39m yet, rig runs \u{1b}[32mrig proj lock\u{1b}[39m with its default\n options first, to create one. That library is created by\n \u{1b}[32mrig proj init\u{1b}[39m, together with the \u{1b}[32m.gitignore\u{1b}[39m files that\n keep it in version control, so \u{1b}[32mrig proj sync\u{1b}[39m fails if it is missing. Pass\n \u{1b}[32m--library\u{1b}[39m to install somewhere else instead.\n\n Use \u{1b}[32m--r-binary\u{1b}[39m to select which R to build against (default: \u{1b}[32mR\u{1b}[39m) and\n \u{1b}[32m--max-concurrent\u{1b}[39m to limit the number of simultaneous installations\n (default: 8).\n\n After a successful sync rig records the lock file it installed from in\n \u{1b}[32m.rvenv/lib/.synced\u{1b}[39m. The \u{1b}[32mrig\u{1b}[39m package in the project library compares the\n two, and warns in every R session while the project library does not match\n \u{1b}[32mrproj.lock\u{1b}[39m."; const ABOUT_PROJ_TREE: &str = "Dependency tree of a project"; diff --git a/src/help/proj-lock.md b/src/help/proj-lock.md index cd657316..b5c48c2c 100644 --- a/src/help/proj-lock.md +++ b/src/help/proj-lock.md @@ -9,9 +9,9 @@ rig reads the project manifest, `rproj.toml`, in the current directory, and uses its built-in solver to find a compatible set of package versions from the configured repositories, without running R. -Use `--r-version` to solve for a specific R version, `--dev` to include -development dependencies, and `--renv` to also write the result as an -`renv.lock` file. +Development dependencies are included by default. Use `--r-version` to solve +for a specific R version, `--no-dev` to leave out development dependencies, +and `--renv` to also write the result as an `renv.lock` file. `rproj.lock` currently records a single `(R version, platform)` target; [`rig proj sync`](#rig-proj-sync) installs that target. Solving for several diff --git a/src/proj.rs b/src/proj.rs index 926419b5..e267deb9 100644 --- a/src/proj.rs +++ b/src/proj.rs @@ -583,7 +583,6 @@ fn solution_to_sorted_vec( /// Everything `rig proj lock` takes from the command line. `rig proj sync` /// builds the default set of these when it has to create the lockfile itself. -#[derive(Default)] struct ProjLockOptions { r_version: Option, platform: Option, @@ -592,6 +591,19 @@ struct ProjLockOptions { renv: bool, } +impl Default for ProjLockOptions { + fn default() -> Self { + ProjLockOptions { + r_version: None, + platform: None, + prefer_binary: None, + // dev dependencies are included unless --no-dev is given + dev: true, + renv: false, + } + } +} + fn sc_proj_lock( args: &ArgMatches, _libargs: &ArgMatches, @@ -601,7 +613,7 @@ fn sc_proj_lock( r_version: args.get_one::("r-version").cloned(), platform: args.get_one::("platform").cloned(), prefer_binary: args.get_one::("prefer-binary").copied(), - dev: args.get_flag("dev"), + dev: !args.get_flag("no-dev"), renv: args.get_flag("renv"), }; proj_lock(Path::new("."), &opts) From 4a8005c2a3fe1464dc99d47c10df3992f7d942ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Cs=C3=A1rdi?= Date: Wed, 2 Sep 2026 21:22:33 +0200 Subject: [PATCH 12/19] Add --no-dev option to rig proj sync --- src/args.rs | 7 +++ src/help-generated.in | 2 +- src/help/proj-sync.md | 3 ++ src/proj.rs | 113 ++++++++++++++++++++++++++++++++++++++++-- 4 files changed, 121 insertions(+), 4 deletions(-) diff --git a/src/args.rs b/src/args.rs index 41396abe..a4194081 100644 --- a/src/args.rs +++ b/src/args.rs @@ -1507,6 +1507,13 @@ pub fn rig_app() -> Command { .num_args(1) .value_parser(clap::value_parser!(usize)) .required(false), + ) + .arg( + Arg::new("no-dev") + .help("Do not install dev (development) dependencies") + .long("no-dev") + .num_args(0) + .required(false), ), ); rig = rig.subcommand(cmd_proj); diff --git a/src/help-generated.in b/src/help-generated.in index 3a4c2251..601a7005 100644 --- a/src/help-generated.in +++ b/src/help-generated.in @@ -71,7 +71,7 @@ const HELP_PROJ_INIT: &str = "\u{1b}[1m\u{1b}[34mDescription:\u{1b}[39m\u{1b}[22 const ABOUT_PROJ_LOCK: &str = "Resolve project dependencies and write rproj.lock"; const HELP_PROJ_LOCK: &str = "\u{1b}[1m\u{1b}[34mDescription:\u{1b}[39m\u{1b}[22m\n Resolve the dependencies of an R project to a concrete set of package\n versions, and write the result to \u{1b}[32mrproj.lock\u{1b}[39m.\n\n rig reads the project manifest, \u{1b}[32mrproj.toml\u{1b}[39m, in the current directory, and\n uses its built-in solver to find a compatible set of package versions from\n the configured repositories, without running R.\n\n Development dependencies are included by default. Use \u{1b}[32m--r-version\u{1b}[39m to solve\n for a specific R version, \u{1b}[32m--no-dev\u{1b}[39m to leave out development dependencies,\n and \u{1b}[32m--renv\u{1b}[39m to also write the result as an \u{1b}[32mrenv.lock\u{1b}[39m file.\n\n \u{1b}[32mrproj.lock\u{1b}[39m currently records a single \u{1b}[32m(R version, platform)\u{1b}[39m target;\n \u{1b}[32mrig proj sync\u{1b}[39m installs that target. Solving for several\n targets in one lockfile is planned but not implemented yet.\n\n\u{1b}[1m\u{1b}[34mSource and binary packages:\u{1b}[39m\u{1b}[22m\n The solver considers binary packages as well as source packages, and\n prefers a binary build when one is available for the same version. Which\n artifact each package is installed from is part of what the solve decides,\n because a binary is only usable together with the exact versions of its\n \u{1b}[32mLinkingTo\u{1b}[39m dependencies that it was compiled against. If those versions\n conflict with the rest of the project, rig picks another build of that\n package, or falls back to its source tarball.\n\n By default a binary build never changes \u{1b}[3mwhich version\u{1b}[23m rig picks: the\n newest suitable version wins, and a binary of it is used if there is one.\n Pass \u{1b}[32m--prefer-binary\u{1b}[39m to let an older version win instead, when the newest\n one has no binary but an older one does — typically because a version was\n released so recently that it has not been built yet. Only the three newest\n versions of a package are considered; \u{1b}[32m--prefer-binary=5\u{1b}[39m considers five.\n Versions held back this way are marked in the output.\n\n Trading a version away for a binary is not free: the binary pins its\n \u{1b}[32mLinkingTo\u{1b}[39m dependencies to the versions it was compiled against, and those\n dependencies then prefer their own binaries in turn, so a whole project can\n end up on older versions.\n\n By default rig solves for the machine it runs on. Use \u{1b}[32m--platform\u{1b}[39m to solve\n for a different one, e.g. to write a lockfile on macOS for a Linux\n deployment:\n\n rig proj lock --platform ubuntu-24.04\n\n \u{1b}[32m--platform source\u{1b}[39m solves for source packages only, and does not download\n any binary package metadata. rig also falls back to source packages when\n there are no binaries for a platform at all. There is then nothing for\n \u{1b}[32m--prefer-binary\u{1b}[39m to prefer, and rig ignores it.\n\n rig keeps the repository metadata and the binary package indices it solves\n from in its cache, and refreshes them once a day. \u{1b}[32m--no-cache\u{1b}[39m downloads\n them again instead, and writes nothing to the cache, which is the way to\n solve against a package that was published minutes ago. It is a good deal\n slower, because the metadata it re-downloads is large. See\n \u{1b}[32mrig config\u{1b}[39m.\n\n The \u{1b}[32mrproj.lock\u{1b}[39m file records, for every package, whether it is a source or a\n binary package and the URL it is downloaded from. It also records where the\n file is cached, which is per \u{1b}[3mbuild\u{1b}[23m rather than per version: a repository\n can offer several binaries of one version for one platform and R version,\n and they are cached side by side."; const ABOUT_PROJ_SYNC: &str = "Install the dependencies rproj.lock resolved"; -const HELP_PROJ_SYNC: &str = "\u{1b}[1m\u{1b}[34mDescription:\u{1b}[39m\u{1b}[22m\n Install the resolved dependencies of an R project into a package library.\n\n rig looks for the project in the current directory and its parents, reads its\n \u{1b}[32mrproj.lock\u{1b}[39m (written by \u{1b}[32mrig proj lock\u{1b}[39m) and installs the\n packages into the project library, \u{1b}[32m.rvenv/lib\u{1b}[39m. If the project has no\n \u{1b}[32mrproj.lock\u{1b}[39m yet, rig runs \u{1b}[32mrig proj lock\u{1b}[39m with its default\n options first, to create one. That library is created by\n \u{1b}[32mrig proj init\u{1b}[39m, together with the \u{1b}[32m.gitignore\u{1b}[39m files that\n keep it in version control, so \u{1b}[32mrig proj sync\u{1b}[39m fails if it is missing. Pass\n \u{1b}[32m--library\u{1b}[39m to install somewhere else instead.\n\n Use \u{1b}[32m--r-binary\u{1b}[39m to select which R to build against (default: \u{1b}[32mR\u{1b}[39m) and\n \u{1b}[32m--max-concurrent\u{1b}[39m to limit the number of simultaneous installations\n (default: 8).\n\n After a successful sync rig records the lock file it installed from in\n \u{1b}[32m.rvenv/lib/.synced\u{1b}[39m. The \u{1b}[32mrig\u{1b}[39m package in the project library compares the\n two, and warns in every R session while the project library does not match\n \u{1b}[32mrproj.lock\u{1b}[39m."; +const HELP_PROJ_SYNC: &str = "\u{1b}[1m\u{1b}[34mDescription:\u{1b}[39m\u{1b}[22m\n Install the resolved dependencies of an R project into a package library.\n\n rig looks for the project in the current directory and its parents, reads its\n \u{1b}[32mrproj.lock\u{1b}[39m (written by \u{1b}[32mrig proj lock\u{1b}[39m) and installs the\n packages into the project library, \u{1b}[32m.rvenv/lib\u{1b}[39m. If the project has no\n \u{1b}[32mrproj.lock\u{1b}[39m yet, rig runs \u{1b}[32mrig proj lock\u{1b}[39m with its default\n options first, to create one. That library is created by\n \u{1b}[32mrig proj init\u{1b}[39m, together with the \u{1b}[32m.gitignore\u{1b}[39m files that\n keep it in version control, so \u{1b}[32mrig proj sync\u{1b}[39m fails if it is missing. Pass\n \u{1b}[32m--library\u{1b}[39m to install somewhere else instead.\n\n Development dependencies are installed by default. \u{1b}[32m--no-dev\u{1b}[39m leaves them\n out.\n\n Use \u{1b}[32m--r-binary\u{1b}[39m to select which R to build against (default: \u{1b}[32mR\u{1b}[39m) and\n \u{1b}[32m--max-concurrent\u{1b}[39m to limit the number of simultaneous installations\n (default: 8).\n\n After a successful sync rig records the lock file it installed from in\n \u{1b}[32m.rvenv/lib/.synced\u{1b}[39m. The \u{1b}[32mrig\u{1b}[39m package in the project library compares the\n two, and warns in every R session while the project library does not match\n \u{1b}[32mrproj.lock\u{1b}[39m."; const ABOUT_PROJ_TREE: &str = "Dependency tree of a project"; const HELP_PROJ_TREE: &str = "\u{1b}[1m\u{1b}[34mDescription:\u{1b}[39m\u{1b}[22m\n Show everything an R project needs, directly or indirectly, as a tree: the\n same closure \u{1b}[32mrig proj deps --recursive\u{1b}[39m lists in a flat\n table, laid out by the shape of the dependency graph.\n\n myproject 0.1.0 — 3 direct, 24 total\n ├── R (>= 4.1) [D]\n ├── cli 3.6.4\n │ ├── R (>= 3.4) [D]\n │ └── utils\n └── dplyr 1.1.4 (>= 1.1.0)\n ├── cli 3.6.4 (>= 3.4.0) (*)\n └── vctrs 0.6.5 (>= 0.6.4)\n └── cpp11 0.5.2 [L]\n [Suggests]\n └── testthat 3.2.3 (>= 3.1.5)\n\n The first line names the project and its version, how many dependencies it\n declares directly and how many distinct packages there are in the whole tree.\n Each line below names a package, the version currently in the repositories,\n and the version requirement it is needed with, if it has one.\n\n rig reads the project manifest, \u{1b}[32mrproj.toml\u{1b}[39m, in the current directory. Unlike\n \u{1b}[32mrig proj deps\u{1b}[39m, the tree needs the package metadata of the\n repositories, which rig downloads if it does not have it yet. It does not need\n R. \u{1b}[32m--json\u{1b}[39m gives machine readable output, as one nested object.\n\n\u{1b}[1m\u{1b}[34mReading the tree:\u{1b}[39m\u{1b}[22m\n A package that several others need is expanded only once, under its first\n occurrence; later occurrences are marked \u{1b}[32m(*)\u{1b}[39m, meaning \"its dependencies are\n above\". \u{1b}[32m--dev\u{1b}[39m adds the project's development dependencies, in their own\n \u{1b}[32m[Suggests]\u{1b}[39m and \u{1b}[32m[Enhances]\u{1b}[39m sections; \u{1b}[32m--no-base\u{1b}[39m leaves out R and the base\n packages. Among the hard dependencies, \u{1b}[32mImports\u{1b}[39m is not marked, \u{1b}[32m[D]\u{1b}[39m is a\n \u{1b}[32mDepends\u{1b}[39m, \u{1b}[32m[L]\u{1b}[39m a \u{1b}[32mLinkingTo\u{1b}[39m, \u{1b}[32m[DL]\u{1b}[39m both.\n\n \u{1b}[32m--why \u{1b}[39m (alias \u{1b}[32m--explain\u{1b}[39m) inverts the tree, so that the named\n package is the root and the tree grows towards the packages that need it, down\n to the project itself. Each line then says how \u{1b}[3mthat\u{1b}[23m package needs the one\n above it, hence \u{1b}[32mneeds\u{1b}[39m.\n\n \u{1b}[32mrig pkg tree\u{1b}[39m, which shows the same tree for a package\n in the repositories, describes all of this in full.\n\n rig follows the dependencies of the \u{1b}[3mlatest\u{1b}[23m version of every package in the\n tree, so a version requirement that would force an older version, with\n different dependencies, is not taken into account. Use\n \u{1b}[32mrig proj lock\u{1b}[39m for a resolution that is consistent across\n versions."; const ABOUT_PROJ: &str = "Manage R projects (experimental)"; diff --git a/src/help/proj-sync.md b/src/help/proj-sync.md index 477a60d6..8cdd7f36 100644 --- a/src/help/proj-sync.md +++ b/src/help/proj-sync.md @@ -13,6 +13,9 @@ options first, to create one. That library is created by keep it in version control, so `rig proj sync` fails if it is missing. Pass `--library` to install somewhere else instead. +Development dependencies are installed by default. `--no-dev` leaves them +out. + Use `--r-binary` to select which R to build against (default: `R`) and `--max-concurrent` to limit the number of simultaneous installations (default: 8). diff --git a/src/proj.rs b/src/proj.rs index e267deb9..de9f8122 100644 --- a/src/proj.rs +++ b/src/proj.rs @@ -722,6 +722,30 @@ fn proj_lock(root: &Path, opts: &ProjLockOptions) -> Result<(), Box> Ok(()) } +/// The lockfile packages that are needed without the dev dependencies. +fn nondev_packages( + root: &Path, + packages: &[PakLockfilePackage], +) -> Result, Box> { + let (_name, _version, deps) = proj_read_manifest_deps(root, false)?; + let by_name: HashMap<&str, &PakLockfilePackage> = + packages.iter().map(|p| (p.package.as_str(), p)).collect(); + + let mut keep: HashSet = HashSet::new(); + let mut todo: Vec = deps.dependencies.iter().map(|d| d.name.clone()).collect(); + while let Some(name) = todo.pop() { + if !keep.insert(name.clone()) { + continue; + } + // R and the base packages are dependencies in the manifest, but never + // lockfile entries, so they simply do not match anything here. + if let Some(pkg) = by_name.get(name.as_str()) { + todo.extend(pkg.dependencies.iter().cloned()); + } + } + Ok(keep) +} + fn sc_proj_sync( args: &ArgMatches, _libargs: &ArgMatches, @@ -753,6 +777,20 @@ fn sc_proj_sync( let lock: RprojLock = toml::from_str(&lock_content)?; let target = lock.targets.first().ok_or("rproj.lock has no targets")?; + let nondev; + let wanted: &[PakLockfilePackage] = if args.get_flag("no-dev") { + let keep = nondev_packages(&root, &target.packages)?; + nondev = target + .packages + .iter() + .filter(|p| keep.contains(&p.package)) + .cloned() + .collect::>(); + &nondev + } else { + &target.packages + }; + // Library path: --library, or the project library by default. The // project library is created by `rig proj init`, together with the // `.gitignore` files that keep it in version control, so do not create @@ -782,7 +820,7 @@ fn sc_proj_sync( } else { vec![] }; - let plan = plan_installs(&target.packages, &already_installed, false); + let plan = plan_installs(wanted, &already_installed, false); print_plan(&format!("({})", library_path.display()), &plan); let todo: Vec<&PakLockfilePackage> = plan .iter() @@ -852,13 +890,13 @@ fn sc_proj_sync( OUTPUT.status(&format!( "Installing {} of {} packages to {}", total_packages, - target.packages.len(), + wanted.len(), library_path.display() )); info!( "Installing {} of {} packages to {}", total_packages, - target.packages.len(), + wanted.len(), library_path.display() ); @@ -1002,3 +1040,72 @@ pub(crate) fn download_lockfile_packages( Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::rproj::{Dependency, Group}; + use std::collections::BTreeMap; + + /// One lockfile entry: its name and the packages it depends on. + fn locked(name: &str, deps: &[&str]) -> PakLockfilePackage { + PakLockfilePackage { + r#ref: name.to_string(), + package: name.to_string(), + version: "1.0.0".to_string(), + r#type: "standard".to_string(), + direct: false, + binary: true, + dependencies: deps.iter().map(|d| d.to_string()).collect(), + vignettes: false, + metadata: HashMap::new(), + sources: vec![], + target: format!("bin/{}_1.0.0.tgz", name), + platform: "testos".to_string(), + rversion: "4.5.1".to_string(), + directpkg: false, + license: "MIT".to_string(), + dep_types: vec![], + params: vec![], + install_args: String::new(), + sysreqs: String::new(), + } + } + + fn dep(version: &str) -> Dependency { + Dependency::Version(version.to_string()) + } + + #[test] + fn nondev_packages_keeps_the_non_dev_closure_only() { + let dir = tempfile::tempdir().unwrap(); + let mut manifest = Rproj::minimal("mypkg"); + manifest.dependencies.insert("cli".to_string(), dep("*")); + manifest.dependency_groups.insert( + "test".to_string(), + Group { + include_groups: vec![], + dependencies: BTreeMap::from([("testthat".to_string(), dep("*"))]), + }, + ); + fs::write( + dir.path().join(RPROJ_MANIFEST_FILE), + toml::to_string_pretty(&manifest).unwrap(), + ) + .unwrap(); + + let packages = vec![ + locked("cli", &["glue"]), + locked("glue", &[]), + locked("testthat", &["waldo", "glue"]), + locked("waldo", &[]), + ]; + + let keep = nondev_packages(dir.path(), &packages).unwrap(); + // `glue` is a dev dependency too, but a non-dev one pulls it in + assert!(keep.contains("cli")); + assert!(keep.contains("glue")); + assert!(!keep.contains("testthat")); + assert!(!keep.contains("waldo")); + } +} From df536e2422f6dccbc3f0e8836b9bb06d7ff527f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Cs=C3=A1rdi?= Date: Wed, 2 Sep 2026 21:34:57 +0200 Subject: [PATCH 13/19] Call ensure_rstudio_which_r_plist() from `rig rstudio` To make sure that the default version is set. --- src/macos.rs | 114 +++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 101 insertions(+), 13 deletions(-) diff --git a/src/macos.rs b/src/macos.rs index 7ea82b89..855c574f 100644 --- a/src/macos.rs +++ b/src/macos.rs @@ -1772,6 +1772,19 @@ pub fn sc_rstudio_( project: Option<&str>, arg: Option<&OsStr>, ) -> Result<(), Box> { + // In user mode RStudio only finds the rig R versions via the + // RSTUDIO_WHICH_R LaunchAgent, so make sure that it is installed and + // active. + if get_mode()? == crate::utils::Mode::User { + if let Err(e) = ensure_rstudio_which_r_plist() { + OUTPUT.warn(&format!( + "Could not register default R version in RStudio: {}", + e + )); + warn!("Could not activate RSTUDIO_WHICH_R LaunchAgent: {}", e); + } + } + let mut args = match project { // open -n -a RStudio None => osvec!["-n", "-a", "RStudio"], @@ -1911,10 +1924,6 @@ fn ensure_rstudio_which_r_plist() -> Result<(), Box> { let plist_path = rstudio_which_r_plist_path()?; - if Path::new(&plist_path).exists() { - return Ok(()); - } - let rbin = Path::new(&get_r_default_bindir()?).join("R"); let rbin_str = rbin.to_string_lossy(); @@ -1939,18 +1948,66 @@ fn ensure_rstudio_which_r_plist() -> Result<(), Box> { "# ); - let plist_dir = Path::new(&plist_path).parent().unwrap(); - std::fs::create_dir_all(plist_dir)?; - std::fs::write(&plist_path, plist)?; - info!("Installed LaunchAgent {}", plist_path); + // Write the plist if it is missing or out of date, e.g. because the + // default R version moved to another directory. + let uptodate = match std::fs::read_to_string(&plist_path) { + Ok(x) => x == plist, + Err(_) => false, + }; + let mut installed = false; + if !uptodate { + // If an old plist is loaded, unload it first, otherwise `launchctl + // load` below is a no-op. + if rstudio_which_r_plist_loaded() { + let out = Command::new("launchctl") + .args(["unload", &plist_path]) + .output()?; + if !out.status.success() { + debug!( + "launchctl unload {} failed: {}", + plist_path, + String::from_utf8_lossy(&out.stderr) + ); + } + } + let plist_dir = Path::new(&plist_path).parent().unwrap(); + std::fs::create_dir_all(plist_dir)?; + std::fs::write(&plist_path, &plist)?; + info!("Installed LaunchAgent {}", plist_path); + installed = true; + } + + // The LaunchAgent might be up to date but not loaded, or loaded but the + // environment variable might be missing or stale, e.g. if another tool + // called `launchctl setenv RSTUDIO_WHICH_R`. Both are fixed by + // (re)loading the LaunchAgent. + let loaded = !installed && rstudio_which_r_plist_loaded(); + let active = loaded && rstudio_which_r_launchd_env().as_deref() == Some(&*rbin_str); - let out = Command::new("launchctl") - .args(["load", &plist_path]) - .output()?; + if active { + return Ok(()); + } + + let (verb, args) = if loaded { + // Loaded, but the environment variable is not set correctly, restart + // the job to set it again. + ( + "kickstart", + vec![ + "kickstart".to_string(), + "-k".to_string(), + rstudio_which_r_service_target()?, + ], + ) + } else { + ("load", vec!["load".to_string(), plist_path.clone()]) + }; + + let out = Command::new("launchctl").args(&args).output()?; if !out.status.success() { let msg = format!( - "Could not register default R version in RStudio: launchctl load {} failed: {}", - plist_path, + "Could not register default R version in RStudio: launchctl {} failed: {}", + verb, String::from_utf8_lossy(&out.stderr) ); OUTPUT.error(&msg); @@ -1963,6 +2020,37 @@ fn ensure_rstudio_which_r_plist() -> Result<(), Box> { Ok(()) } +fn rstudio_which_r_plist_loaded() -> bool { + match Command::new("launchctl") + .args(["list", "io.r-lib.rig.rstudio-which-r"]) + .output() + { + Ok(out) => out.status.success(), + Err(_) => false, + } +} + +fn rstudio_which_r_launchd_env() -> Option { + let out = Command::new("launchctl") + .args(["getenv", "RSTUDIO_WHICH_R"]) + .output() + .ok()?; + if !out.status.success() { + return None; + } + let val = String::from_utf8_lossy(&out.stdout).trim().to_string(); + if val.is_empty() { + None + } else { + Some(val) + } +} + +fn rstudio_which_r_service_target() -> Result> { + let uid = nix::unistd::getuid().as_raw(); + Ok(format!("gui/{}/io.r-lib.rig.rstudio-which-r", uid)) +} + fn is_rstudio_installed() -> bool { Path::new("/Applications/RStudio.app/Contents/MacOS/rsession").exists() } From 00133aefe08f318c5286c1b8c274184c2bfc17b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Cs=C3=A1rdi?= Date: Wed, 2 Sep 2026 22:06:05 +0200 Subject: [PATCH 14/19] rig proj sync creates rest of project layout --- src/args.rs | 13 +- src/data/rvenv/Activate.ps1 | 45 +++ src/data/rvenv/R.sh | 17 + src/data/rvenv/activate.bat | 30 ++ src/data/rvenv/activate.csh | 28 ++ src/data/rvenv/activate.fish | 46 +++ src/data/rvenv/activate.sh | 61 ++++ src/data/rvenv/deactivate.bat | 18 + src/help-generated.in | 2 +- src/help/proj-sync.md | 39 ++- src/proj.rs | 238 +++++++++++-- src/rvenv.rs | 639 +++++++++++++++++++++++++++++++++- src/utils.rs | 24 ++ 13 files changed, 1156 insertions(+), 44 deletions(-) create mode 100644 src/data/rvenv/Activate.ps1 create mode 100644 src/data/rvenv/R.sh create mode 100644 src/data/rvenv/activate.bat create mode 100644 src/data/rvenv/activate.csh create mode 100644 src/data/rvenv/activate.fish create mode 100644 src/data/rvenv/activate.sh create mode 100644 src/data/rvenv/deactivate.bat diff --git a/src/args.rs b/src/args.rs index a4194081..fe3b3f1d 100644 --- a/src/args.rs +++ b/src/args.rs @@ -1494,15 +1494,18 @@ pub fn rig_app() -> Command { .required(false), ) .arg( - Arg::new("r-binary") - .help("Path to R binary (default: R)") - .long("r-binary") - .num_args(1) + Arg::new("no-install-r") + .help( + "Fail if the R version the lockfile needs is not installed,\n\ + instead of installing it", + ) + .long("no-install-r") + .num_args(0) .required(false), ) .arg( Arg::new("max-concurrent") - .help("Maximum number of concurrent installations (default: 4)") + .help("Maximum number of concurrent installations (default: 8)") .long("max-concurrent") .num_args(1) .value_parser(clap::value_parser!(usize)) diff --git a/src/data/rvenv/Activate.ps1 b/src/data/rvenv/Activate.ps1 new file mode 100644 index 00000000..74806096 --- /dev/null +++ b/src/data/rvenv/Activate.ps1 @@ -0,0 +1,45 @@ +# Managed by rig (rig proj sync). Do not edit, `rig proj sync` rewrites it. +# +# Dot-source it from PowerShell: +# +# . .rvenv\bin\Activate.ps1 +# +# The project path below is absolute, baked in by `rig proj sync`. Re-run +# `rig proj sync` after moving the project. + +function global:deactivate([switch]$NonDestructive) { + if (Test-Path -Path Function:_old_virtual_prompt) { + Copy-Item -Path Function:_old_virtual_prompt -Destination Function:prompt + Remove-Item -Path Function:_old_virtual_prompt + } + if (Test-Path -Path env:_OLD_RVENV_PATH) { + Copy-Item -Path env:_OLD_RVENV_PATH -Destination env:PATH + Remove-Item -Path env:_OLD_RVENV_PATH + } + foreach ($name in "RVENV", "R_LIBS_USER", "R_LIBS", "R_LIBS_SITE", "R_REPOSITORIES") { + if (Test-Path -Path "env:$name") { + Remove-Item -Path "env:$name" + } + } + if (-not $NonDestructive) { + Remove-Item -Path Function:deactivate + } +} + +deactivate -NonDestructive + +$env:RVENV = "@RVENV@" +Copy-Item -Path env:PATH -Destination env:_OLD_RVENV_PATH +$env:PATH = "$env:RVENV\bin;$env:PATH" +$env:R_LIBS_USER = "$env:RVENV\lib" +$env:R_LIBS = "" +$env:R_LIBS_SITE = "/nonexistent/rvenv-no-site" +$env:R_REPOSITORIES = "$env:RVENV\etc\repositories" + +if (-not $env:RVENV_DISABLE_PROMPT) { + Copy-Item -Path Function:prompt -Destination Function:_old_virtual_prompt + function global:prompt { + Write-Host -NoNewline -ForegroundColor Green "(@RVENV_NAME@) " + _old_virtual_prompt + } +} diff --git a/src/data/rvenv/R.sh b/src/data/rvenv/R.sh new file mode 100644 index 00000000..fa95cfc9 --- /dev/null +++ b/src/data/rvenv/R.sh @@ -0,0 +1,17 @@ +#!/bin/sh +# Managed by rig (rig proj sync). Do not edit, `rig proj sync` rewrites it. +# +# A wrapper script, not a symlink: a symlink would resolve R_HOME correctly +# but would carry no environment, so putting .rvenv/bin on PATH would leak +# into the user's own package library. The exec path below is absolute, which +# also pins the R version this project was solved for. +# +# RVENV is derived from this script's own location, so the project directory +# can be moved or checked out anywhere. +RVENV=$(cd "$(dirname "$0")/.." && pwd) +export RVENV +# R_LIBS is empty, so that .libPaths()[1] stays the project library, and +# R_LIBS_SITE points at a path that cannot exist, because an empty one does +# not reliably disable the site library on every R version. +@RVENV_EXPORTS@ +exec "@R_BINARY@" "$@" diff --git a/src/data/rvenv/activate.bat b/src/data/rvenv/activate.bat new file mode 100644 index 00000000..4bd659a1 --- /dev/null +++ b/src/data/rvenv/activate.bat @@ -0,0 +1,30 @@ +@echo off +rem Managed by rig (rig proj sync). Do not edit, `rig proj sync` rewrites it. +rem +rem Run it from cmd.exe: +rem +rem .rvenv\bin\activate.bat +rem +rem The project path below is absolute, baked in by `rig proj sync`. Re-run +rem `rig proj sync` after moving the project. + +set "RVENV=@RVENV@" + +if defined _OLD_RVENV_PATH ( + set "PATH=%_OLD_RVENV_PATH%" +) else ( + set "_OLD_RVENV_PATH=%PATH%" +) +set "PATH=%RVENV%\bin;%PATH%" + +if defined _OLD_RVENV_PROMPT ( + set "PROMPT=%_OLD_RVENV_PROMPT%" +) else ( + set "_OLD_RVENV_PROMPT=%PROMPT%" +) +if not defined RVENV_DISABLE_PROMPT set "PROMPT=(@RVENV_NAME@) %PROMPT%" + +set "R_LIBS_USER=%RVENV%\lib" +set "R_LIBS=" +set "R_LIBS_SITE=/nonexistent/rvenv-no-site" +set "R_REPOSITORIES=%RVENV%\etc\repositories" diff --git a/src/data/rvenv/activate.csh b/src/data/rvenv/activate.csh new file mode 100644 index 00000000..e16e3b6d --- /dev/null +++ b/src/data/rvenv/activate.csh @@ -0,0 +1,28 @@ +# Managed by rig (rig proj sync). Do not edit, `rig proj sync` rewrites it. +# +# For csh and tcsh. Source it, do not run it: +# +# source .rvenv/bin/activate.csh +# +# The project path below is absolute, baked in by `rig proj sync`. Re-run +# `rig proj sync` after moving the project. + +alias deactivate 'test $?_OLD_RVENV_PATH != 0 && setenv PATH "$_OLD_RVENV_PATH" && unset _OLD_RVENV_PATH; test $?_OLD_RVENV_PROMPT != 0 && set prompt="$_OLD_RVENV_PROMPT" && unset _OLD_RVENV_PROMPT; unsetenv RVENV; unsetenv R_LIBS_USER; unsetenv R_LIBS; unsetenv R_LIBS_SITE; unsetenv R_REPOSITORIES; test "\!:*" != "nondestructive" && unalias deactivate; rehash' + +deactivate nondestructive + +setenv RVENV "@RVENV@" +set _OLD_RVENV_PATH="$PATH" +setenv PATH "$RVENV/bin:$PATH" +setenv R_LIBS_USER "$RVENV/lib" +setenv R_LIBS "" +setenv R_LIBS_SITE /nonexistent/rvenv-no-site +setenv R_REPOSITORIES "$RVENV/etc/repositories" + +# `prompt` is only set in an interactive shell, hence the second test. +if (! $?RVENV_DISABLE_PROMPT && $?prompt) then + set _OLD_RVENV_PROMPT="$prompt:q" + set prompt = "(@RVENV_NAME@) $prompt:q" +endif + +rehash diff --git a/src/data/rvenv/activate.fish b/src/data/rvenv/activate.fish new file mode 100644 index 00000000..42646aec --- /dev/null +++ b/src/data/rvenv/activate.fish @@ -0,0 +1,46 @@ +# Managed by rig (rig proj sync). Do not edit, `rig proj sync` rewrites it. +# +# For fish. Source it, do not run it: +# +# source .rvenv/bin/activate.fish +# +# The project path below is absolute, baked in by `rig proj sync`. Re-run +# `rig proj sync` after moving the project. + +function deactivate -d "Leave the rig project environment" + if test -n "$_OLD_RVENV_PATH" + set -gx PATH $_OLD_RVENV_PATH + set -e _OLD_RVENV_PATH + end + if functions -q _old_fish_prompt + functions -e fish_prompt + functions -c _old_fish_prompt fish_prompt + functions -e _old_fish_prompt + end + set -e RVENV + set -e R_LIBS_USER + set -e R_LIBS + set -e R_LIBS_SITE + set -e R_REPOSITORIES + if test "$argv[1]" != "nondestructive" + functions -e deactivate + end +end + +deactivate nondestructive + +set -gx RVENV "@RVENV@" +set -g _OLD_RVENV_PATH $PATH +set -gx PATH "$RVENV/bin" $PATH +set -gx R_LIBS_USER "$RVENV/lib" +set -gx R_LIBS "" +set -gx R_LIBS_SITE /nonexistent/rvenv-no-site +set -gx R_REPOSITORIES "$RVENV/etc/repositories" + +if test -z "$RVENV_DISABLE_PROMPT" + functions -c fish_prompt _old_fish_prompt + function fish_prompt + printf "%s(%s)%s " (set_color normal) "@RVENV_NAME@" (set_color normal) + _old_fish_prompt + end +end diff --git a/src/data/rvenv/activate.sh b/src/data/rvenv/activate.sh new file mode 100644 index 00000000..43891d72 --- /dev/null +++ b/src/data/rvenv/activate.sh @@ -0,0 +1,61 @@ +# Managed by rig (rig proj sync). Do not edit, `rig proj sync` rewrites it. +# +# This file must be sourced, not run: +# +# . .rvenv/bin/activate +# +# A sourced script cannot find its own path portably, so the project path +# below is absolute, baked in by `rig proj sync`. Re-run `rig proj sync` +# after moving the project. +# +# You do not need this file to use the project: `.rvenv/bin/R` and +# `.rvenv/bin/Rscript` set the same environment on their own, and R started +# by an IDE picks the project up through `.Renviron`. + +deactivate() { + if [ -n "${_OLD_RVENV_PATH:-}" ] || [ "${_OLD_RVENV_PATH-x}" != x ]; then + PATH="$_OLD_RVENV_PATH" + export PATH + unset _OLD_RVENV_PATH + fi + if [ -n "${_OLD_RVENV_PS1:-}" ] || [ "${_OLD_RVENV_PS1-x}" != x ]; then + PS1="$_OLD_RVENV_PS1" + export PS1 + unset _OLD_RVENV_PS1 + fi + unset RVENV + unset R_LIBS_USER + unset R_LIBS + unset R_LIBS_SITE + unset R_REPOSITORIES + if [ ! "${1:-}" = "nondestructive" ]; then + unset -f deactivate + fi + # Forget the hashed locations of R and Rscript. + if [ -n "${BASH:-}" ] || [ -n "${ZSH_VERSION:-}" ]; then + hash -r 2>/dev/null + fi +} + +# Start from a clean slate, in case another environment is active. +deactivate nondestructive + +RVENV="@RVENV@" +export RVENV +_OLD_RVENV_PATH="$PATH" +PATH="$RVENV/bin:$PATH" +export PATH +export R_LIBS_USER="$RVENV/lib" +export R_LIBS= +export R_LIBS_SITE=/nonexistent/rvenv-no-site +export R_REPOSITORIES="$RVENV/etc/repositories" + +if [ -z "${RVENV_DISABLE_PROMPT:-}" ]; then + _OLD_RVENV_PS1="${PS1:-}" + PS1="(@RVENV_NAME@) ${PS1:-}" + export PS1 +fi + +if [ -n "${BASH:-}" ] || [ -n "${ZSH_VERSION:-}" ]; then + hash -r 2>/dev/null +fi diff --git a/src/data/rvenv/deactivate.bat b/src/data/rvenv/deactivate.bat new file mode 100644 index 00000000..98c95317 --- /dev/null +++ b/src/data/rvenv/deactivate.bat @@ -0,0 +1,18 @@ +@echo off +rem Managed by rig (rig proj sync). Do not edit, `rig proj sync` rewrites it. +rem The cmd.exe counterpart of `activate.bat`; cmd.exe has no functions, so +rem this has to be a file of its own. + +if defined _OLD_RVENV_PATH ( + set "PATH=%_OLD_RVENV_PATH%" + set "_OLD_RVENV_PATH=" +) +if defined _OLD_RVENV_PROMPT ( + set "PROMPT=%_OLD_RVENV_PROMPT%" + set "_OLD_RVENV_PROMPT=" +) +set "RVENV=" +set "R_LIBS_USER=" +set "R_LIBS=" +set "R_LIBS_SITE=" +set "R_REPOSITORIES=" diff --git a/src/help-generated.in b/src/help-generated.in index 601a7005..7bda8f10 100644 --- a/src/help-generated.in +++ b/src/help-generated.in @@ -71,7 +71,7 @@ const HELP_PROJ_INIT: &str = "\u{1b}[1m\u{1b}[34mDescription:\u{1b}[39m\u{1b}[22 const ABOUT_PROJ_LOCK: &str = "Resolve project dependencies and write rproj.lock"; const HELP_PROJ_LOCK: &str = "\u{1b}[1m\u{1b}[34mDescription:\u{1b}[39m\u{1b}[22m\n Resolve the dependencies of an R project to a concrete set of package\n versions, and write the result to \u{1b}[32mrproj.lock\u{1b}[39m.\n\n rig reads the project manifest, \u{1b}[32mrproj.toml\u{1b}[39m, in the current directory, and\n uses its built-in solver to find a compatible set of package versions from\n the configured repositories, without running R.\n\n Development dependencies are included by default. Use \u{1b}[32m--r-version\u{1b}[39m to solve\n for a specific R version, \u{1b}[32m--no-dev\u{1b}[39m to leave out development dependencies,\n and \u{1b}[32m--renv\u{1b}[39m to also write the result as an \u{1b}[32mrenv.lock\u{1b}[39m file.\n\n \u{1b}[32mrproj.lock\u{1b}[39m currently records a single \u{1b}[32m(R version, platform)\u{1b}[39m target;\n \u{1b}[32mrig proj sync\u{1b}[39m installs that target. Solving for several\n targets in one lockfile is planned but not implemented yet.\n\n\u{1b}[1m\u{1b}[34mSource and binary packages:\u{1b}[39m\u{1b}[22m\n The solver considers binary packages as well as source packages, and\n prefers a binary build when one is available for the same version. Which\n artifact each package is installed from is part of what the solve decides,\n because a binary is only usable together with the exact versions of its\n \u{1b}[32mLinkingTo\u{1b}[39m dependencies that it was compiled against. If those versions\n conflict with the rest of the project, rig picks another build of that\n package, or falls back to its source tarball.\n\n By default a binary build never changes \u{1b}[3mwhich version\u{1b}[23m rig picks: the\n newest suitable version wins, and a binary of it is used if there is one.\n Pass \u{1b}[32m--prefer-binary\u{1b}[39m to let an older version win instead, when the newest\n one has no binary but an older one does — typically because a version was\n released so recently that it has not been built yet. Only the three newest\n versions of a package are considered; \u{1b}[32m--prefer-binary=5\u{1b}[39m considers five.\n Versions held back this way are marked in the output.\n\n Trading a version away for a binary is not free: the binary pins its\n \u{1b}[32mLinkingTo\u{1b}[39m dependencies to the versions it was compiled against, and those\n dependencies then prefer their own binaries in turn, so a whole project can\n end up on older versions.\n\n By default rig solves for the machine it runs on. Use \u{1b}[32m--platform\u{1b}[39m to solve\n for a different one, e.g. to write a lockfile on macOS for a Linux\n deployment:\n\n rig proj lock --platform ubuntu-24.04\n\n \u{1b}[32m--platform source\u{1b}[39m solves for source packages only, and does not download\n any binary package metadata. rig also falls back to source packages when\n there are no binaries for a platform at all. There is then nothing for\n \u{1b}[32m--prefer-binary\u{1b}[39m to prefer, and rig ignores it.\n\n rig keeps the repository metadata and the binary package indices it solves\n from in its cache, and refreshes them once a day. \u{1b}[32m--no-cache\u{1b}[39m downloads\n them again instead, and writes nothing to the cache, which is the way to\n solve against a package that was published minutes ago. It is a good deal\n slower, because the metadata it re-downloads is large. See\n \u{1b}[32mrig config\u{1b}[39m.\n\n The \u{1b}[32mrproj.lock\u{1b}[39m file records, for every package, whether it is a source or a\n binary package and the URL it is downloaded from. It also records where the\n file is cached, which is per \u{1b}[3mbuild\u{1b}[23m rather than per version: a repository\n can offer several binaries of one version for one platform and R version,\n and they are cached side by side."; const ABOUT_PROJ_SYNC: &str = "Install the dependencies rproj.lock resolved"; -const HELP_PROJ_SYNC: &str = "\u{1b}[1m\u{1b}[34mDescription:\u{1b}[39m\u{1b}[22m\n Install the resolved dependencies of an R project into a package library.\n\n rig looks for the project in the current directory and its parents, reads its\n \u{1b}[32mrproj.lock\u{1b}[39m (written by \u{1b}[32mrig proj lock\u{1b}[39m) and installs the\n packages into the project library, \u{1b}[32m.rvenv/lib\u{1b}[39m. If the project has no\n \u{1b}[32mrproj.lock\u{1b}[39m yet, rig runs \u{1b}[32mrig proj lock\u{1b}[39m with its default\n options first, to create one. That library is created by\n \u{1b}[32mrig proj init\u{1b}[39m, together with the \u{1b}[32m.gitignore\u{1b}[39m files that\n keep it in version control, so \u{1b}[32mrig proj sync\u{1b}[39m fails if it is missing. Pass\n \u{1b}[32m--library\u{1b}[39m to install somewhere else instead.\n\n Development dependencies are installed by default. \u{1b}[32m--no-dev\u{1b}[39m leaves them\n out.\n\n Use \u{1b}[32m--r-binary\u{1b}[39m to select which R to build against (default: \u{1b}[32mR\u{1b}[39m) and\n \u{1b}[32m--max-concurrent\u{1b}[39m to limit the number of simultaneous installations\n (default: 8).\n\n After a successful sync rig records the lock file it installed from in\n \u{1b}[32m.rvenv/lib/.synced\u{1b}[39m. The \u{1b}[32mrig\u{1b}[39m package in the project library compares the\n two, and warns in every R session while the project library does not match\n \u{1b}[32mrproj.lock\u{1b}[39m."; +const HELP_PROJ_SYNC: &str = "\u{1b}[1m\u{1b}[34mDescription:\u{1b}[39m\u{1b}[22m\n Bring an R project's environment in line with its \u{1b}[32mrproj.lock\u{1b}[39m: install the\n resolved dependencies, and write the rest of the \u{1b}[32m.rvenv\u{1b}[39m layout.\n\n rig looks for the project in the current directory and its parents, reads its\n \u{1b}[32mrproj.lock\u{1b}[39m (written by \u{1b}[32mrig proj lock\u{1b}[39m) and installs the\n packages into the project library, \u{1b}[32m.rvenv/lib\u{1b}[39m. If the project has no\n \u{1b}[32mrproj.lock\u{1b}[39m yet, rig runs \u{1b}[32mrig proj lock\u{1b}[39m with its default\n options first, to create one. That library is created by\n \u{1b}[32mrig proj init\u{1b}[39m, together with the \u{1b}[32m.gitignore\u{1b}[39m files that\n keep it in version control, so \u{1b}[32mrig proj sync\u{1b}[39m fails if it is missing. Pass\n \u{1b}[32m--library\u{1b}[39m to install somewhere else instead.\n\n Development dependencies are installed by default. \u{1b}[32m--no-dev\u{1b}[39m leaves them\n out. \u{1b}[32m--max-concurrent\u{1b}[39m limits the number of simultaneous installations\n (default: 8).\n\n\u{1b}[1m\u{1b}[34mThe R version:\u{1b}[39m\u{1b}[22m\n The lock file records the R version its solve is valid for, and an installed\n R package is tied to the R minor version, so that is the R rig installs the\n packages with -- not whatever \u{1b}[32mR\u{1b}[39m is on the \u{1b}[32mPATH\u{1b}[39m. If that R version is\n missing, rig installs it first, the way \u{1b}[32mrig add\u{1b}[39m would; pass\n \u{1b}[32m--no-install-r\u{1b}[39m to fail instead, e.g. in CI.\n\n\u{1b}[1m\u{1b}[34mWhat sync writes:\u{1b}[39m\u{1b}[22m\n Everything below \u{1b}[32m.rvenv\u{1b}[39m, except the library and the shim package in it, is\n machine-specific, is not committed, and is rewritten on every sync:\n\n - \u{1b}[32m.rvenv/bin/R\u{1b}[39m and \u{1b}[32m.rvenv/bin/Rscript\u{1b}[39m, wrapper scripts that set the\n project's environment and then hand over to the real R. Run them directly,\n or put \u{1b}[32m.rvenv/bin\u{1b}[39m on your \u{1b}[32mPATH\u{1b}[39m. They also pass \u{1b}[32mR CMD ...\u{1b}[39m through.\n - \u{1b}[32m.rvenv/bin/activate\u{1b}[39m and its \u{1b}[32mactivate.csh\u{1b}[39m / \u{1b}[32mactivate.fish\u{1b}[39m /\n \u{1b}[32mactivate.bat\u{1b}[39m / \u{1b}[32mActivate.ps1\u{1b}[39m siblings, for the shells that prefer to be\n activated. Source the one for your shell, and \u{1b}[32mdeactivate\u{1b}[39m when you are\n done. Activation is a convenience, not a requirement: the wrappers work\n without it, and an R session started by an IDE picks the project up through\n the project's \u{1b}[32m.Renviron\u{1b}[39m.\n - \u{1b}[32m.rvenv/rvenv.cfg\u{1b}[39m, which records the R version, the platform and the\n architecture the environment was built for. rig warns when it syncs an\n environment that was built for a different R.\n - \u{1b}[32m.rvenv/etc/repositories\u{1b}[39m, the repositories from \u{1b}[32mrproj.toml\u{1b}[39m (CRAN, if it\n names none), which the wrappers point \u{1b}[32mR_REPOSITORIES\u{1b}[39m at.\n\n \u{1b}[32mR --vanilla\u{1b}[39m ignores the project's \u{1b}[32m.Renviron\u{1b}[39m, so it only stays inside the\n project when started through the wrappers.\n\n After a successful sync rig records the lock file it installed from in\n \u{1b}[32m.rvenv/lib/.synced\u{1b}[39m. The \u{1b}[32mrig\u{1b}[39m package in the project library compares the\n two, and warns in every R session while the project library does not match\n \u{1b}[32mrproj.lock\u{1b}[39m."; const ABOUT_PROJ_TREE: &str = "Dependency tree of a project"; const HELP_PROJ_TREE: &str = "\u{1b}[1m\u{1b}[34mDescription:\u{1b}[39m\u{1b}[22m\n Show everything an R project needs, directly or indirectly, as a tree: the\n same closure \u{1b}[32mrig proj deps --recursive\u{1b}[39m lists in a flat\n table, laid out by the shape of the dependency graph.\n\n myproject 0.1.0 — 3 direct, 24 total\n ├── R (>= 4.1) [D]\n ├── cli 3.6.4\n │ ├── R (>= 3.4) [D]\n │ └── utils\n └── dplyr 1.1.4 (>= 1.1.0)\n ├── cli 3.6.4 (>= 3.4.0) (*)\n └── vctrs 0.6.5 (>= 0.6.4)\n └── cpp11 0.5.2 [L]\n [Suggests]\n └── testthat 3.2.3 (>= 3.1.5)\n\n The first line names the project and its version, how many dependencies it\n declares directly and how many distinct packages there are in the whole tree.\n Each line below names a package, the version currently in the repositories,\n and the version requirement it is needed with, if it has one.\n\n rig reads the project manifest, \u{1b}[32mrproj.toml\u{1b}[39m, in the current directory. Unlike\n \u{1b}[32mrig proj deps\u{1b}[39m, the tree needs the package metadata of the\n repositories, which rig downloads if it does not have it yet. It does not need\n R. \u{1b}[32m--json\u{1b}[39m gives machine readable output, as one nested object.\n\n\u{1b}[1m\u{1b}[34mReading the tree:\u{1b}[39m\u{1b}[22m\n A package that several others need is expanded only once, under its first\n occurrence; later occurrences are marked \u{1b}[32m(*)\u{1b}[39m, meaning \"its dependencies are\n above\". \u{1b}[32m--dev\u{1b}[39m adds the project's development dependencies, in their own\n \u{1b}[32m[Suggests]\u{1b}[39m and \u{1b}[32m[Enhances]\u{1b}[39m sections; \u{1b}[32m--no-base\u{1b}[39m leaves out R and the base\n packages. Among the hard dependencies, \u{1b}[32mImports\u{1b}[39m is not marked, \u{1b}[32m[D]\u{1b}[39m is a\n \u{1b}[32mDepends\u{1b}[39m, \u{1b}[32m[L]\u{1b}[39m a \u{1b}[32mLinkingTo\u{1b}[39m, \u{1b}[32m[DL]\u{1b}[39m both.\n\n \u{1b}[32m--why \u{1b}[39m (alias \u{1b}[32m--explain\u{1b}[39m) inverts the tree, so that the named\n package is the root and the tree grows towards the packages that need it, down\n to the project itself. Each line then says how \u{1b}[3mthat\u{1b}[23m package needs the one\n above it, hence \u{1b}[32mneeds\u{1b}[39m.\n\n \u{1b}[32mrig pkg tree\u{1b}[39m, which shows the same tree for a package\n in the repositories, describes all of this in full.\n\n rig follows the dependencies of the \u{1b}[3mlatest\u{1b}[23m version of every package in the\n tree, so a version requirement that would force an older version, with\n different dependencies, is not taken into account. Use\n \u{1b}[32mrig proj lock\u{1b}[39m for a resolution that is consistent across\n versions."; const ABOUT_PROJ: &str = "Manage R projects (experimental)"; diff --git a/src/help/proj-sync.md b/src/help/proj-sync.md index 8cdd7f36..6bfb66e6 100644 --- a/src/help/proj-sync.md +++ b/src/help/proj-sync.md @@ -2,7 +2,8 @@ Install the dependencies rproj.lock resolved ## Description -Install the resolved dependencies of an R project into a package library. +Bring an R project's environment in line with its `rproj.lock`: install the +resolved dependencies, and write the rest of the `.rvenv` layout. rig looks for the project in the current directory and its parents, reads its `rproj.lock` (written by [`rig proj lock`](#rig-proj-lock)) and installs the @@ -14,12 +15,40 @@ keep it in version control, so `rig proj sync` fails if it is missing. Pass `--library` to install somewhere else instead. Development dependencies are installed by default. `--no-dev` leaves them -out. - -Use `--r-binary` to select which R to build against (default: `R`) and -`--max-concurrent` to limit the number of simultaneous installations +out. `--max-concurrent` limits the number of simultaneous installations (default: 8). +## The R version + +The lock file records the R version its solve is valid for, and an installed +R package is tied to the R minor version, so that is the R rig installs the +packages with -- not whatever `R` is on the `PATH`. If that R version is +missing, rig installs it first, the way [`rig add`](add.qmd) would; pass +`--no-install-r` to fail instead, e.g. in CI. + +## What sync writes + +Everything below `.rvenv`, except the library and the shim package in it, is +machine-specific, is not committed, and is rewritten on every sync: + +- `.rvenv/bin/R` and `.rvenv/bin/Rscript`, wrapper scripts that set the + project's environment and then hand over to the real R. Run them directly, + or put `.rvenv/bin` on your `PATH`. They also pass `R CMD ...` through. +- `.rvenv/bin/activate` and its `activate.csh` / `activate.fish` / + `activate.bat` / `Activate.ps1` siblings, for the shells that prefer to be + activated. Source the one for your shell, and `deactivate` when you are + done. Activation is a convenience, not a requirement: the wrappers work + without it, and an R session started by an IDE picks the project up through + the project's `.Renviron`. +- `.rvenv/rvenv.cfg`, which records the R version, the platform and the + architecture the environment was built for. rig warns when it syncs an + environment that was built for a different R. +- `.rvenv/etc/repositories`, the repositories from `rproj.toml` (CRAN, if it + names none), which the wrappers point `R_REPOSITORIES` at. + +`R --vanilla` ignores the project's `.Renviron`, so it only stays inside the +project when started through the wrappers. + After a successful sync rig records the lock file it installed from in `.rvenv/lib/.synced`. The `rig` package in the project library compares the two, and warns in every R session while the project library does not match diff --git a/src/proj.rs b/src/proj.rs index de9f8122..207f8ac6 100644 --- a/src/proj.rs +++ b/src/proj.rs @@ -13,9 +13,10 @@ use pubgrub::{resolve, SelectedDependencies}; use simple_error::*; use tabular::*; +use crate::args::rig_app; use crate::built::BuiltCache; use crate::cache::get_cache_dir; -use crate::common::{get_arch, get_default_r_version, get_platform}; +use crate::common::{get_arch, get_default_r_version, get_platform, sc_get_list_details}; use crate::dcf::*; use crate::download::download_multiple_first_available_with_progress; use crate::install::{ @@ -32,16 +33,27 @@ use crate::pkg::tree::proj_tree; use crate::platform::{detect_platform, parse_platform_string}; use crate::renv::*; use crate::repos::binaries::loader::{BinaryTarget, P3mBinaryLoader}; +use crate::repos::cranlike_metadata::minor_r_version; use crate::repos::*; use crate::resolve::resolve_versions; use crate::rproj::{Rproj, RprojLock, RprojLockTarget, RPROJ_LOCK_VERSION, RPROJ_MANIFEST_FILE}; use crate::rvenv::{ - existing_targets, find_project_root, project_library, rvenv_init, write_sync_stamp, - RPROJ_LOCK_FILE, + existing_targets, find_project_root, project_library, read_rvenv_cfg, rvenv_init, rvenv_sync, + write_sync_stamp, RvenvCfg, RPROJ_LOCK_FILE, }; +use crate::rversion::InstalledVersion; use crate::solver::*; use crate::utils::create_parent_dir_if_needed; +#[cfg(target_os = "macos")] +use crate::macos::{get_r_binary, sc_add}; + +#[cfg(target_os = "windows")] +use crate::windows::{get_r_binary, sc_add}; + +#[cfg(target_os = "linux")] +use crate::linux::{get_r_binary, sc_add}; + pub const BASE_PKGS: &[&str] = &[ "base", "compiler", @@ -262,13 +274,9 @@ fn proj_read_deps(input: &str, dev: bool) -> Result> { Ok(package) } -/// Read the project's `rproj.toml` manifest and return its name, version and -/// dependencies, with the soft dependencies dropped unless `dev`. The manifest -/// is read from `root`, the project directory. -fn proj_read_manifest_deps( - root: &Path, - dev: bool, -) -> Result<(String, RPackageVersion, PackageDependencies), Box> { +/// Read the project's `rproj.toml` manifest from `root`, the project +/// directory. +fn proj_read_manifest(root: &Path) -> Result> { let path = root.join(RPROJ_MANIFEST_FILE); if !path.exists() { OUTPUT.error(&format!( @@ -279,16 +287,27 @@ fn proj_read_manifest_deps( bail!("{} not found", RPROJ_MANIFEST_FILE); } - OUTPUT.status(&format!( - "Reading dependencies from {}", - RPROJ_MANIFEST_FILE - )); - info!("Reading dependencies from {}", RPROJ_MANIFEST_FILE); let manifest: Rproj = toml::from_str(&fs::read_to_string(path)?).map_err(|e| { OUTPUT.error(&format!("Cannot parse {}: {}", RPROJ_MANIFEST_FILE, e)); error!("Cannot parse {}: {}", RPROJ_MANIFEST_FILE, e); e })?; + Ok(manifest) +} + +/// Read the project's `rproj.toml` manifest and return its name, version and +/// dependencies, with the soft dependencies dropped unless `dev`. The manifest +/// is read from `root`, the project directory. +fn proj_read_manifest_deps( + root: &Path, + dev: bool, +) -> Result<(String, RPackageVersion, PackageDependencies), Box> { + OUTPUT.status(&format!( + "Reading dependencies from {}", + RPROJ_MANIFEST_FILE + )); + info!("Reading dependencies from {}", RPROJ_MANIFEST_FILE); + let manifest = proj_read_manifest(root)?; let deps = manifest.to_dep_version_specs(dev)?; let version = RPackageVersion::from_str(&manifest.project.version)?; @@ -746,6 +765,119 @@ fn nondev_packages( Ok(keep) } +/// The R installation an environment for `r_version` uses: its name, as +/// `rig list` shows it, and the absolute path of its R binary. +/// +/// The lock file records the R version its solve is valid for, and an +/// installed R package is tied to the R minor version, so this is not a +/// preference, it is what the environment *is*. When that R is missing and +/// `install` is set, install it first -- the thing renv cannot do. +fn rvenv_r_installation( + r_version: &str, + install: bool, +) -> Result<(String, PathBuf), Box> { + if let Some(name) = find_r_installation(r_version)? { + let binary = get_r_binary(&name)?; + return Ok((name, binary)); + } + + if !install { + let msg = format!( + "R {} is not installed, install it with `rig add {}` \ + (or drop --no-install-r)", + r_version, r_version + ); + OUTPUT.error(&msg); + error!("{}", msg); + bail!("{}", msg); + } + + OUTPUT.status(&format!( + "R {} is not installed, installing it now", + r_version + )); + info!("R {} is not installed, installing it now", r_version); + // `rig add` is a subcommand, not a function that takes a version, so go + // through clap. It escalates on its own in admin mode. + let matches = rig_app().try_get_matches_from(["rig", "add", r_version])?; + let (_name, addargs) = match matches.subcommand() { + Some(x) => x, + None => bail!("Internal error: `rig add` did not parse"), + }; + sc_add(addargs)?; + + match find_r_installation(r_version)? { + Some(name) => { + let binary = get_r_binary(&name)?; + Ok((name, binary)) + } + None => { + let msg = format!("Installed R {}, but cannot find it now", r_version); + OUTPUT.error(&msg); + error!("{}", msg); + bail!("{}", msg) + } + } +} + +/// The installed R that matches `r_version`: the installation of that name, +/// or, failing that, one with the same minor version -- the lock file records +/// a minor version like `4.6`, while an installation can be called `4.6.1` or +/// `4.6-arm64`. A native-architecture installation wins over a foreign one. +fn find_r_installation(r_version: &str) -> Result, Box> { + let installed = sc_get_list_details()?; + if let Some(exact) = installed.iter().find(|v| v.name == r_version) { + return Ok(Some(exact.name.clone())); + } + + let want = r_minor_of(r_version); + let native = std::env::consts::ARCH; + let mut best: Option<&InstalledVersion> = None; + for candidate in installed.iter() { + // An installation with no version, or a version that is not a number + // (`devel`, `next`), is never a match for a lock file's R version. + let version = match &candidate.version { + Some(v) => v, + None => continue, + }; + if want.is_none() || r_minor_of(version) != want { + continue; + } + let arch = rvenv_r_arch(&candidate.name); + let is_native = arch == native || (native == "aarch64" && arch == "arm64"); + if is_native || best.is_none() { + best = Some(candidate); + } + if is_native { + break; + } + } + Ok(best.map(|v| v.name.clone())) +} + +/// `.` of an R version, or `None` if it is not a version number +/// at all (`devel`, `next`). Quiet, unlike `minor_r_version`, because this +/// runs over every installed R version, most of which do not match anyway. +fn r_minor_of(version: &str) -> Option { + let mut parts = version.split('.'); + let major: u32 = parts.next()?.parse().ok()?; + let minor: u32 = parts.next().unwrap_or("0").parse().ok()?; + Some(format!("{}.{}", major, minor)) +} + +/// The architecture of an R installation, from its name (`4.6-arm64`), or the +/// machine's own if the name does not say. +fn rvenv_r_arch(name: &str) -> String { + match name.rsplit_once('-') { + Some((_, arch)) if arch == "arm64" || arch == "x86_64" => arch.to_string(), + // rig calls it `arm64` on macOS and `aarch64` everywhere else. + _ => match std::env::consts::ARCH { + "aarch64" if cfg!(target_os = "macos") => "arm64".to_string(), + other => other.to_string(), + }, + } +} + fn sc_proj_sync( args: &ArgMatches, _libargs: &ArgMatches, @@ -813,6 +945,58 @@ fn sc_proj_sync( } }; + // Everything below installs against the R version the lock file was + // solved for, so resolve (and, unless --no-install-r, install) it before + // touching the library: installed R packages are tied to the R minor + // version, so the R on `PATH` is not good enough. + let (r_name, r_binary) = + rvenv_r_installation(&target.r_version, !args.get_flag("no-install-r"))?; + + // The project environment, as opposed to an arbitrary `--library`, also + // owns the wrappers, the activation scripts and the sync stamp. + let in_project_library = library_path == project_library(&root); + if in_project_library { + let cfg = RvenvCfg { + r_version: r_name.clone(), + r_minor: minor_r_version(&target.r_version)?, + r_binary: r_binary.clone(), + platform: target.platform.clone(), + r_arch: rvenv_r_arch(&r_name), + rig_version: env!("CARGO_PKG_VERSION").to_string(), + }; + // An environment that was built against a different R is not stale, + // it is broken: R packages are tied to the R minor version. Say so, + // because the packages already in the library are about to be used + // with a different R than they were installed for. + if let Some(old) = read_rvenv_cfg(&root)? { + if old.r_minor != cfg.r_minor || old.r_arch != cfg.r_arch { + let msg = format!( + "This environment was built for R {} ({}), rebuilding it for R {} ({}). \ + Remove {} and sync again if a package misbehaves.", + old.r_minor, + old.r_arch, + cfg.r_minor, + cfg.r_arch, + project_library(&root).display() + ); + OUTPUT.warn(&msg); + info!("{}", msg); + } + } + + let manifest = proj_read_manifest(&root)?; + let written = rvenv_sync(&root, &cfg, &manifest.repository)?; + for path in &written { + let path = path.strip_prefix(&root).unwrap_or(path); + info!("Updated {}", path.display()); + } + OUTPUT.success(&format!( + "Updated the project environment for R {} ({})", + r_name, + r_binary.display() + )); + } + // A package already in the library, at the version and provenance the // lockfile asks for, does not need to be downloaded or reinstalled. let already_installed = if library_path.exists() { @@ -831,15 +1015,9 @@ fn sc_proj_sync( // The shim package in the project library compares this stamp to // `rproj.lock` and warns in every R session while they differ, so it has // to be updated even when there was nothing to install. - let stamp_lib = if library_path == project_library(&root) { - Some(library_path.clone()) - } else { - None - }; - if todo.is_empty() { - if let Some(lib) = &stamp_lib { - write_sync_stamp(lib, &lock_path)?; + if in_project_library { + write_sync_stamp(&library_path, &lock_path)?; } OUTPUT.success(&format!( "Everything is up to date in {}", @@ -861,11 +1039,11 @@ fn sc_proj_sync( // Ensure library directory exists fs::create_dir_all(&library_path)?; - // Get R binary path - use argument or default to "R" - let r_binary = args - .get_one::("r-binary") - .map(|s| s.as_str()) - .unwrap_or("R"); + // Install with the R version the lock file was solved for, not whatever + // is on `PATH`: an installed R package is tied to the R minor version. + let r_binary = r_binary + .to_str() + .ok_or("The R installation path is not valid Unicode")?; // Build Vec for the packages that need installing let built = BuiltCache::new(&target.r_version, r_binary); @@ -902,8 +1080,8 @@ fn sc_proj_sync( let installed = install_packages(packages, &library_path, r_binary, max_concurrent)?; - if let Some(lib) = &stamp_lib { - write_sync_stamp(lib, &lock_path)?; + if in_project_library { + write_sync_stamp(&library_path, &lock_path)?; } OUTPUT.success(&format!( diff --git a/src/rvenv.rs b/src/rvenv.rs index 3123d831..565fd0b4 100644 --- a/src/rvenv.rs +++ b/src/rvenv.rs @@ -12,8 +12,19 @@ //! .rvenv/ //! lib/.gitignore # tracked -- keeps lib/ and lib/rig, ignores the rest //! lib/rig/ # tracked -- pre-built shim package -//! lib/... # untracked -- the real dependencies, from `rig proj sync` -//! lib/.synced # untracked -- sync stamp, the lock file's md5 sum +//! ``` +//! +//! `rig proj sync` adds the machine-specific rest, none of which is +//! committed: +//! +//! ```text +//! .rvenv/ +//! lib/... # the real dependencies +//! lib/.synced # sync stamp, a copy of the lock file +//! rvenv.cfg # what this environment was built against +//! etc/repositories # what R_REPOSITORIES points at +//! bin/R, bin/Rscript # wrapper scripts (.exe shims on Windows) +//! bin/activate* # shell activation scripts //! ``` //! //! Two things here are less obvious than they look. @@ -47,16 +58,37 @@ use std::path::{Path, PathBuf}; use simple_error::bail; use crate::hardcoded::{HC_RVENV_SHIM_35, HC_RVENV_SHIM_40, HC_RVENV_SHIM_LT_35}; -use crate::rproj::RPROJ_MANIFEST_FILE; +use crate::repositories::{write_repositories_file, RepoFileEntry, RepositoriesContents}; +use crate::rproj::{Repository as ManifestRepository, RPROJ_MANIFEST_FILE}; use crate::utils::write_atomically; +#[cfg(not(windows))] +use crate::utils::write_executable; pub const RVENV_DIR: &str = ".rvenv"; pub const RVENV_LIB_SUBDIR: &str = "lib"; +pub const RVENV_BIN_SUBDIR: &str = "bin"; +pub const RVENV_ETC_SUBDIR: &str = "etc"; pub const RVENV_SHIM_PKG: &str = "rig"; pub const RVENV_RENVIRON_FILE: &str = ".Renviron"; pub const RVENV_GITIGNORE_FILE: &str = ".gitignore"; +pub const RVENV_CFG_FILE: &str = "rvenv.cfg"; +pub const RVENV_REPOS_FILE: &str = "repositories"; pub const RPROJ_LOCK_FILE: &str = "rproj.lock"; +/// What `R_LIBS_SITE` is set to in an active environment. +/// +/// Setting it empty does not reliably disable the site library on every R +/// version, so point it at a path that cannot exist instead. +pub const RVENV_NO_SITE: &str = "/nonexistent/rvenv-no-site"; + +/// The repository R uses when the project's manifest names none. +const RVENV_DEFAULT_REPO_URL: &str = "https://cloud.r-project.org"; + +/// The name the environment's main repository goes into the repositories +/// file under. R starts with an unresolved `CRAN = "@CRAN@"` entry in +/// `getOption("repos")`, and only a repository of that name replaces it. +const RVENV_CRAN_NAME: &str = "CRAN"; + /// The stamp file `rig proj sync` writes into the project library: a copy of /// the lock file it installed from. The shim package compares it to /// `rproj.lock` to decide whether to warn about an unsynced project. A copy @@ -138,11 +170,26 @@ fn lib_gitignore_body() -> &'static str { // -------------------------------------------------------------------- paths -- +/// `/.rvenv`, the project environment. +pub fn project_venv(root: &Path) -> PathBuf { + root.join(RVENV_DIR) +} + /// `/.rvenv/lib`, the project package library. pub fn project_library(root: &Path) -> PathBuf { root.join(RVENV_DIR).join(RVENV_LIB_SUBDIR) } +/// `/.rvenv/bin`, the wrapper scripts and the activation scripts. +pub fn project_bin(root: &Path) -> PathBuf { + root.join(RVENV_DIR).join(RVENV_BIN_SUBDIR) +} + +/// `/.rvenv/etc`, the environment's own R configuration files. +pub fn project_etc(root: &Path) -> PathBuf { + root.join(RVENV_DIR).join(RVENV_ETC_SUBDIR) +} + /// The project root at or above `start`: the nearest directory holding an /// `rproj.toml`, an `rproj.lock` or an `.rvenv` directory. pub fn find_project_root(start: &Path) -> Option { @@ -355,6 +402,348 @@ pub fn rvenv_init(root: &Path, r_version: &str) -> Result, Box.`, the version R packages are actually tied to. + pub r_minor: String, + /// The absolute path of the R binary the wrappers forward to. + pub r_binary: PathBuf, + /// The platform the lock file was solved for. + pub platform: String, + /// The architecture of the R installation. + pub r_arch: String, + /// The version of rig that wrote this file. + pub rig_version: String, +} + +impl RvenvCfg { + /// The `rvenv.cfg` body: `key = value` lines, like `pyvenv.cfg`. + pub fn body(&self) -> String { + format!( + "\ +# Managed by rig (rig proj sync). Do not edit, `rig proj sync` rewrites it. +# +# R packages are tied to the R minor version, the platform and the +# architecture, so this file records all three. +r-version = {} +r-minor = {} +r-binary = {} +platform = {} +r-arch = {} +rig = {} +", + self.r_version, + self.r_minor, + self.r_binary.display(), + self.platform, + self.r_arch, + self.rig_version + ) + } + + /// Parse an `rvenv.cfg` body. Unknown keys are ignored, so that an older + /// rig can still read a file a newer one wrote. + pub fn parse(text: &str) -> Result> { + let mut fields: std::collections::HashMap<&str, &str> = std::collections::HashMap::new(); + for line in text.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + match line.split_once('=') { + Some((key, value)) => { + fields.insert(key.trim(), value.trim()); + } + None => bail!("Malformed line in {}: {}", RVENV_CFG_FILE, line), + } + } + let get = |key: &str| -> Result> { + match fields.get(key) { + Some(value) => Ok((*value).to_string()), + None => bail!("Missing `{}` from {}", key, RVENV_CFG_FILE), + } + }; + Ok(RvenvCfg { + r_version: get("r-version")?, + r_minor: get("r-minor")?, + r_binary: PathBuf::from(get("r-binary")?), + platform: get("platform")?, + r_arch: get("r-arch")?, + rig_version: get("rig")?, + }) + } +} + +/// Read `/.rvenv/rvenv.cfg`, or `None` if the environment has never +/// been synced. +pub fn read_rvenv_cfg(root: &Path) -> Result, Box> { + let path = project_venv(root).join(RVENV_CFG_FILE); + if !path.exists() { + return Ok(None); + } + Ok(Some(RvenvCfg::parse(&fs::read_to_string(&path)?)?)) +} + +/// The environment variables that make an R session use the project. +/// +/// The one source of truth for the activation environment: the Windows +/// `.exe` shims bake this list in verbatim, and the Unix wrapper and the +/// activation scripts spell out the same variables, relative to `$RVENV`. +/// +/// `R_LIBS` is deliberately empty, so that the project library stays +/// `.libPaths()[1]`, which is where `install.packages()` writes. +pub fn rvenv_env_vars(venv: &Path) -> Vec<(String, String)> { + let venv = venv.display().to_string(); + vec![ + ("RVENV".to_string(), venv.clone()), + ( + "R_LIBS_USER".to_string(), + format!("{}{}{}", venv, std::path::MAIN_SEPARATOR, RVENV_LIB_SUBDIR), + ), + ("R_LIBS".to_string(), String::new()), + ("R_LIBS_SITE".to_string(), RVENV_NO_SITE.to_string()), + ( + "R_REPOSITORIES".to_string(), + format!( + "{}{}{}{}{}", + venv, + std::path::MAIN_SEPARATOR, + RVENV_ETC_SUBDIR, + std::path::MAIN_SEPARATOR, + RVENV_REPOS_FILE + ), + ), + ] +} + +/// The repositories the environment installs from, as `R_REPOSITORIES` +/// entries. +/// +/// `R_REPOSITORIES` is a plain environment variable R reads at startup, so it +/// survives `--vanilla` and is inherited by child processes -- unlike +/// `options(repos = )`, which neither does. +fn repositories_contents(repos: &[ManifestRepository]) -> RepositoriesContents { + let entry = |name: &str, url: &str, menu: &str| RepoFileEntry { + name: name.to_string(), + description: menu.to_string(), + url: url.to_string(), + default: true, + source: true, + win_binary: true, + mac_binary: true, + }; + let data = if repos.is_empty() { + vec![entry(RVENV_CRAN_NAME, RVENV_DEFAULT_REPO_URL, "CRAN")] + } else { + // Whatever the project's first (highest precedence) repository is + // called, it goes into the file as `CRAN`, keeping its own name as + // the menu name. R starts with an unresolved `CRAN = "@CRAN@"` + // placeholder in `getOption("repos")`, and only an entry of that + // name replaces it -- leave the placeholder in, and + // `install.packages()` fails with "trying to use CRAN without + // setting a mirror". Naming the main repository `CRAN` is the usual + // R idiom for this, the same thing `options(repos = c(CRAN = ...))` + // does. + repos + .iter() + .enumerate() + .map(|(i, r)| { + let name = if i == 0 { RVENV_CRAN_NAME } else { &r.name }; + entry(name, &r.url, &r.name) + }) + .collect() + }; + RepositoriesContents { + data, + comments: vec![( + 1, + "# Managed by rig (rig proj sync). Do not edit, `rig proj sync` rewrites it." + .to_string(), + )], + } +} + +/// Substitute the placeholders of an `src/data/rvenv` template. +fn render(template: &str, venv: &Path, name: &str, r_binary: &Path) -> String { + template + .replace("@RVENV@", &venv.display().to_string()) + .replace("@RVENV_NAME@", name) + .replace("@R_BINARY@", &r_binary.display().to_string()) + .replace("@RVENV_EXPORTS@", shell_exports().trim_end()) +} + +/// The `export` lines of the `.rvenv/bin/R` wrapper, from +/// [`rvenv_env_vars`], so that the wrapper and the Windows shims cannot +/// drift apart. +/// +/// The paths are relative to `$RVENV`, which the wrapper works out from its +/// own location -- that is what keeps the environment relocatable. `RVENV` +/// itself is left out for the same reason: the wrapper sets it. +#[cfg(not(windows))] +fn shell_exports() -> String { + rvenv_env_vars(Path::new("$RVENV")) + .into_iter() + .filter(|(name, _)| name != "RVENV") + .map(|(name, value)| format!("export {}=\"{}\"\n", name, value)) + .collect() +} + +#[cfg(windows)] +fn shell_exports() -> String { + // The Windows wrappers are `.exe` shims with the same variables baked + // into them, so there is no shell wrapper to fill in here. + String::new() +} + +/// The `Rscript` next to an `R` binary. +fn rscript_of(r_binary: &Path) -> PathBuf { + let name = if cfg!(windows) { + "Rscript.exe" + } else { + "Rscript" + }; + match r_binary.parent() { + Some(dir) => dir.join(name), + None => PathBuf::from(name), + } +} + +/// Write the machine-specific part of the `.rvenv` layout -- `rvenv.cfg`, +/// `etc/repositories`, the `bin/` wrappers and the activation scripts -- and +/// return what was written. +/// +/// Everything here is derived from `cfg` and the manifest, so this is +/// idempotent: running it twice writes the same bytes. +pub fn rvenv_sync( + root: &Path, + cfg: &RvenvCfg, + repos: &[ManifestRepository], +) -> Result, Box> { + let venv = project_venv(root); + let bin = project_bin(root); + let etc = project_etc(root); + fs::create_dir_all(&bin)?; + fs::create_dir_all(&etc)?; + + // The name in the shell prompt of an activated environment. The project + // directory's name, like Python's, not the manifest's project name: a + // prompt is about where you are. + let name = root + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .filter(|n| !n.is_empty()) + .unwrap_or_else(|| RVENV_DIR.to_string()); + + let mut written = vec![]; + + let cfg_path = venv.join(RVENV_CFG_FILE); + write_atomically(&cfg_path, cfg.body().as_bytes())?; + written.push(cfg_path); + + let repos_path = etc.join(RVENV_REPOS_FILE); + write_repositories_file( + repositories_contents(repos), + repos_path + .to_str() + .ok_or("The project path is not valid Unicode")?, + )?; + written.push(repos_path); + + written.extend(write_wrappers(&venv, &bin, &cfg.r_binary)?); + + for (file, template) in ACTIVATE_TEMPLATES { + let path = bin.join(file); + write_atomically( + &path, + render(template, &venv, &name, &cfg.r_binary).as_bytes(), + )?; + written.push(path); + } + + Ok(written) +} + +/// The activation scripts, one per shell. These are sourced, not run, so +/// they do not need the executable bit -- except `activate.bat`, which is +/// run, and which needs no bit on Windows anyway. +const ACTIVATE_TEMPLATES: &[(&str, &str)] = &[ + ("activate", include_str!("data/rvenv/activate.sh")), + ("activate.csh", include_str!("data/rvenv/activate.csh")), + ("activate.fish", include_str!("data/rvenv/activate.fish")), + ("activate.bat", include_str!("data/rvenv/activate.bat")), + ("deactivate.bat", include_str!("data/rvenv/deactivate.bat")), + ("Activate.ps1", include_str!("data/rvenv/Activate.ps1")), +]; + +/// `.rvenv/bin/R` and `.rvenv/bin/Rscript`: wrapper scripts on Unix, `.exe` +/// shims on Windows. Either way they set the environment of +/// [`rvenv_env_vars`] and then hand over to the real R. +/// +/// A symlink would not do: it resolves `R_HOME` correctly, but it carries no +/// environment, so putting `.rvenv/bin` on `PATH` would install into the +/// user's own library. +#[cfg(not(windows))] +fn write_wrappers( + venv: &Path, + bin: &Path, + r_binary: &Path, +) -> Result, Box> { + let template = include_str!("data/rvenv/R.sh"); + let mut written = vec![]; + for (file, target) in [ + ("R", r_binary.to_path_buf()), + ("Rscript", rscript_of(r_binary)), + ] { + let path = bin.join(file); + write_executable(&path, render(template, venv, "", &target).as_bytes())?; + written.push(path); + } + Ok(written) +} + +#[cfg(windows)] +fn write_wrappers( + venv: &Path, + bin: &Path, + r_binary: &Path, +) -> Result, Box> { + let envs = rvenv_env_vars(venv); + let mut written = vec![]; + for (file, target) in [ + ("R.exe", r_binary.to_path_buf()), + ("Rscript.exe", rscript_of(r_binary)), + ] { + let path = bin.join(file); + // No marker: these shims are not rig's default-version quick links, + // they belong to one project and one R installation. + crate::windows::write_shim_link_env( + &path, + target + .to_str() + .ok_or("The R installation path is not valid Unicode")?, + "", + &envs, + )?; + written.push(path); + } + Ok(written) +} + #[cfg(test)] mod tests { use super::*; @@ -542,4 +931,248 @@ mod tests { assert_eq!(written, expected); assert!(tmp.path().join(".rvenv/lib/rig/DESCRIPTION").exists()); } + + // ------------------------------------------------------------- sync -- + + /// The scripts use `@` themselves (`"$@"`, `@echo off`), so look for the + /// template's placeholders by name rather than for a stray `@`. + fn assert_no_placeholders(body: &str, file: &str) { + for placeholder in ["@RVENV@", "@RVENV_NAME@", "@R_BINARY@", "@RVENV_EXPORTS@"] { + assert!( + !body.contains(placeholder), + "{} is still in {}", + placeholder, + file + ); + } + } + + fn test_cfg() -> RvenvCfg { + RvenvCfg { + r_version: "4.6-arm64".to_string(), + r_minor: "4.6".to_string(), + r_binary: PathBuf::from("/opt/R/4.6/bin/R"), + platform: "aarch64-apple-darwin20".to_string(), + r_arch: "arm64".to_string(), + rig_version: "0.10.0".to_string(), + } + } + + #[test] + fn rvenv_cfg_roundtrips() { + let cfg = test_cfg(); + assert_eq!(RvenvCfg::parse(&cfg.body()).unwrap(), cfg); + } + + #[test] + fn rvenv_cfg_needs_every_field() { + assert!(RvenvCfg::parse("r-version = 4.6\n").is_err()); + // An unknown key is not an error: an older rig has to be able to read + // what a newer one wrote. + let mut text = test_cfg().body(); + text.push_str("something-new = 1\n"); + assert_eq!(RvenvCfg::parse(&text).unwrap(), test_cfg()); + // but a line that is not a comment and not a key = value is + assert!(RvenvCfg::parse("r-version\n").is_err()); + } + + #[test] + fn read_rvenv_cfg_is_none_before_the_first_sync() { + let tmp = tempfile::tempdir().unwrap(); + assert!(read_rvenv_cfg(tmp.path()).unwrap().is_none()); + } + + #[test] + fn rvenv_sync_writes_the_machine_specific_layout() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let written = rvenv_sync(root, &test_cfg(), &[]).unwrap(); + for path in &written { + assert!(path.exists(), "{} was not written", path.display()); + } + + let wrappers: Vec<&str> = if cfg!(windows) { + vec!["R.exe", "Rscript.exe"] + } else { + vec!["R", "Rscript"] + }; + let mut expected: Vec = vec![ + project_venv(root).join(RVENV_CFG_FILE), + project_etc(root).join(RVENV_REPOS_FILE), + ]; + expected.extend(wrappers.iter().map(|f| project_bin(root).join(f))); + expected.extend( + ACTIVATE_TEMPLATES + .iter() + .map(|(f, _)| project_bin(root).join(f)), + ); + let mut written = written; + written.sort(); + expected.sort(); + assert_eq!(written, expected); + + assert_eq!(read_rvenv_cfg(root).unwrap(), Some(test_cfg())); + } + + #[test] + fn rvenv_sync_is_idempotent() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let written = rvenv_sync(root, &test_cfg(), &[]).unwrap(); + let before: Vec> = written.iter().map(|p| fs::read(p).unwrap()).collect(); + rvenv_sync(root, &test_cfg(), &[]).unwrap(); + let after: Vec> = written.iter().map(|p| fs::read(p).unwrap()).collect(); + assert_eq!(before, after); + } + + #[cfg(not(windows))] + #[test] + fn the_wrapper_sets_the_environment_and_execs_the_real_r() { + use std::os::unix::fs::PermissionsExt; + + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let cfg = test_cfg(); + rvenv_sync(root, &cfg, &[]).unwrap(); + + for (file, binary) in [ + ("R", "/opt/R/4.6/bin/R"), + ("Rscript", "/opt/R/4.6/bin/Rscript"), + ] { + let path = project_bin(root).join(file); + let body = fs::read_to_string(&path).unwrap(); + // Absolute path, so that the wrapper pins the R version, and + // "$@" so that `R CMD INSTALL` and friends pass through. + assert!( + body.contains(&format!("exec \"{}\" \"$@\"", binary)), + "{}", + body + ); + // The environment comes from `rvenv_env_vars`, relative to + // $RVENV, which the wrapper works out from its own location. + assert!(body.contains("RVENV=$(cd \"$(dirname \"$0\")/..\" && pwd)")); + for (name, value) in rvenv_env_vars(Path::new("$RVENV")) { + if name == "RVENV" { + continue; + } + assert!( + body.contains(&format!("export {}=\"{}\"", name, value)), + "{} is not exported by {}", + name, + file + ); + } + assert_no_placeholders(&body, file); + + let mode = fs::metadata(&path).unwrap().permissions().mode(); + assert!(mode & 0o111 != 0, "{} is not executable ({:o})", file, mode); + } + } + + #[test] + fn every_activation_script_sets_the_same_variables() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + rvenv_sync(root, &test_cfg(), &[]).unwrap(); + for (file, _) in ACTIVATE_TEMPLATES { + if *file == "deactivate.bat" { + continue; + } + let body = fs::read_to_string(project_bin(root).join(file)).unwrap(); + for (name, _) in rvenv_env_vars(Path::new("/x")) { + assert!( + body.contains(name.as_str()), + "{} does not set {}", + file, + name + ); + } + // The project path is baked in: a sourced script cannot find its + // own location portably. + assert!(body.contains(&project_venv(root).display().to_string())); + assert_no_placeholders(&body, file); + } + } + + /// The data rows of a written repositories file, as + /// `(name, menu name, url)`. The first two lines are the comment and the + /// header. + fn written_repositories(root: &Path) -> Vec<(String, String, String)> { + fs::read_to_string(project_etc(root).join(RVENV_REPOS_FILE)) + .unwrap() + .lines() + .skip(2) + .map(|line| { + let f: Vec<&str> = line.split('\t').collect(); + (f[0].to_string(), f[1].to_string(), f[2].to_string()) + }) + .collect() + } + + #[test] + fn the_default_repository_is_cran() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + rvenv_sync(root, &test_cfg(), &[]).unwrap(); + assert_eq!( + written_repositories(root), + vec![( + "CRAN".to_string(), + "CRAN".to_string(), + RVENV_DEFAULT_REPO_URL.to_string() + )] + ); + } + + #[test] + fn the_first_repository_is_written_as_cran() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let repos = vec![ + ManifestRepository { + name: "internal".to_string(), + url: "https://example.com/internal".to_string(), + }, + ManifestRepository { + name: "extra".to_string(), + url: "https://example.com/extra".to_string(), + }, + ]; + rvenv_sync(root, &test_cfg(), &repos).unwrap(); + // The first one is called CRAN in the file, whatever the manifest + // calls it: R replaces its own `@CRAN@` placeholder with an entry of + // that name only, and a leftover placeholder breaks + // `install.packages()`. Its own name survives as the menu name. + assert_eq!( + written_repositories(root), + vec![ + ( + "CRAN".to_string(), + "internal".to_string(), + "https://example.com/internal".to_string() + ), + ( + "extra".to_string(), + "extra".to_string(), + "https://example.com/extra".to_string() + ), + ] + ); + } + + #[test] + fn the_environment_is_not_the_users_library() { + // The three things that keep an active session out of the user's own + // library, all of which have to survive `--vanilla`. + let vars: std::collections::HashMap = + rvenv_env_vars(Path::new("/p/.rvenv")).into_iter().collect(); + assert_eq!(vars["RVENV"], "/p/.rvenv"); + assert_eq!(vars["R_LIBS_USER"], "/p/.rvenv/lib"); + // Empty, so that the project library stays .libPaths()[1]. + assert_eq!(vars["R_LIBS"], ""); + // Not empty: an empty R_LIBS_SITE does not disable the site library + // on every R version. + assert_eq!(vars["R_LIBS_SITE"], RVENV_NO_SITE); + assert_eq!(vars["R_REPOSITORIES"], "/p/.rvenv/etc/repositories"); + } } diff --git a/src/utils.rs b/src/utils.rs index 5d8b239c..e9b13a70 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -142,6 +142,30 @@ pub fn write_atomically(path: &Path, bytes: &[u8]) -> Result<(), Box> Ok(()) } +/// Write an executable file, e.g. one of the `.rvenv/bin` wrapper scripts. +/// +/// Same as [`write_atomically`], plus the executable bit. Unix only: Windows +/// has no such bit, the extension decides there, and the `.rvenv` wrappers +/// are `.exe` shims on Windows anyway. +#[cfg(unix)] +pub fn write_executable(path: &Path, bytes: &[u8]) -> Result<(), Box> { + use nix::sys::stat::{umask, Mode}; + use std::os::unix::fs::PermissionsExt; + + write_atomically(path, bytes)?; + + // 0o777 masked by the umask, like a compiler writing an executable: + // whether the group may write is the user's decision, not ours. + // `umask()` has no read-only form, so read it by setting it and putting + // it back. + let current = umask(Mode::from_bits_truncate(0o022)); + umask(current); + // `Mode::bits()` is `mode_t`, which is u16 on macOS and u32 on Linux. + let mode = u32::from(0o777 & !current.bits()); + std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode))?; + Ok(()) +} + pub fn calculate_hash(s: &str) -> String { let mut hasher = Sha256::new(); hasher.update(s); From a5dbb68a5000d8ff10a8a6a6b5a4ce80c3aaf0d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Cs=C3=A1rdi?= Date: Wed, 2 Sep 2026 22:40:55 +0200 Subject: [PATCH 15/19] rig proj is stricter when selecting R versions `rig proj sync` installs the requested patch version. `rig proj lock` chooses an allowed version to create the lock file. --- src/help-generated.in | 4 +- src/help/proj-lock.md | 9 ++ src/help/proj-sync.md | 16 +- src/proj.rs | 335 +++++++++++++++++++++++++++++++++--------- 4 files changed, 285 insertions(+), 79 deletions(-) diff --git a/src/help-generated.in b/src/help-generated.in index 7bda8f10..04958c15 100644 --- a/src/help-generated.in +++ b/src/help-generated.in @@ -69,9 +69,9 @@ const HELP_PROJ_IMPORT: &str = "\u{1b}[1m\u{1b}[34mDescription:\u{1b}[39m\u{1b}[ const ABOUT_PROJ_INIT: &str = "Create a new R project"; const HELP_PROJ_INIT: &str = "\u{1b}[1m\u{1b}[34mDescription:\u{1b}[39m\u{1b}[22m\n Set up an R project in the current directory: the \u{1b}[32mrproj.toml\u{1b}[39m manifest, plus\n the part of the project's virtual environment (\u{1b}[32m.rvenv\u{1b}[39m) that belongs in\n version control.\n\n \u{1b}[32mrproj.toml\u{1b}[39m is rig's modern project and package file. It describes the\n project's metadata and its R and package dependencies, and can do everything\n a \u{1b}[32mDESCRIPTION\u{1b}[39m file can, plus dependency groups, optional dependencies,\n workspaces and declared scripts. \u{1b}[32mrig proj init\u{1b}[39m writes a minimal skeleton — a\n \u{1b}[32m[project]\u{1b}[39m table with the name (taken from the current directory) and\n version, and a \u{1b}[32m[dependencies]\u{1b}[39m table with a single R requirement — that you\n then fill in. Use \u{1b}[32mrig proj import\u{1b}[39m instead to take the\n dependencies from an existing \u{1b}[32mDESCRIPTION\u{1b}[39m.\n\n\u{1b}[1m\u{1b}[34mFiles:\u{1b}[39m\u{1b}[22m\n \u{1b}[32mrig proj init\u{1b}[39m creates these, and nothing else. All of them are meant to be\n committed, so that a fresh clone of the project works right away:\n\n - \u{1b}[32mrproj.toml\u{1b}[39m — the manifest. Its R requirement is \u{1b}[32m>= .\u{1b}[39m of\n the project's R version.\n - \u{1b}[32m.Renviron\u{1b}[39m — points R at the project library, \u{1b}[32m.rvenv/lib\u{1b}[39m. This is what\n makes the project work in an editor (RStudio, Positron, VS Code), which\n starts R itself.\n - \u{1b}[32m.gitignore\u{1b}[39m — a marked \u{1b}[32m# rig rvenv start\u{1b}[39m / \u{1b}[32m# rig rvenv end\u{1b}[39m block that\n ignores everything in \u{1b}[32m.rvenv\u{1b}[39m except the library directory. An existing\n \u{1b}[32m.gitignore\u{1b}[39m is \u{1b}[3mnot\u{1b}[23m replaced: rig only adds or refreshes its own block,\n and leaves the rest of the file alone.\n - \u{1b}[32m.rvenv/lib/.gitignore\u{1b}[39m — keeps the library directory itself, and the \u{1b}[32mrig\u{1b}[39m\n package in it, in version control, and ignores the installed dependencies.\n The directory has to exist in every checkout, because plain R does not\n create a missing library directory.\n - \u{1b}[32m.rvenv/lib/rig\u{1b}[39m — a small, pre-built R package that rig manages. It is not\n a dependency of your project. \u{1b}[32m.Renviron\u{1b}[39m loads it in every R session\n started in the project, where it turns the relative library path into an\n absolute one — so that R processes started from a subdirectory still use the\n project library — and warns while the project is out of sync with\n \u{1b}[32mrproj.lock\u{1b}[39m.\n\n The rest of \u{1b}[32m.rvenv\u{1b}[39m is machine-specific and is created by\n \u{1b}[32mrig proj sync\u{1b}[39m, which installs the project's dependencies\n into \u{1b}[32m.rvenv/lib\u{1b}[39m.\n\n Note that \u{1b}[32mR --vanilla\u{1b}[39m ignores \u{1b}[32m.Renviron\u{1b}[39m, and so does not use the project\n library.\n\n\u{1b}[1m\u{1b}[34mOptions:\u{1b}[39m\u{1b}[22m\n \u{1b}[32m--r-version\u{1b}[39m sets the R version the project is for. It does not have to be\n installed. Defaults to the current default R version, or the current R\n release if there is no default.\n\n rig refuses to overwrite any of the files above; pass \u{1b}[32m--force\u{1b}[39m to replace\n them. \u{1b}[32m--force\u{1b}[39m still does not rewrite the whole \u{1b}[32m.gitignore\u{1b}[39m, only rig's\n block in it."; const ABOUT_PROJ_LOCK: &str = "Resolve project dependencies and write rproj.lock"; -const HELP_PROJ_LOCK: &str = "\u{1b}[1m\u{1b}[34mDescription:\u{1b}[39m\u{1b}[22m\n Resolve the dependencies of an R project to a concrete set of package\n versions, and write the result to \u{1b}[32mrproj.lock\u{1b}[39m.\n\n rig reads the project manifest, \u{1b}[32mrproj.toml\u{1b}[39m, in the current directory, and\n uses its built-in solver to find a compatible set of package versions from\n the configured repositories, without running R.\n\n Development dependencies are included by default. Use \u{1b}[32m--r-version\u{1b}[39m to solve\n for a specific R version, \u{1b}[32m--no-dev\u{1b}[39m to leave out development dependencies,\n and \u{1b}[32m--renv\u{1b}[39m to also write the result as an \u{1b}[32mrenv.lock\u{1b}[39m file.\n\n \u{1b}[32mrproj.lock\u{1b}[39m currently records a single \u{1b}[32m(R version, platform)\u{1b}[39m target;\n \u{1b}[32mrig proj sync\u{1b}[39m installs that target. Solving for several\n targets in one lockfile is planned but not implemented yet.\n\n\u{1b}[1m\u{1b}[34mSource and binary packages:\u{1b}[39m\u{1b}[22m\n The solver considers binary packages as well as source packages, and\n prefers a binary build when one is available for the same version. Which\n artifact each package is installed from is part of what the solve decides,\n because a binary is only usable together with the exact versions of its\n \u{1b}[32mLinkingTo\u{1b}[39m dependencies that it was compiled against. If those versions\n conflict with the rest of the project, rig picks another build of that\n package, or falls back to its source tarball.\n\n By default a binary build never changes \u{1b}[3mwhich version\u{1b}[23m rig picks: the\n newest suitable version wins, and a binary of it is used if there is one.\n Pass \u{1b}[32m--prefer-binary\u{1b}[39m to let an older version win instead, when the newest\n one has no binary but an older one does — typically because a version was\n released so recently that it has not been built yet. Only the three newest\n versions of a package are considered; \u{1b}[32m--prefer-binary=5\u{1b}[39m considers five.\n Versions held back this way are marked in the output.\n\n Trading a version away for a binary is not free: the binary pins its\n \u{1b}[32mLinkingTo\u{1b}[39m dependencies to the versions it was compiled against, and those\n dependencies then prefer their own binaries in turn, so a whole project can\n end up on older versions.\n\n By default rig solves for the machine it runs on. Use \u{1b}[32m--platform\u{1b}[39m to solve\n for a different one, e.g. to write a lockfile on macOS for a Linux\n deployment:\n\n rig proj lock --platform ubuntu-24.04\n\n \u{1b}[32m--platform source\u{1b}[39m solves for source packages only, and does not download\n any binary package metadata. rig also falls back to source packages when\n there are no binaries for a platform at all. There is then nothing for\n \u{1b}[32m--prefer-binary\u{1b}[39m to prefer, and rig ignores it.\n\n rig keeps the repository metadata and the binary package indices it solves\n from in its cache, and refreshes them once a day. \u{1b}[32m--no-cache\u{1b}[39m downloads\n them again instead, and writes nothing to the cache, which is the way to\n solve against a package that was published minutes ago. It is a good deal\n slower, because the metadata it re-downloads is large. See\n \u{1b}[32mrig config\u{1b}[39m.\n\n The \u{1b}[32mrproj.lock\u{1b}[39m file records, for every package, whether it is a source or a\n binary package and the URL it is downloaded from. It also records where the\n file is cached, which is per \u{1b}[3mbuild\u{1b}[23m rather than per version: a repository\n can offer several binaries of one version for one platform and R version,\n and they are cached side by side."; +const HELP_PROJ_LOCK: &str = "\u{1b}[1m\u{1b}[34mDescription:\u{1b}[39m\u{1b}[22m\n Resolve the dependencies of an R project to a concrete set of package\n versions, and write the result to \u{1b}[32mrproj.lock\u{1b}[39m.\n\n rig reads the project manifest, \u{1b}[32mrproj.toml\u{1b}[39m, in the current directory, and\n uses its built-in solver to find a compatible set of package versions from\n the configured repositories, without running R.\n\n Development dependencies are included by default. Use \u{1b}[32m--r-version\u{1b}[39m to solve\n for a specific R version, \u{1b}[32m--no-dev\u{1b}[39m to leave out development dependencies,\n and \u{1b}[32m--renv\u{1b}[39m to also write the result as an \u{1b}[32mrenv.lock\u{1b}[39m file.\n\n \u{1b}[32mrproj.lock\u{1b}[39m currently records a single \u{1b}[32m(R version, platform)\u{1b}[39m target;\n \u{1b}[32mrig proj sync\u{1b}[39m installs that target. Solving for several\n targets in one lockfile is planned but not implemented yet.\n\n\u{1b}[1m\u{1b}[34mThe R version:\u{1b}[39m\u{1b}[22m\n Without \u{1b}[32m--r-version\u{1b}[39m rig solves for the default R version, provided the\n manifest's own \u{1b}[32mR\u{1b}[39m requirement allows it. If it does not, rig takes the newest\n installed R version that does, and failing that the current R release. The\n version it picks does not have to be installed: \u{1b}[32mrig proj lock\u{1b}[39m never runs R,\n and \u{1b}[32mrig proj sync\u{1b}[39m installs the R version the lock file\n names.\n\n\u{1b}[1m\u{1b}[34mSource and binary packages:\u{1b}[39m\u{1b}[22m\n The solver considers binary packages as well as source packages, and\n prefers a binary build when one is available for the same version. Which\n artifact each package is installed from is part of what the solve decides,\n because a binary is only usable together with the exact versions of its\n \u{1b}[32mLinkingTo\u{1b}[39m dependencies that it was compiled against. If those versions\n conflict with the rest of the project, rig picks another build of that\n package, or falls back to its source tarball.\n\n By default a binary build never changes \u{1b}[3mwhich version\u{1b}[23m rig picks: the\n newest suitable version wins, and a binary of it is used if there is one.\n Pass \u{1b}[32m--prefer-binary\u{1b}[39m to let an older version win instead, when the newest\n one has no binary but an older one does — typically because a version was\n released so recently that it has not been built yet. Only the three newest\n versions of a package are considered; \u{1b}[32m--prefer-binary=5\u{1b}[39m considers five.\n Versions held back this way are marked in the output.\n\n Trading a version away for a binary is not free: the binary pins its\n \u{1b}[32mLinkingTo\u{1b}[39m dependencies to the versions it was compiled against, and those\n dependencies then prefer their own binaries in turn, so a whole project can\n end up on older versions.\n\n By default rig solves for the machine it runs on. Use \u{1b}[32m--platform\u{1b}[39m to solve\n for a different one, e.g. to write a lockfile on macOS for a Linux\n deployment:\n\n rig proj lock --platform ubuntu-24.04\n\n \u{1b}[32m--platform source\u{1b}[39m solves for source packages only, and does not download\n any binary package metadata. rig also falls back to source packages when\n there are no binaries for a platform at all. There is then nothing for\n \u{1b}[32m--prefer-binary\u{1b}[39m to prefer, and rig ignores it.\n\n rig keeps the repository metadata and the binary package indices it solves\n from in its cache, and refreshes them once a day. \u{1b}[32m--no-cache\u{1b}[39m downloads\n them again instead, and writes nothing to the cache, which is the way to\n solve against a package that was published minutes ago. It is a good deal\n slower, because the metadata it re-downloads is large. See\n \u{1b}[32mrig config\u{1b}[39m.\n\n The \u{1b}[32mrproj.lock\u{1b}[39m file records, for every package, whether it is a source or a\n binary package and the URL it is downloaded from. It also records where the\n file is cached, which is per \u{1b}[3mbuild\u{1b}[23m rather than per version: a repository\n can offer several binaries of one version for one platform and R version,\n and they are cached side by side."; const ABOUT_PROJ_SYNC: &str = "Install the dependencies rproj.lock resolved"; -const HELP_PROJ_SYNC: &str = "\u{1b}[1m\u{1b}[34mDescription:\u{1b}[39m\u{1b}[22m\n Bring an R project's environment in line with its \u{1b}[32mrproj.lock\u{1b}[39m: install the\n resolved dependencies, and write the rest of the \u{1b}[32m.rvenv\u{1b}[39m layout.\n\n rig looks for the project in the current directory and its parents, reads its\n \u{1b}[32mrproj.lock\u{1b}[39m (written by \u{1b}[32mrig proj lock\u{1b}[39m) and installs the\n packages into the project library, \u{1b}[32m.rvenv/lib\u{1b}[39m. If the project has no\n \u{1b}[32mrproj.lock\u{1b}[39m yet, rig runs \u{1b}[32mrig proj lock\u{1b}[39m with its default\n options first, to create one. That library is created by\n \u{1b}[32mrig proj init\u{1b}[39m, together with the \u{1b}[32m.gitignore\u{1b}[39m files that\n keep it in version control, so \u{1b}[32mrig proj sync\u{1b}[39m fails if it is missing. Pass\n \u{1b}[32m--library\u{1b}[39m to install somewhere else instead.\n\n Development dependencies are installed by default. \u{1b}[32m--no-dev\u{1b}[39m leaves them\n out. \u{1b}[32m--max-concurrent\u{1b}[39m limits the number of simultaneous installations\n (default: 8).\n\n\u{1b}[1m\u{1b}[34mThe R version:\u{1b}[39m\u{1b}[22m\n The lock file records the R version its solve is valid for, and an installed\n R package is tied to the R minor version, so that is the R rig installs the\n packages with -- not whatever \u{1b}[32mR\u{1b}[39m is on the \u{1b}[32mPATH\u{1b}[39m. If that R version is\n missing, rig installs it first, the way \u{1b}[32mrig add\u{1b}[39m would; pass\n \u{1b}[32m--no-install-r\u{1b}[39m to fail instead, e.g. in CI.\n\n\u{1b}[1m\u{1b}[34mWhat sync writes:\u{1b}[39m\u{1b}[22m\n Everything below \u{1b}[32m.rvenv\u{1b}[39m, except the library and the shim package in it, is\n machine-specific, is not committed, and is rewritten on every sync:\n\n - \u{1b}[32m.rvenv/bin/R\u{1b}[39m and \u{1b}[32m.rvenv/bin/Rscript\u{1b}[39m, wrapper scripts that set the\n project's environment and then hand over to the real R. Run them directly,\n or put \u{1b}[32m.rvenv/bin\u{1b}[39m on your \u{1b}[32mPATH\u{1b}[39m. They also pass \u{1b}[32mR CMD ...\u{1b}[39m through.\n - \u{1b}[32m.rvenv/bin/activate\u{1b}[39m and its \u{1b}[32mactivate.csh\u{1b}[39m / \u{1b}[32mactivate.fish\u{1b}[39m /\n \u{1b}[32mactivate.bat\u{1b}[39m / \u{1b}[32mActivate.ps1\u{1b}[39m siblings, for the shells that prefer to be\n activated. Source the one for your shell, and \u{1b}[32mdeactivate\u{1b}[39m when you are\n done. Activation is a convenience, not a requirement: the wrappers work\n without it, and an R session started by an IDE picks the project up through\n the project's \u{1b}[32m.Renviron\u{1b}[39m.\n - \u{1b}[32m.rvenv/rvenv.cfg\u{1b}[39m, which records the R version, the platform and the\n architecture the environment was built for. rig warns when it syncs an\n environment that was built for a different R.\n - \u{1b}[32m.rvenv/etc/repositories\u{1b}[39m, the repositories from \u{1b}[32mrproj.toml\u{1b}[39m (CRAN, if it\n names none), which the wrappers point \u{1b}[32mR_REPOSITORIES\u{1b}[39m at.\n\n \u{1b}[32mR --vanilla\u{1b}[39m ignores the project's \u{1b}[32m.Renviron\u{1b}[39m, so it only stays inside the\n project when started through the wrappers.\n\n After a successful sync rig records the lock file it installed from in\n \u{1b}[32m.rvenv/lib/.synced\u{1b}[39m. The \u{1b}[32mrig\u{1b}[39m package in the project library compares the\n two, and warns in every R session while the project library does not match\n \u{1b}[32mrproj.lock\u{1b}[39m."; +const HELP_PROJ_SYNC: &str = "\u{1b}[1m\u{1b}[34mDescription:\u{1b}[39m\u{1b}[22m\n Bring an R project's environment in line with its \u{1b}[32mrproj.lock\u{1b}[39m: install the\n resolved dependencies, and write the rest of the \u{1b}[32m.rvenv\u{1b}[39m layout.\n\n rig looks for the project in the current directory and its parents, reads its\n \u{1b}[32mrproj.lock\u{1b}[39m (written by \u{1b}[32mrig proj lock\u{1b}[39m) and installs the\n packages into the project library, \u{1b}[32m.rvenv/lib\u{1b}[39m. If the project has no\n \u{1b}[32mrproj.lock\u{1b}[39m yet, rig runs \u{1b}[32mrig proj lock\u{1b}[39m with its default\n options first, to create one. That library is created by\n \u{1b}[32mrig proj init\u{1b}[39m, together with the \u{1b}[32m.gitignore\u{1b}[39m files that\n keep it in version control, so \u{1b}[32mrig proj sync\u{1b}[39m fails if it is missing. Pass\n \u{1b}[32m--library\u{1b}[39m to install somewhere else instead.\n\n Development dependencies are installed by default. \u{1b}[32m--no-dev\u{1b}[39m leaves them\n out. \u{1b}[32m--max-concurrent\u{1b}[39m limits the number of simultaneous installations\n (default: 8).\n\n\u{1b}[1m\u{1b}[34mThe R version:\u{1b}[39m\u{1b}[22m\n The lock file records the R version its solve is valid for, and that is the R\n rig installs the packages with -- not whatever \u{1b}[32mR\u{1b}[39m is on the \u{1b}[32mPATH\u{1b}[39m. It has to\n be that very version: another patch release of the same minor version would\n run the packages, but it is not the R the project was solved for, so rig does\n not quietly use it.\n\n If that R version is not installed, rig installs it first, the way\n \u{1b}[32mrig add\u{1b}[39m would; pass \u{1b}[32m--no-install-r\u{1b}[39m to fail instead, e.g. in CI.\n rig never rewrites \u{1b}[32mrproj.lock\u{1b}[39m to an R version that is already installed --\n run \u{1b}[32mrig proj lock\u{1b}[39m to change the R version a project is\n locked for.\n\n\u{1b}[1m\u{1b}[34mWhat sync writes:\u{1b}[39m\u{1b}[22m\n Everything below \u{1b}[32m.rvenv\u{1b}[39m, except the library and the shim package in it, is\n machine-specific, is not committed, and is rewritten on every sync:\n\n - \u{1b}[32m.rvenv/bin/R\u{1b}[39m and \u{1b}[32m.rvenv/bin/Rscript\u{1b}[39m, wrapper scripts that set the\n project's environment and then hand over to the real R. Run them directly,\n or put \u{1b}[32m.rvenv/bin\u{1b}[39m on your \u{1b}[32mPATH\u{1b}[39m. They also pass \u{1b}[32mR CMD ...\u{1b}[39m through.\n - \u{1b}[32m.rvenv/bin/activate\u{1b}[39m and its \u{1b}[32mactivate.csh\u{1b}[39m / \u{1b}[32mactivate.fish\u{1b}[39m /\n \u{1b}[32mactivate.bat\u{1b}[39m / \u{1b}[32mActivate.ps1\u{1b}[39m siblings, for the shells that prefer to be\n activated. Source the one for your shell, and \u{1b}[32mdeactivate\u{1b}[39m when you are\n done. Activation is a convenience, not a requirement: the wrappers work\n without it, and an R session started by an IDE picks the project up through\n the project's \u{1b}[32m.Renviron\u{1b}[39m.\n - \u{1b}[32m.rvenv/rvenv.cfg\u{1b}[39m, which records the R version, the platform and the\n architecture the environment was built for. rig warns when it syncs an\n environment that was built for a different R.\n - \u{1b}[32m.rvenv/etc/repositories\u{1b}[39m, the repositories from \u{1b}[32mrproj.toml\u{1b}[39m (CRAN, if it\n names none), which the wrappers point \u{1b}[32mR_REPOSITORIES\u{1b}[39m at.\n\n \u{1b}[32mR --vanilla\u{1b}[39m ignores the project's \u{1b}[32m.Renviron\u{1b}[39m, so it only stays inside the\n project when started through the wrappers.\n\n After a successful sync rig records the lock file it installed from in\n \u{1b}[32m.rvenv/lib/.synced\u{1b}[39m. The \u{1b}[32mrig\u{1b}[39m package in the project library compares the\n two, and warns in every R session while the project library does not match\n \u{1b}[32mrproj.lock\u{1b}[39m."; const ABOUT_PROJ_TREE: &str = "Dependency tree of a project"; const HELP_PROJ_TREE: &str = "\u{1b}[1m\u{1b}[34mDescription:\u{1b}[39m\u{1b}[22m\n Show everything an R project needs, directly or indirectly, as a tree: the\n same closure \u{1b}[32mrig proj deps --recursive\u{1b}[39m lists in a flat\n table, laid out by the shape of the dependency graph.\n\n myproject 0.1.0 — 3 direct, 24 total\n ├── R (>= 4.1) [D]\n ├── cli 3.6.4\n │ ├── R (>= 3.4) [D]\n │ └── utils\n └── dplyr 1.1.4 (>= 1.1.0)\n ├── cli 3.6.4 (>= 3.4.0) (*)\n └── vctrs 0.6.5 (>= 0.6.4)\n └── cpp11 0.5.2 [L]\n [Suggests]\n └── testthat 3.2.3 (>= 3.1.5)\n\n The first line names the project and its version, how many dependencies it\n declares directly and how many distinct packages there are in the whole tree.\n Each line below names a package, the version currently in the repositories,\n and the version requirement it is needed with, if it has one.\n\n rig reads the project manifest, \u{1b}[32mrproj.toml\u{1b}[39m, in the current directory. Unlike\n \u{1b}[32mrig proj deps\u{1b}[39m, the tree needs the package metadata of the\n repositories, which rig downloads if it does not have it yet. It does not need\n R. \u{1b}[32m--json\u{1b}[39m gives machine readable output, as one nested object.\n\n\u{1b}[1m\u{1b}[34mReading the tree:\u{1b}[39m\u{1b}[22m\n A package that several others need is expanded only once, under its first\n occurrence; later occurrences are marked \u{1b}[32m(*)\u{1b}[39m, meaning \"its dependencies are\n above\". \u{1b}[32m--dev\u{1b}[39m adds the project's development dependencies, in their own\n \u{1b}[32m[Suggests]\u{1b}[39m and \u{1b}[32m[Enhances]\u{1b}[39m sections; \u{1b}[32m--no-base\u{1b}[39m leaves out R and the base\n packages. Among the hard dependencies, \u{1b}[32mImports\u{1b}[39m is not marked, \u{1b}[32m[D]\u{1b}[39m is a\n \u{1b}[32mDepends\u{1b}[39m, \u{1b}[32m[L]\u{1b}[39m a \u{1b}[32mLinkingTo\u{1b}[39m, \u{1b}[32m[DL]\u{1b}[39m both.\n\n \u{1b}[32m--why \u{1b}[39m (alias \u{1b}[32m--explain\u{1b}[39m) inverts the tree, so that the named\n package is the root and the tree grows towards the packages that need it, down\n to the project itself. Each line then says how \u{1b}[3mthat\u{1b}[23m package needs the one\n above it, hence \u{1b}[32mneeds\u{1b}[39m.\n\n \u{1b}[32mrig pkg tree\u{1b}[39m, which shows the same tree for a package\n in the repositories, describes all of this in full.\n\n rig follows the dependencies of the \u{1b}[3mlatest\u{1b}[23m version of every package in the\n tree, so a version requirement that would force an older version, with\n different dependencies, is not taken into account. Use\n \u{1b}[32mrig proj lock\u{1b}[39m for a resolution that is consistent across\n versions."; const ABOUT_PROJ: &str = "Manage R projects (experimental)"; diff --git a/src/help/proj-lock.md b/src/help/proj-lock.md index b5c48c2c..7aac75bb 100644 --- a/src/help/proj-lock.md +++ b/src/help/proj-lock.md @@ -17,6 +17,15 @@ and `--renv` to also write the result as an `renv.lock` file. [`rig proj sync`](#rig-proj-sync) installs that target. Solving for several targets in one lockfile is planned but not implemented yet. +## The R version + +Without `--r-version` rig solves for the default R version, provided the +manifest's own `R` requirement allows it. If it does not, rig takes the newest +installed R version that does, and failing that the current R release. The +version it picks does not have to be installed: `rig proj lock` never runs R, +and [`rig proj sync`](#rig-proj-sync) installs the R version the lock file +names. + ## Source and binary packages The solver considers binary packages as well as source packages, and diff --git a/src/help/proj-sync.md b/src/help/proj-sync.md index 6bfb66e6..b968e7ac 100644 --- a/src/help/proj-sync.md +++ b/src/help/proj-sync.md @@ -20,11 +20,17 @@ out. `--max-concurrent` limits the number of simultaneous installations ## The R version -The lock file records the R version its solve is valid for, and an installed -R package is tied to the R minor version, so that is the R rig installs the -packages with -- not whatever `R` is on the `PATH`. If that R version is -missing, rig installs it first, the way [`rig add`](add.qmd) would; pass -`--no-install-r` to fail instead, e.g. in CI. +The lock file records the R version its solve is valid for, and that is the R +rig installs the packages with -- not whatever `R` is on the `PATH`. It has to +be that very version: another patch release of the same minor version would +run the packages, but it is not the R the project was solved for, so rig does +not quietly use it. + +If that R version is not installed, rig installs it first, the way +[`rig add`](add.qmd) would; pass `--no-install-r` to fail instead, e.g. in CI. +rig never rewrites `rproj.lock` to an R version that is already installed -- +run [`rig proj lock`](#rig-proj-lock) to change the R version a project is +locked for. ## What sync writes diff --git a/src/proj.rs b/src/proj.rs index 207f8ac6..475ee70c 100644 --- a/src/proj.rs +++ b/src/proj.rs @@ -41,7 +41,6 @@ use crate::rvenv::{ existing_targets, find_project_root, project_library, read_rvenv_cfg, rvenv_init, rvenv_sync, write_sync_stamp, RvenvCfg, RPROJ_LOCK_FILE, }; -use crate::rversion::InstalledVersion; use crate::solver::*; use crate::utils::create_parent_dir_if_needed; @@ -635,28 +634,115 @@ fn sc_proj_lock( dev: !args.get_flag("no-dev"), renv: args.get_flag("renv"), }; - proj_lock(Path::new("."), &opts) + proj_lock(Path::new("."), &opts, args) } -/// Solve the dependencies of the project in `root` and write `rproj.lock` -/// (and `renv.lock` with `--renv`) into it. -fn proj_lock(root: &Path, opts: &ProjLockOptions) -> Result<(), Box> { - let rver = match &opts.r_version { - Some(rv) => rv.to_string(), - None => match get_default_r_version()? { - Some(rv) => rv, - None => { - OUTPUT.error("Cannot determine R version, please specify it with --r-version."); - error!("Cannot determine R version, please specify it with --r-version."); - bail!("Cannot determine R version, please specify it with --r-version.") - } - }, +/// The R version to solve the project for, when the caller did not name one: +/// the default R version if the manifest's `R` requirement allows it, else the +/// newest installed R that does, else the current R release. +/// +/// The version does not have to be installed. `rig proj lock` never runs R, +/// and `rig proj sync` installs the R version the lock file names. +fn proj_lock_r_version( + deps: &PackageDependencies, + args: &ArgMatches, +) -> Result> { + let req = deps.dependencies.iter().find(|d| d.name == "R"); + let allowed = |version: &str| match req { + Some(req) => req.satisfies(version).unwrap_or(false), + None => true, }; + if let Some(rv) = get_default_r_version()? { + if allowed(&rv) { + return Ok(rv); + } + info!( + "The default R ({}) does not satisfy the project's R {}", + rv, + r_requirement(req) + ); + } + + // The newest installed R the manifest allows, so that a project needing + // an R other than the default one does not have to download one. + let mut installed: Vec = sc_get_list_details()? + .iter() + .filter_map(|v| v.version.as_deref()) + .filter(|v| allowed(v)) + .filter_map(|v| RPackageVersion::from_str(v).ok()) + .collect(); + installed.sort(); + if let Some(rv) = installed.pop() { + let msg = format!( + "Solving for R {}, the project needs R {}.", + rv, + r_requirement(req) + ); + OUTPUT.info(&msg); + info!("{}", msg); + return Ok(rv.original); + } + + // Nothing installed will do, so the project needs an R it does not have + // yet. The current release is the only version to pick without asking, + // and `rig proj sync` installs it. + match resolve_release_r_version(args) { + Some(rv) if allowed(&rv) => { + let msg = format!( + "Solving for the current R release ({}), the project needs R {}.", + rv, + r_requirement(req) + ); + OUTPUT.info(&msg); + info!("{}", msg); + Ok(rv) + } + _ => { + let msg = format!( + "No R version satisfies the project's R {}, specify one with --r-version.", + r_requirement(req) + ); + OUTPUT.error(&msg); + error!("{}", msg); + bail!("{}", msg) + } + } +} + +/// A manifest's `R` requirement as it reads in `rproj.toml`, e.g. `>= 4.5`, +/// for the messages of [`proj_lock_r_version`]. +fn r_requirement(req: Option<&DepVersionSpec>) -> String { + let constraints = match req { + Some(req) => &req.constraints, + None => return "*".to_string(), + }; + if constraints.is_empty() { + return "*".to_string(); + } + constraints + .iter() + .map(|c| format!("{} {}", c.constraint_type, c.version)) + .collect::>() + .join(", ") +} + +/// Solve the dependencies of the project in `root` and write `rproj.lock` +/// (and `renv.lock` with `--renv`) into it. +fn proj_lock(root: &Path, opts: &ProjLockOptions, args: &ArgMatches) -> Result<(), Box> { // Do this first, to report local errors early let dev = opts.dev; let (_name, _version, mut pkg_deps) = proj_read_manifest_deps(root, dev)?; + // The R version has to satisfy the manifest's own `R` requirement, + // otherwise the solve either fails or produces a lock file for an R the + // project rules out. `--r-version` is taken as given, the solver reports + // the conflict if there is one. + let rver = match &opts.r_version { + Some(rv) => rv.to_string(), + None => proj_lock_r_version(&pkg_deps, args)?, + }; + if opts.renv { pkg_deps.dependencies.push(DepVersionSpec { name: "renv".to_string(), @@ -765,27 +851,31 @@ fn nondev_packages( Ok(keep) } -/// The R installation an environment for `r_version` uses: its name, as -/// `rig list` shows it, and the absolute path of its R binary. +/// The R installation an environment for `r_version` on `arch` uses: its +/// name, as `rig list` shows it, and the absolute path of its R binary. /// -/// The lock file records the R version its solve is valid for, and an -/// installed R package is tied to the R minor version, so this is not a -/// preference, it is what the environment *is*. When that R is missing and -/// `install` is set, install it first -- the thing renv cannot do. +/// The lock file records the R version and the platform its solve is valid +/// for, so neither is a preference, they are what the environment *is*. When +/// that R is missing and `install` is set, install it first -- the thing renv +/// cannot do. fn rvenv_r_installation( r_version: &str, + arch: &str, install: bool, ) -> Result<(String, PathBuf), Box> { - if let Some(name) = find_r_installation(r_version)? { + if let Some(name) = find_r_installation(r_version, arch)? { let binary = get_r_binary(&name)?; return Ok((name, binary)); } + let add_args = r_add_args(r_version, arch); if !install { let msg = format!( - "R {} is not installed, install it with `rig add {}` \ + "R {} ({}) is not installed, install it with `rig {}` \ (or drop --no-install-r)", - r_version, r_version + r_version, + arch, + add_args[1..].join(" ") ); OUTPUT.error(&msg); error!("{}", msg); @@ -793,26 +883,32 @@ fn rvenv_r_installation( } OUTPUT.status(&format!( - "R {} is not installed, installing it now", - r_version + "R {} ({}) is not installed, installing it now", + r_version, arch )); - info!("R {} is not installed, installing it now", r_version); + info!( + "R {} ({}) is not installed, installing it now", + r_version, arch + ); // `rig add` is a subcommand, not a function that takes a version, so go // through clap. It escalates on its own in admin mode. - let matches = rig_app().try_get_matches_from(["rig", "add", r_version])?; + let matches = rig_app().try_get_matches_from(add_args)?; let (_name, addargs) = match matches.subcommand() { Some(x) => x, None => bail!("Internal error: `rig add` did not parse"), }; sc_add(addargs)?; - match find_r_installation(r_version)? { + match find_r_installation(r_version, arch)? { Some(name) => { let binary = get_r_binary(&name)?; Ok((name, binary)) } None => { - let msg = format!("Installed R {}, but cannot find it now", r_version); + let msg = format!( + "Installed R {} ({}), but cannot find it now", + r_version, arch + ); OUTPUT.error(&msg); error!("{}", msg); bail!("{}", msg) @@ -820,49 +916,82 @@ fn rvenv_r_installation( } } -/// The installed R that matches `r_version`: the installation of that name, -/// or, failing that, one with the same minor version -- the lock file records -/// a minor version like `4.6`, while an installation can be called `4.6.1` or -/// `4.6-arm64`. A native-architecture installation wins over a foreign one. -fn find_r_installation(r_version: &str) -> Result, Box> { - let installed = sc_get_list_details()?; - if let Some(exact) = installed.iter().find(|v| v.name == r_version) { - return Ok(Some(exact.name.clone())); +/// The `rig add` command line that installs `r_version` for `arch`. Only +/// macOS has R builds for more than one architecture, and only there does +/// `rig add` take `--arch`. +fn r_add_args(r_version: &str, arch: &str) -> Vec { + let mut args = vec!["rig".to_string(), "add".to_string()]; + if cfg!(target_os = "macos") { + args.push("--arch".to_string()); + args.push(arch.to_string()); } + args.push(r_version.to_string()); + args +} - let want = r_minor_of(r_version); - let native = std::env::consts::ARCH; - let mut best: Option<&InstalledVersion> = None; - for candidate in installed.iter() { - // An installation with no version, or a version that is not a number - // (`devel`, `next`), is never a match for a lock file's R version. - let version = match &candidate.version { - Some(v) => v, - None => continue, - }; - if want.is_none() || r_minor_of(version) != want { - continue; +/// The installed R that matches `r_version` on `arch`: the installation of +/// that name, or, failing that, one with the very same version -- the lock +/// file records a version like `4.6.1`, while an installation of it can be +/// called `4.6.1` or `4.6.1-arm64`. +/// +/// The architecture has to match too: an R of another architecture cannot use +/// the packages the lock file resolved, whatever its version. +fn find_r_installation(r_version: &str, arch: &str) -> Result, Box> { + let installed = sc_get_list_details()?; + let matching = installed.iter().find(|candidate| { + if rvenv_r_arch(&candidate.name) != arch { + return false; } - let arch = rvenv_r_arch(&candidate.name); - let is_native = arch == native || (native == "aarch64" && arch == "arm64"); - if is_native || best.is_none() { - best = Some(candidate); + if candidate.name == r_version { + return true; } - if is_native { - break; + // An installation with no version, or a version that is not a number + // (`devel`, `next`), is never a match for a lock file's R version. + match &candidate.version { + Some(version) => r_version_matches(r_version, version), + None => false, } + }); + Ok(matching.map(|v| v.name.clone())) +} + +/// The architecture the lock file's target platform needs, in the form +/// [`rvenv_r_arch`] reports it, or the machine's own if the platform does not +/// name one. +fn target_r_arch(platform: &str) -> String { + match platform.rsplit_once('-') { + Some((_, "arm64")) | Some((_, "aarch64")) => native_arch_name("aarch64"), + Some((_, "x86_64")) => "x86_64".to_string(), + _ => native_arch_name(std::env::consts::ARCH), } - Ok(best.map(|v| v.name.clone())) } -/// `.` of an R version, or `None` if it is not a version number -/// at all (`devel`, `next`). Quiet, unlike `minor_r_version`, because this -/// runs over every installed R version, most of which do not match anyway. -fn r_minor_of(version: &str) -> Option { - let mut parts = version.split('.'); - let major: u32 = parts.next()?.parse().ok()?; - let minor: u32 = parts.next().unwrap_or("0").parse().ok()?; - Some(format!("{}.{}", major, minor)) +/// Whether an installed R version is the one the lock file asks for: the same +/// version, or, if the lock file names a minor version only (`4.6`), any patch +/// release of it (`4.6.1`). +/// +/// Another patch release of the same minor version is not a match. R packages +/// are compatible across patch releases, so using one would work, but the lock +/// file says which R the project is for, and `rig proj sync` installs that one +/// instead of silently building the environment for a different R. +fn r_version_matches(want: &str, have: &str) -> bool { + if want == have { + return true; + } + let (want, have) = match (r_components(want), r_components(have)) { + (Some(w), Some(h)) => (w, h), + _ => return false, + }; + !want.is_empty() && want.len() < 3 && have.len() >= want.len() && have[..want.len()] == want[..] +} + +/// The numeric components of an R version, or `None` if it is not a version +/// number at all (`devel`, `next`). Quiet, unlike `minor_r_version`, because +/// this runs over every installed R version, most of which do not match anyway. +fn r_components(version: &str) -> Option> { + RPackageVersion::from_str(version) + .ok() + .map(|v| v.components) } /// The architecture of an R installation, from its name (`4.6-arm64`), or the @@ -870,11 +999,17 @@ fn r_minor_of(version: &str) -> Option { fn rvenv_r_arch(name: &str) -> String { match name.rsplit_once('-') { Some((_, arch)) if arch == "arm64" || arch == "x86_64" => arch.to_string(), - // rig calls it `arm64` on macOS and `aarch64` everywhere else. - _ => match std::env::consts::ARCH { - "aarch64" if cfg!(target_os = "macos") => "arm64".to_string(), - other => other.to_string(), - }, + _ => native_arch_name(std::env::consts::ARCH), + } +} + +/// An architecture the way rig names it: `arm64` on macOS and `aarch64` +/// everywhere else. +fn native_arch_name(arch: &str) -> String { + match arch { + "aarch64" | "arm64" if cfg!(target_os = "macos") => "arm64".to_string(), + "arm64" => "aarch64".to_string(), + other => other.to_string(), } } @@ -902,7 +1037,7 @@ fn sc_proj_sync( RPROJ_LOCK_FILE )); info!("No {}, running `rig proj lock` first", RPROJ_LOCK_FILE); - proj_lock(&root, &ProjLockOptions::default())?; + proj_lock(&root, &ProjLockOptions::default(), args)?; } let lock_content = fs::read_to_string(&lock_path)?; @@ -949,8 +1084,12 @@ fn sc_proj_sync( // solved for, so resolve (and, unless --no-install-r, install) it before // touching the library: installed R packages are tied to the R minor // version, so the R on `PATH` is not good enough. + // The architecture comes from the lock file's platform, not from the + // machine: a lock file solved for macos-x86_64 needs an x86_64 R even on + // an arm64 Mac. + let r_arch = target_r_arch(&target.platform); let (r_name, r_binary) = - rvenv_r_installation(&target.r_version, !args.get_flag("no-install-r"))?; + rvenv_r_installation(&target.r_version, &r_arch, !args.get_flag("no-install-r"))?; // The project environment, as opposed to an arbitrary `--library`, also // owns the wrappers, the activation scripts and the sync stamp. @@ -1254,6 +1393,58 @@ mod tests { Dependency::Version(version.to_string()) } + #[test] + fn a_lock_files_r_version_matches_that_version_only() { + assert!(r_version_matches("4.6.1", "4.6.1")); + // A different patch release is a different R + assert!(!r_version_matches("4.6.1", "4.6.0")); + assert!(!r_version_matches("4.6.1", "4.6.2")); + assert!(!r_version_matches("4.6.1", "4.5.1")); + // A minor version matches all of its patch releases + assert!(r_version_matches("4.6", "4.6.1")); + assert!(r_version_matches("4.6", "4.6")); + assert!(!r_version_matches("4.6", "4.5.1")); + // `devel` and `next` are not version numbers + assert!(!r_version_matches("4.6.1", "devel")); + assert!(!r_version_matches("devel", "4.6.1")); + assert!(r_version_matches("devel", "devel")); + } + + #[test] + fn the_target_platform_decides_the_architecture() { + let native = native_arch_name(std::env::consts::ARCH); + assert_eq!(target_r_arch("macos-x86_64"), "x86_64"); + assert_eq!(target_r_arch("linux-ubuntu-24.04-x86_64"), "x86_64"); + assert_eq!(target_r_arch("windows"), native); + assert_eq!(target_r_arch("source"), native); + if cfg!(target_os = "macos") { + assert_eq!(target_r_arch("macos-arm64"), "arm64"); + assert_eq!(target_r_arch("macos-aarch64"), "arm64"); + } else { + assert_eq!(target_r_arch("linux-ubuntu-24.04-aarch64"), "aarch64"); + } + } + + #[test] + fn r_is_installed_for_the_architecture_the_lock_file_needs() { + assert_eq!( + r_add_args("4.6.1", "x86_64"), + if cfg!(target_os = "macos") { + vec!["rig", "add", "--arch", "x86_64", "4.6.1"] + } else { + vec!["rig", "add", "4.6.1"] + } + ); + } + + #[test] + fn the_r_requirement_reads_as_it_does_in_the_manifest() { + let deps = Rproj::minimal("mypkg").to_dep_version_specs(false).unwrap(); + let req = deps.dependencies.iter().find(|d| d.name == "R"); + assert_eq!(r_requirement(req), ">= 4.1"); + assert_eq!(r_requirement(None), "*"); + } + #[test] fn nondev_packages_keeps_the_non_dev_closure_only() { let dir = tempfile::tempdir().unwrap(); From 540a4b7798966d98d952556e821c125de9b0b6ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Cs=C3=A1rdi?= Date: Wed, 2 Sep 2026 22:54:53 +0200 Subject: [PATCH 16/19] Projects configure PPM as CRAN repository Should not be really important, projects should not use `install.packages()` or `pak::pkg_install()` or other ways to install packages from within R. --- src/help-generated.in | 2 +- src/help/proj-sync.md | 9 +- src/rvenv.rs | 213 +++++++++++++++++++++++++++++++++++------- 3 files changed, 189 insertions(+), 35 deletions(-) diff --git a/src/help-generated.in b/src/help-generated.in index 04958c15..60293cbe 100644 --- a/src/help-generated.in +++ b/src/help-generated.in @@ -71,7 +71,7 @@ const HELP_PROJ_INIT: &str = "\u{1b}[1m\u{1b}[34mDescription:\u{1b}[39m\u{1b}[22 const ABOUT_PROJ_LOCK: &str = "Resolve project dependencies and write rproj.lock"; const HELP_PROJ_LOCK: &str = "\u{1b}[1m\u{1b}[34mDescription:\u{1b}[39m\u{1b}[22m\n Resolve the dependencies of an R project to a concrete set of package\n versions, and write the result to \u{1b}[32mrproj.lock\u{1b}[39m.\n\n rig reads the project manifest, \u{1b}[32mrproj.toml\u{1b}[39m, in the current directory, and\n uses its built-in solver to find a compatible set of package versions from\n the configured repositories, without running R.\n\n Development dependencies are included by default. Use \u{1b}[32m--r-version\u{1b}[39m to solve\n for a specific R version, \u{1b}[32m--no-dev\u{1b}[39m to leave out development dependencies,\n and \u{1b}[32m--renv\u{1b}[39m to also write the result as an \u{1b}[32mrenv.lock\u{1b}[39m file.\n\n \u{1b}[32mrproj.lock\u{1b}[39m currently records a single \u{1b}[32m(R version, platform)\u{1b}[39m target;\n \u{1b}[32mrig proj sync\u{1b}[39m installs that target. Solving for several\n targets in one lockfile is planned but not implemented yet.\n\n\u{1b}[1m\u{1b}[34mThe R version:\u{1b}[39m\u{1b}[22m\n Without \u{1b}[32m--r-version\u{1b}[39m rig solves for the default R version, provided the\n manifest's own \u{1b}[32mR\u{1b}[39m requirement allows it. If it does not, rig takes the newest\n installed R version that does, and failing that the current R release. The\n version it picks does not have to be installed: \u{1b}[32mrig proj lock\u{1b}[39m never runs R,\n and \u{1b}[32mrig proj sync\u{1b}[39m installs the R version the lock file\n names.\n\n\u{1b}[1m\u{1b}[34mSource and binary packages:\u{1b}[39m\u{1b}[22m\n The solver considers binary packages as well as source packages, and\n prefers a binary build when one is available for the same version. Which\n artifact each package is installed from is part of what the solve decides,\n because a binary is only usable together with the exact versions of its\n \u{1b}[32mLinkingTo\u{1b}[39m dependencies that it was compiled against. If those versions\n conflict with the rest of the project, rig picks another build of that\n package, or falls back to its source tarball.\n\n By default a binary build never changes \u{1b}[3mwhich version\u{1b}[23m rig picks: the\n newest suitable version wins, and a binary of it is used if there is one.\n Pass \u{1b}[32m--prefer-binary\u{1b}[39m to let an older version win instead, when the newest\n one has no binary but an older one does — typically because a version was\n released so recently that it has not been built yet. Only the three newest\n versions of a package are considered; \u{1b}[32m--prefer-binary=5\u{1b}[39m considers five.\n Versions held back this way are marked in the output.\n\n Trading a version away for a binary is not free: the binary pins its\n \u{1b}[32mLinkingTo\u{1b}[39m dependencies to the versions it was compiled against, and those\n dependencies then prefer their own binaries in turn, so a whole project can\n end up on older versions.\n\n By default rig solves for the machine it runs on. Use \u{1b}[32m--platform\u{1b}[39m to solve\n for a different one, e.g. to write a lockfile on macOS for a Linux\n deployment:\n\n rig proj lock --platform ubuntu-24.04\n\n \u{1b}[32m--platform source\u{1b}[39m solves for source packages only, and does not download\n any binary package metadata. rig also falls back to source packages when\n there are no binaries for a platform at all. There is then nothing for\n \u{1b}[32m--prefer-binary\u{1b}[39m to prefer, and rig ignores it.\n\n rig keeps the repository metadata and the binary package indices it solves\n from in its cache, and refreshes them once a day. \u{1b}[32m--no-cache\u{1b}[39m downloads\n them again instead, and writes nothing to the cache, which is the way to\n solve against a package that was published minutes ago. It is a good deal\n slower, because the metadata it re-downloads is large. See\n \u{1b}[32mrig config\u{1b}[39m.\n\n The \u{1b}[32mrproj.lock\u{1b}[39m file records, for every package, whether it is a source or a\n binary package and the URL it is downloaded from. It also records where the\n file is cached, which is per \u{1b}[3mbuild\u{1b}[23m rather than per version: a repository\n can offer several binaries of one version for one platform and R version,\n and they are cached side by side."; const ABOUT_PROJ_SYNC: &str = "Install the dependencies rproj.lock resolved"; -const HELP_PROJ_SYNC: &str = "\u{1b}[1m\u{1b}[34mDescription:\u{1b}[39m\u{1b}[22m\n Bring an R project's environment in line with its \u{1b}[32mrproj.lock\u{1b}[39m: install the\n resolved dependencies, and write the rest of the \u{1b}[32m.rvenv\u{1b}[39m layout.\n\n rig looks for the project in the current directory and its parents, reads its\n \u{1b}[32mrproj.lock\u{1b}[39m (written by \u{1b}[32mrig proj lock\u{1b}[39m) and installs the\n packages into the project library, \u{1b}[32m.rvenv/lib\u{1b}[39m. If the project has no\n \u{1b}[32mrproj.lock\u{1b}[39m yet, rig runs \u{1b}[32mrig proj lock\u{1b}[39m with its default\n options first, to create one. That library is created by\n \u{1b}[32mrig proj init\u{1b}[39m, together with the \u{1b}[32m.gitignore\u{1b}[39m files that\n keep it in version control, so \u{1b}[32mrig proj sync\u{1b}[39m fails if it is missing. Pass\n \u{1b}[32m--library\u{1b}[39m to install somewhere else instead.\n\n Development dependencies are installed by default. \u{1b}[32m--no-dev\u{1b}[39m leaves them\n out. \u{1b}[32m--max-concurrent\u{1b}[39m limits the number of simultaneous installations\n (default: 8).\n\n\u{1b}[1m\u{1b}[34mThe R version:\u{1b}[39m\u{1b}[22m\n The lock file records the R version its solve is valid for, and that is the R\n rig installs the packages with -- not whatever \u{1b}[32mR\u{1b}[39m is on the \u{1b}[32mPATH\u{1b}[39m. It has to\n be that very version: another patch release of the same minor version would\n run the packages, but it is not the R the project was solved for, so rig does\n not quietly use it.\n\n If that R version is not installed, rig installs it first, the way\n \u{1b}[32mrig add\u{1b}[39m would; pass \u{1b}[32m--no-install-r\u{1b}[39m to fail instead, e.g. in CI.\n rig never rewrites \u{1b}[32mrproj.lock\u{1b}[39m to an R version that is already installed --\n run \u{1b}[32mrig proj lock\u{1b}[39m to change the R version a project is\n locked for.\n\n\u{1b}[1m\u{1b}[34mWhat sync writes:\u{1b}[39m\u{1b}[22m\n Everything below \u{1b}[32m.rvenv\u{1b}[39m, except the library and the shim package in it, is\n machine-specific, is not committed, and is rewritten on every sync:\n\n - \u{1b}[32m.rvenv/bin/R\u{1b}[39m and \u{1b}[32m.rvenv/bin/Rscript\u{1b}[39m, wrapper scripts that set the\n project's environment and then hand over to the real R. Run them directly,\n or put \u{1b}[32m.rvenv/bin\u{1b}[39m on your \u{1b}[32mPATH\u{1b}[39m. They also pass \u{1b}[32mR CMD ...\u{1b}[39m through.\n - \u{1b}[32m.rvenv/bin/activate\u{1b}[39m and its \u{1b}[32mactivate.csh\u{1b}[39m / \u{1b}[32mactivate.fish\u{1b}[39m /\n \u{1b}[32mactivate.bat\u{1b}[39m / \u{1b}[32mActivate.ps1\u{1b}[39m siblings, for the shells that prefer to be\n activated. Source the one for your shell, and \u{1b}[32mdeactivate\u{1b}[39m when you are\n done. Activation is a convenience, not a requirement: the wrappers work\n without it, and an R session started by an IDE picks the project up through\n the project's \u{1b}[32m.Renviron\u{1b}[39m.\n - \u{1b}[32m.rvenv/rvenv.cfg\u{1b}[39m, which records the R version, the platform and the\n architecture the environment was built for. rig warns when it syncs an\n environment that was built for a different R.\n - \u{1b}[32m.rvenv/etc/repositories\u{1b}[39m, the repositories from \u{1b}[32mrproj.toml\u{1b}[39m (CRAN, if it\n names none), which the wrappers point \u{1b}[32mR_REPOSITORIES\u{1b}[39m at.\n\n \u{1b}[32mR --vanilla\u{1b}[39m ignores the project's \u{1b}[32m.Renviron\u{1b}[39m, so it only stays inside the\n project when started through the wrappers.\n\n After a successful sync rig records the lock file it installed from in\n \u{1b}[32m.rvenv/lib/.synced\u{1b}[39m. The \u{1b}[32mrig\u{1b}[39m package in the project library compares the\n two, and warns in every R session while the project library does not match\n \u{1b}[32mrproj.lock\u{1b}[39m."; +const HELP_PROJ_SYNC: &str = "\u{1b}[1m\u{1b}[34mDescription:\u{1b}[39m\u{1b}[22m\n Bring an R project's environment in line with its \u{1b}[32mrproj.lock\u{1b}[39m: install the\n resolved dependencies, and write the rest of the \u{1b}[32m.rvenv\u{1b}[39m layout.\n\n rig looks for the project in the current directory and its parents, reads its\n \u{1b}[32mrproj.lock\u{1b}[39m (written by \u{1b}[32mrig proj lock\u{1b}[39m) and installs the\n packages into the project library, \u{1b}[32m.rvenv/lib\u{1b}[39m. If the project has no\n \u{1b}[32mrproj.lock\u{1b}[39m yet, rig runs \u{1b}[32mrig proj lock\u{1b}[39m with its default\n options first, to create one. That library is created by\n \u{1b}[32mrig proj init\u{1b}[39m, together with the \u{1b}[32m.gitignore\u{1b}[39m files that\n keep it in version control, so \u{1b}[32mrig proj sync\u{1b}[39m fails if it is missing. Pass\n \u{1b}[32m--library\u{1b}[39m to install somewhere else instead.\n\n Development dependencies are installed by default. \u{1b}[32m--no-dev\u{1b}[39m leaves them\n out. \u{1b}[32m--max-concurrent\u{1b}[39m limits the number of simultaneous installations\n (default: 8).\n\n\u{1b}[1m\u{1b}[34mThe R version:\u{1b}[39m\u{1b}[22m\n The lock file records the R version its solve is valid for, and that is the R\n rig installs the packages with -- not whatever \u{1b}[32mR\u{1b}[39m is on the \u{1b}[32mPATH\u{1b}[39m. It has to\n be that very version: another patch release of the same minor version would\n run the packages, but it is not the R the project was solved for, so rig does\n not quietly use it.\n\n If that R version is not installed, rig installs it first, the way\n \u{1b}[32mrig add\u{1b}[39m would; pass \u{1b}[32m--no-install-r\u{1b}[39m to fail instead, e.g. in CI.\n rig never rewrites \u{1b}[32mrproj.lock\u{1b}[39m to an R version that is already installed --\n run \u{1b}[32mrig proj lock\u{1b}[39m to change the R version a project is\n locked for.\n\n\u{1b}[1m\u{1b}[34mWhat sync writes:\u{1b}[39m\u{1b}[22m\n Everything below \u{1b}[32m.rvenv\u{1b}[39m, except the library and the shim package in it, is\n machine-specific, is not committed, and is rewritten on every sync:\n\n - \u{1b}[32m.rvenv/bin/R\u{1b}[39m and \u{1b}[32m.rvenv/bin/Rscript\u{1b}[39m, wrapper scripts that set the\n project's environment and then hand over to the real R. Run them directly,\n or put \u{1b}[32m.rvenv/bin\u{1b}[39m on your \u{1b}[32mPATH\u{1b}[39m. They also pass \u{1b}[32mR CMD ...\u{1b}[39m through.\n - \u{1b}[32m.rvenv/bin/activate\u{1b}[39m and its \u{1b}[32mactivate.csh\u{1b}[39m / \u{1b}[32mactivate.fish\u{1b}[39m /\n \u{1b}[32mactivate.bat\u{1b}[39m / \u{1b}[32mActivate.ps1\u{1b}[39m siblings, for the shells that prefer to be\n activated. Source the one for your shell, and \u{1b}[32mdeactivate\u{1b}[39m when you are\n done. Activation is a convenience, not a requirement: the wrappers work\n without it, and an R session started by an IDE picks the project up through\n the project's \u{1b}[32m.Renviron\u{1b}[39m.\n - \u{1b}[32m.rvenv/rvenv.cfg\u{1b}[39m, which records the R version, the platform and the\n architecture the environment was built for. rig warns when it syncs an\n environment that was built for a different R.\n - \u{1b}[32m.rvenv/etc/repositories\u{1b}[39m, which the wrappers point \u{1b}[32mR_REPOSITORIES\u{1b}[39m at. It\n lists P3M first, at the binary URL of the platform the lock file was solved\n for, so that an \u{1b}[32minstall.packages()\u{1b}[39m in the environment installs the same\n binary packages \u{1b}[32mrig proj sync\u{1b}[39m does. The repositories from \u{1b}[32mrproj.toml\u{1b}[39m\n follow it, at lower precedence. A lock file solved for source packages only\n has no P3M entry, and then the file holds the \u{1b}[32mrproj.toml\u{1b}[39m repositories\n alone (CRAN, if it names none).\n\n \u{1b}[32mR --vanilla\u{1b}[39m ignores the project's \u{1b}[32m.Renviron\u{1b}[39m, so it only stays inside the\n project when started through the wrappers.\n\n After a successful sync rig records the lock file it installed from in\n \u{1b}[32m.rvenv/lib/.synced\u{1b}[39m. The \u{1b}[32mrig\u{1b}[39m package in the project library compares the\n two, and warns in every R session while the project library does not match\n \u{1b}[32mrproj.lock\u{1b}[39m."; const ABOUT_PROJ_TREE: &str = "Dependency tree of a project"; const HELP_PROJ_TREE: &str = "\u{1b}[1m\u{1b}[34mDescription:\u{1b}[39m\u{1b}[22m\n Show everything an R project needs, directly or indirectly, as a tree: the\n same closure \u{1b}[32mrig proj deps --recursive\u{1b}[39m lists in a flat\n table, laid out by the shape of the dependency graph.\n\n myproject 0.1.0 — 3 direct, 24 total\n ├── R (>= 4.1) [D]\n ├── cli 3.6.4\n │ ├── R (>= 3.4) [D]\n │ └── utils\n └── dplyr 1.1.4 (>= 1.1.0)\n ├── cli 3.6.4 (>= 3.4.0) (*)\n └── vctrs 0.6.5 (>= 0.6.4)\n └── cpp11 0.5.2 [L]\n [Suggests]\n └── testthat 3.2.3 (>= 3.1.5)\n\n The first line names the project and its version, how many dependencies it\n declares directly and how many distinct packages there are in the whole tree.\n Each line below names a package, the version currently in the repositories,\n and the version requirement it is needed with, if it has one.\n\n rig reads the project manifest, \u{1b}[32mrproj.toml\u{1b}[39m, in the current directory. Unlike\n \u{1b}[32mrig proj deps\u{1b}[39m, the tree needs the package metadata of the\n repositories, which rig downloads if it does not have it yet. It does not need\n R. \u{1b}[32m--json\u{1b}[39m gives machine readable output, as one nested object.\n\n\u{1b}[1m\u{1b}[34mReading the tree:\u{1b}[39m\u{1b}[22m\n A package that several others need is expanded only once, under its first\n occurrence; later occurrences are marked \u{1b}[32m(*)\u{1b}[39m, meaning \"its dependencies are\n above\". \u{1b}[32m--dev\u{1b}[39m adds the project's development dependencies, in their own\n \u{1b}[32m[Suggests]\u{1b}[39m and \u{1b}[32m[Enhances]\u{1b}[39m sections; \u{1b}[32m--no-base\u{1b}[39m leaves out R and the base\n packages. Among the hard dependencies, \u{1b}[32mImports\u{1b}[39m is not marked, \u{1b}[32m[D]\u{1b}[39m is a\n \u{1b}[32mDepends\u{1b}[39m, \u{1b}[32m[L]\u{1b}[39m a \u{1b}[32mLinkingTo\u{1b}[39m, \u{1b}[32m[DL]\u{1b}[39m both.\n\n \u{1b}[32m--why \u{1b}[39m (alias \u{1b}[32m--explain\u{1b}[39m) inverts the tree, so that the named\n package is the root and the tree grows towards the packages that need it, down\n to the project itself. Each line then says how \u{1b}[3mthat\u{1b}[23m package needs the one\n above it, hence \u{1b}[32mneeds\u{1b}[39m.\n\n \u{1b}[32mrig pkg tree\u{1b}[39m, which shows the same tree for a package\n in the repositories, describes all of this in full.\n\n rig follows the dependencies of the \u{1b}[3mlatest\u{1b}[23m version of every package in the\n tree, so a version requirement that would force an older version, with\n different dependencies, is not taken into account. Use\n \u{1b}[32mrig proj lock\u{1b}[39m for a resolution that is consistent across\n versions."; const ABOUT_PROJ: &str = "Manage R projects (experimental)"; diff --git a/src/help/proj-sync.md b/src/help/proj-sync.md index b968e7ac..b09551e2 100644 --- a/src/help/proj-sync.md +++ b/src/help/proj-sync.md @@ -49,8 +49,13 @@ machine-specific, is not committed, and is rewritten on every sync: - `.rvenv/rvenv.cfg`, which records the R version, the platform and the architecture the environment was built for. rig warns when it syncs an environment that was built for a different R. -- `.rvenv/etc/repositories`, the repositories from `rproj.toml` (CRAN, if it - names none), which the wrappers point `R_REPOSITORIES` at. +- `.rvenv/etc/repositories`, which the wrappers point `R_REPOSITORIES` at. It + lists P3M first, at the binary URL of the platform the lock file was solved + for, so that an `install.packages()` in the environment installs the same + binary packages `rig proj sync` does. The repositories from `rproj.toml` + follow it, at lower precedence. A lock file solved for source packages only + has no P3M entry, and then the file holds the `rproj.toml` repositories + alone (CRAN, if it names none). `R --vanilla` ignores the project's `.Renviron`, so it only stays inside the project when started through the wrappers. diff --git a/src/rvenv.rs b/src/rvenv.rs index 565fd0b4..5c70e69b 100644 --- a/src/rvenv.rs +++ b/src/rvenv.rs @@ -58,6 +58,7 @@ use std::path::{Path, PathBuf}; use simple_error::bail; use crate::hardcoded::{HC_RVENV_SHIM_35, HC_RVENV_SHIM_40, HC_RVENV_SHIM_LT_35}; +use crate::repos::binaries::ppm_url; use crate::repositories::{write_repositories_file, RepoFileEntry, RepositoriesContents}; use crate::rproj::{Repository as ManifestRepository, RPROJ_MANIFEST_FILE}; use crate::utils::write_atomically; @@ -89,6 +90,34 @@ const RVENV_DEFAULT_REPO_URL: &str = "https://cloud.r-project.org"; /// `getOption("repos")`, and only a repository of that name replaces it. const RVENV_CRAN_NAME: &str = "CRAN"; +/// The menu name of the P3M entry in the repositories file. +const PPM_MENU_NAME: &str = "Posit Public Package Manager"; + +/// The P3M repository a lock file target installs from, or `None` for a +/// source-only lock file, which has no P3M target. +/// +/// The lock file's platform is a P3M target name, `-`, e.g. +/// `macos-arm64` or `jammy-x86_64`, and the platform part is exactly what +/// goes into a Linux binary URL. macOS and Windows have no such path +/// component: their binaries are served from the top-level repository. A +/// source-only solve records the machine's architecture instead, e.g. +/// `x86_64`, with no P3M target in it. +/// +/// The URL is the `latest` snapshot rather than the dated snapshot the lock +/// file's package URLs point at: an `install.packages()` in the environment +/// is by definition installing something the lock file does not have, so it +/// should see current versions. +fn ppm_repo_url(platform: &str) -> Option { + let target = platform + .strip_suffix("-x86_64") + .or_else(|| platform.strip_suffix("-arm64"))?; + match target { + "" => None, + "macos" | "windows" => Some(format!("{}/cran/latest", ppm_url())), + linux => Some(format!("{}/cran/__linux__/{}/latest", ppm_url(), linux)), + } +} + /// The stamp file `rig proj sync` writes into the project library: a copy of /// the lock file it installed from. The shim package compares it to /// `rproj.lock` to decide whether to warn about an unsynced project. A copy @@ -537,7 +566,7 @@ pub fn rvenv_env_vars(venv: &Path) -> Vec<(String, String)> { /// `R_REPOSITORIES` is a plain environment variable R reads at startup, so it /// survives `--vanilla` and is inherited by child processes -- unlike /// `options(repos = )`, which neither does. -fn repositories_contents(repos: &[ManifestRepository]) -> RepositoriesContents { +fn repositories_contents(platform: &str, repos: &[ManifestRepository]) -> RepositoriesContents { let entry = |name: &str, url: &str, menu: &str| RepoFileEntry { name: name.to_string(), description: menu.to_string(), @@ -547,27 +576,46 @@ fn repositories_contents(repos: &[ManifestRepository]) -> RepositoriesContents { win_binary: true, mac_binary: true, }; - let data = if repos.is_empty() { - vec![entry(RVENV_CRAN_NAME, RVENV_DEFAULT_REPO_URL, "CRAN")] - } else { - // Whatever the project's first (highest precedence) repository is - // called, it goes into the file as `CRAN`, keeping its own name as - // the menu name. R starts with an unresolved `CRAN = "@CRAN@"` - // placeholder in `getOption("repos")`, and only an entry of that - // name replaces it -- leave the placeholder in, and - // `install.packages()` fails with "trying to use CRAN without - // setting a mirror". Naming the main repository `CRAN` is the usual - // R idiom for this, the same thing `options(repos = c(CRAN = ...))` - // does. - repos - .iter() - .enumerate() - .map(|(i, r)| { - let name = if i == 0 { RVENV_CRAN_NAME } else { &r.name }; - entry(name, &r.url, &r.name) - }) - .collect() - }; + + // The first entry goes into the file as `CRAN`, keeping its own name as + // the menu name. R starts with an unresolved `CRAN = "@CRAN@"` + // placeholder in `getOption("repos")`, and only an entry of that name + // replaces it -- leave the placeholder in, and `install.packages()` + // fails with "trying to use CRAN without setting a mirror". Naming the + // main repository `CRAN` is the usual R idiom for this, the same thing + // `options(repos = c(CRAN = ...))` does. + let mut data = vec![]; + match ppm_repo_url(platform) { + // `rig proj sync` installs P3M binaries for the lock file's target, + // so an `install.packages()` in the environment should reach the + // same packages. That means P3M first, at the target's own binary + // URL -- the project's own repositories keep their names and follow + // it at lower precedence. + Some(url) => data.push(entry(RVENV_CRAN_NAME, &url, PPM_MENU_NAME)), + // A source-only lock file has no P3M target, so there is nothing to + // prefer over what the project asks for. + None if repos.is_empty() => { + data.push(entry(RVENV_CRAN_NAME, RVENV_DEFAULT_REPO_URL, "CRAN")) + } + None => {} + } + let first_is_ppm = !data.is_empty(); + for (i, r) in repos.iter().enumerate() { + let name = if i == 0 && !first_is_ppm { + RVENV_CRAN_NAME + } else { + &r.name + }; + // A repositories file with two entries of the same name is a + // `getOption("repos")` with a duplicated name, which R handles + // badly. The higher-precedence entry wins; a project repository + // called `CRAN` is dropped in favor of P3M, which serves the same + // packages. + if data.iter().any(|e: &RepoFileEntry| e.name == name) { + continue; + } + data.push(entry(name, &r.url, &r.name)); + } RepositoriesContents { data, comments: vec![( @@ -657,7 +705,7 @@ pub fn rvenv_sync( let repos_path = etc.join(RVENV_REPOS_FILE); write_repositories_file( - repositories_contents(repos), + repositories_contents(&cfg.platform, repos), repos_path .to_str() .ok_or("The project path is not valid Unicode")?, @@ -952,7 +1000,7 @@ mod tests { r_version: "4.6-arm64".to_string(), r_minor: "4.6".to_string(), r_binary: PathBuf::from("/opt/R/4.6/bin/R"), - platform: "aarch64-apple-darwin20".to_string(), + platform: "macos-arm64".to_string(), r_arch: "arm64".to_string(), rig_version: "0.10.0".to_string(), } @@ -1103,17 +1151,58 @@ mod tests { .lines() .skip(2) .map(|line| { - let f: Vec<&str> = line.split('\t').collect(); - (f[0].to_string(), f[1].to_string(), f[2].to_string()) + // Fields with a space in them are quoted in the file, as + // base R's own `repositories` is. + let f: Vec = line + .split('\t') + .map(|v| v.trim_matches('"').to_string()) + .collect(); + (f[0].clone(), f[1].clone(), f[2].clone()) }) .collect() } + /// A [`RvenvCfg`] for another P3M target. + fn test_cfg_for(platform: &str) -> RvenvCfg { + RvenvCfg { + platform: platform.to_string(), + ..test_cfg() + } + } + #[test] - fn the_default_repository_is_cran() { + fn the_default_repository_is_the_targets_ppm() { let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); rvenv_sync(root, &test_cfg(), &[]).unwrap(); + assert_eq!( + written_repositories(root), + vec![( + "CRAN".to_string(), + PPM_MENU_NAME.to_string(), + format!("{}/cran/latest", ppm_url()) + )] + ); + } + + #[test] + fn a_linux_target_gets_its_own_binary_url() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + rvenv_sync(root, &test_cfg_for("jammy-x86_64"), &[]).unwrap(); + assert_eq!( + written_repositories(root)[0].2, + format!("{}/cran/__linux__/jammy/latest", ppm_url()) + ); + } + + #[test] + fn a_source_only_target_falls_back_to_cran() { + // A source-only lock file records the machine's architecture, not a + // P3M target, so there are no binaries to install from. + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + rvenv_sync(root, &test_cfg_for("x86_64"), &[]).unwrap(); assert_eq!( written_repositories(root), vec![( @@ -1125,7 +1214,7 @@ mod tests { } #[test] - fn the_first_repository_is_written_as_cran() { + fn the_project_repositories_follow_ppm() { let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); let repos = vec![ @@ -1139,10 +1228,51 @@ mod tests { }, ]; rvenv_sync(root, &test_cfg(), &repos).unwrap(); - // The first one is called CRAN in the file, whatever the manifest - // calls it: R replaces its own `@CRAN@` placeholder with an entry of - // that name only, and a leftover placeholder breaks - // `install.packages()`. Its own name survives as the menu name. + // `rig proj sync` installs P3M binaries, so an `install.packages()` + // in the environment installs from P3M as well. The project's own + // repositories keep their names and follow it. + assert_eq!( + written_repositories(root), + vec![ + ( + "CRAN".to_string(), + PPM_MENU_NAME.to_string(), + format!("{}/cran/latest", ppm_url()) + ), + ( + "internal".to_string(), + "internal".to_string(), + "https://example.com/internal".to_string() + ), + ( + "extra".to_string(), + "extra".to_string(), + "https://example.com/extra".to_string() + ), + ] + ); + } + + #[test] + fn the_first_repository_is_written_as_cran_without_ppm() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let repos = vec![ + ManifestRepository { + name: "internal".to_string(), + url: "https://example.com/internal".to_string(), + }, + ManifestRepository { + name: "extra".to_string(), + url: "https://example.com/extra".to_string(), + }, + ]; + rvenv_sync(root, &test_cfg_for("x86_64"), &repos).unwrap(); + // Without a P3M entry the first project repository is called CRAN in + // the file, whatever the manifest calls it: R replaces its own + // `@CRAN@` placeholder with an entry of that name only, and a + // leftover placeholder breaks `install.packages()`. Its own name + // survives as the menu name. assert_eq!( written_repositories(root), vec![ @@ -1160,6 +1290,25 @@ mod tests { ); } + #[test] + fn a_project_repository_called_cran_does_not_duplicate_the_name() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let repos = vec![ManifestRepository { + name: "CRAN".to_string(), + url: "https://cran.r-project.org".to_string(), + }]; + rvenv_sync(root, &test_cfg(), &repos).unwrap(); + assert_eq!( + written_repositories(root), + vec![( + "CRAN".to_string(), + PPM_MENU_NAME.to_string(), + format!("{}/cran/latest", ppm_url()) + )] + ); + } + #[test] fn the_environment_is_not_the_users_library() { // The three things that keep an active session out of the user's own From 4cd8d0b954bd61c8d4d68e2cee312beb83a62f41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Cs=C3=A1rdi?= Date: Wed, 2 Sep 2026 23:08:39 +0200 Subject: [PATCH 17/19] Fix tests --- src/rvenv.rs | 9 +++++++-- src/utils.rs | 4 +++- src/windows/mod.rs | 10 ++++++++++ 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/src/rvenv.rs b/src/rvenv.rs index 5c70e69b..1744af39 100644 --- a/src/rvenv.rs +++ b/src/rvenv.rs @@ -1315,13 +1315,18 @@ mod tests { // library, all of which have to survive `--vanilla`. let vars: std::collections::HashMap = rvenv_env_vars(Path::new("/p/.rvenv")).into_iter().collect(); + // `rvenv_env_vars()` joins with the native separator, `\` on Windows. + let sep = std::path::MAIN_SEPARATOR; assert_eq!(vars["RVENV"], "/p/.rvenv"); - assert_eq!(vars["R_LIBS_USER"], "/p/.rvenv/lib"); + assert_eq!(vars["R_LIBS_USER"], format!("/p/.rvenv{}lib", sep)); // Empty, so that the project library stays .libPaths()[1]. assert_eq!(vars["R_LIBS"], ""); // Not empty: an empty R_LIBS_SITE does not disable the site library // on every R version. assert_eq!(vars["R_LIBS_SITE"], RVENV_NO_SITE); - assert_eq!(vars["R_REPOSITORIES"], "/p/.rvenv/etc/repositories"); + assert_eq!( + vars["R_REPOSITORIES"], + format!("/p/.rvenv{}etc{}repositories", sep, sep) + ); } } diff --git a/src/utils.rs b/src/utils.rs index e9b13a70..faedec84 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -160,7 +160,9 @@ pub fn write_executable(path: &Path, bytes: &[u8]) -> Result<(), Box> // it back. let current = umask(Mode::from_bits_truncate(0o022)); umask(current); - // `Mode::bits()` is `mode_t`, which is u16 on macOS and u32 on Linux. + // `Mode::bits()` is `mode_t`, which is u16 on macOS and u32 on Linux, so + // the conversion is a no-op on Linux. + #[allow(clippy::useless_conversion)] let mode = u32::from(0o777 & !current.bits()); std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode))?; Ok(()) diff --git a/src/windows/mod.rs b/src/windows/mod.rs index 736a76d1..77f2ca7f 100644 --- a/src/windows/mod.rs +++ b/src/windows/mod.rs @@ -59,6 +59,16 @@ fn find_shim_template() -> Result> { if bundled.is_file() { return Ok(bundled); } + // Test binaries live in `target//deps`, one level below the + // `rig-shim.exe` cargo builds for the `rig-shim` bin target. + if dir.file_name() == Some(std::ffi::OsStr::new("deps")) { + if let Some(parent) = dir.parent() { + let sibling = parent.join("rig-shim.exe"); + if sibling.is_file() { + return Ok(sibling); + } + } + } bail!( "Cannot find the shim template rig-shim.exe next to rig ({}).", dir.display() From 456887a13261635f54c2d6c1d91fab6d13fa2437 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Cs=C3=A1rdi?= Date: Wed, 2 Sep 2026 23:08:59 +0200 Subject: [PATCH 18/19] rig run now works in projects --- NEWS.md | 6 +++ src/args.rs | 7 +++ src/help-generated.in | 2 +- src/help/run.md | 25 ++++++++++ src/proj.rs | 89 ++++++++++++++++++++++++++------- src/rvenv.rs | 55 ++++++++++++++++++++ src/scrun.rs | 113 +++++++++++++++++++++++++++++++++++++++--- 7 files changed, 269 insertions(+), 28 deletions(-) diff --git a/NEWS.md b/NEWS.md index 115b364d..af206b28 100644 --- a/NEWS.md +++ b/NEWS.md @@ -26,6 +26,12 @@ * New `rig proj tree` shows the dependency closure of a project as a tree. +* `rig run` now uses the project environment, if you call it in a project + directory: it starts `.rvenv/bin/R`, with the project's package library + and the R version the project's lock file names, and it syncs the project + first if the environment is missing or out of date. Use `--no-project` + (or `--r-version`) to run the default R version instead. + * `rig library add`, `rig library default`, `rig library list` and `rig library rm` have a new `--r-version` (`-r`) option, to operate on the libraries of an R version other than the default one. diff --git a/src/args.rs b/src/args.rs index fe3b3f1d..98e36451 100644 --- a/src/args.rs +++ b/src/args.rs @@ -1216,6 +1216,13 @@ pub fn rig_app() -> Command { .long("r-version") .required(false), ) + .arg( + Arg::new("no-project") + .help("Ignore the project environment, use the default R version") + .long("no-project") + .action(clap::ArgAction::SetTrue) + .required(false), + ) .arg( Arg::new("app-type") .help("Explicitly specify app type to run") diff --git a/src/help-generated.in b/src/help-generated.in index 60293cbe..1f189df3 100644 --- a/src/help-generated.in +++ b/src/help-generated.in @@ -101,7 +101,7 @@ const HELP_RTOOLS_RM: &str = "\u{1b}[1m\u{1b}[34mDescription:\u{1b}[39m\u{1b}[22 const ABOUT_RTOOLS: &str = "Manage Rtools installations"; const HELP_RTOOLS: &str = "\u{1b}[1m\u{1b}[34mDescription:\u{1b}[39m\u{1b}[22m\n Manage Rtools installations (Windows only).\n\n Rtools is the collection of build tools (compilers, \u{1b}[32mmake\u{1b}[39m, etc.) needed\n to build R packages from source on Windows. Each R version needs a\n matching Rtools version.\n\n \u{1b}[32mrig rtools list\u{1b}[39m lists the installed Rtools versions.\n \u{1b}[32mrig rtools add\u{1b}[39m installs Rtools, by default every version needed by the\n currently installed R versions.\n \u{1b}[32mrig rtools rm\u{1b}[39m removes Rtools versions.\n\n On non-Windows platforms this command does nothing and is hidden."; const ABOUT_RUN: &str = "Run R, an R script or an R project"; -const HELP_RUN: &str = "\u{1b}[1m\u{1b}[34mDescription:\u{1b}[39m\u{1b}[22m\n Run R, an R script or an R project, using the selected R version.\n\n All of these examples allow an \u{1b}[32m--r-version\u{1b}[39m argument, to use a specific\n R version.\n\n rig run # start R\n rig run -f # run an R script\n rig run -e # evaluate an R expression\n rig run ::