taktwerk 0.8.0

Fixed-step model execution engine for Linux with its own OPC UA server
//! The project folder: which TOML file is what, paths kept inside the folder.

use std::path::{Component, Path, PathBuf};

use serde::Serialize;

/// Directories never scanned.
const SKIP: &[&str] = &["target", "node_modules", ".git", ".venv", "lib", "pki"];
/// How deep the scan goes below the folder.
const MAX_DEPTH: usize = 6;

/// What a TOML file in the folder is.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Kind {
    /// A project file: `[engine]` or `[[instance]]`.
    Project,
    /// A campaign file: a `project` and cases, a grid, samples or replicates.
    Campaign,
    /// A scenario file: events, a stop, overrides or a seed.
    Scenario,
    /// A raw model package descriptor: `[abi]` and `[[variables]]`.
    Model,
}

/// What `text` (a TOML file) is, if taktwerk reads it.
#[must_use]
pub fn classify(text: &str) -> Option<Kind> {
    let table: toml::Table = text.parse().ok()?;
    let has = |k: &str| table.contains_key(k);
    if has("engine") || has("instance") {
        Some(Kind::Project)
    } else if has("project")
        && ["case", "grid", "sample", "replicates", "out"]
            .iter()
            .any(|k| has(k))
    {
        Some(Kind::Campaign)
    } else if has("event") || has("stop") || has("set") || has("seed") {
        Some(Kind::Scenario)
    } else if has("abi") && has("variables") {
        Some(Kind::Model)
    } else {
        None
    }
}

/// One file of the folder taktwerk reads.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Entry {
    /// Path relative to the folder, `/`-separated.
    pub path: String,
    /// What it is.
    pub kind: Kind,
}

/// `path` relative to `root`, `/`-separated.
#[must_use]
pub fn relative(root: &Path, path: &Path) -> String {
    let rel = path.strip_prefix(root).unwrap_or(path);
    rel.components()
        .filter_map(|c| match c {
            Component::Normal(s) => Some(s.to_string_lossy().into_owned()),
            _ => None,
        })
        .collect::<Vec<_>>()
        .join("/")
}

/// Every project, campaign, scenario and model file below `root`, sorted by path. Campaign
/// output and run records are not entered: their `run.toml` files are runs, not scenarios.
#[must_use]
pub fn scan(root: &Path) -> Vec<Entry> {
    let mut found = Vec::new();
    let mut stack = vec![(root.to_owned(), 0)];
    while let Some((dir, depth)) = stack.pop() {
        if dir.join("campaign.json").is_file() || dir.join("run.json").is_file() {
            continue;
        }
        let Ok(entries) = std::fs::read_dir(&dir) else {
            continue;
        };
        for entry in entries.flatten() {
            let path = entry.path();
            let name = entry.file_name().to_string_lossy().into_owned();
            let Ok(ty) = entry.file_type() else { continue };
            if ty.is_dir() {
                if depth < MAX_DEPTH && !name.starts_with('.') && !SKIP.contains(&name.as_str()) {
                    stack.push((path, depth + 1));
                }
            } else if name.ends_with(".toml")
                && let Ok(text) = std::fs::read_to_string(&path)
                && let Some(kind) = classify(&text)
            {
                found.push(Entry {
                    path: relative(root, &path),
                    kind,
                });
            }
        }
    }
    found.sort_by(|a, b| a.path.cmp(&b.path));
    found
}

/// `rel` resolved inside `root` (which is canonical): `None` when it does not exist or leaves
/// the folder.
#[must_use]
pub fn resolve(root: &Path, rel: &str) -> Option<PathBuf> {
    let path = root.join(rel.trim_start_matches('/')).canonicalize().ok()?;
    path.starts_with(root).then_some(path)
}

#[cfg(test)]
#[allow(clippy::unwrap_used, reason = "tests")]
mod tests {
    use super::*;

    #[test]
    fn files_are_classified_by_their_tables() {
        assert_eq!(classify("[engine]\ntick_ms = 1.0\n"), Some(Kind::Project));
        assert_eq!(
            classify("project = \"p.toml\"\n[grid]\n\"a.k\" = [1, 2]\n"),
            Some(Kind::Campaign)
        );
        assert_eq!(
            classify("[stop]\ntime_s = 1.0\n[[event]]\nat_s = 0.5\n"),
            Some(Kind::Scenario)
        );
        assert_eq!(
            classify("name = \"pi\"\n[[variables]]\nname = \"u\"\n[abi]\n"),
            Some(Kind::Model)
        );
        assert_eq!(classify("[package]\nname = \"x\"\n"), None);
        assert_eq!(classify("not toml ["), None);
    }

    #[test]
    fn the_scan_skips_outputs_and_paths_stay_inside() {
        let root = std::env::temp_dir().join(format!("tw-scan-{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&root);
        std::fs::create_dir_all(root.join("out/c/runs/r1")).unwrap();
        std::fs::create_dir_all(root.join("sc")).unwrap();
        std::fs::write(root.join("p.toml"), "[engine]\ntick_ms = 1.0\n").unwrap();
        std::fs::write(root.join("sc/s.toml"), "[stop]\ncycles = 3\n").unwrap();
        std::fs::write(root.join("out/c/campaign.json"), "{}").unwrap();
        std::fs::write(root.join("out/c/runs/r1/run.toml"), "[stop]\ncycles = 3\n").unwrap();
        std::fs::write(root.join("Cargo.toml"), "[package]\nname = \"x\"\n").unwrap();
        let root = root.canonicalize().unwrap();
        let found = scan(&root);
        assert_eq!(
            found,
            [
                Entry {
                    path: "p.toml".into(),
                    kind: Kind::Project
                },
                Entry {
                    path: "sc/s.toml".into(),
                    kind: Kind::Scenario
                },
            ]
        );
        assert_eq!(resolve(&root, "sc/s.toml"), Some(root.join("sc/s.toml")));
        assert_eq!(resolve(&root, "/p.toml"), Some(root.join("p.toml")));
        assert_eq!(resolve(&root, "../"), None);
        assert_eq!(resolve(&root, "sc/../../etc"), None);
        assert_eq!(resolve(&root, "nope.toml"), None);
        std::fs::remove_dir_all(&root).unwrap();
    }
}