rototo 0.1.0-alpha.4

Control plane for runtime configuration of your application.
Documentation
use std::path::{Path, PathBuf};

use crate::error::{Result, RototoError};

use super::{DocumentCollection, DocumentKind, SourceStore};

impl SourceStore {
    pub(crate) async fn add_named_toml_documents(
        &mut self,
        directory: &str,
        collection: DocumentCollection,
    ) -> Result<()> {
        let directory_path = self.root.join(directory);
        let entries = match sorted_directory_entries(&directory_path).await {
            Ok(entries) => entries,
            Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()),
            Err(err) => {
                return Err(RototoError::new(format!(
                    "failed to read {}: {err}",
                    directory_path.display()
                )));
            }
        };

        for path in entries {
            if path.extension().and_then(|extension| extension.to_str()) != Some("toml") {
                continue;
            }
            let Some(stem) = path.file_stem().and_then(|stem| stem.to_str()) else {
                continue;
            };
            let relative_path =
                PathBuf::from(directory).join(path.file_name().expect("entry has filename"));
            let kind = match collection {
                DocumentCollection::Qualifiers => DocumentKind::Qualifier {
                    id: stem.to_owned(),
                },
                DocumentCollection::Variables => DocumentKind::Variable {
                    id: stem.to_owned(),
                },
                DocumentCollection::Resources => DocumentKind::Resource {
                    id: stem.to_owned(),
                },
            };
            self.add_disk_document(relative_path, kind).await;
            if matches!(collection, DocumentCollection::Resources) {
                self.add_resource_object_documents(stem).await?;
            }
        }

        Ok(())
    }

    pub(crate) async fn add_resource_object_documents(&mut self, resource_id: &str) -> Result<()> {
        let objects_dir = self
            .root
            .join("resources")
            .join(format!("{resource_id}-objects"));
        let entries = match sorted_directory_entries(&objects_dir).await {
            Ok(entries) => entries,
            Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()),
            Err(err) => {
                return Err(RototoError::new(format!(
                    "failed to read {}: {err}",
                    objects_dir.display()
                )));
            }
        };

        for path in entries {
            if path.extension().and_then(|extension| extension.to_str()) != Some("toml") {
                continue;
            }
            let Some(object_id) = path.file_stem().and_then(|stem| stem.to_str()) else {
                continue;
            };
            let relative_path = PathBuf::from("resources")
                .join(format!("{resource_id}-objects"))
                .join(path.file_name().expect("entry has filename"));
            self.add_disk_document(
                relative_path,
                DocumentKind::ResourceObject {
                    resource_id: resource_id.to_owned(),
                    object_id: object_id.to_owned(),
                },
            )
            .await;
        }

        let overlay_prefix = format!("resources/{resource_id}-objects/");
        let overlay_paths = self
            .overlays
            .keys()
            .filter(|path| path.starts_with(&overlay_prefix) && path.ends_with(".toml"))
            .cloned()
            .collect::<Vec<_>>();
        for path in overlay_paths {
            let Some(object_id) = overlay_object_id(&path, &overlay_prefix) else {
                continue;
            };
            self.add_disk_document(
                PathBuf::from(&path),
                DocumentKind::ResourceObject {
                    resource_id: resource_id.to_owned(),
                    object_id,
                },
            )
            .await;
        }

        Ok(())
    }

    pub(crate) async fn add_schema_documents(&mut self) -> Result<()> {
        let schemas = self.root.join("schemas");
        let entries = match sorted_directory_entries(&schemas).await {
            Ok(entries) => entries,
            Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()),
            Err(err) => {
                return Err(RototoError::new(format!(
                    "failed to read {}: {err}",
                    schemas.display()
                )));
            }
        };

        for path in entries {
            if path.extension().and_then(|extension| extension.to_str()) != Some("json") {
                continue;
            }
            let relative_path =
                PathBuf::from("schemas").join(path.file_name().expect("entry has filename"));
            self.add_disk_document(relative_path, DocumentKind::Schema)
                .await;
        }

        Ok(())
    }

    pub(crate) async fn add_custom_lint_documents(&mut self) -> Result<()> {
        let lint = self.root.join("lint");
        let entries = match sorted_directory_entries(&lint).await {
            Ok(entries) => entries,
            Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()),
            Err(err) => {
                return Err(RototoError::new(format!(
                    "failed to read {}: {err}",
                    lint.display()
                )));
            }
        };

        for path in entries {
            if path.extension().and_then(|extension| extension.to_str()) != Some("lua") {
                continue;
            }
            let relative_path =
                PathBuf::from("lint").join(path.file_name().expect("entry has filename"));
            self.add_disk_document(relative_path, DocumentKind::CustomLint)
                .await;
        }

        Ok(())
    }

    pub(crate) async fn add_overlay_documents(&mut self) -> Result<()> {
        let paths = self.overlays.keys().cloned().collect::<Vec<_>>();
        for path in paths {
            if self.document_by_path(&path).is_some() {
                continue;
            }
            let Some(kind) = overlay_document_kind(&path) else {
                continue;
            };
            let resource_id = match &kind {
                DocumentKind::Resource { id } => Some(id.clone()),
                _ => None,
            };
            self.add_disk_document(PathBuf::from(&path), kind).await;
            if let Some(resource_id) = resource_id {
                self.add_resource_object_documents(&resource_id).await?;
            }
        }
        Ok(())
    }
}

