diskplan_traversal/
lib.rs

1//! A mechanism for traversing a schema and applying its nodes to an underlying
2//! filesystem structure
3#![warn(missing_docs)]
4
5use std::{
6    borrow::Cow,
7    collections::HashMap,
8    fmt::{Display, Write as _},
9};
10
11use anyhow::{anyhow, bail, Context as _, Result};
12use camino::{Utf8Path, Utf8PathBuf};
13use tracing::{span, Level};
14
15use diskplan_filesystem::{Filesystem, PlantedPath, SetAttrs};
16use diskplan_schema::{Binding, DirectorySchema, SchemaNode, SchemaType};
17
18use self::{eval::evaluate, pattern::CompiledPattern};
19
20mod eval;
21mod pattern;
22mod stack;
23pub use stack::{StackFrame, VariableSource};
24
25/// Walks the schema and directory structure in concert, applying or reporting changes
26pub fn traverse<FS>(
27    path: impl AsRef<Utf8Path>,
28    stack: &StackFrame,
29    filesystem: &mut FS,
30) -> Result<()>
31where
32    FS: Filesystem,
33{
34    let path = path.as_ref();
35    let span = span!(Level::DEBUG, "traverse", path = path.as_str());
36    let _span = span.enter();
37
38    if !path.is_absolute() {
39        bail!("Path must be absolute: {}", path);
40    }
41    let (schema_node, root) = stack.config.schema_for(path)?;
42    let start_path = PlantedPath::new(root, None)?;
43    let remaining_path = path
44        .strip_prefix(root.path())
45        .expect("Located root must prefix path");
46    tracing::debug!(
47        r#"Traversing root directory "{}" ("{}" relative path remains)"#,
48        start_path,
49        remaining_path,
50    );
51    traverse_node(schema_node, &start_path, remaining_path, stack, filesystem).with_context(
52        || {
53            schema_context(
54                "Failed to apply schema",
55                schema_node,
56                start_path.absolute(),
57                remaining_path,
58                stack,
59            )
60        },
61    )?;
62    Ok(())
63}
64
65fn traverse_node<'a, FS>(
66    schema_node: &'a SchemaNode<'a>,
67    path: &PlantedPath,
68    remaining: &Utf8Path,
69    stack: &StackFrame<'a, '_, '_>,
70    filesystem: &mut FS,
71) -> Result<()>
72where
73    FS: Filesystem,
74{
75    let span = span!(Level::DEBUG, "traverse_node", node = schema_node.line);
76    let _span = span.enter();
77
78    let mut unresolved = if remaining == "" { None } else { Some(vec![]) };
79    let expanded = expand_uses(schema_node, stack)?;
80
81    // Resolve attributes from all used definitions
82    let mut owner = None;
83    let mut group = None;
84    let mut mode = None;
85    for usage in std::iter::once(&schema_node).chain(expanded.iter()) {
86        owner = owner.or(usage.attributes.owner.as_ref());
87        group = group.or(usage.attributes.group.as_ref());
88        mode = mode.or(usage.attributes.mode);
89    }
90    // Evaluate attribute expressions
91    let evaluated_owner;
92    let owner = match owner {
93        Some(expr) => {
94            evaluated_owner = evaluate(expr, stack, path)?;
95            Some(stack.config.map_user(&evaluated_owner))
96        }
97        None => Some(stack.owner()),
98    };
99    let evaluated_group;
100    let group = match group {
101        Some(expr) => {
102            evaluated_group = evaluate(expr, stack, path)?;
103            Some(stack.config.map_group(&evaluated_group))
104        }
105        None => Some(stack.group()),
106    };
107    let mode = Some(mode.map(Into::into).unwrap_or_else(|| stack.mode()));
108    let attrs = SetAttrs { owner, group, mode };
109
110    let mut stack = stack.push(VariableSource::Empty);
111    if let Some(owner) = owner {
112        stack.put_owner(owner);
113    }
114    if let Some(group) = group {
115        stack.put_group(group);
116    }
117    let stack = &stack;
118
119    for schema_node in expanded {
120        tracing::debug!("Applying: {}", schema_node);
121        // Create this entry, following symlinks
122        create(schema_node, path, attrs.clone(), stack, filesystem)
123            .with_context(|| format!("Creating {}", &path))?;
124
125        // Traverse over children
126        if let SchemaType::Directory(ref directory_schema) = schema_node.schema {
127            let resolution = traverse_directory(
128                schema_node,
129                directory_schema,
130                path,
131                remaining,
132                stack,
133                filesystem,
134            )
135            .with_context(|| {
136                schema_context(
137                    "Applying directory schema",
138                    schema_node,
139                    path.absolute(),
140                    remaining,
141                    stack,
142                )
143            })?;
144            match resolution {
145                Resolution::FullyResolved => unresolved = None,
146                Resolution::Unresolved(path) => {
147                    if let Some(ref mut issues) = unresolved {
148                        issues.push((schema_node, path));
149                    }
150                }
151            }
152        }
153    }
154    if let Some(issues) = unresolved {
155        let mut message =
156            format!("No schema within \"{path}\" was able to produce \"{remaining}\"");
157        for (schema_node, _) in issues {
158            write!(message, "\nInside: {schema_node}:")?;
159            if let SchemaType::Directory(dir) = &schema_node.schema {
160                if dir.entries().is_empty() {
161                    write!(message, "\n  No entries to match",)?;
162                }
163                for (binding, node) in dir.entries() {
164                    write!(message, "\n  Considered: {binding} - {node}")?;
165                }
166            }
167        }
168        Err(anyhow!("{}", message)).with_context(|| {
169            schema_context(
170                "Applying directory entries",
171                schema_node,
172                path.absolute(),
173                remaining,
174                stack,
175            )
176        })?;
177    }
178    Ok(())
179}
180
181#[must_use]
182enum Resolution {
183    FullyResolved,
184    Unresolved(Utf8PathBuf),
185}
186
187#[derive(Debug, Clone, Copy)]
188enum Source {
189    Disk,
190    Path,
191    Schema,
192}
193
194impl Display for Source {
195    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
196        match self {
197            Source::Disk => write!(f, "on disk"),
198            Source::Path => write!(f, "the target path"),
199            Source::Schema => write!(f, "the schema"),
200        }
201    }
202}
203
204fn schema_context(
205    message: &str,
206    schema_node: &SchemaNode,
207    path: &Utf8Path,
208    remaining: &Utf8Path,
209    stack: &StackFrame,
210) -> anyhow::Error {
211    anyhow!(
212        "{}\n  To path: \"{}\" (\"{}\" remaining)\n  {}\n{}",
213        message,
214        path,
215        remaining,
216        schema_node,
217        stack,
218    )
219}
220
221fn traverse_directory<'a, FS>(
222    schema_node: &SchemaNode,
223    directory_schema: &'a DirectorySchema,
224    directory_path: &PlantedPath,
225    remaining: &Utf8Path,
226    stack: &StackFrame<'a, '_, '_>,
227    filesystem: &mut FS,
228) -> Result<Resolution>
229where
230    FS: Filesystem,
231{
232    let stack = stack.push(VariableSource::Directory(directory_schema));
233
234    // Pull the front off the relative remaining_path
235    let (sought, remaining) = remaining
236        .as_str()
237        .split_once('/')
238        .map(|(name, remaining)| (Some(name), Utf8Path::new(remaining)))
239        .unwrap_or(if remaining == "" {
240            (None, Utf8Path::new(""))
241        } else {
242            (Some(remaining.as_str()), Utf8Path::new(""))
243        });
244
245    // Collect an unordered map of names (each mapped to None) for...
246    //  - what's on disk
247    //  - the next component of our intended path (sought)
248    //  - any static bindings
249    //  - any variable bindings for which we have a value from the stack
250    //    and whose value matches the node's match pattern
251    //
252    let mut names: HashMap<Cow<str>, (Source, Option<_>)> = HashMap::new();
253    let with_source = |src: Source| move |key| (key, (src, None));
254    names.extend(
255        filesystem
256            .list_directory(directory_path.absolute())
257            .unwrap_or_default()
258            .into_iter()
259            .map(Cow::Owned)
260            .map(with_source(Source::Disk)),
261    );
262    names.extend(sought.map(Cow::Borrowed).map(with_source(Source::Path)));
263    let mut compiled_schema_entries = Vec::with_capacity(directory_schema.entries().len());
264    for (binding, child_node) in directory_schema.entries() {
265        // Note: Since we don't know the name of the thing we're matching yet, any path
266        // variable (e.g. SAME_PATH_NAME) used in the pattern expression will be evaluated
267        // using the parent directory
268        let pattern = CompiledPattern::compile(
269            child_node.match_pattern.as_ref(),
270            child_node.avoid_pattern.as_ref(),
271            &stack,
272            directory_path,
273        )?;
274
275        // Include names for all static bindings and dynamic bindings whose variable evaluates
276        // (has a value on the stack) and where that value matches the child schema's pattern
277        if let Some(name) = match *binding {
278            Binding::Static(name) => Some(Cow::Borrowed(name)),
279            Binding::Dynamic(var) => evaluate(&var.into(), &stack, directory_path)
280                .ok()
281                .filter(|name| pattern.matches(name))
282                .map(Cow::Owned),
283        } {
284            names.insert(name, (Source::Schema, None));
285        }
286        compiled_schema_entries.push((binding, child_node, pattern));
287    }
288
289    tracing::trace!("Within {}...", directory_path);
290
291    // Traverse the directory schema's sub-entries (static first, then variable), updating the
292    // map of names so each matched name points to its binding and schema node.
293    //
294    for (binding, child_node, pattern) in compiled_schema_entries {
295        // Match this static/variable binding and schema against all names, flagging any conflicts
296        // with previously matched names. Since static bindings are ordered first, and static-
297        // then-variable conflicts explicitly ignored
298        for (name, (_, have_match)) in names.iter_mut() {
299            match binding {
300                // Static binding produces a match for that name only
301                Binding::Static(bound_name) if bound_name == name => match have_match {
302                    // Didn't already have a match for this name
303                    None => {
304                        *have_match = Some((binding, child_node));
305                        Ok(())
306                    }
307                    // Somehow already had a match. This should be impossible
308                    Some((bound, _)) => Err(anyhow!(
309                        r#""{}" matches multiple static bindings "{}" and "{}""#,
310                        name,
311                        bound,
312                        binding
313                    )),
314                },
315                // Dynamic bindings must match their inner schema pattern
316                Binding::Dynamic(_) if pattern.matches(name) => {
317                    match have_match {
318                        // Didn't already have a match for this name
319                        None => {
320                            *have_match = Some((binding, child_node));
321                            Ok(())
322                        }
323                        // Name and schema pattern matched. See if we had a conflicting match
324                        Some((bound, _)) => match bound {
325                            Binding::Static(_) => Ok(()), // Keep previous static binding
326                            Binding::Dynamic(_) => Err(anyhow!(
327                                r#""{}" matches multiple dynamic bindings "{}" and "{}" ({:?})"#,
328                                name,
329                                bound,
330                                binding,
331                                pattern,
332                            )),
333                        },
334                    }
335                }
336                _ => Ok(()),
337            }?;
338        }
339    }
340
341    // Report
342    for (name, (source, have_match)) in names.iter() {
343        match have_match {
344            None => tracing::warn!(
345                r#""{}" from {} has no match in "{}" under {}"#,
346                name,
347                source,
348                directory_path,
349                schema_node
350            ),
351            Some((Binding::Static(_), _)) => {
352                tracing::trace!(r#""{}" from {} matches same, binding static"#, name, source)
353            }
354            Some((Binding::Dynamic(id), node)) => tracing::trace!(
355                r#""{}" from {} matches {:?}, binding to variable ${{{}}}"#,
356                name,
357                source,
358                node.match_pattern,
359                id.value()
360            ),
361        }
362    }
363
364    // Consider nothing to seek as if it were found
365    let mut sought_matched = sought.is_none();
366
367    for (name, (_, matched)) in names {
368        let Some((binding, child_schema)) = matched else { continue };
369        let name = name.as_ref();
370        let child_path = directory_path.join(name)?;
371
372        // If this name is part of the target path, record that we found a match and keep
373        // traversing that path. If it is not, we're no longer completing the target path
374        // in this branch ("remaining" is cleared for further traversal)
375        let remaining = if sought == Some(name) {
376            sought_matched = true;
377            remaining
378        } else {
379            Utf8Path::new("")
380        };
381
382        match binding {
383            Binding::Static(s) => {
384                tracing::debug!(
385                    r#"Traversing static directory entry "{}" at {} ("{}" relative path remains)"#,
386                    s,
387                    &child_path,
388                    remaining,
389                );
390                traverse_node(child_schema, &child_path, remaining, &stack, filesystem)
391                    .with_context(|| format!("Processing path {}", &child_path))?;
392            }
393            Binding::Dynamic(var) => {
394                tracing::debug!(
395                    r#"Traversing variable directory entry ${}="{}" at {} ("{}" relative path remains)"#,
396                    var,
397                    name,
398                    &child_path,
399                    remaining,
400                );
401                let stack = StackFrame::push(&stack, VariableSource::Binding(var, name.into()));
402                traverse_node(child_schema, &child_path, remaining, &stack, filesystem)
403                    .with_context(|| {
404                        format!(
405                            r#"Processing path {} (with {})"#,
406                            &child_path,
407                            &stack
408                                .variables()
409                                .as_binding()
410                                .map(|(var, value)| format!("${var} = {value}"))
411                                .unwrap_or_else(|| "<no binding>".into()),
412                        )
413                    })?;
414            }
415        }
416    }
417    if !sought_matched {
418        let unresolved = Utf8PathBuf::from(format!("{}/{}", sought.unwrap(), remaining));
419        Ok(Resolution::Unresolved(unresolved))
420    } else {
421        Ok(Resolution::FullyResolved)
422    }
423}
424
425fn create<FS>(
426    schema_node: &SchemaNode,
427    path: &PlantedPath,
428    attrs: SetAttrs,
429    stack: &StackFrame,
430    filesystem: &mut FS,
431) -> Result<()>
432where
433    FS: Filesystem,
434{
435    let span = span!(
436        Level::DEBUG,
437        "create",
438        node = schema_node.line,
439        path = path.absolute().as_str(),
440        attrs = &attrs.owner
441    );
442    let _span = span.enter();
443
444    // References held to data within by `to_create`, but only in the symlink branch
445    let link_str;
446    let link_path;
447    let link_target;
448
449    let to_create;
450    if let Some(expr) = &schema_node.symlink {
451        link_str = evaluate(expr, stack, path)?;
452        link_path = Utf8Path::new(&link_str);
453        tracing::info!("Creating {} -> {}", path, link_path);
454
455        // Allow relative symlinks only if there is no schema to apply to the target (allowing us
456        // to create it and return early)
457        if !link_path.is_absolute() {
458            if schema_node.attributes.is_empty()
459                && schema_node.uses.is_empty()
460                && schema_node
461                    .schema
462                    .as_directory()
463                    .map(|d| d.entries().is_empty())
464                    .unwrap_or_default()
465            {
466                filesystem
467                    .create_symlink(path.absolute(), link_path)
468                    .context("As symlink")?;
469                return Ok(());
470            } else {
471                bail!(concat!(
472                    "Relative paths in symlinks are only supported for directories whose schema ",
473                    "nodes have no attributes, use statements, or child entries"
474                ));
475            }
476        }
477
478        let (_, link_root) = stack.config.schema_for(link_path).with_context(|| {
479            anyhow!(
480                "No schema found for symlink target {} -> {}",
481                path,
482                link_path
483            )
484        })?;
485        link_target = PlantedPath::new(link_root, Some(link_path))
486            .with_context(|| format!("Following symlink {path} -> {link_path}"))?;
487
488        // Create the link target (using its own schema to build it)
489        if !filesystem.exists(link_target.absolute()) {
490            traverse(link_target.absolute(), stack, filesystem)?;
491            assert!(filesystem.exists(link_target.absolute()));
492        }
493        // Create the symlink pointing to the target
494        filesystem
495            .create_symlink(path.absolute(), link_target.absolute())
496            .context("As symlink")?;
497        // Use the target path for creation. Further traversal will use the original
498        // path, and resolve canonical paths through the symlink
499        to_create = link_target.absolute();
500    } else {
501        tracing::info!("Creating {}", path);
502        to_create = path.absolute();
503    }
504
505    match &schema_node.schema {
506        SchemaType::Directory(_) => {
507            if !filesystem.is_directory(to_create) {
508                tracing::debug!("Make directory: {}", to_create);
509                filesystem
510                    .create_directory(to_create, attrs)
511                    .context("As directory")?;
512            } else {
513                let dir_attrs = filesystem.attributes(to_create)?;
514                if !attrs.matches(&dir_attrs) {
515                    filesystem.set_attributes(to_create, attrs)?;
516                }
517            }
518        }
519        SchemaType::File(file) => {
520            if !filesystem.is_file(to_create) {
521                let source = evaluate(file.source(), stack, path)?;
522                let content = filesystem.read_file(source)?;
523                filesystem
524                    .create_file(to_create, attrs, content)
525                    .context("As file")?;
526            }
527        }
528    }
529    Ok(())
530}
531
532fn expand_uses<'a>(
533    schema_node: &'a SchemaNode<'_>,
534    stack: &StackFrame<'a, '_, '_>,
535) -> Result<Vec<&'a SchemaNode<'a>>> {
536    // Expand `schema_node` to itself and any `:use`s within
537    let mut use_schemas = Vec::with_capacity(1 + schema_node.uses.len());
538    use_schemas.push(schema_node);
539    // Include schema_node itself and its :defs in the stack frame
540    let stack = stack.push(match schema_node {
541        SchemaNode {
542            schema: SchemaType::Directory(d),
543            ..
544        } => VariableSource::Directory(d),
545        _ => VariableSource::Empty,
546    });
547    for used in &schema_node.uses {
548        tracing::trace!("Seeking definition of '{}'", used);
549        use_schemas.push(
550            stack
551                .find_definition(used)
552                .ok_or_else(|| anyhow!("No definition (:def) found for \"{}\"", used))?,
553        );
554    }
555    Ok(use_schemas)
556}
557
558#[cfg(test)]
559mod tests;