Skip to main content

max / alloy

Open the config a broken schema named, not only the schema's error docs/CONSOLE.md routes an unknown `schema_version` and an unknown field type to the text-edit pane "with a diagnostic explaining why", and the first pass could not: both make `Schema::parse` fail before anything has read `target_path`, so the app was left with no file to open and the old red paragraph. The schema is ours and the config is the user's. A version bump or a mistyped field type is our authoring mistake, and it should not be the reason someone cannot open their own file. `Schema::recover` reads the header again leniently -- every value optional, unknown keys allowed, no version check -- for the one thing that matters after a failure, the path. Separate from `parse` rather than a mode of it, so the strict read and the salvage read cannot disagree about what a schema says. The app also gets its real name back: `target_tool` survives a broken body, so the list stops falling back to the file stem whenever the header was fine. A schema with no `[schema]` block, or none naming a path, is still an error and still says so. There is nothing to open.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-17 01:50 UTC
Signed with PGP, not checked
Commit: a3873508684d3db6217eea0f0c9111d43dc27786
Parent: 90e5b97
3 files changed, +116 insertions, -7 deletions
M Cargo.lock +4 -4
@@ -2643,10 +2643,6 @@
2643 2643 "winnow",
2644 2644 ]
2645 2645
2646 - [[patch.unused]]
2647 - name = "docengine"
2648 - version = "0.7.0"
2649 -
2650 2646 [[patch.unused]]
2651 2647 name = "kberg"
2652 2648 version = "0.1.0"
@@ -2702,3 +2698,7 @@
2702 2698 [[patch.unused]]
2703 2699 name = "synckit-config"
2704 2700 version = "0.2.0"
2701 +
2702 + [[patch.unused]]
2703 + name = "docengine"
2704 + version = "0.7.0"
@@ -301,6 +301,34 @@
301 301 Self::parse(&text).with_context(|| format!("in schema {}", path.display()))
302 302 }
303 303
304 + /// What can be read out of a schema whose body did not parse.
305 + ///
306 + /// docs/CONSOLE.md routes an unknown `schema_version` and an unknown field
307 + /// type to the text-edit pane "with a diagnostic explaining why", and that
308 + /// is only possible if the file the schema pointed at survives the failure
309 + /// that stopped it being a form. The header is where the path is, it is the
310 + /// part a version bump or a mistyped field type does not touch, and it is
311 + /// read leniently here on purpose: every value optional, unknown keys
312 + /// allowed, no version check. A file this cannot read is a file with no
313 + /// `[schema]` block at all, which is not a schema.
314 + ///
315 + /// Deliberately separate from [`parse`](Self::parse) rather than a mode of
316 + /// it. Loosening the real parser to salvage a path would mean the strict
317 + /// read and the lenient one could disagree about what a schema says, which
318 + /// is worse than not salvaging it.
319 + pub(crate) fn recover(text: &str) -> Option<Recovered> {
320 + let raw: LenientSchema = toml::from_str(text).ok()?;
321 + Some(Recovered {
322 + target_tool: raw.schema.target_tool,
323 + target_path: raw.schema.target_path?,
324 + // An unrecognised syntax colors nothing. Guessing at a name the
325 + // parser rejected would be a second opinion about the same string.
326 + syntax: raw.schema.syntax.as_deref().map_or(Syntax::Toml, |name| {
327 + Syntax::parse(name).unwrap_or(Syntax::Text)
328 + }),
329 + })
330 + }
331 +
304 332 pub(crate) fn parse(text: &str) -> Result<Self> {
305 333 let raw: RawSchema = toml::from_str(text)?;
306 334
@@ -440,6 +468,29 @@
440 468 // what carries forward compatibility, so leniency here buys nothing.
441 469 // ---------------------------------------------------------------------------
442 470
471 + /// What [`Schema::recover`] salvages from a schema that did not parse.
472 + #[derive(Debug)]
473 + pub(crate) struct Recovered {
474 + /// The tool the app list should still name, when the header said.
475 + pub(crate) target_tool: Option<String>,
476 + pub(crate) target_path: String,
477 + pub(crate) syntax: Syntax,
478 + }
479 +
480 + /// The header, read as loosely as it can be. See [`Schema::recover`]; the
481 + /// missing `deny_unknown_fields` is the point of this type, not an oversight.
482 + #[derive(Deserialize)]
483 + struct LenientSchema {
484 + schema: LenientHeader,
485 + }
486 +
487 + #[derive(Deserialize)]
488 + struct LenientHeader {
489 + target_tool: Option<String>,
490 + target_path: Option<String>,
491 + syntax: Option<String>,
492 + }
493 +
443 494 #[derive(Deserialize)]
444 495 #[serde(deny_unknown_fields)]
445 496 struct RawSchema {
@@ -311,12 +311,37 @@
311 311
312 312 let schema = match Schema::load(source) {
313 313 Ok(schema) => schema,
314 + // The schema is broken and the file it named may be perfectly
315 + // fine. docs/CONSOLE.md routes an unknown `schema_version` and an
316 + // unknown field type here, and both are failures of the schema
317 + // rather than of the config, so refusing to open the config would
318 + // punish the user for our authoring mistake. The header is read
319 + // again leniently for the one thing that matters, the path.
314 320 Err(error) => {
321 + let reason = format!("{error:#}");
322 + let recovered = std::fs::read_to_string(source)
323 + .ok()
324 + .and_then(|text| Schema::recover(&text));
325 + let Some(recovered) = recovered else {
326 + return Self {
327 + name: fallback,
328 + source: source.to_path_buf(),
329 + target: None,
330 + state: Err(reason),
331 + };
332 + };
333 + let target = expand(&recovered.target_path);
334 + let state = target.as_deref().map_or_else(
335 + || Err(reason.clone()),
336 + |path| {
337 + Editor::open(path, recovered.syntax, Some(reason.clone())).map(Pane::Text)
338 + },
339 + );
315 340 return Self {
316 - name: fallback,
341 + name: recovered.target_tool.unwrap_or(fallback),
317 342 source: source.to_path_buf(),
318 - target: None,
319 - state: Err(format!("{error:#}")),
343 + target,
344 + state,
320 345 };
321 346 }
322 347 };
@@ -2845,6 +2870,39 @@
2845 2870 std::fs::remove_file(&target).ok();
2846 2871 }
2847 2872
2873 + // The schema is ours and the config is the user's. A version bump or a
2874 + // mistyped field type is our authoring mistake, so it must not be the
2875 + // reason someone cannot open their own file.
2876 + #[test]
2877 + fn a_broken_schema_still_opens_the_file_it_named() {
2878 + let dir = std::env::temp_dir().join("alloy-settings-recover");
2879 + std::fs::create_dir_all(&dir).unwrap();
2880 + let target = dir.join("recover.toml");
2881 + std::fs::write(&target, "theme = \"akari-night\"\n").unwrap();
2882 + let path = dir.join("recover.toml.schema");
2883 + std::fs::write(
2884 + &path,
2885 + format!(
2886 + "[schema]\ntarget = \"recover.toml\"\ntarget_tool = \"recovered\"\n\
2887 + target_path = \"{}\"\nschema_version = \"9\"\n",
2888 + target.display()
2889 + ),
2890 + )
2891 + .unwrap();
2892 +
2893 + let app = App::open(&path);
2894 + assert_eq!(app.name, "recovered", "named by the header, not the file");
2895 + assert_eq!(app.target.as_deref(), Some(target.as_path()));
2896 + assert!(
2897 + matches!(app.state, Ok(Pane::Text(_))),
2898 + "the config opens even though its schema did not: {:?}",
2899 + app.state.as_ref().err()
2900 + );
2901 +
2902 + std::fs::remove_file(&path).ok();
2903 + std::fs::remove_file(&target).ok();
2904 + }
2905 +
2848 2906 #[test]
2849 2907 fn a_schema_too_broken_to_parse_still_appears_under_its_file_name() {
2850 2908 let dir = std::env::temp_dir().join("alloy-settings-test");