Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions crates/rustmotion-cli/build.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
//! Generates the `SKILL_FILES` table embedded into the `rustmotion` binary by
//! `src/skills.rs`.
//!
//! `rustmotion skills install` is the only channel through which an LLM agent
//! receives the generation rules under `.claude/skills/rustmotion/`. Before
//! this build script existed, the embedded table was a hand-maintained
//! literal list: any rule file added to disk was invisible to `install`
//! until someone remembered to add a matching entry. That silent gap let 17
//! of 47 rule files — including ones explicitly named by CLAUDE.md — never
//! reach an installed project (issue #165).
//!
//! Walking the directory at build time instead of listing files by hand
//! makes "every `.md` file under `.claude/skills/rustmotion/` is embedded"
//! true by construction, not by memory. `cargo:rerun-if-changed` on the
//! directory (Cargo scans it recursively) means adding, removing, or editing
//! a rule file triggers a rebuild of the generated table on the next build.

use std::env;
use std::fs;
use std::path::{Path, PathBuf};

/// Recursively collect every `.md` file under `dir`, sorted by file name at
/// each directory level so the generated table has a stable, reproducible
/// order across machines and OSes.
fn collect_md_files(dir: &Path, out: &mut Vec<PathBuf>) {
let mut entries: Vec<_> = fs::read_dir(dir)
.unwrap_or_else(|e| panic!("failed to read directory {}: {e}", dir.display()))
.filter_map(|e| e.ok())
.collect();
entries.sort_by_key(|e| e.file_name());

for entry in entries {
let path = entry.path();
if path.is_dir() {
collect_md_files(&path, out);
} else if path.extension().and_then(|e| e.to_str()) == Some("md") {
out.push(path);
}
}
}

fn main() {
let manifest_dir =
PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is set by cargo"));
// crates/rustmotion-cli -> crates -> <workspace root>
let workspace_root = manifest_dir
.parent()
.and_then(Path::parent)
.unwrap_or_else(|| {
panic!(
"expected {} to live at <workspace>/crates/rustmotion-cli",
manifest_dir.display()
)
})
.to_path_buf();

let skills_root = workspace_root.join(".claude/skills/rustmotion");

// Rerun whenever any file under the skills tree is added, removed, or
// edited — Cargo scans directories given to rerun-if-changed recursively.
println!("cargo:rerun-if-changed={}", skills_root.display());
// Once any rerun-if-changed is emitted, Cargo stops rebuilding on build.rs
// changes implicitly, so it must be listed explicitly too.
println!("cargo:rerun-if-changed=build.rs");

let skill_md = skills_root.join("SKILL.md");
assert!(
skill_md.is_file(),
"expected {} to exist — is the workspace layout intact?",
skill_md.display()
);

let rules_dir = skills_root.join("rules");
let mut rule_files = Vec::new();
collect_md_files(&rules_dir, &mut rule_files);

// SKILL.md first — `skills::show("skill")` and `skills::list()` treat it
// as the main skill definition — followed by every rule file,
// alphabetically. This mirrors the order of the previous hand-written
// list, but nothing downstream is allowed to depend on it: `show()`
// locates SKILL.md by path, not by position.
let mut all_files = vec![skill_md];
all_files.extend(rule_files);

let mut generated = String::from("&[\n");
for path in &all_files {
let rel_path = path
.strip_prefix(&workspace_root)
.unwrap_or(path)
.to_str()
.unwrap_or_else(|| panic!("non-UTF-8 skill file path: {}", path.display()))
.replace('\\', "/"); // normalize separators if built on Windows
let abs_path = path
.to_str()
.unwrap_or_else(|| panic!("non-UTF-8 skill file path: {}", path.display()));
generated.push_str(&format!(
" SkillFile {{ path: {rel_path:?}, content: include_str!({abs_path:?}) }},\n"
));
}
generated.push_str("]\n");

let out_dir = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR is set by cargo"));
fs::write(out_dir.join("skill_files.rs"), generated).expect("failed to write skill_files.rs");
}
4 changes: 4 additions & 0 deletions crates/rustmotion-cli/src/commands/batch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,10 @@ fn render_row(
crf,
format.map(str::to_string),
transparent,
// `batch` has no `--hardware-acceleration` flag of its own (Lot D's
// scope stops at commands/render.rs; wiring one into `cmd_batch`
// touches this file). Always software here.
false,
)
}

Expand Down
195 changes: 194 additions & 1 deletion crates/rustmotion-cli/src/commands/info.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
use rustmotion::components::{ChildComponent, Component};
use rustmotion::engine::animator::spring_rest_time;
use rustmotion::engine::render::deserialize_children;
use rustmotion::error::Result;
use rustmotion::loader::load_input;
use rustmotion::schema;
use rustmotion::schema::{self, AnimationEffect, ResolvedScenario, SpringConfig};
use std::path::PathBuf;

pub fn cmd_info(input: &PathBuf) -> Result<()> {
Expand Down Expand Up @@ -55,5 +58,195 @@ pub fn cmd_info(input: &PathBuf) -> Result<()> {
}
}

let springs = collect_springs(&scenario);
if !springs.is_empty() {
println!("Springs:");
for report in &springs {
println!(" {}", report.describe());
}
}

Ok(())
}

