1#![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
25pub 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 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 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(schema_node, path, attrs.clone(), stack, filesystem)
123 .with_context(|| format!("Creating {}", &path))?;
124
125 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 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 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 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 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 for (binding, child_node, pattern) in compiled_schema_entries {
295 for (name, (_, have_match)) in names.iter_mut() {
299 match binding {
300 Binding::Static(bound_name) if bound_name == name => match have_match {
302 None => {
304 *have_match = Some((binding, child_node));
305 Ok(())
306 }
307 Some((bound, _)) => Err(anyhow!(
309 r#""{}" matches multiple static bindings "{}" and "{}""#,
310 name,
311 bound,
312 binding
313 )),
314 },
315 Binding::Dynamic(_) if pattern.matches(name) => {
317 match have_match {
318 None => {
320 *have_match = Some((binding, child_node));
321 Ok(())
322 }
323 Some((bound, _)) => match bound {
325 Binding::Static(_) => Ok(()), 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 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 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 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 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 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 if !filesystem.exists(link_target.absolute()) {
490 traverse(link_target.absolute(), stack, filesystem)?;
491 assert!(filesystem.exists(link_target.absolute()));
492 }
493 filesystem
495 .create_symlink(path.absolute(), link_target.absolute())
496 .context("As symlink")?;
497 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 let mut use_schemas = Vec::with_capacity(1 + schema_node.uses.len());
538 use_schemas.push(schema_node);
539 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;