clankers-cli 0.1.6

Command-line interface for clankeRS
Documentation
use std::collections::BTreeSet;
use std::fs;
use std::path::{Component, Path, PathBuf};

use anyhow::{bail, Context, Result};

// TEMPLATE_FILES: (destination path, contents) for every bundled template
// file, embedded into the binary so `clankers new` works from a plain
// `cargo install clankers-cli` with no repo checkout.
include!(concat!(env!("OUT_DIR"), "/templates_gen.rs"));

pub fn execute(name: &str, template: &str) -> Result<()> {
    let normalized = template.replace('-', "_");
    let prefix = format!("{normalized}/");
    let files: Vec<(&str, &str)> = TEMPLATE_FILES
        .iter()
        .filter_map(|(path, contents)| {
            path.strip_prefix(prefix.as_str())
                .map(|rel| (rel, *contents))
        })
        .collect();
    if files.is_empty() {
        bail!(
            "template '{template}' not found; available: {}",
            available_templates().join(", ")
        );
    }

    let dest = Path::new(name);
    if dest.exists() {
        bail!("directory '{}' already exists", name);
    }

    for (rel, contents) in &files {
        let path = dest.join(rel);
        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent)?;
        }
        fs::write(&path, contents).with_context(|| format!("write {}", path.display()))?;
    }

    replace_in_tree(dest, "{{PROJECT_NAME}}", name)?;

    let clankers_dep = clankers_dependency_line(dest)?;
    replace_in_tree(dest, "{{CLANKERS_DEPENDENCY}}", &clankers_dep)?;

    println!("clankeRS project created: {name}\n\nNext steps:\n  cd {name}\n  clankers run");
    Ok(())
}

fn available_templates() -> Vec<String> {
    TEMPLATE_FILES
        .iter()
        .filter_map(|(path, _)| path.split('/').next())
        .collect::<BTreeSet<_>>()
        .into_iter()
        .map(|name| name.replace('_', "-"))
        .collect()
}

fn clankers_dependency_line(dest: &Path) -> Result<String> {
    if let Some(workspace) = find_workspace_root() {
        let clankers_crate = workspace.join("crates/clankers");
        if clankers_crate.exists() {
            let dest_abs = std::env::current_dir()?.join(dest);
            if let Some(rel) = relative_path(&dest_abs, &clankers_crate) {
                return Ok(format!(
                    "clankers = {{ path = \"{}\" }}",
                    rel.to_string_lossy()
                ));
            }
        }
    }
    // Published crates.io install — no repo checkout required
    Ok(r#"clankers = "0.1""#.to_string())
}

fn relative_path(from: &Path, to: &Path) -> Option<PathBuf> {
    let from = from.components().collect::<Vec<_>>();
    let to = to.components().collect::<Vec<_>>();

    let mut i = 0;
    while i < from.len() && i < to.len() && from[i] == to[i] {
        i += 1;
    }

    let mut rel = PathBuf::new();
    for _ in i..from.len() {
        if matches!(from[i], Component::Normal(_)) {
            rel.push("..");
        }
    }
    for part in &to[i..] {
        rel.push(part.as_os_str());
    }
    if rel.as_os_str().is_empty() {
        rel.push(".");
    }
    Some(rel)
}

fn find_workspace_root() -> Option<PathBuf> {
    let cwd = std::env::current_dir().ok()?;
    for ancestor in cwd.ancestors() {
        if ancestor.join("crates/clankers").exists() {
            return Some(ancestor.to_path_buf());
        }
    }
    None
}

fn replace_in_tree(dir: &Path, from: &str, to: &str) -> Result<()> {
    for entry in walkdir::WalkDir::new(dir) {
        let entry = entry?;
        if entry.file_type().is_file() {
            let path = entry.path();
            let content = fs::read_to_string(path).context("read template file")?;
            if content.contains(from) {
                fs::write(path, content.replace(from, to))?;
            }
        }
    }
    Ok(())
}

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

    #[test]
    fn relative_path_to_sibling_crate() {
        let from = Path::new("/workspace/clankeRS/hello_clanker");
        let to = Path::new("/workspace/clankeRS/crates/clankers");
        assert_eq!(
            relative_path(from, to).unwrap(),
            PathBuf::from("../crates/clankers")
        );
    }

    #[test]
    fn all_templates_are_embedded_with_manifests() {
        for template in [
            "basic_node",
            "perception_node",
            "ml_inference_node",
            "replay_test_node",
        ] {
            let manifest = format!("{template}/Cargo.toml");
            assert!(
                TEMPLATE_FILES.iter().any(|(path, _)| *path == manifest),
                "embedded table is missing {manifest}"
            );
            let main = format!("{template}/src/main.rs");
            assert!(
                TEMPLATE_FILES.iter().any(|(path, _)| *path == main),
                "embedded table is missing {main}"
            );
        }
    }
}