rustmotion 0.7.0

A CLI tool that renders motion design videos from JSON scenarios. No browser, no Node.js — just a single Rust binary.
Documentation
use rustmotion::error::{Result, RustmotionError};
use std::path::{Path, PathBuf};

/// An embedded skill or rule file.
struct SkillFile {
    /// Relative path from the target root (e.g. ".claude/skills/rustmotion/rules/hex-colors.md")
    path: &'static str,
    /// File content embedded at compile time.
    content: &'static str,
}

/// CLAUDE.md project instructions (written at project root).
const CLAUDE_MD: &str = include_str!("../../CLAUDE.md");

/// All skill files embedded at compile time.
///
/// Generated by `build.rs`, which walks `.claude/skills/rustmotion/` at
/// build time and embeds every `.md` file it finds via `include_str!`. This
/// is intentionally not a hand-maintained literal: a manually curated list
/// silently drops any rule file nobody remembered to add (issue #165). See
/// `tests/skill_files_match_disk.rs` for the guard that keeps this table and
/// the on-disk rule set from drifting apart again.
///
/// SKILL.md is always the first entry (see `build.rs`), but code here must
/// not rely on that position — locate it by path instead.
const SKILL_FILES: &[SkillFile] = include!(concat!(env!("OUT_DIR"), "/skill_files.rs"));

/// Resolve the target directory for skill installation.
fn resolve_target(global: bool) -> Result<PathBuf> {
    if global {
        let home = dirs::home_dir().ok_or(RustmotionError::FileRead {
            path: "~".to_string(),
            source: std::io::Error::new(
                std::io::ErrorKind::NotFound,
                "Could not determine home directory",
            ),
        })?;
        Ok(home)
    } else {
        Ok(PathBuf::from("."))
    }
}

/// Write a file, creating parent directories as needed.
/// Returns true if the file was written (new or updated), false if unchanged.
fn write_if_changed(path: &Path, content: &str) -> Result<bool> {
    if let Ok(existing) = std::fs::read_to_string(path) {
        if existing == content {
            return Ok(false);
        }
    }
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent)?;
    }
    std::fs::write(path, content)?;
    Ok(true)
}

/// Install skills to the target directory.
/// - local (default): writes to `./.claude/skills/rustmotion/` + `./CLAUDE.md`
/// - global (`--global`): writes to `~/.claude/skills/rustmotion/`
pub fn install(global: bool) -> Result<()> {
    let root = resolve_target(global)?;
    let mut written = 0u32;
    let mut skipped = 0u32;

    // Write skill files
    for sf in SKILL_FILES {
        let target = root.join(sf.path);
        if write_if_changed(&target, sf.content)? {
            written += 1;
        } else {
            skipped += 1;
        }
    }

    // Write CLAUDE.md only in local mode. The project may already own this file —
    // merge into a delimited block rather than claiming the whole document.
    if !global {
        let claude_path = root.join("CLAUDE.md");
        let existing = std::fs::read_to_string(&claude_path).ok();
        let merged = crate::cli::claude_md::merge(existing.as_deref(), CLAUDE_MD);
        if write_if_changed(&claude_path, &merged)? {
            written += 1;
        } else {
            skipped += 1;
        }
    }

    let location = if global {
        "~/.claude/skills/rustmotion/"
    } else {
        ".claude/skills/rustmotion/"
    };

    if written == 0 {
        println!(
            "Skills already up to date ({} files) in {}",
            skipped, location
        );
    } else {
        println!(
            "Installed {} file(s) to {} ({} unchanged)",
            written, location, skipped
        );
        if !global {
            println!("Claude Code will now use rustmotion skills in this project.");
        }
    }

    Ok(())
}

/// List all available skills and rules.
pub fn list() {
    println!("rustmotion skills ({} files)\n", SKILL_FILES.len());
    println!("  SKILL.md (main skill definition)\n");
    println!("Rules:");
    for sf in SKILL_FILES {
        if sf.path.contains("/rules/") {
            let name = sf.path.rsplit('/').next().unwrap_or(sf.path);
            // Extract first line as title
            let title = sf
                .content
                .lines()
                .next()
                .unwrap_or("")
                .trim_start_matches("# ")
                .trim_start_matches("Rule: ");
            println!("  {:<35} {}", name, title);
        }
    }
    println!("\nUsage:");
    println!("  rustmotion skills install          Install to current project (.claude/skills/)");
    println!("  rustmotion skills install --global  Install globally (~/.claude/skills/)");
    println!("  rustmotion skills show <name>       Show a rule (e.g. 'hex-colors')");
}

/// Show the content of a specific rule.
pub fn show(name: &str) -> Result<()> {
    // Try matching by filename (with or without .md)
    let needle = name.trim_end_matches(".md");
    for sf in SKILL_FILES {
        let filename = sf
            .path
            .rsplit('/')
            .next()
            .unwrap_or("")
            .trim_end_matches(".md");
        if filename == needle {
            print!("{}", sf.content);
            return Ok(());
        }
    }

    // Special case: SKILL.md. Matched by path rather than position — the
    // generated table happens to put SKILL.md first, but nothing here should
    // depend on that ordering to keep working if it ever changes.
    if needle.eq_ignore_ascii_case("skill") {
        if let Some(sf) = SKILL_FILES.iter().find(|sf| sf.path.ends_with("/SKILL.md")) {
            print!("{}", sf.content);
            return Ok(());
        }
    }

    Err(RustmotionError::UnknownSkill {
        name: name.to_string(),
    })
}

/// Uninstall skills from the target directory.
/// - local (default): removes `./.claude/skills/rustmotion/` + `./CLAUDE.md`
/// - global (`--global`): removes `~/.claude/skills/rustmotion/`
pub fn uninstall(global: bool) -> Result<()> {
    let root = resolve_target(global)?;
    let skills_dir = root.join(".claude/skills/rustmotion");
    let location = if global {
        "~/.claude/skills/rustmotion/"
    } else {
        ".claude/skills/rustmotion/"
    };

    if !skills_dir.exists() {
        println!("Nothing to remove — {} does not exist.", location);
        return Ok(());
    }

    std::fs::remove_dir_all(&skills_dir)?;
    let mut removed = 1;

    // Remove only what we put there. A CLAUDE.md carrying the project's own
    // instructions keeps them; the file is deleted only when our block was all it
    // ever held.
    if !global {
        let claude_path = root.join("CLAUDE.md");
        if let Ok(existing) = std::fs::read_to_string(&claude_path) {
            match crate::cli::claude_md::strip(&existing) {
                Some(remaining) => {
                    if remaining != existing {
                        std::fs::write(&claude_path, remaining)?;
                        removed += 1;
                    }
                }
                None => {
                    std::fs::remove_file(&claude_path)?;
                    removed += 1;
                }
            }
        }
    }

    // Clean up empty parent directories
    let skills_parent = root.join(".claude/skills");
    if skills_parent.exists() && skills_parent.read_dir()?.next().is_none() {
        std::fs::remove_dir(&skills_parent).ok();
        let claude_dir = root.join(".claude");
        if claude_dir.exists() && claude_dir.read_dir()?.next().is_none() {
            std::fs::remove_dir(&claude_dir).ok();
        }
    }

    println!(
        "Removed rustmotion skills from {} ({} items)",
        location, removed
    );
    Ok(())
}