/// Where a `SpringConfig` was found, and the settle time computed for it —
/// the "measure du repos" issue #167 lot E asks `rustmotion info` to
/// surface, so an author can size the enclosing animation's `duration`
/// around a spring instead of guessing (see `SpringConfig::duration`'s doc
/// comment for why the two are not automatically kept in sync).
#[derive(Debug)]
struct SpringReport {
label: String,
rest_seconds: f64,
duration_was_set: bool,
}

impl SpringReport {
fn describe(&self) -> String {
if self.duration_was_set {
format!(
"{}: settles at {:.3}s (spring.duration set explicitly)",
self.label, self.rest_seconds
)
} else {
format!(
"{}: settles at {:.3}s (natural — no spring.duration set; \
pin the enclosing animation's duration to at least this to \
avoid cutting the spring short)",
self.label, self.rest_seconds
)
}
}
}

fn collect_springs(scenario: &ResolvedScenario) -> Vec<SpringReport> {
let mut out = Vec::new();
for (vi, view) in scenario.views.iter().enumerate() {
for (si, scene) in view.scenes.iter().enumerate() {
let children = deserialize_children(scene);
let path = format!("view {} / scene {}", vi + 1, si + 1);
collect_springs_in_children(&children, &path, &mut out);
}
}
out
}

fn collect_springs_in_children(
children: &[ChildComponent],
path: &str,
out: &mut Vec<SpringReport>,
) {
for (i, child) in children.iter().enumerate() {
let p = format!("{path} / layer {}", i + 1);
if let Some(anim) = child.component.as_animatable() {
for effect in anim.animation_effects() {
if let Some((_, timing)) = effect.as_preset() {
if let Some(spring) = &timing.spring {
out.push(spring_report(&p, spring));
}
}
if let AnimationEffect::Keyframes(k) = effect {
for kf_anim in &k.keyframes {
if let Some(spring) = &kf_anim.spring {
out.push(spring_report(&p, spring));
}
}
}
}
}
match &child.component {
Component::Card(c) => collect_springs_in_children(&c.children, &p, out),
Component::Flex(c) => collect_springs_in_children(&c.children, &p, out),
Component::Grid(c) => collect_springs_in_children(&c.children, &p, out),
Component::Positioned(c) => collect_springs_in_children(&c.children, &p, out),
Component::Container(c) => collect_springs_in_children(&c.children, &p, out),
_ => {}
}
}
}

fn spring_report(label: &str, spring: &SpringConfig) -> SpringReport {
SpringReport {
label: label.to_string(),
rest_seconds: spring_rest_time(spring),
duration_was_set: spring.duration.is_some(),
}
}

#[cfg(test)]
mod spring_report_tests {
//! Issue #167 lot E: `rustmotion info` must surface the settle time of
//! every spring it finds, recursing into containers the same way
//! `validate_schema::validate_children` already does.
use super::*;
use rustmotion::components::ChildComponent;

#[test]
fn finds_a_spring_on_a_top_level_preset() {
let child: ChildComponent = serde_json::from_value(serde_json::json!({
"type": "text",
"content": "hi",
"style": {
"animation": [{ "name": "bounce_in", "duration": 0.6, "spring": { "damping": 12, "stiffness": 100, "mass": 1 } }]
}
}))
.unwrap();
let mut out = Vec::new();
collect_springs_in_children(&[child], "test", &mut out);
assert_eq!(
out.len(),
1,
"expected exactly one spring report: {:?}",
out.iter().map(|r| &r.label).collect::<Vec<_>>()
);
assert!(out[0].rest_seconds > 0.0);
assert!(!out[0].duration_was_set);
}

#[test]
fn finds_a_spring_nested_inside_a_card() {
let child: ChildComponent = serde_json::from_value(serde_json::json!({
"type": "card",
"children": [{
"type": "text",
"content": "hi",
"style": {
"animation": [{ "name": "fade_in_up", "duration": 0.6, "spring": { "damping": 15, "stiffness": 100, "mass": 1 } }]
}
}]
}))
.unwrap();
let mut out = Vec::new();
collect_springs_in_children(&[child], "test", &mut out);
assert_eq!(
out.len(),
1,
"spring nested inside a card must be found: {out:?}"
);
assert!(
out[0].label.contains("layer 1"),
"expected the nested layer to be labelled: {}",
out[0].label
);
}

#[test]
fn reports_the_pinned_duration_when_spring_duration_is_set() {
let child: ChildComponent = serde_json::from_value(serde_json::json!({
"type": "text",
"content": "hi",
"style": {
"animation": [{
"name": "bounce_in",
"duration": 0.6,
"spring": { "damping": 6, "stiffness": 120, "mass": 1, "duration": 0.8 }
}]
}
}))
.unwrap();
let mut out = Vec::new();
collect_springs_in_children(&[child], "test", &mut out);
assert_eq!(out.len(), 1);
assert!(out[0].duration_was_set);
assert!(
(out[0].rest_seconds - 0.8).abs() < 1e-9,
"spring.duration must be reported verbatim as the settle time, got {}",
out[0].rest_seconds
);
}

#[test]
fn no_springs_produces_an_empty_report() {
let child: ChildComponent = serde_json::from_value(serde_json::json!({
"type": "text",
"content": "hi",
"style": {
"animation": [{ "name": "fade_in_up", "duration": 0.6 }]
}
}))
.unwrap();
let mut out = Vec::new();
collect_springs_in_children(&[child], "test", &mut out);
assert!(out.is_empty(), "unexpected spring reports: {out:?}");
}
}
Loading
Loading