diskplan_schema/
lib.rs

1//! This crate provides the means to constuct a tree of [SchemaNode]s from text form (see
2//! [parse_schema]).
3//!
4//! The language of the text form uses significant whitespace (four spaces) for indentation,
5//! distinguishes between files and directories by the presence of a `/`, and whether
6//! this is a symlink by presence of an `->` (followed by its target path expression).
7//! That is, each indented node of the directory tree takes one of the following forms:
8//!
9//! | Syntax                | Description
10//! |-----------------------|---------------------------
11//! | _str_                 | A file
12//! | _str_`/`              | A directory
13//! | _str_ `->` _expr_     | A symlink to a file
14//! | _str_/ `->` _expr_    | A symlink to a directory
15//!
16//! Properties of a given node are set using the following tags:
17//!
18//! | Tag                       | Types     | Description
19//! |---------------------------|-----------|---------------------------
20//! |`:owner` _expr_            | All       | Sets the owner of this file/directory/symlink target
21//! |`:group` _expr_            | All       | Sets the group of this file, directory or symlink target
22//! |`:mode` _octal_            | All       | Sets the permissions of this file/directory/symlink target
23//! |`:source` _expr_           | File      | Copies content into this file from the path given by _expr_
24//! |`:let` _ident_ `=` _expr_  | Directory | Sets a variable at this level to be used by deeper levels
25//! |`:def` _ident_             | Directory | Defines a sub-schema that can be reused by `:use`
26//! |`:use` _ident_             | Directory | Reuses a sub-schema defined by `:def`
27//!
28//!
29//! # Simple Schema
30//!
31//! The top level of a schema describes a directory, whose [attributes][Attributes] may be set by `:owner`, `:group` and `:mode` tags:
32//! ```
33//! use diskplan_schema::*;
34//!
35//! let schema_root = parse_schema("
36//!     :owner person
37//!     :group user
38//!     :mode 777
39//! ")?;
40//!
41//! assert!(matches!(schema_root.schema, SchemaType::Directory(_)));
42//! assert_eq!(schema_root.attributes.owner.unwrap(), "person");
43//! assert_eq!(schema_root.attributes.group.unwrap(), "user");
44//! assert_eq!(schema_root.attributes.mode.unwrap(), 0o777);
45//! # Ok::<(), anyhow::Error>(())
46//! ```
47//!
48//! A [DirectorySchema] may contain sub-directories and files:
49//! ```
50//! # use diskplan_schema::*;
51//! #
52//! // ...
53//! # let text =
54//! "
55//!     subdirectory/
56//!         :owner admin
57//!         :mode 700
58//!
59//!     file_name
60//!         :source content/example_file
61//! "
62//! # ;
63//! // ...
64//! assert_eq!(
65//!     parse_schema(text)?
66//!         .schema
67//!         .as_directory()
68//!         .expect("Not a directory")
69//!         .entries()
70//!         .len(),
71//!     2
72//! );
73//! # Ok::<(), anyhow::Error>(())
74//! ```
75//!
76//! It may also contain symlinks to directories and files, whose own schemas will apply to the
77//! target:
78//!
79//! ```
80//! # use diskplan_schema::*;
81//! #
82//! // ...
83//! # let text =
84//! "
85//!     example_link/ -> /another/disk/example_target/
86//!         :owner admin
87//!         :mode 700
88//!
89//!         file_to_create_at_target_end
90//!             :source content/example_file
91//! "
92//! # ;
93//! // ...
94//! # match parse_schema(text)?.schema {
95//! #     SchemaType::Directory(directory) => {
96//! #
97//! let (binding, node) = directory.entries().first().unwrap();
98//! assert!(matches!(
99//!     binding,
100//!     Binding::Static(ref name) if name == &String::from("example_link")
101//! ));
102//! assert_eq!(
103//!     node.symlink.as_ref().unwrap().to_string(),
104//!     String::from("/another/disk/example_target/")
105//! );
106//! assert!(matches!(node.schema, SchemaType::Directory(_)));
107//! #
108//! #     }
109//! #     _ => panic!("Expected directory schema")
110//! # }
111//! #
112//! # Ok::<(), anyhow::Error>(())
113//! ```
114//!
115//! ## Variable Substitution
116//!
117//! Variables can be used to drive construction, for example:
118//! ```
119//! # diskplan_schema::parse_schema(
120//! "
121//!     :let asset_type = character
122//!     :let asset_name = Monkey
123//!
124//!     assets/
125//!         $asset_type/
126//!             $asset/
127//!                 reference/
128//! "
129//! # ).unwrap();
130//! ```
131//!
132//! Variables will also pick up on names already on disk (even if a `:let` provides a different
133//! value). For example, if we had `assets/prop/Banana` on disk already, `$asset_type` would match
134//! against and take the value "prop" (as well as "character") and `$asset` would take the value
135//! "Banana" (as well as "Monkey"), producing:
136//! ```text
137//! assets
138//! ├── character
139//! │   └── Monkey
140//! │       └── reference
141//! └── prop
142//!     └── Banana
143//!         └── reference
144//! ```
145//!
146//! ## Pattern Matching
147//!
148//! Any node of the schema can have a `:match` tag, which, via a Regular Expression, controls the
149//! possible values a variable can take.
150//!
151//! **IMPORTANT:** _No two variables can match the same value_. If they do, an error will occur during
152//! execution, so be careful to ensure there is no overlap between patterns. The use of `:avoid`
153//! can help restrict the pattern matching and ensure proper partitioning.
154//!
155//! Static names (without variables) always take precedence and do not need to be unique with
156//! respect to variable patterns (and vice versa).
157//!
158//! For example, this is legal in the schema but will always error in practice:
159//! ```text
160//! $first/
161//! $second/
162//! ```
163//! For instance, when operating on the path `/test`, it yields:
164//! ```text
165//! Error: "test" matches multiple dynamic bindings "$first" and "$second" (Any)
166//! ```
167//!
168//! A working example might be:
169//! ```text
170//! $first/
171//!     :match [A-Z].*
172//! $second/
173//!     :match [^A-Z].*
174//! ```
175//!
176//! ## Schema Reuse
177//!
178//! Portions of a schema can be built from reusable definitions.
179//!
180//! A definition is formed using the `:def` keyword, followed by its name and a body like any
181//! other schema node:
182//! ```text
183//! :def reusable/
184//!     anything_inside/
185//! ```
186//! It is used by adding the `:use` tag inside any other (same or deeper level) node:
187//! ```text
188//! reused_here/
189//!     :use reusable
190//! ```
191//! Multiple `:use` tags may be used. Attributes are resolved in the following order:
192//! ```text
193//! example/
194//!     ## Attributes set here win (before or after any :use lines)
195//!     :owner root
196//!
197//!     ## First :use is next in precedence
198//!     :use one
199//!
200//!     ## Subsequent :use lines take lower precedence
201//!     :use two
202//! ```
203#![warn(missing_docs)]
204
205use std::{collections::HashMap, fmt::Display};
206
207mod attributes;
208pub use attributes::Attributes;
209
210mod expression;
211pub use expression::{Expression, Identifier, Special, Token};
212
213mod text;
214pub use text::{parse_schema, ParseError};
215
216/// A node in an abstract directory hierarchy
217#[derive(Debug, Clone, PartialEq)]
218pub struct SchemaNode<'t> {
219    /// A reference to the line in the text representation where this node was defined
220    pub line: &'t str,
221
222    /// Condition against which to match file/directory names
223    pub match_pattern: Option<Expression<'t>>,
224
225    /// Condition against which file/directory names must not match
226    pub avoid_pattern: Option<Expression<'t>>,
227
228    /// Symlink target - if this produces a symbolic link. Operates on the target end.
229    pub symlink: Option<Expression<'t>>,
230
231    /// Links to other schemas `:use`d by this one (found in parent [`DirectorySchema`] definitions)
232    pub uses: Vec<Identifier<'t>>,
233
234    /// Properties of this file/directory
235    pub attributes: Attributes<'t>,
236
237    /// Properties specific to the underlying (file or directory) type
238    pub schema: SchemaType<'t>,
239}
240
241impl<'t> std::fmt::Display for SchemaNode<'t> {
242    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
243        write!(f, "Schema node \"{}\"", self.line)?;
244        if let Some(ref match_pattern) = self.match_pattern {
245            write!(f, ", matching \"{match_pattern}\"")?;
246        }
247        if let Some(ref avoid_pattern) = self.avoid_pattern {
248            write!(f, ", avoiding \"{avoid_pattern}\"")?;
249        }
250
251        match &self.schema {
252            SchemaType::Directory(ds) => {
253                let len = ds.entries().len();
254                write!(
255                    f,
256                    " (directory with {} entr{})",
257                    len,
258                    if len == 1 { "y" } else { "ies" }
259                )?
260            }
261            SchemaType::File(fs) => write!(f, " (file from source: {})", fs.source())?,
262        }
263        Ok(())
264    }
265}
266
267/// File/directory specific aspects of a node in the tree
268#[derive(Debug, Clone, PartialEq)]
269pub enum SchemaType<'t> {
270    /// Indicates that this node describes a directory
271    Directory(DirectorySchema<'t>),
272    /// Indicates that this node describes a file
273    File(FileSchema<'t>),
274}
275
276impl<'t> SchemaType<'t> {
277    /// Returns the inner [`DirectorySchema`] if this node is a directory node
278    pub fn as_directory(&self) -> Option<&DirectorySchema<'t>> {
279        match self {
280            SchemaType::Directory(directory) => Some(directory),
281            _ => None,
282        }
283    }
284
285    /// Returns the inner [`FileSchema`] if this node is a file node
286    pub fn as_file(&self) -> Option<&FileSchema<'t>> {
287        match self {
288            SchemaType::File(file) => Some(file),
289            _ => None,
290        }
291    }
292}
293
294/// A DirectorySchema is a container of variables, definitions (named schemas) and a directory listing
295#[derive(Debug, Default, Clone, PartialEq)]
296pub struct DirectorySchema<'t> {
297    /// Text replacement variables
298    vars: HashMap<Identifier<'t>, Expression<'t>>,
299
300    /// Definitions of sub-schemas
301    defs: HashMap<Identifier<'t>, SchemaNode<'t>>,
302
303    /// Disk entries to be created within this directory
304    entries: Vec<(Binding<'t>, SchemaNode<'t>)>,
305}
306
307impl<'t> DirectorySchema<'t> {
308    /// Constructs a new description of a directory in the schema
309    pub fn new(
310        vars: HashMap<Identifier<'t>, Expression<'t>>,
311        defs: HashMap<Identifier<'t>, SchemaNode<'t>>,
312        entries: Vec<(Binding<'t>, SchemaNode<'t>)>,
313    ) -> Self {
314        let mut entries = entries;
315        entries.sort_by(|(a, _), (b, _)| a.cmp(b));
316        DirectorySchema {
317            vars,
318            defs,
319            entries,
320        }
321    }
322    /// Provides access to the variables defined in this node
323    pub fn vars(&self) -> &HashMap<Identifier<'t>, Expression<'t>> {
324        &self.vars
325    }
326    /// Returns the expression associated with the given variable, if any was set in the schema
327    pub fn get_var<'a>(&'a self, id: &Identifier<'a>) -> Option<&'a Expression<'t>> {
328        self.vars.get(id)
329    }
330
331    /// Provides access to the sub-schema definitions defined in this node
332    pub fn defs(&self) -> &HashMap<Identifier, SchemaNode> {
333        &self.defs
334    }
335    /// Returns the sub-schema associated with the given definition, if any was set in the schema
336    pub fn get_def<'a>(&'a self, id: &Identifier<'a>) -> Option<&'a SchemaNode<'t>> {
337        self.defs.get(id)
338    }
339
340    /// Provides access to the child nodes of this node, with their bindings
341    pub fn entries(&self) -> &[(Binding<'t>, SchemaNode<'t>)] {
342        &self.entries[..]
343    }
344}
345
346/// How an entry is bound in a schema, either to a static fixed name or to a variable
347#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
348pub enum Binding<'t> {
349    /// A static, fixed name
350    Static(&'t str), // Static is ordered first
351    /// A dynamic name bound to the given variable
352    Dynamic(Identifier<'t>),
353}
354
355impl Display for Binding<'_> {
356    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
357        match self {
358            Binding::Static(s) => write!(f, "{s}"),
359            Binding::Dynamic(id) => write!(f, "${id}"),
360        }
361    }
362}
363
364/// A description of a file
365#[derive(Debug, Clone, PartialEq, Eq)]
366pub struct FileSchema<'t> {
367    /// Path to the resource to be copied as file content
368    // TODO: Make source enum: Enforce(...), Default(...) latter only creates if missing
369    source: Expression<'t>,
370}
371
372impl<'t> FileSchema<'t> {
373    /// Constructs a new description of a file
374    pub fn new(source: Expression<'t>) -> Self {
375        FileSchema { source }
376    }
377    /// Returns the expression of the path from where the file will inherit its content
378    pub fn source(&self) -> &Expression<'t> {
379        &self.source
380    }
381}
382
383#[cfg(test)]
384mod tests;