Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,31 @@ jobs:
components: rustfmt
- run: cargo fmt --all -- --check

coverage:
name: Coverage
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
Comment thread
coderabbitai[bot] marked this conversation as resolved.
- uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable
with:
toolchain: stable
components: llvm-tools-preview
- uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
with:
save-if: ${{ github.event_name == 'push' }}
- uses: taiki-e/install-action@7f4eb899022d8fe70b20c4f3de697aa85c309026 # v2.85.11
with:
tool: cargo-llvm-cov
# Informational only: no --fail-under-lines, so coverage can never fail the build.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
- name: Run coverage
run: |
{
echo "### Coverage summary"
echo '```'
cargo llvm-cov --workspace --summary-only
Comment thread
tonythethompson marked this conversation as resolved.
Outdated
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
echo '```'
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
} >> "$GITHUB_STEP_SUMMARY"

roadmap-drift:
name: Roadmap drift
runs-on: ubuntu-latest
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,6 @@ desktop.ini
# Python
__pycache__/
*.py[cod]

# Coverage
*.profraw
93 changes: 93 additions & 0 deletions src/cmd/list.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,96 @@ pub fn execute(root: &Path) -> Result<()> {

Ok(())
}

#[cfg(test)]
mod tests {
use super::*;
use crate::state::lockfile::{LockfileEntry, PluginActivation};

fn base_entry(version: &str, package_type: &str) -> LockfileEntry {
LockfileEntry {
version: version.to_string(),
package_type: package_type.to_string(),
source: "binary".to_string(),
target: None,
artifact_url: None,
artifact_sha256: None,
executable_path: None,
archive_root: None,
include: None,
entry: None,
installed_at: "0".to_string(),
nu_version_at_install: None,
activation: None,
registry_url: None,
registry_revision: None,
index_sha256: None,
signing_key_fingerprint: None,
git_url: None,
git_rev: None,
cargo_name: None,
cargo_lock_sha256: None,
built_sha256: None,
payload_path: String::new(),
revision_id: None,
payload_sha256: None,
executable_sha256: None,
selection_reason: None,
origin: None,
module_activation: None,
module_import_mode: None,
locked_dependencies: Default::default(),
}
}

#[test]
fn execute_empty_lockfile() {
let dir = tempfile::tempdir().unwrap();
Lockfile::empty().save(dir.path()).unwrap();
execute(dir.path()).unwrap();
}

#[test]
fn execute_one_package() {
let dir = tempfile::tempdir().unwrap();
let mut lock = Lockfile::empty();
lock.packages
.insert("owner/pkg".to_string(), base_entry("1.0.0", "plugin"));
lock.save(dir.path()).unwrap();
execute(dir.path()).unwrap();
}

#[test]
fn execute_multiple_packages_with_one_active() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();

let mut lock = Lockfile::empty();
let mut active = base_entry("1.0.0", "plugin");
active.activation = Some(PluginActivation {
plugin_registry_path: "/path/to/plugins.msgpackz".to_string(),
nu_executable_sha256: "abc123".to_string(),
nu_version: "0.113.1".to_string(),
activated_at: "0".to_string(),
});
lock.packages.insert("owner/active".to_string(), active);
lock.packages
.insert("owner/inactive".to_string(), base_entry("2.0.0", "module"));
lock.save(root).unwrap();

std::fs::create_dir_all(root.join("nu_state")).unwrap();
let nu_paths = NuPaths {
nu_executable: "/usr/bin/nu".to_string(),
nu_version: "0.113.1".to_string(),
plugin_registry_path: "/path/to/plugins.msgpackz".to_string(),
nu_executable_hash: "abc123".to_string(),
platform: "x86_64-unknown-linux-gnu".to_string(),
data_dir: None,
vendor_autoload_dirs: vec![],
vendor_autoload_dir: None,
};
nu_paths.save(root).unwrap();

execute(root).unwrap();
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
68 changes: 68 additions & 0 deletions src/cmd/nu_pin_offer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,3 +106,71 @@ pub fn is_nu_mismatch(diagnosis: &PackageIncompatibility) -> bool {
| Incompatibility::NuUnsatisfied { .. }
)
}

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

fn diagnosis_with_pin(pin: &str) -> PackageIncompatibility {
PackageIncompatibility {
issue: Incompatibility::NuTooOld {
constraint: ">=0.113.0".to_string(),
},
suggested_pin: Some(pin.to_string()),
available_versions: vec![],
}
}

