Skip to main content

pencil_puzzles/
registry.rs

1//! Object-safe text and JSON adaptation for the typed Genre interface (I1/I2).
2use pencil_puzzles_core::{
3    Budget, Difficulty, Hint, Metadata, ParseError, Rating, Solutions, Violation,
4    text::{self, Document},
5};
6use std::error::Error;
7/// Actionable failure from a type-erased operation.
8pub type RegistryError = Box<dyn Error + Send + Sync>;
9/// A generated text record and its full rating.
10#[derive(Debug)]
11#[non_exhaustive]
12pub struct Generated {
13    /// Canonical text including a complete solution and reproduction metadata.
14    pub text: String,
15    /// The complete L18 rating.
16    pub rating: Rating,
17    /// Candidates tried.
18    pub attempts: u32,
19}
20/// An object-safe adapter. Text records carry shared metadata between operations.
21/// All parsing is validated, and never invokes a solver or rater (F6).
22pub trait DynGenre: Sync {
23    /// Genre-specific limits for grid and solution text blocks.
24    fn text_limits(&self) -> text::GridLimits;
25
26    /// Metadata for listing and discovery.
27    fn metadata(&self) -> &'static Metadata;
28    /// Default parameters in their stable text encoding.
29    fn default_params(&self) -> String;
30    /// Validate shape text and supply default generator settings.
31    fn params_for_shape(&self, shape: &str) -> Result<String, RegistryError>;
32    /// Read one document and write canonical text, preserving shared keys.
33    fn parse_document(&self, document: Document) -> Result<String, RegistryError>;
34    /// Decode a puzzle ID to canonical text.
35    fn read_id(&self, id: &str) -> Result<String, RegistryError>;
36    /// Encode the puzzle's stable ID.
37    fn id(&self, text: &str) -> Result<String, RegistryError>;
38    /// Generate a unique puzzle; limits decide success, never which puzzle.
39    fn generate(
40        &self,
41        params: &str,
42        difficulty: Option<Difficulty>,
43        seed: u64,
44        max_attempts: u32,
45        budget: &Budget,
46    ) -> Result<Generated, RegistryError>;
47    /// Solve with proof of uniqueness; witnesses are answer text.
48    fn solve(&self, text: &str, budget: &Budget) -> Result<Solutions<String>, RegistryError>;
49    /// Rate a unique puzzle.
50    fn rate(&self, text: &str, budget: &Budget) -> Result<Rating, RegistryError>;
51    /// Rate and list canonical deductions (P9, L19).
52    fn rate_with_steps(
53        &self,
54        text: &str,
55        budget: &Budget,
56    ) -> Result<(Rating, Vec<pencil_puzzles_core::Step>), RegistryError>;
57    /// Check explicit answer text, or the record's solution. Finished marks use P4 defaults.
58    fn check(
59        &self,
60        text: &str,
61        answer: Option<&str>,
62        finished: bool,
63        budget: &Budget,
64    ) -> Result<Vec<Violation>, RegistryError>;
65    /// Hint from explicit marks, the recorded solution, or an empty answer.
66    fn hint(
67        &self,
68        text: &str,
69        answer: Option<&str>,
70        budget: &Budget,
71    ) -> Result<Hint, RegistryError>;
72    /// Replace or omit the solution while preserving the other shared keys.
73    fn with_solution(&self, text: &str, answer: Option<&str>) -> Result<String, RegistryError>;
74    /// The optional complete solution in a text record.
75    fn solution(&self, text: &str) -> Result<Option<String>, RegistryError>;
76    /// Set the record's level, score and rater version.
77    fn with_rating(&self, text: &str, rating: &Rating) -> Result<String, RegistryError>;
78    /// Build a blank editor or populate it from an existing puzzle.
79    fn editor(
80        &self,
81        shape: &str,
82        puzzle: Option<&str>,
83    ) -> Result<pencil_puzzles_core::PuzzleEditor, RegistryError>;
84    /// Turn validated visual clues into a canonical puzzle record.
85    fn puzzle_from_editor(&self, shape: &str, values: &[String]) -> Result<String, RegistryError>;
86    /// Build a browser cell view using the genre's presentation (I4).
87    fn view(
88        &self,
89        text: &str,
90        answer: Option<&str>,
91    ) -> Result<pencil_puzzles_core::View, RegistryError>;
92    /// Validate client mark values and write canonical answer text.
93    fn answer_from_values(&self, text: &str, values: &[String]) -> Result<String, RegistryError>;
94    /// Draw a validated puzzle, optionally with explicit answer text.
95    fn render(&self, text: &str, answer: Option<&str>) -> Result<String, RegistryError>;
96    /// Encode the F14 envelope; seeds are strings, and the full rating is optional.
97    #[cfg(feature = "serde")]
98    fn to_json(
99        &self,
100        text: &str,
101        rating: Option<&Rating>,
102    ) -> Result<serde_json::Value, RegistryError>;
103    /// Decode a strict F14 envelope, validating the puzzle and solution without solving.
104    #[cfg(feature = "serde")]
105    fn read_json(&self, value: serde_json::Value) -> Result<Input, RegistryError>;
106}
107/// A validated input record and the registered genre that interprets it.
108pub struct Input {
109    /// The genre adapter.
110    pub genre: &'static dyn DynGenre,
111    /// Canonical text, preserving shared metadata.
112    pub text: String,
113    /// A complete rating when supplied in a JSON envelope.
114    pub rating: Option<Rating>,
115}
116// The typed adapter is only needed when a genre is compiled in. Add future
117// genre features here as they join the registry.
118#[cfg(any(
119    feature = "fillomino",
120    feature = "hashi",
121    feature = "hitori",
122    feature = "kakurasu",
123    feature = "kakuro",
124    feature = "sudoku",
125    feature = "nurikabe"
126))]
127mod typed {
128    use super::*;
129    use pencil_puzzles_core::{Genre, Record};
130    use std::marker::PhantomData;
131    fn one<G: Genre>(source: &str) -> Result<Record<G::Puzzle, G::Answer>, RegistryError> {
132        let mut docs = text::parse_with_limits(source, G::text_limits())?;
133        if docs.len() != 1 {
134            return Err(ParseError::new("expected exactly one puzzle").into());
135        }
136        let mut doc = docs.remove(0);
137        if doc
138            .genre()
139            .is_some_and(|id| G::metadata().aliases.contains(&id))
140        {
141            doc.set_genre(G::metadata().id);
142        }
143        Ok(G::parse_document(doc)?)
144    }
145    fn answer<G: Genre>(
146        record: &Record<G::Puzzle, G::Answer>,
147        source: Option<&str>,
148    ) -> Result<G::Answer, RegistryError> {
149        match source {
150            Some(source) => Ok(G::parse_answer(&record.puzzle, source)?),
151            None => record.solution.clone().ok_or_else(|| {
152                ParseError::new("provide an answer file or a solution block").into()
153            }),
154        }
155    }
156    #[cfg(feature = "serde")]
157    type GenreRecord<G> = Record<<G as Genre>::Puzzle, <G as Genre>::Answer>;
158    #[cfg(feature = "serde")]
159    type Decoded<G> = (GenreRecord<G>, Option<Rating>);
160
161    trait WireGenre: Genre {
162        #[cfg(feature = "serde")]
163        fn encode(
164            record: &Record<Self::Puzzle, Self::Answer>,
165            rating: Option<&Rating>,
166        ) -> Result<serde_json::Value, RegistryError>;
167        #[cfg(feature = "serde")]
168        fn decode(value: serde_json::Value) -> Result<Decoded<Self>, RegistryError>;
169    }
170    #[cfg(not(feature = "serde"))]
171    impl<G: Genre> WireGenre for G {}
172    #[cfg(feature = "serde")]
173    #[derive(serde::Serialize, serde::Deserialize)]
174    #[serde(
175        deny_unknown_fields,
176        bound(deserialize = "P: serde::Deserialize<'de>, A: serde::Deserialize<'de>")
177    )]
178    struct Envelope<P, A> {
179        genre: String,
180        puzzle: P,
181        #[serde(skip_serializing_if = "Option::is_none", default)]
182        solution: Option<A>,
183        #[serde(skip_serializing_if = "Option::is_none", default)]
184        rating: Option<Rating>,
185        #[serde(skip_serializing_if = "Option::is_none", default)]
186        seed: Option<String>,
187        #[serde(skip_serializing_if = "Option::is_none", default)]
188        generator_version: Option<u32>,
189        #[serde(skip_serializing_if = "Option::is_none", default)]
190        params: Option<String>,
191        #[serde(skip_serializing_if = "Option::is_none", default)]
192        difficulty: Option<Difficulty>,
193    }
194    #[cfg(feature = "serde")]
195    impl<G> WireGenre for G
196    where
197        G: Genre,
198        G::Puzzle: serde::Serialize + serde::de::DeserializeOwned,
199        G::Answer: serde::Serialize + serde::de::DeserializeOwned,
200    {
201        fn encode(
202            record: &Record<Self::Puzzle, Self::Answer>,
203            rating: Option<&Rating>,
204        ) -> Result<serde_json::Value, RegistryError> {
205            Ok(serde_json::to_value(Envelope {
206                genre: G::metadata().id.into(),
207                puzzle: &record.puzzle,
208                solution: record.solution.as_ref(),
209                rating: rating.cloned(),
210                seed: record.shared.seed.map(|s| s.to_string()),
211                generator_version: record.shared.generator_version,
212                params: record.shared.params.clone(),
213                difficulty: record.shared.difficulty,
214            })?)
215        }
216        fn decode(value: serde_json::Value) -> Result<Decoded<Self>, RegistryError> {
217            let envelope: Envelope<G::Puzzle, G::Answer> = serde_json::from_value(value)?;
218            let m = G::metadata();
219            if envelope.genre != m.id && !m.aliases.contains(&envelope.genre.as_str()) {
220                return Err(ParseError::new("the envelope names a different genre").into());
221            }
222            let mut record = Record::new(envelope.puzzle);
223            record.solution = envelope.solution;
224            record.shared.seed = envelope.seed.map(|s| s.parse::<u64>()).transpose()?;
225            record.shared.generator_version = envelope.generator_version;
226            record.shared.params = envelope.params;
227            record.shared.difficulty = envelope.difficulty;
228            if let Some(r) = &envelope.rating {
229                record.shared.rating = Some((r.level, r.score));
230                record.shared.rater_version = Some(r.rater_version);
231            }
232            // Reuse the genre's validation of solution shape/completeness, without solving.
233            let record = one::<G>(&G::write_record(&record))?;
234            Ok((record, envelope.rating))
235        }
236    }
237    pub(super) struct Adapter<G>(PhantomData<fn() -> G>);
238    impl<G: WireGenre> DynGenre for Adapter<G> {
239        fn metadata(&self) -> &'static Metadata {
240            G::metadata()
241        }
242        fn text_limits(&self) -> text::GridLimits {
243            G::text_limits()
244        }
245        fn default_params(&self) -> String {
246            G::Params::default().to_string()
247        }
248        fn params_for_shape(&self, shape: &str) -> Result<String, RegistryError> {
249            Ok(G::params_for_shape(shape.parse()?).to_string())
250        }
251        fn parse_document(&self, mut document: Document) -> Result<String, RegistryError> {
252            if let Some(id) = document.genre()
253                && id != G::metadata().id
254                && !G::metadata().aliases.contains(&id)
255            {
256                return Err(ParseError::at_line(
257                    document.start_line(),
258                    "the document names a different genre",
259                )
260                .into());
261            }
262            document.set_genre(G::metadata().id);
263            Ok(G::write_record(&G::parse_document(document)?))
264        }
265        fn read_id(&self, id: &str) -> Result<String, RegistryError> {
266            let (name, shape, description) = pencil_puzzles_core::id::split(id)?;
267            if name != G::metadata().id && !G::metadata().aliases.contains(&name) {
268                return Err(ParseError::at_char(1, "the ID names a different genre").into());
269            }
270            let canonical = pencil_puzzles_core::id::join(G::metadata().id, shape, description);
271            Ok(G::write_record(&Record::new(G::from_id(&canonical)?)))
272        }
273        fn id(&self, text: &str) -> Result<String, RegistryError> {
274            Ok(G::id(&one::<G>(text)?.puzzle))
275        }
276        fn generate(
277            &self,
278            params: &str,
279            difficulty: Option<Difficulty>,
280            seed: u64,
281            max_attempts: u32,
282            budget: &Budget,
283        ) -> Result<Generated, RegistryError> {
284            let g = G::generate(&params.parse()?, difficulty, seed, max_attempts, budget)?;
285            Ok(Generated {
286                text: G::write_record(&g.record),
287                rating: g.rating,
288                attempts: g.attempts,
289            })
290        }
291        fn solve(&self, text: &str, budget: &Budget) -> Result<Solutions<String>, RegistryError> {
292            Ok(G::solve(&one::<G>(text)?.puzzle, budget)?.map(|a| a.to_string()))
293        }
294        fn rate(&self, text: &str, budget: &Budget) -> Result<Rating, RegistryError> {
295            Ok(G::rate(&one::<G>(text)?.puzzle, budget)?)
296        }
297        fn rate_with_steps(
298            &self,
299            text: &str,
300            budget: &Budget,
301        ) -> Result<(Rating, Vec<pencil_puzzles_core::Step>), RegistryError> {
302            Ok(G::rate_with_steps(&one::<G>(text)?.puzzle, budget)?)
303        }
304        fn check(
305            &self,
306            text: &str,
307            source: Option<&str>,
308            finished: bool,
309            budget: &Budget,
310        ) -> Result<Vec<Violation>, RegistryError> {
311            let r = one::<G>(text)?;
312            let a = answer::<G>(&r, source)?;
313            let a = if finished { G::finished(&a) } else { a };
314            G::check(&r.puzzle, &a, budget)
315        }
316        fn hint(
317            &self,
318            text: &str,
319            source: Option<&str>,
320            budget: &Budget,
321        ) -> Result<Hint, RegistryError> {
322            let r = one::<G>(text)?;
323            let a = if source.is_none() && r.solution.is_none() {
324                G::empty_answer(&r.puzzle)
325            } else {
326                answer::<G>(&r, source)?
327            };
328            Ok(G::hint(&r.puzzle, &a, budget)?)
329        }
330        fn with_solution(&self, text: &str, source: Option<&str>) -> Result<String, RegistryError> {
331            let mut r = one::<G>(text)?;
332            r.solution = source.map(|s| G::parse_answer(&r.puzzle, s)).transpose()?;
333            let text = G::write_record(&r);
334            let _ = one::<G>(&text)?;
335            Ok(text)
336        }
337        fn solution(&self, text: &str) -> Result<Option<String>, RegistryError> {
338            Ok(one::<G>(text)?.solution.map(|a| a.to_string()))
339        }
340        fn with_rating(&self, text: &str, rating: &Rating) -> Result<String, RegistryError> {
341            let mut r = one::<G>(text)?;
342            r.shared.rating = Some((rating.level, rating.score));
343            r.shared.rater_version = Some(rating.rater_version);
344            Ok(G::write_record(&r))
345        }
346        fn editor(
347            &self,
348            shape: &str,
349            puzzle: Option<&str>,
350        ) -> Result<pencil_puzzles_core::PuzzleEditor, RegistryError> {
351            let p = puzzle.map(one::<G>).transpose()?;
352            Ok(G::editor(shape.parse()?, p.as_ref().map(|r| &r.puzzle)))
353        }
354        fn puzzle_from_editor(
355            &self,
356            shape: &str,
357            values: &[String],
358        ) -> Result<String, RegistryError> {
359            Ok(G::write_record(&Record::new(G::puzzle_from_editor(
360                shape.parse()?,
361                values,
362            )?)))
363        }
364        fn view(
365            &self,
366            text: &str,
367            source: Option<&str>,
368        ) -> Result<pencil_puzzles_core::View, RegistryError> {
369            let r = one::<G>(text)?;
370            let answer = source.map(|s| G::parse_answer(&r.puzzle, s)).transpose()?;
371            Ok(G::view(&r.puzzle, answer.as_ref()))
372        }
373        fn answer_from_values(
374            &self,
375            text: &str,
376            values: &[String],
377        ) -> Result<String, RegistryError> {
378            let r = one::<G>(text)?;
379            Ok(G::answer_from_values(&r.puzzle, values)?.to_string())
380        }
381        fn render(&self, text: &str, source: Option<&str>) -> Result<String, RegistryError> {
382            let r = one::<G>(text)?;
383            let a = source.map(|s| G::parse_answer(&r.puzzle, s)).transpose()?;
384            Ok(G::render(&r.puzzle, a.as_ref()))
385        }
386        #[cfg(feature = "serde")]
387        fn to_json(
388            &self,
389            text: &str,
390            rating: Option<&Rating>,
391        ) -> Result<serde_json::Value, RegistryError> {
392            G::encode(&one::<G>(text)?, rating)
393        }
394        #[cfg(feature = "serde")]
395        fn read_json(&self, value: serde_json::Value) -> Result<Input, RegistryError> {
396            let (r, rating) = G::decode(value)?;
397            Ok(Input {
398                genre: find(G::metadata().id)
399                    .ok_or_else(|| ParseError::new("genre is not registered"))?,
400                text: G::write_record(&r),
401                rating,
402            })
403        }
404    }
405    #[cfg(feature = "fillomino")]
406    pub(super) static FILLOMINO: Adapter<crate::fillomino::Fillomino> = Adapter(PhantomData);
407    #[cfg(feature = "hashi")]
408    pub(super) static HASHI: Adapter<crate::hashi::Hashi> = Adapter(PhantomData);
409    #[cfg(feature = "hitori")]
410    pub(super) static HITORI: Adapter<crate::hitori::Hitori> = Adapter(PhantomData);
411    #[cfg(feature = "kakurasu")]
412    pub(super) static KAKURASU: Adapter<crate::kakurasu::Kakurasu> = Adapter(PhantomData);
413    #[cfg(feature = "kakuro")]
414    pub(super) static KAKURO: Adapter<crate::kakuro::Kakuro> = Adapter(PhantomData);
415    #[cfg(feature = "nurikabe")]
416    pub(super) static NURIKABE: Adapter<crate::nurikabe::Nurikabe> = Adapter(PhantomData);
417    #[cfg(feature = "sudoku")]
418    pub(super) static SUDOKU: Adapter<crate::sudoku::Sudoku> = Adapter(PhantomData);
419}
420/// Every compiled-in genre, in canonical id order (I1, K4).
421static GENRES: &[&dyn DynGenre] = &[
422    #[cfg(feature = "fillomino")]
423    &typed::FILLOMINO,
424    #[cfg(feature = "hashi")]
425    &typed::HASHI,
426    #[cfg(feature = "hitori")]
427    &typed::HITORI,
428    #[cfg(feature = "kakurasu")]
429    &typed::KAKURASU,
430    #[cfg(feature = "kakuro")]
431    &typed::KAKURO,
432    #[cfg(feature = "nurikabe")]
433    &typed::NURIKABE,
434    #[cfg(feature = "sudoku")]
435    &typed::SUDOKU,
436];
437/// Every compiled-in genre, in canonical id order (I1, K4).
438pub fn genres() -> &'static [&'static dyn DynGenre] {
439    GENRES
440}
441/// Find a genre by its canonical id or any alias (N2).
442pub fn find(name: &str) -> Option<&'static dyn DynGenre> {
443    genres()
444        .iter()
445        .copied()
446        .find(|g| g.metadata().id == name || g.metadata().aliases.contains(&name))
447}
448/// Parse IDs, text records (including mixed-genre files), or F14 JSON envelopes.
449/// Text without a genre is rejected because the registry cannot infer it (I2).
450pub fn read(source: &str) -> Result<Vec<Input>, RegistryError> {
451    let trimmed = source.trim();
452    #[cfg(feature = "serde")]
453    if trimmed.starts_with('{') || trimmed.starts_with('[') {
454        let value: serde_json::Value = serde_json::from_str(trimmed)?;
455        let values = match value {
456            serde_json::Value::Array(values) => values,
457            value => vec![value],
458        };
459        return values
460            .into_iter()
461            .map(|value| {
462                let id = value
463                    .get("genre")
464                    .and_then(|v| v.as_str())
465                    .ok_or_else(|| ParseError::new("the JSON envelope needs a genre"))?;
466                let g = find(id).ok_or_else(|| ParseError::new(format!("unknown genre {id:?}")))?;
467                g.read_json(value)
468            })
469            .collect();
470    }
471    if !trimmed.contains('\n') && trimmed.matches(':').count() >= 2 {
472        let (id, _, _) = pencil_puzzles_core::id::split(trimmed)?;
473        let genre = find(id).ok_or_else(|| ParseError::new(format!("unknown genre {id:?}")))?;
474        return Ok(vec![Input {
475            genre,
476            text: genre.read_id(trimmed)?,
477            rating: None,
478        }]);
479    }
480    let docs = text::parse_with_genre_limits(source, |id| {
481        id.and_then(find)
482            .map_or_else(text::GridLimits::default, |g| g.text_limits())
483    })?;
484    if docs.is_empty() {
485        return Err(ParseError::new("there is no puzzle in the input").into());
486    }
487    docs.into_iter()
488        .map(|d| {
489            let id = d.genre().ok_or_else(|| {
490                ParseError::at_line(d.start_line(), "each puzzle needs a genre: line")
491            })?;
492            let genre = find(id).ok_or_else(|| {
493                ParseError::at_line(d.start_line(), format!("unknown genre {id:?}"))
494            })?;
495            Ok(Input {
496                genre,
497                text: genre.parse_document(d)?,
498                rating: None,
499            })
500        })
501        .collect()
502}