fn overlay_document_kind(path: &str) -> Option<DocumentKind> {
    if path == "rototo-workspace.toml" {
        return Some(DocumentKind::Manifest);
    }
    let parts = path.split('/').collect::<Vec<_>>();
    match parts.as_slice() {
        ["qualifiers", file] if file.ends_with(".toml") => {
            let id = file.strip_suffix(".toml")?;
            (!id.is_empty()).then(|| DocumentKind::Qualifier { id: id.to_owned() })
        }
        ["variables", file] if file.ends_with(".toml") => {
            let id = file.strip_suffix(".toml")?;
            (!id.is_empty()).then(|| DocumentKind::Variable { id: id.to_owned() })
        }
        ["resources", file] if file.ends_with(".toml") => {
            let id = file.strip_suffix(".toml")?;
            (!id.is_empty()).then(|| DocumentKind::Resource { id: id.to_owned() })
        }
        ["resources", dir, file] if dir.ends_with("-objects") && file.ends_with(".toml") => {
            let resource_id = dir.strip_suffix("-objects")?;
            let object_id = file.strip_suffix(".toml")?;
            (!resource_id.is_empty() && !object_id.is_empty()).then(|| {
                DocumentKind::ResourceObject {
                    resource_id: resource_id.to_owned(),
                    object_id: object_id.to_owned(),
                }
            })
        }
        ["schemas", file] if file.ends_with(".json") => Some(DocumentKind::Schema),
        ["lint", file] if file.ends_with(".lua") => Some(DocumentKind::CustomLint),
        _ => None,
    }
}

fn overlay_object_id(path: &str, prefix: &str) -> Option<String> {
    let file = path.strip_prefix(prefix)?;
    if file.contains('/') {
        return None;
    }
    let key = file.strip_suffix(".toml")?;
    (!key.is_empty()).then(|| key.to_owned())
}

async fn sorted_directory_entries(path: &Path) -> std::io::Result<Vec<PathBuf>> {
    let mut entries = Vec::new();
    let mut read_dir = tokio::fs::read_dir(path).await?;
    while let Some(entry) = read_dir.next_entry().await? {
        let file_type = entry.file_type().await?;
        if file_type.is_file()
            || (file_type.is_symlink()
                && tokio::fs::metadata(entry.path())
                    .await
                    .map(|metadata| metadata.is_file())
                    .unwrap_or(true))
        {
            entries.push(entry.path());
        }
    }
    entries.sort();
    Ok(entries)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[cfg(unix)]
    #[tokio::test]
    async fn sorted_directory_entries_skips_symlinked_directories() {
        let tempdir = tempfile::tempdir().unwrap();
        let root = tempdir.path();
        let target_dir = root.join("looks-like-file.toml");
        tokio::fs::create_dir(&target_dir).await.unwrap();
        tokio::fs::write(root.join("real.toml"), "schema_version = 1")
            .await
            .unwrap();
        std::os::unix::fs::symlink(&target_dir, root.join("linked.toml")).unwrap();

        let entries = sorted_directory_entries(root).await.unwrap();
        let names = entries
            .iter()
            .map(|path| path.file_name().unwrap().to_string_lossy().into_owned())
            .collect::<Vec<_>>();

        assert_eq!(names, vec!["real.toml"]);
    }
}