#[test]
fn accept_proceeds_to_install_and_fails_hermetically_on_bad_pin() {
// A malformed pin fails local version normalization before any
// network call, so this exercises the accept branch deterministically.
let dir = tempfile::tempdir().unwrap();
let diagnosis = diagnosis_with_pin("not-a-version");
let err =
offer_managed_nu_pin_with_interaction(dir.path(), "0.112.0", &diagnosis, true, || {
Ok("y\n".to_string())
})
.unwrap_err();
assert!(
err.to_string().contains("Failed to install managed Nu"),
"expected install failure context, got: {err}"
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

#[test]
fn decline_returns_false_without_installing() {
let dir = tempfile::tempdir().unwrap();
let diagnosis = diagnosis_with_pin("0.113.1");
let result =
offer_managed_nu_pin_with_interaction(dir.path(), "0.112.0", &diagnosis, true, || {
Ok("n\n".to_string())
})
.unwrap();
assert!(!result);
}

#[test]
fn invalid_input_is_treated_as_decline() {
let dir = tempfile::tempdir().unwrap();
let diagnosis = diagnosis_with_pin("0.113.1");
let result =
offer_managed_nu_pin_with_interaction(dir.path(), "0.112.0", &diagnosis, true, || {
Ok("maybe\n".to_string())
})
.unwrap();
assert!(!result);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

#[test]
fn non_interactive_short_circuits_without_reading_input() {
let dir = tempfile::tempdir().unwrap();
let diagnosis = diagnosis_with_pin("0.113.1");
let result =
offer_managed_nu_pin_with_interaction(dir.path(), "0.112.0", &diagnosis, false, || {
panic!("read_line must not be called when non-interactive")
})
.unwrap();
assert!(!result);
}
}
133 changes: 133 additions & 0 deletions src/cmd/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,139 @@ fn wrap_words(text: &str, width: usize) -> Vec<String> {
mod tests {
use super::*;

fn test_key_b64() -> String {
let signing_key = ed25519_dalek::SigningKey::generate(&mut rand_core::OsRng);
base64::Engine::encode(
&base64::engine::general_purpose::STANDARD,
signing_key.verifying_key().to_bytes(),
)
}

#[test]
fn list_registries_prints_none_when_empty() {
let dir = tempfile::tempdir().unwrap();
list_registries(dir.path()).unwrap();
}

#[test]
fn list_registries_prints_configured_entries() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let mut config = crate::config::Config::default();
config.registries.insert(
"custom".to_string(),
crate::config::RegistryConfig {
url: "https://example.com/index.json".to_string(),
sync_interval: "24h".to_string(),
enabled: true,
trust_key: None,
},
);
config.save(root).unwrap();
list_registries(root).unwrap();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}

#[test]
fn add_registry_persists_config_and_trust_key() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let key_b64 = test_key_b64();
add_registry(root, "custom", "https://example.com/index.json", &key_b64).unwrap();

let config = crate::config::Config::load(root).unwrap();
assert!(config.registries.contains_key("custom"));
let trust = TrustStore::load(root).unwrap();
assert!(trust.keys.contains_key("custom"));
}

#[test]
fn add_registry_rejects_duplicate_name() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let key_b64 = test_key_b64();
add_registry(root, "custom", "https://example.com/index.json", &key_b64).unwrap();

let err =
add_registry(root, "custom", "https://example.com/other.json", &key_b64).unwrap_err();
assert!(err.to_string().contains("already exists"));
}

#[test]
fn remove_registry_removes_config_and_cached_index() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let key_b64 = test_key_b64();
add_registry(root, "custom", "https://example.com/index.json", &key_b64).unwrap();
std::fs::create_dir_all(root.join("registry/custom")).unwrap();

remove_registry(root, "custom").unwrap();

let config = crate::config::Config::load(root).unwrap();
assert!(!config.registries.contains_key("custom"));
assert!(!root.join("registry/custom").exists());
}

#[test]
fn remove_registry_errors_when_not_found() {
let dir = tempfile::tempdir().unwrap();
let err = remove_registry(dir.path(), "missing").unwrap_err();
assert!(err.to_string().contains("not found"));
}

#[test]
fn list_packages_prints_index_contents() {
use crate::core::package::{
Artifact, Package, PackageType, RegistryIndex, ScopedId, VersionEntry,
};

let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::create_dir_all(root.join("registry/official")).unwrap();

let index = RegistryIndex {
schema_version: 1,
updated_at: "2026-06-27T00:00:00Z".to_string(),
registry_revision: Some("abc123".to_string()),
trust: None,
packages: vec![Package {
id: ScopedId::new("test", "pkg"),
description: "A test package for listing".to_string(),
repo: "https://github.com/test/pkg".to_string(),
package_type: PackageType::Plugin,
tags: vec!["test".to_string()],
versions: vec![VersionEntry {
version: semver::Version::new(1, 0, 0),
nu_version: ">=0.113.0 <0.114.0".to_string(),
verified_with: vec![],
artifact: Artifact {
kind: "binary".to_string(),
url: None,
sha256: None,
targets: std::collections::HashMap::new(),
archive_root: None,
include: None,
entry: None,
},
source: None,
dependencies: std::collections::BTreeMap::new(),
activation: None,
provenance: None,
evidence_tier: None,
deferral_reason: None,
}],
}],
};
let content = serde_json::to_string_pretty(&index).unwrap();
std::fs::write(root.join("registry/official/index.json"), content).unwrap();
std::fs::write(
root.join("config.toml"),
"[general]\ndefault_registry = \"official\"\n",
)
.unwrap();

list_packages(root).unwrap();
}

#[test]
fn wrap_words_keeps_short_text_on_one_line() {
assert_eq!(
Expand Down
28 changes: 28 additions & 0 deletions src/cmd/remove.rs
Original file line number Diff line number Diff line change
Expand Up @@ -368,4 +368,32 @@ mod tests {
"--yes must bypass the guard: {msg}"
);
}

#[test]
fn execute_removes_installed_package_end_to_end() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::create_dir_all(root.join("owner/pkg")).unwrap();

let mut lockfile = Lockfile::empty();
let mut entry = base_entry();
entry.payload_path = "owner/pkg".to_string();
lockfile.packages.insert("owner/pkg".to_string(), entry);
lockfile.save(root).unwrap();

execute_with_tty(
&RemoveArgs {
package: "owner/pkg".to_string(),
yes: true,
force: false,
},
root,
false,
)
.unwrap();

let reloaded = Lockfile::load(root).unwrap();
assert!(!reloaded.packages.contains_key("owner/pkg"));
assert!(!root.join("owner/pkg").exists());
}
}
Loading
Loading