max / alloy
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
3 files changed,
+116 insertions,
-7 deletions
| @@ -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"); |