]> git.taranathan.com Git - subsystem-generator.git/commitdiff
first commit, works ig
authormoo <moogoesmeow123@gmail.com>
Sat, 9 May 2026 18:55:37 +0000 (11:55 -0700)
committermoo <moogoesmeow123@gmail.com>
Sat, 9 May 2026 18:55:37 +0000 (11:55 -0700)
.gitignore [new file with mode: 0644]
Cargo.lock [new file with mode: 0644]
Cargo.toml [new file with mode: 0644]
src/main.rs [new file with mode: 0644]

diff --git a/.gitignore b/.gitignore
new file mode 100644 (file)
index 0000000..ea8c4bf
--- /dev/null
@@ -0,0 +1 @@
+/target
diff --git a/Cargo.lock b/Cargo.lock
new file mode 100644 (file)
index 0000000..f4408e7
--- /dev/null
@@ -0,0 +1,532 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "anstream"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
+dependencies = [
+ "anstyle",
+ "anstyle-parse",
+ "anstyle-query",
+ "anstyle-wincon",
+ "colorchoice",
+ "is_terminal_polyfill",
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle"
+version = "1.0.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
+
+[[package]]
+name = "anstyle-parse"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
+dependencies = [
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle-query"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "anstyle-wincon"
+version = "3.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
+dependencies = [
+ "anstyle",
+ "once_cell_polyfill",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "anyhow"
+version = "1.0.102"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
+
+[[package]]
+name = "bitflags"
+version = "1.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
+
+[[package]]
+name = "bitflags"
+version = "2.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
+
+[[package]]
+name = "byteorder"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "clap"
+version = "4.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51"
+dependencies = [
+ "clap_builder",
+ "clap_derive",
+]
+
+[[package]]
+name = "clap_builder"
+version = "4.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
+dependencies = [
+ "anstream",
+ "anstyle",
+ "clap_lex",
+ "strsim",
+]
+
+[[package]]
+name = "clap_derive"
+version = "4.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "clap_lex"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
+
+[[package]]
+name = "colorchoice"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
+
+[[package]]
+name = "crossterm"
+version = "0.25.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e64e6c0fbe2c17357405f7c758c1ef960fce08bdfb2c03d88d2a18d7e09c4b67"
+dependencies = [
+ "bitflags 1.3.2",
+ "crossterm_winapi",
+ "libc",
+ "mio",
+ "parking_lot",
+ "signal-hook",
+ "signal-hook-mio",
+ "winapi",
+]
+
+[[package]]
+name = "crossterm_winapi"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b"
+dependencies = [
+ "winapi",
+]
+
+[[package]]
+name = "dyn-clone"
+version = "1.0.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
+
+[[package]]
+name = "errno"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "fuzzy-matcher"
+version = "0.3.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94"
+dependencies = [
+ "thread_local",
+]
+
+[[package]]
+name = "fxhash"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c"
+dependencies = [
+ "byteorder",
+]
+
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[package]]
+name = "inquire"
+version = "0.7.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fddf93031af70e75410a2511ec04d49e758ed2f26dad3404a934e0fb45cc12a"
+dependencies = [
+ "bitflags 2.11.1",
+ "crossterm",
+ "dyn-clone",
+ "fuzzy-matcher",
+ "fxhash",
+ "newline-converter",
+ "once_cell",
+ "unicode-segmentation",
+ "unicode-width",
+]
+
+[[package]]
+name = "is_terminal_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
+
+[[package]]
+name = "libc"
+version = "0.2.186"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
+
+[[package]]
+name = "lock_api"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
+dependencies = [
+ "scopeguard",
+]
+
+[[package]]
+name = "log"
+version = "0.4.29"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
+
+[[package]]
+name = "mio"
+version = "0.8.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c"
+dependencies = [
+ "libc",
+ "log",
+ "wasi",
+ "windows-sys 0.48.0",
+]
+
+[[package]]
+name = "newline-converter"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47b6b097ecb1cbfed438542d16e84fd7ad9b0c76c8a65b7f9039212a3d14dc7f"
+dependencies = [
+ "unicode-segmentation",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
+[[package]]
+name = "once_cell_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
+
+[[package]]
+name = "parking_lot"
+version = "0.12.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
+dependencies = [
+ "lock_api",
+ "parking_lot_core",
+]
+
+[[package]]
+name = "parking_lot_core"
+version = "0.9.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "redox_syscall",
+ "smallvec",
+ "windows-link",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "redox_syscall"
+version = "0.5.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
+dependencies = [
+ "bitflags 2.11.1",
+]
+
+[[package]]
+name = "scopeguard"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+
+[[package]]
+name = "signal-hook"
+version = "0.3.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
+dependencies = [
+ "libc",
+ "signal-hook-registry",
+]
+
+[[package]]
+name = "signal-hook-mio"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
+dependencies = [
+ "libc",
+ "mio",
+ "signal-hook",
+]
+
+[[package]]
+name = "signal-hook-registry"
+version = "1.4.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
+dependencies = [
+ "errno",
+ "libc",
+]
+
+[[package]]
+name = "smallvec"
+version = "1.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
+
+[[package]]
+name = "strsim"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
+
+[[package]]
+name = "subsystem-generator"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "clap",
+ "inquire",
+]
+
+[[package]]
+name = "syn"
+version = "2.0.117"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "thread_local"
+version = "1.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "unicode-segmentation"
+version = "1.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c"
+
+[[package]]
+name = "unicode-width"
+version = "0.1.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
+
+[[package]]
+name = "utf8parse"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
+
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
+[[package]]
+name = "winapi"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
+dependencies = [
+ "winapi-i686-pc-windows-gnu",
+ "winapi-x86_64-pc-windows-gnu",
+]
+
+[[package]]
+name = "winapi-i686-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
+
+[[package]]
+name = "winapi-x86_64-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-sys"
+version = "0.48.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
+dependencies = [
+ "windows-targets",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
+dependencies = [
+ "windows_aarch64_gnullvm",
+ "windows_aarch64_msvc",
+ "windows_i686_gnu",
+ "windows_i686_msvc",
+ "windows_x86_64_gnu",
+ "windows_x86_64_gnullvm",
+ "windows_x86_64_msvc",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644 (file)
index 0000000..8efed6b
--- /dev/null
@@ -0,0 +1,9 @@
+[package]
+name = "subsystem-generator"
+version = "0.1.0"
+edition = "2024"
+
+[dependencies]
+anyhow = "1.0.102"
+clap = { version = "4.6.1", features = ["derive"] }
+inquire = "0.7"
diff --git a/src/main.rs b/src/main.rs
new file mode 100644 (file)
index 0000000..ce59644
--- /dev/null
@@ -0,0 +1,511 @@
+use clap::Parser;
+use inquire::{Confirm, CustomType, Select, Text};
+use std::{
+    collections::HashSet,
+    fs, io,
+    path::{Path, PathBuf},
+};
+
+#[derive(clap::Parser, Debug)]
+#[command(version, about, long_about = None)]
+struct Args {
+    /// Overwrite existing files if they already exist
+    #[arg(long, short)]
+    force: bool,
+}
+
+#[derive(Debug)]
+struct MotorConfig {
+    field_base: String,
+    constant_base: String,
+    can_id: i32,
+    inverted: bool,
+}
+
+#[derive(Debug)]
+struct GeneratorConfig {
+    subsystem: String,
+    project_root: PathBuf,
+    subsystems_path: PathBuf,
+    package_prefix: String,
+    neutral_mode: String,
+    motors: Vec<MotorConfig>,
+}
+
+fn main() -> Result<(), Box<dyn std::error::Error>> {
+    let args = Args::parse();
+    let mut config = prompt_config()?;
+    normalize_motor_identifiers(&mut config.motors);
+
+    let package = format!("{}.{}", config.package_prefix, config.subsystem);
+    let subsystem_dir = config
+        .project_root
+        .join(&config.subsystems_path)
+        .join(&config.subsystem);
+
+    fs::create_dir_all(&subsystem_dir)?;
+
+    let io_interface = format!("{}IO", config.subsystem);
+    let io_impl = format!("{}IOTalonFX", config.subsystem);
+    let io_inputs = format!("{}IOInputs", config.subsystem);
+    let constants_class = format!("{}Constants", config.subsystem);
+
+    let files = vec![
+        (
+            subsystem_dir.join(format!("{constants_class}.java")),
+            render_constants(&package, &constants_class, &config.motors),
+        ),
+        (
+            subsystem_dir.join(format!("{io_interface}.java")),
+            render_io_interface(
+                &package,
+                &io_interface,
+                &io_inputs,
+                &constants_class,
+                &config.motors,
+            ),
+        ),
+        (
+            subsystem_dir.join(format!("{io_impl}.java")),
+            render_io_impl(
+                &package,
+                &io_impl,
+                &io_interface,
+                &io_inputs,
+                &constants_class,
+                &config.neutral_mode,
+                &config.motors,
+            ),
+        ),
+        (
+            subsystem_dir.join(format!("{}.java", config.subsystem)),
+            render_subsystem(&package, &config.subsystem, &io_interface, &config.motors),
+        ),
+    ];
+
+    for (path, contents) in files {
+        write_file(&path, &contents, args.force)?;
+        println!("Created {}", path.display());
+    }
+
+    Ok(())
+}
+
+fn prompt_config() -> Result<GeneratorConfig, Box<dyn std::error::Error>> {
+    let project_root = Text::new("WPILib project root")
+        .with_default(".")
+        .prompt()?;
+    let subsystems_path = Text::new("Subsystems path (relative to project root)")
+        .with_default("src/main/java/frc/robot/subsystems")
+        .prompt()?;
+    let package_prefix = Text::new("Java package")
+        .with_default("frc.robot.subsystems")
+        .prompt()?;
+    let subsystem_name = Text::new("Subsystem name")
+        .with_help_message("Used for class name and package segment")
+        .prompt()?;
+    let subsystem = to_pascal_case(&subsystem_name);
+
+    let motor_count = CustomType::<usize>::new("Number of TalonFX motors")
+        .with_default(1)
+        .with_error_message("Enter an int greater than zero")
+        .prompt()?;
+    if motor_count == 0 {
+        return Err("motor count must be at least 1".into());
+    }
+
+    let neutral_mode = Select::new("Neutral mode for generated motors", vec!["Brake", "Coast"])
+        .prompt()?
+        .to_string();
+
+    let mut motors = Vec::with_capacity(motor_count);
+    for i in 0..motor_count {
+        let default_name = format!("motor{}", i + 1);
+        let name = Text::new(&format!("Motor {} name", i + 1))
+            .with_default(&default_name)
+            .with_help_message("Examples: left, right, roller, feeder")
+            .prompt()?;
+        let can_id = CustomType::<i32>::new(&format!("Motor {} CAN ID", i + 1))
+            .with_error_message("CAN ID must be an integer from 0 to 62")
+            .with_validator(|value: &i32| {
+                if (0..=62).contains(value) {
+                    Ok(inquire::validator::Validation::Valid)
+                } else {
+                    Ok(inquire::validator::Validation::Invalid(
+                        "CAN ID must be between 0 and 62".into(),
+                    ))
+                }
+            })
+            .prompt()?;
+        let inverted = Confirm::new(&format!("Is '{}' inverted?", name))
+            .with_default(false)
+            .prompt()?;
+
+        motors.push(MotorConfig {
+            field_base: to_lower_camel_case(&name),
+            constant_base: to_upper_snake_case(&name),
+            can_id,
+            inverted,
+        });
+    }
+
+    Ok(GeneratorConfig {
+        subsystem,
+        project_root: PathBuf::from(project_root),
+        subsystems_path: PathBuf::from(subsystems_path),
+        package_prefix,
+        neutral_mode,
+        motors,
+    })
+}
+
+fn normalize_motor_identifiers(motors: &mut [MotorConfig]) {
+    let mut seen_fields = HashSet::new();
+    let mut seen_constants = HashSet::new();
+
+    for (index, motor) in motors.iter_mut().enumerate() {
+        let base_field = if motor.field_base.is_empty() {
+            format!("motor{}", index + 1)
+        } else {
+            motor.field_base.clone()
+        };
+
+        let mut unique_field = base_field.clone();
+        let mut n = 2;
+        while !seen_fields.insert(unique_field.clone()) {
+            unique_field = format!("{base_field}{n}");
+            n += 1;
+        }
+        motor.field_base = unique_field;
+
+        let base_constant = if motor.constant_base.is_empty() {
+            format!("MOTOR_{}", index + 1)
+        } else {
+            motor.constant_base.clone()
+        };
+
+        let mut unique_constant = base_constant.clone();
+        let mut m = 2;
+        while !seen_constants.insert(unique_constant.clone()) {
+            unique_constant = format!("{base_constant}_{m}");
+            m += 1;
+        }
+        motor.constant_base = unique_constant;
+    }
+}
+
+fn write_file(path: &Path, contents: &str, force: bool) -> io::Result<()> {
+    if path.exists() && !force {
+        return Err(io::Error::new(
+            io::ErrorKind::AlreadyExists,
+            format!(
+                "{} already exists. Re-run with --force to overwrite.",
+                path.display()
+            ),
+        ));
+    }
+
+    fs::write(path, contents)
+}
+
+fn to_pascal_case(name: &str) -> String {
+    let mut out = String::new();
+    for part in name.split(|c: char| !c.is_ascii_alphanumeric()) {
+        if part.is_empty() {
+            continue;
+        }
+        let mut chars = part.chars();
+        if let Some(first) = chars.next() {
+            out.push(first.to_ascii_uppercase());
+            for c in chars {
+                out.push(c.to_ascii_lowercase());
+            }
+        }
+    }
+
+    if out.is_empty() {
+        "Subsystem".to_string()
+    } else if out.chars().next().is_some_and(|c| c.is_ascii_digit()) {
+        format!("Subsystem{out}")
+    } else {
+        out
+    }
+}
+
+fn to_lower_camel_case(name: &str) -> String {
+    let pascal = to_pascal_case(name);
+    if pascal.is_empty() {
+        return "motor".to_string();
+    }
+    let mut chars = pascal.chars();
+    let first = chars.next().unwrap_or('m').to_ascii_lowercase();
+    let mut out = String::new();
+    out.push(first);
+    out.extend(chars);
+    if out.chars().next().is_some_and(|c| c.is_ascii_digit()) {
+        format!("motor{out}")
+    } else {
+        out
+    }
+}
+
+fn to_upper_snake_case(name: &str) -> String {
+    let mut out = String::new();
+    let mut last_was_sep = false;
+    for c in name.chars() {
+        if c.is_ascii_alphanumeric() {
+            out.push(c.to_ascii_uppercase());
+            last_was_sep = false;
+        } else if !last_was_sep && !out.is_empty() {
+            out.push('_');
+            last_was_sep = true;
+        }
+    }
+
+    let out = out.trim_matches('_').to_string();
+    if out.is_empty() {
+        "MOTOR".to_string()
+    } else if out.chars().next().is_some_and(|c| c.is_ascii_digit()) {
+        format!("MOTOR_{out}")
+    } else {
+        out
+    }
+}
+
+fn upper_first(value: &str) -> String {
+    let mut chars = value.chars();
+    match chars.next() {
+        Some(first) => {
+            let mut out = String::new();
+            out.push(first.to_ascii_uppercase());
+            out.extend(chars);
+            out
+        }
+        None => String::new(),
+    }
+}
+
+fn render_constants(package: &str, constants_class: &str, motors: &[MotorConfig]) -> String {
+    let mut motor_constants = String::new();
+    for motor in motors {
+        motor_constants.push_str(&format!(
+            "  public static final int {}_MOTOR_ID = {};\n",
+            motor.constant_base, motor.can_id
+        ));
+    }
+
+    format!(
+        "package {package};
+
+public class {constants_class} {{
+
+{motor_constants}}}
+"
+    )
+}
+
+fn render_io_interface(
+    package: &str,
+    io_interface: &str,
+    io_inputs: &str,
+    _constants_class: &str,
+    motors: &[MotorConfig],
+) -> String {
+    let mut input_fields = String::new();
+    for motor in motors {
+        let f = &motor.field_base;
+        input_fields.push_str(&format!("    public double {f}PositionRot = 0.0;\n"));
+        input_fields.push_str(&format!("    public double {f}VelocityRps = 0.0;\n"));
+        input_fields.push_str(&format!("    public double {f}StatorCurrentAmps = 0.0;\n"));
+        input_fields.push_str(&format!("    public double {f}SupplyCurrentAmps = 0.0;\n"));
+        input_fields.push_str(&format!("    public double {f}AppliedVolts = 0.0;\n"));
+    }
+
+    let mut speed_methods = String::new();
+    let mut stop_calls = String::new();
+    for motor in motors {
+        let method_suffix = upper_first(&motor.field_base);
+        speed_methods.push_str(&format!(
+            "  public void set{method_suffix}SpeedRaw(double speed);\n\n"
+        ));
+        speed_methods.push_str(&format!(
+            "  public void set{method_suffix}Control(ControlRequest request);\n\n"
+        ));
+        stop_calls.push_str(&format!("    set{method_suffix}SpeedRaw(0.0);\n"));
+    }
+
+    format!(
+        "package {package};
+
+import org.littletonrobotics.junction.AutoLog;
+import com.ctre.phoenix6.controls.ControlRequest;
+
+public interface {io_interface} {{
+  @AutoLog
+  public static class {io_inputs} {{
+{input_fields}  }}
+
+  public void updateInputs({io_inputs} inputs);
+
+{speed_methods}  public default void stop() {{
+{stop_calls}  }}
+
+  public default void close() {{}}
+}}
+"
+    )
+}
+
+fn render_io_impl(
+    package: &str,
+    io_impl: &str,
+    io_interface: &str,
+    io_inputs: &str,
+    constants_class: &str,
+    neutral_mode: &str,
+    motors: &[MotorConfig],
+) -> String {
+    let mut motor_fields = String::new();
+    for motor in motors {
+        motor_fields.push_str(&format!(
+            "  private final TalonFX {}Motor = new TalonFX({constants_class}.{}_MOTOR_ID, Constants.CANIVORE_SUB);\n",
+            motor.field_base, motor.constant_base
+        ));
+    }
+
+    let mut config_calls = String::new();
+    for motor in motors {
+        let inverted = if motor.inverted {
+            "InvertedValue.Clockwise_Positive"
+        } else {
+            "InvertedValue.CounterClockwise_Positive"
+        };
+        let neutral = if neutral_mode == "Brake" {
+            "NeutralModeValue.Brake"
+        } else {
+            "NeutralModeValue.Coast"
+        };
+        config_calls.push_str(&format!(
+            "    {}Motor.getConfigurator().apply(config);\n",
+            motor.field_base
+        ));
+        config_calls.push_str(&format!(
+            "    {}Motor.getConfigurator().apply(new MotorOutputConfigs().withInverted({inverted}).withNeutralMode({neutral}));\n",
+            motor.field_base
+        ));
+    }
+
+    let mut input_assignments = String::new();
+    for motor in motors {
+        let f = &motor.field_base;
+        input_assignments.push_str(&format!(
+            "    inputs.{f}PositionRot = {f}Motor.getPosition().getValueAsDouble();\n"
+        ));
+        input_assignments.push_str(&format!(
+            "    inputs.{f}VelocityRps = {f}Motor.getVelocity().getValueAsDouble();\n"
+        ));
+        input_assignments.push_str(&format!(
+            "    inputs.{f}StatorCurrentAmps = {f}Motor.getStatorCurrent().getValueAsDouble();\n"
+        ));
+        input_assignments.push_str(&format!(
+            "    inputs.{f}SupplyCurrentAmps = {f}Motor.getSupplyCurrent().getValueAsDouble();\n"
+        ));
+        input_assignments.push_str(&format!(
+            "    inputs.{f}AppliedVolts = {f}Motor.getMotorVoltage().getValueAsDouble();\n"
+        ));
+    }
+
+    let mut raw_speed_methods = String::new();
+    for motor in motors {
+        let method_suffix = upper_first(&motor.field_base);
+        raw_speed_methods.push_str(&format!(
+            "  @Override\n  public void set{method_suffix}SpeedRaw(double speed) {{\n    {}Motor.set(speed);\n  }}\n\n",
+            motor.field_base
+        ));
+        raw_speed_methods.push_str(&format!(
+            "  @Override\n  public void set{method_suffix}Control(ControlRequest request) {{\n    {}Motor.setControl(request);\n  }}\n\n",
+            motor.field_base
+        ));
+    }
+
+    let mut close_calls = String::new();
+    for motor in motors {
+        close_calls.push_str(&format!("    {}Motor.close();\n", motor.field_base));
+    }
+
+    format!(
+        "package {package};
+
+import com.ctre.phoenix6.configs.MotorOutputConfigs;
+import com.ctre.phoenix6.configs.TalonFXConfiguration;
+import com.ctre.phoenix6.controls.ControlRequest;
+import com.ctre.phoenix6.hardware.TalonFX;
+import com.ctre.phoenix6.signals.InvertedValue;
+import com.ctre.phoenix6.signals.NeutralModeValue;
+import frc.robot.constants.Constants;
+
+public class {io_impl} implements {io_interface} {{
+{motor_fields}
+  public {io_impl}() {{
+    TalonFXConfiguration config = new TalonFXConfiguration();
+    // TODO: tune PID, current limits, and motion magic limits
+{config_calls}  }}
+
+  @Override
+  public void updateInputs({io_inputs} inputs) {{
+{input_assignments}  }}
+
+{raw_speed_methods}  @Override
+  public void close() {{
+{close_calls}  }}
+}}
+"
+    )
+}
+
+fn render_subsystem(
+    package: &str,
+    subsystem: &str,
+    io_interface: &str,
+    motors: &[MotorConfig],
+) -> String {
+    let io_inputs_auto = format!("{subsystem}IOInputsAutoLogged");
+    let mut methods = String::new();
+    for motor in motors {
+        let method_suffix = upper_first(&motor.field_base);
+        methods.push_str(&format!(
+            "  public void set{method_suffix}SpeedRaw(double speed) {{\n    io.set{method_suffix}SpeedRaw(speed);\n  }}\n\n"
+        ));
+    }
+    format!(
+        "package {package};
+
+import org.littletonrobotics.junction.Logger;
+
+import edu.wpi.first.wpilibj2.command.SubsystemBase;
+
+public class {subsystem} extends SubsystemBase {{
+  private final {io_interface} io;
+  private final {io_inputs_auto} inputs = new {io_inputs_auto}();
+
+  public {subsystem}({io_interface} io) {{
+    this.io = io;
+  }}
+
+  @Override
+  public void periodic() {{
+    io.updateInputs(inputs);
+    Logger.processInputs(\"{subsystem}\", inputs);
+  }}
+
+{methods}  public void stop() {{
+    io.stop();
+  }}
+
+  public void close() {{
+    io.close();
+  }}
+}}
+"
+    )
+}