Skip to content

Commit f8e00da

Browse files
authored
feat(nodeup): enrich error messages with actionable diagnostic context (#298)
## Summary - enrich nodeup error messages with deterministic, actionable key-value diagnostics in the cause text - keep JSON error envelope shape stable (`kind`, `message`, `exit_code`) and keep `<cause>. Hint: <next action>` formatting contract - sanitize URL diagnostics to omit query strings and fragments ## What changed - add shared error context helpers for I/O and reqwest conversions - include contextual diagnostics in install/resolve/update/self-schema error paths - enrich release-index retry and HTTP failures with url/status/attempt context - enrich SHASUM parse failures with runtime/archive/line/preview metadata - update nodeup foundation doc contract for diagnostic cause content and URL sanitization ## Validation - `cargo test -p nodeup` - `cargo test`
1 parent 761b909 commit f8e00da

11 files changed

Lines changed: 453 additions & 84 deletions

File tree

crates/nodeup/src/commands/run_cmd.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,11 @@ pub fn execute(
2929
) -> Result<i32> {
3030
if command.is_empty() {
3131
return Err(NodeupError::invalid_input_with_hint(
32-
"Missing delegated command arguments for `nodeup run`",
32+
format!(
33+
"Missing delegated command arguments for `nodeup run` (runtime={runtime}, \
34+
delegated_argv_len={})",
35+
command.len()
36+
),
3337
"Use `nodeup run [--install] <runtime> <command> [args...]`.",
3438
));
3539
}

crates/nodeup/src/commands/self_cmd.rs

Lines changed: 124 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -570,7 +570,7 @@ fn migrate_settings_schema(app: &NodeupApp) -> Result<SchemaMigrationResult> {
570570

571571
let content = fs::read_to_string(&file_path)?;
572572
let raw_value: Value = toml::from_str(&content)?;
573-
let from_schema = extract_schema_version(&raw_value)?;
573+
let from_schema = extract_schema_version(&raw_value, &file_path)?;
574574

575575
if from_schema > SETTINGS_SCHEMA_VERSION {
576576
return Err(self_invalid_input(format!(
@@ -588,7 +588,7 @@ fn migrate_settings_schema(app: &NodeupApp) -> Result<SchemaMigrationResult> {
588588
});
589589
}
590590

591-
let migrated = migrate_settings_legacy(&raw_value, from_schema)?;
591+
let migrated = migrate_settings_legacy(&raw_value, from_schema, &file_path)?;
592592
app.store.save_settings(&migrated)?;
593593

594594
Ok(SchemaMigrationResult {
@@ -614,7 +614,7 @@ fn migrate_overrides_schema(app: &NodeupApp) -> Result<SchemaMigrationResult> {
614614

615615
let content = fs::read_to_string(&file_path)?;
616616
let raw_value: Value = toml::from_str(&content)?;
617-
let from_schema = extract_schema_version(&raw_value)?;
617+
let from_schema = extract_schema_version(&raw_value, &file_path)?;
618618

619619
if from_schema > OVERRIDES_SCHEMA_VERSION {
620620
return Err(self_invalid_input(format!(
@@ -632,7 +632,7 @@ fn migrate_overrides_schema(app: &NodeupApp) -> Result<SchemaMigrationResult> {
632632
});
633633
}
634634

635-
let migrated = migrate_overrides_legacy(&raw_value, from_schema)?;
635+
let migrated = migrate_overrides_legacy(&raw_value, from_schema, &file_path)?;
636636
app.overrides.save(&migrated)?;
637637

638638
Ok(SchemaMigrationResult {
@@ -643,40 +643,60 @@ fn migrate_overrides_schema(app: &NodeupApp) -> Result<SchemaMigrationResult> {
643643
})
644644
}
645645

646-
fn extract_schema_version(value: &Value) -> Result<u32> {
647-
let table = value
648-
.as_table()
649-
.ok_or_else(|| self_invalid_input("Expected a TOML table at the document root"))?;
646+
fn extract_schema_version(value: &Value, file_path: &Path) -> Result<u32> {
647+
let table = value.as_table().ok_or_else(|| {
648+
self_invalid_input(format!(
649+
"Expected TOML table at document root (file={}, actual_type={})",
650+
file_path.display(),
651+
toml_value_type(value)
652+
))
653+
})?;
650654

651655
let Some(version_value) = table.get("schema_version") else {
652656
return Ok(0);
653657
};
654658

655-
let version = version_value
656-
.as_integer()
657-
.ok_or_else(|| self_invalid_input("schema_version must be an integer"))?;
659+
let version = version_value.as_integer().ok_or_else(|| {
660+
self_invalid_input(format!(
661+
"schema_version must be an integer (file={}, actual_type={})",
662+
file_path.display(),
663+
toml_value_type(version_value)
664+
))
665+
})?;
658666

659667
if version < 0 {
660-
return Err(self_invalid_input("schema_version cannot be negative"));
668+
return Err(self_invalid_input(format!(
669+
"schema_version cannot be negative (file={}, value={version})",
670+
file_path.display()
671+
)));
661672
}
662673

663674
Ok(version as u32)
664675
}
665676

666-
fn migrate_settings_legacy(value: &Value, from_schema: u32) -> Result<SettingsFile> {
677+
fn migrate_settings_legacy(
678+
value: &Value,
679+
from_schema: u32,
680+
file_path: &Path,
681+
) -> Result<SettingsFile> {
667682
if from_schema != 0 {
668683
return Err(self_invalid_input(format!(
669-
"Unsupported legacy settings schema version: {from_schema}"
684+
"Unsupported legacy settings schema version: {from_schema} (file={})",
685+
file_path.display()
670686
)));
671687
}
672688

673-
let table = value
674-
.as_table()
675-
.ok_or_else(|| self_invalid_input("Expected settings file to be a TOML table"))?;
689+
let table = value.as_table().ok_or_else(|| {
690+
self_invalid_input(format!(
691+
"Expected settings file to be a TOML table (file={}, actual_type={})",
692+
file_path.display(),
693+
toml_value_type(value)
694+
))
695+
})?;
676696

677-
let default_selector = optional_string(table, "default_selector")?;
678-
let linked_runtimes = string_table(table, "linked_runtimes")?;
679-
let tracked_selectors = string_array(table, "tracked_selectors")?;
697+
let default_selector = optional_string(table, "default_selector", file_path)?;
698+
let linked_runtimes = string_table(table, "linked_runtimes", file_path)?;
699+
let tracked_selectors = string_array(table, "tracked_selectors", file_path)?;
680700

681701
Ok(SettingsFile {
682702
schema_version: SETTINGS_SCHEMA_VERSION,
@@ -686,19 +706,28 @@ fn migrate_settings_legacy(value: &Value, from_schema: u32) -> Result<SettingsFi
686706
})
687707
}
688708

689-
fn migrate_overrides_legacy(value: &Value, from_schema: u32) -> Result<OverridesFile> {
709+
fn migrate_overrides_legacy(
710+
value: &Value,
711+
from_schema: u32,
712+
file_path: &Path,
713+
) -> Result<OverridesFile> {
690714
if from_schema != 0 {
691715
return Err(self_invalid_input(format!(
692-
"Unsupported legacy overrides schema version: {from_schema}"
716+
"Unsupported legacy overrides schema version: {from_schema} (file={})",
717+
file_path.display()
693718
)));
694719
}
695720

696-
let table = value
697-
.as_table()
698-
.ok_or_else(|| self_invalid_input("Expected overrides file to be a TOML table"))?;
721+
let table = value.as_table().ok_or_else(|| {
722+
self_invalid_input(format!(
723+
"Expected overrides file to be a TOML table (file={}, actual_type={})",
724+
file_path.display(),
725+
toml_value_type(value)
726+
))
727+
})?;
699728

700729
let entries = if let Some(entries_value) = table.get("entries") {
701-
parse_override_entries(entries_value)?
730+
parse_override_entries(entries_value, file_path)?
702731
} else {
703732
Vec::new()
704733
};
@@ -709,79 +738,115 @@ fn migrate_overrides_legacy(value: &Value, from_schema: u32) -> Result<Overrides
709738
})
710739
}
711740

712-
fn optional_string(table: &Table, field: &str) -> Result<Option<String>> {
741+
fn optional_string(table: &Table, field: &str, file_path: &Path) -> Result<Option<String>> {
713742
let Some(value) = table.get(field) else {
714743
return Ok(None);
715744
};
716745

717-
let string = value
718-
.as_str()
719-
.ok_or_else(|| self_invalid_input(format!("Expected '{field}' to be a string")))?;
746+
let string = value.as_str().ok_or_else(|| {
747+
self_invalid_input(format!(
748+
"Expected '{field}' to be a string (file={}, actual_type={})",
749+
file_path.display(),
750+
toml_value_type(value)
751+
))
752+
})?;
720753

721754
Ok(Some(string.to_string()))
722755
}
723756

724-
fn string_table(table: &Table, field: &str) -> Result<BTreeMap<String, String>> {
757+
fn string_table(table: &Table, field: &str, file_path: &Path) -> Result<BTreeMap<String, String>> {
725758
let Some(value) = table.get(field) else {
726759
return Ok(BTreeMap::new());
727760
};
728761

729-
let map = value
730-
.as_table()
731-
.ok_or_else(|| self_invalid_input(format!("Expected '{field}' to be a table")))?;
762+
let map = value.as_table().ok_or_else(|| {
763+
self_invalid_input(format!(
764+
"Expected '{field}' to be a table (file={}, actual_type={})",
765+
file_path.display(),
766+
toml_value_type(value)
767+
))
768+
})?;
732769

733770
let mut result = BTreeMap::new();
734771
for (key, item) in map {
735772
let value = item.as_str().ok_or_else(|| {
736-
self_invalid_input(format!("Expected '{field}.{key}' to be a string"))
773+
self_invalid_input(format!(
774+
"Expected '{field}.{key}' to be a string (file={}, actual_type={})",
775+
file_path.display(),
776+
toml_value_type(item)
777+
))
737778
})?;
738779
result.insert(key.clone(), value.to_string());
739780
}
740781

741782
Ok(result)
742783
}
743784

744-
fn string_array(table: &Table, field: &str) -> Result<Vec<String>> {
785+
fn string_array(table: &Table, field: &str, file_path: &Path) -> Result<Vec<String>> {
745786
let Some(value) = table.get(field) else {
746787
return Ok(Vec::new());
747788
};
748789

749-
let items = value
750-
.as_array()
751-
.ok_or_else(|| self_invalid_input(format!("Expected '{field}' to be an array")))?;
790+
let items = value.as_array().ok_or_else(|| {
791+
self_invalid_input(format!(
792+
"Expected '{field}' to be an array (file={}, actual_type={})",
793+
file_path.display(),
794+
toml_value_type(value)
795+
))
796+
})?;
752797

753798
let mut result = Vec::new();
754799
for (index, item) in items.iter().enumerate() {
755800
let value = item.as_str().ok_or_else(|| {
756-
self_invalid_input(format!("Expected '{field}[{index}]' to be a string"))
801+
self_invalid_input(format!(
802+
"Expected '{field}[{index}]' to be a string (file={}, actual_type={})",
803+
file_path.display(),
804+
toml_value_type(item)
805+
))
757806
})?;
758807
result.push(value.to_string());
759808
}
760809

761810
Ok(result)
762811
}
763812

764-
fn parse_override_entries(value: &Value) -> Result<Vec<OverrideEntry>> {
765-
let items = value
766-
.as_array()
767-
.ok_or_else(|| self_invalid_input("Expected 'entries' to be an array"))?;
813+
fn parse_override_entries(value: &Value, file_path: &Path) -> Result<Vec<OverrideEntry>> {
814+
let items = value.as_array().ok_or_else(|| {
815+
self_invalid_input(format!(
816+
"Expected 'entries' to be an array (file={}, actual_type={})",
817+
file_path.display(),
818+
toml_value_type(value)
819+
))
820+
})?;
768821

769822
let mut entries = Vec::new();
770823
for (index, item) in items.iter().enumerate() {
771824
let table = item.as_table().ok_or_else(|| {
772-
self_invalid_input(format!("Expected 'entries[{index}]' to be a table"))
825+
self_invalid_input(format!(
826+
"Expected 'entries[{index}]' to be a table (file={}, actual_type={})",
827+
file_path.display(),
828+
toml_value_type(item)
829+
))
773830
})?;
774831

775832
let path = table.get("path").and_then(Value::as_str).ok_or_else(|| {
776-
self_invalid_input(format!("Expected 'entries[{index}].path' to be a string"))
833+
let actual_type = table.get("path").map(toml_value_type).unwrap_or("none");
834+
self_invalid_input(format!(
835+
"Expected 'entries[{index}].path' to be a string (file={}, \
836+
actual_type={actual_type})",
837+
file_path.display()
838+
))
777839
})?;
778840

779841
let selector = table
780842
.get("selector")
781843
.and_then(Value::as_str)
782844
.ok_or_else(|| {
845+
let actual_type = table.get("selector").map(toml_value_type).unwrap_or("none");
783846
self_invalid_input(format!(
784-
"Expected 'entries[{index}].selector' to be a string"
847+
"Expected 'entries[{index}].selector' to be a string (file={}, \
848+
actual_type={actual_type})",
849+
file_path.display()
785850
))
786851
})?;
787852

@@ -794,6 +859,18 @@ fn parse_override_entries(value: &Value) -> Result<Vec<OverrideEntry>> {
794859
Ok(entries)
795860
}
796861

862+
fn toml_value_type(value: &Value) -> &'static str {
863+
match value {
864+
Value::String(_) => "string",
865+
Value::Integer(_) => "integer",
866+
Value::Float(_) => "float",
867+
Value::Boolean(_) => "boolean",
868+
Value::Datetime(_) => "datetime",
869+
Value::Array(_) => "array",
870+
Value::Table(_) => "table",
871+
}
872+
}
873+
797874
fn log_failure(action: SelfAction, error: NodeupError) -> NodeupError {
798875
info!(
799876
command_path = action.command_path(),

crates/nodeup/src/commands/toolchain.rs

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,10 @@ fn render_human_toolchain_list(
121121
fn install(runtimes: &[String], output: OutputFormat, app: &NodeupApp) -> Result<i32> {
122122
if runtimes.is_empty() {
123123
return Err(NodeupError::invalid_input_with_hint(
124-
"Missing runtime selector for `nodeup toolchain install`",
124+
format!(
125+
"Missing runtime selector for `nodeup toolchain install` (requested_count={})",
126+
runtimes.len()
127+
),
125128
"Run `nodeup toolchain install <runtime>...`.",
126129
));
127130
}
@@ -136,7 +139,10 @@ fn install(runtimes: &[String], output: OutputFormat, app: &NodeupApp) -> Result
136139
ResolvedRuntimeTarget::Version { version } => version,
137140
ResolvedRuntimeTarget::LinkedPath { .. } => {
138141
return Err(NodeupError::invalid_input_with_hint(
139-
"`toolchain install` only supports semantic version or channel selectors",
142+
format!(
143+
"`toolchain install` only supports semantic version or channel selectors \
144+
(selector={runtime})"
145+
),
140146
"Use selectors like `22.1.0`, `v22.1.0`, `lts`, `current`, or `latest`. \
141147
Linked runtimes are added with `nodeup toolchain link <name> <path>`.",
142148
));
@@ -176,7 +182,10 @@ fn install(runtimes: &[String], output: OutputFormat, app: &NodeupApp) -> Result
176182
fn uninstall(runtimes: &[String], output: OutputFormat, app: &NodeupApp) -> Result<i32> {
177183
if runtimes.is_empty() {
178184
return Err(NodeupError::invalid_input_with_hint(
179-
"Missing runtime selector for `nodeup toolchain uninstall`",
185+
format!(
186+
"Missing runtime selector for `nodeup toolchain uninstall` (requested_count={})",
187+
runtimes.len()
188+
),
180189
"Run `nodeup toolchain uninstall <runtime>...`.",
181190
));
182191
}
@@ -198,7 +207,10 @@ fn uninstall(runtimes: &[String], output: OutputFormat, app: &NodeupApp) -> Resu
198207
RuntimeSelector::Version(version) => format!("v{version}"),
199208
_ => {
200209
return Err(NodeupError::invalid_input_with_hint(
201-
"`toolchain uninstall` only supports exact version selectors",
210+
format!(
211+
"`toolchain uninstall` only supports exact version selectors \
212+
(selector={runtime})"
213+
),
202214
"Use selectors like `22.1.0` or `v22.1.0`. Channels and linked runtime names \
203215
are not supported here.",
204216
));

0 commit comments

Comments
 (0)