Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
2 changes: 1 addition & 1 deletion crates/fensu-cli/src/catalogue/data/catalogue.json

Large diffs are not rendered by default.

12 changes: 11 additions & 1 deletion crates/fensu-cli/src/check/_helpers/evaluation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,16 @@ fn evaluate_rust_target(request: EvaluationRequest<'_>) -> Result<TargetEvaluati
&config.resolved_ownership_roots.iter().map(|root| root.path.clone()).collect::<Vec<_>>(),
)?
};
if let Some(diagnostic) = analysis
.diagnostics
.iter()
.find(|diagnostic| diagnostic.code == fensu_rust::CARGO_UNAVAILABLE_CODE)
{
return Err(format!(
"FP{} Rust analysis cannot run: {}",
diagnostic.code, diagnostic.message
));
}
let repository_facts: Option<serde_json::Value> = collect_repository_facts
.then(|| serde_json::to_value(&analysis.facts).map_err(|error| error.to_string()))
.transpose()?;
Expand Down Expand Up @@ -389,7 +399,7 @@ fn evaluate_rust_target(request: EvaluationRequest<'_>) -> Result<TargetEvaluati
.find(|diagnostic| diagnostic.code == fensu_rust::METADATA_SETUP_CODE)
.map_or_else(
|| "Cargo reported no workspace source files".to_owned(),
|diagnostic| diagnostic.message.clone(),
|diagnostic| format!("FP{} {}", diagnostic.code, diagnostic.message),
);
return Err(format!(
"Rust analysis found 0 Rust files, but the target has {rust_source_count}; no Rust rule can be evaluated. {cause}"
Expand Down
88 changes: 83 additions & 5 deletions crates/fensu-cli/tests/command/test_check_rust.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
use crate::helpers::{require_workspace_python, run_check, run_check_with, write};
use crate::test_types::{
InvalidCheckConfigTestCase, RustCacheTestCase, RustCheckTestCase,
RustCustomCacheInvalidationTestCase, RustCustomCoverageTestCase, RustCustomFailClosedTestCase,
RustCustomFileRuleTestCase, RustCustomHostRoutingTestCase, RustCustomMalformedSourceTestCase,
RustCustomNarrowCacheTestCase, RustCustomPolicyTestCase, RustMetadataCacheTestCase,
InvalidCheckConfigTestCase, RustCacheTestCase, RustCargoAvailabilityTestCase,
RustCheckTestCase, RustCustomCacheInvalidationTestCase, RustCustomCoverageTestCase,
RustCustomFailClosedTestCase, RustCustomFileRuleTestCase, RustCustomHostRoutingTestCase,
RustCustomMalformedSourceTestCase, RustCustomNarrowCacheTestCase, RustCustomPolicyTestCase,
RustMetadataCacheTestCase,
};

const RUST_CONFIG: &str = "[targets.rust]\nanalyzer = \"rust\"\nroots = [\"src\"]\ntests = []\ntooling = []\nrule_packs = [\"rust\"]\nselect = [\"FPRSL302\"]\n";
Expand Down Expand Up @@ -310,6 +311,83 @@ fn given_unavailable_cargo_metadata_when_checking_then_failure_is_read_only_and_
}
}

#[test]
fn given_cargo_availability_when_checking_then_missing_cargo_is_not_blamed_on_the_manifest() {
let valid_manifest = "[package]\nname = \"example\"\nversion = \"0.1.0\"\nedition = \"2021\"\n";
let test_cases = [
RustCargoAvailabilityTestCase {
description: "missing cargo is an environment error with its own code",
manifest: valid_manifest,
environment: &[("CARGO", "/cargo-does-not-exist/cargo"), ("PATH", "/cargo-does-not-exist")],
expected_exit_code: 2,
expected_present: "FPRSL902 Rust analysis cannot run: cargo not found; install the Rust toolchain (see rust-toolchain.toml)",
expected_absent: "FPRSL901",
},
RustCargoAvailabilityTestCase {
description: "a valid manifest with working cargo reaches the selected rule",
manifest: valid_manifest,
environment: &[],
expected_exit_code: 1,
expected_present: "FPRSL302",
expected_absent: "FPRSL90",
},
RustCargoAvailabilityTestCase {
description: "a manifest cargo rejects keeps the manifest setup code",
manifest: "[package]\nversion = \"0.1.0\"\nedition = \"2021\"\n",
environment: &[],
expected_exit_code: 2,
expected_present: "FPRSL901 Cargo could not discover workspace packages and targets",
expected_absent: "FPRSL902",
},
];
for test_case in test_cases {
let repository = tempfile::tempdir().expect("temporary repository");
write(repository.path().join("Cargo.toml"), test_case.manifest);
write(
repository.path().join("rust-toolchain.toml"),
"[toolchain]\nchannel = \"stable\"\n",
);
write(
repository.path().join("src/lib.rs"),
"pub fn value() -> usize { 1 }\n",
);
write(repository.path().join("fensu.toml"), RUST_CONFIG);
let output = std::process::Command::new(env!("CARGO_BIN_EXE_fensu"))
.args(["check", "--no-color", "--no-cache"])
.current_dir(repository.path())
.env(
"FENSU_PYTHON",
repository.path().join("python-does-not-exist"),
)
.env("NO_COLOR", "1")
.envs(test_case.environment.iter().copied())
.output()
.expect("native fensu process runs");
let combined = format!(
"{}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);

assert_eq!(
output.status.code(),
Some(test_case.expected_exit_code),
"{}: {combined}",
test_case.description
);
assert!(
combined.contains(test_case.expected_present),
"{}: {combined}",
test_case.description
);
assert!(
!combined.contains(test_case.expected_absent),
"{}: {combined}",
test_case.description
);
}
}

#[test]
fn given_selected_custom_rust_file_rule_when_checking_then_owned_item_and_tree_facts_emit_fault() {
let python = require_workspace_python!();
Expand Down Expand Up @@ -929,7 +1007,7 @@ fn given_unavailable_rust_facts_when_custom_rule_is_selected_then_check_fails_cl
manifest: package,
rule_suffix: "",
search_path: |empty| empty.as_os_str().to_owned(),
expected_error: "failed to start `cargo metadata`",
expected_error: "FPRSL902 Rust analysis cannot run: cargo not found",
},
RustCustomFailClosedTestCase {
description: "workspace has Rust sources but no packages",
Expand Down
9 changes: 9 additions & 0 deletions crates/fensu-cli/tests/command/test_types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,15 @@ pub(crate) struct RustMetadataCacheTestCase {
pub(crate) expected_error: &'static str,
}

pub(crate) struct RustCargoAvailabilityTestCase {
pub(crate) description: &'static str,
pub(crate) manifest: &'static str,
pub(crate) environment: &'static [(&'static str, &'static str)],
pub(crate) expected_exit_code: i32,
pub(crate) expected_present: &'static str,
pub(crate) expected_absent: &'static str,
}

pub(crate) struct RustCustomFileRuleTestCase {
pub(crate) description: &'static str,
pub(crate) expected_exit_code: i32,
Expand Down
7 changes: 7 additions & 0 deletions crates/fensu-rust/src/constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ pub const PARSER_CONTRACT_VERSION: &str = "rust-syn-workspace-v1";
pub const CACHE_CONTRACT_VERSION: &str = "rust-rules-v7";
pub const FACT_SCHEMA_VERSION: &str = "rust-facts-v1";
pub const METADATA_SETUP_CODE: &str = "RSL901";
pub const CARGO_UNAVAILABLE_CODE: &str = "RSL902";
pub(crate) const CRATE_PATH_ROOT: &str = "crate";
pub(crate) const SELF_PATH_ROOT: &str = "self";
pub(crate) const SUPER_PATH_ROOT: &str = "super";
Expand Down Expand Up @@ -103,6 +104,12 @@ pub const TESTS_DIRECTORY: &str = "tests";
pub const BIN_DIRECTORY: &str = "bin";
pub const CARGO_MANIFEST_FILE: &str = "Cargo.toml";
pub const CARGO_LOCK_FILE: &str = "Cargo.lock";
pub(crate) const RUST_TOOLCHAIN_FILES: [&str; 2] = ["rust-toolchain.toml", "rust-toolchain"];
pub(crate) const TOOLCHAIN_UNAVAILABLE_MARKERS: [&str; 3] = [
"rustup could not choose a version of cargo",
"error: toolchain '",
"could not download nonexistent rust version",
];
pub const RUST_SUFFIX: &str = "rs";
pub const SELF_MODULE: &str = "self";
pub const SUPER_MODULE: &str = "super";
Expand Down
3 changes: 2 additions & 1 deletion crates/fensu-rust/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ pub mod rules;
pub mod types;

pub use constants::{
CACHE_CONTRACT_VERSION, FACT_SCHEMA_VERSION, METADATA_SETUP_CODE, PARSER_CONTRACT_VERSION,
CACHE_CONTRACT_VERSION, CARGO_UNAVAILABLE_CODE, FACT_SCHEMA_VERSION, METADATA_SETUP_CODE,
PARSER_CONTRACT_VERSION,
};
pub use facts::models::RustWorkspaceFacts;
pub use models::{AnalysisDiagnostic, RepositoryAnalysis};
64 changes: 59 additions & 5 deletions crates/fensu-rust/src/rules/_helpers/sources/scanning.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,14 @@ pub(crate) fn scan_workspace(repo_root: &path::Path) -> models::WorkspaceScan {
let metadata = match command.exec() {
Ok(value) => value,
Err(error) => {
violations.push(manifest_setup_violation(
repo_root,
&manifest_path,
format!("Cargo could not discover workspace packages and targets: {error}"),
));
violations.push(match cargo_unavailable_message(repo_root, &error) {
Some(message) => cargo_unavailable_violation(repo_root, &manifest_path, message),
None => manifest_setup_violation(
repo_root,
&manifest_path,
format!("Cargo could not discover workspace packages and targets: {error}"),
),
});
return models::WorkspaceScan {
crates: Vec::new(),
violations,
Expand Down Expand Up @@ -875,6 +878,57 @@ pub(crate) fn manifest_setup_violation(
})
}

/// Describe a `cargo metadata` failure caused by the toolchain rather than the manifest.
fn cargo_unavailable_message(
repo_root: &path::Path,
error: &cargo_metadata::Error,
) -> Option<String> {
let cause = match error {
cargo_metadata::Error::Io(io) if io.kind() == std::io::ErrorKind::NotFound => {
"cargo not found".to_owned()
}
cargo_metadata::Error::Io(io) => format!("cargo could not run: {io}"),
cargo_metadata::Error::CargoMetadata { stderr } => {
let line = stderr
.lines()
.find(|line| reports_unavailable_toolchain(line))?;
format!(
"cargo could not run: {}",
line.trim_start_matches("error: ")
)
}
_ => return None,
};
let guide = constants::RUST_TOOLCHAIN_FILES
.iter()
.find(|name| repo_root.join(name).is_file())
.map_or_else(String::new, |name| format!(" (see {name})"));
Some(format!("{cause}; install the Rust toolchain{guide}"))
}

fn reports_unavailable_toolchain(line: &str) -> bool {
constants::TOOLCHAIN_UNAVAILABLE_MARKERS
.iter()
.any(|marker| line.contains(marker))
}

fn cargo_unavailable_violation(
repo_root: &path::Path,
manifest_path: &path::Path,
message: String,
) -> models::Violation {
let relative = manifest_path
.strip_prefix(repo_root)
.unwrap_or(manifest_path);
models::Violation::new(models::ViolationRequest {
code: constants::CARGO_UNAVAILABLE_CODE,
path: relative,
line: None,
message,
remediation: "install the Rust toolchain so that `cargo metadata` can run",
})
}

fn workspace_lint_violations(
repo_root: &path::Path,
manifest_path: &path::Path,
Expand Down
15 changes: 12 additions & 3 deletions docs/rust.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,9 +200,18 @@ Rust sources, Cargo manifests, existing lockfiles, and target settings are cache
Existing lockfiles are read with locked metadata resolution. Lock-free workspaces use declared
dependency metadata without creating a lockfile. A Cargo metadata setup failure produces a
diagnostic and prevents caching, even if that diagnostic is excluded by rule selection. When the
target has Rust sources but Cargo yields no source files, for example because `cargo` is not on
`PATH` or the workspace has no packages, `fensu check` exits 2 with the cause instead of
reporting a result.
target has Rust sources but Cargo yields no source files, for example because the workspace has
no packages, `fensu check` exits 2 with the cause instead of reporting a result.

When `cargo` itself cannot run, because it is not on `PATH` or rustup has no usable toolchain,
`fensu check` exits 2 with `FPRSL902` instead of blaming the manifest:

```text
FPRSL902 Rust analysis cannot run: cargo not found; install the Rust toolchain (see rust-toolchain.toml)
```

`FPRSL901` remains the code for a manifest that cannot be read, parsed, or accepted by a working
`cargo`.
## File placement and embedded resources

The `rust` pack enables `FPRSR311` (`rust-rsr311`) and `FPRSL106` (`rust-rsl106`)
Expand Down
6 changes: 6 additions & 0 deletions src/fensu/rules/catalog/_helpers/rust_rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
"RSL306",
"RSL307",
"RSL901",
"RSL902",
"RSN001",
"RSN002",
"RSN003",
Expand Down Expand Up @@ -188,6 +189,7 @@
"RSS012": "tuple types keep a bounded number of positional elements",
"RSL104": "underscore-prefixed Rust main entries remain inside their owning domain",
"RSL105": "publicly named Rust main entries have an external consumer",
"RSL902": "cargo runs, so that Rust analysis can read the workspace",
}

_REMEDIATIONS: dict[str, str] = {
Expand All @@ -209,6 +211,10 @@
"Prefix the filename with '_' and restrict its visibility until another domain consumes "
"it, or keep the public name crate-visible."
),
"RSL902": (
"Install the Rust toolchain, for example as rust-toolchain.toml pins it, and put cargo "
"on PATH or set CARGO. fensu check exits 2 until cargo runs."
),
}

_CONSTRAINTS: dict[str, tuple[RuleConstraint, ...]] = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ def test_given_native_web_packs_when_building_catalogue_then_each_pack_is_standa
[
NativeRustPackCatalogueTestCase(
description="Rust pack exposes every structure-engine identity",
expected_rule_count=120,
expected_rule_count=121,
)
],
ids=lambda case: case.description,
Expand Down
Loading