A Rust-y Rewrite: Script to App
A 383-line script, an overheating room, and a bit of curiosity.

Windows 11 has a quirk: if your power plan’s maximum CPU is set to 100%, Intel CPUs interpret that as “always turbo.” Notepad doesn’t need 5GHz. My office doesn’t need to be a sauna.
At the time, the fix was a PowerShell script. Watch for specific processes, switch to a high-performance plan when they run, drop back tp balanced when they’re done. It was 383 lines, one file, and happily sat in the system tray (did you know PowerShell can create Windows Form elements? Now you do).
Then, a few weeks later, I wanted to change the listening processes and I really wanted to see how often it was toggling back and forth. I could have built that into the script, but I felt an app would do it better.
The ideal of any curious developer: “What features do I need and what tech brings that to life the best?” And then the reality of being curious developer hit: “I wonder what language I could learn…”. Whoops.
What followed wasn’t a principled rewrite driven by exact requirements. Curiosity came first, then features and justification followed. This is what the process taught me.
What the Script Did (And Where It Stopped)
The original had genuine strengths. It was fast to write and easy to read, deployable instantly with no compilation or toolchain. The core decision logic fit in one readable timer callback.
$timer.add_Tick({
$matches = Get-MatchingProcesses
$hasPerfProcess = $matches.Count -gt 0
if ($hasPerfProcess) {
$script:LastPerformanceSeenAt = $now
}
$shouldBePerformance = $hasPerfProcess -or (
$script:LastPerformanceSeenAt -and
(($now - $script:LastPerformanceSeenAt).TotalSeconds -lt $Config.HoldPerformanceSeconds)
)
if ($shouldBePerformance -and $script:CurrentMode -ne $Config.PerformancePlanName) {
Set-ActivePlan -PlanGuid $Config.PerformancePlanGuid -Reason "..."
}
})
State was script-scoped variables and my config was a hashtable at the top. Power switching was a shell-out to powercfg.exe. The UI was a Windows Forms NotifyIcon with a context menu. One thread, one file. No build step.
The structural limit wasn’t any of that. The decision logic and the timer were tangled with the UI in the same callback. There was no way to test the hold period logic without running the whole app, and no clean path to a live process list without marshaling data back into the Forms thread on every tick (Forms isn’t thread-safe). Settings changes meant editing the source file directly.
Why Rust, Not the Obvious Choices
The spec was honest about the primary motivation: learning goal, native binary, strong Windows API support. The alternatives were genuinely evaluated though.
- C# was the obvious starting point. It has first-class Win32 bindings and WPF for UI, on a stack the team already knew. The dealbreaker wasn’t technical—producing a single self-contained
.exerequires extra publish flags, and more importantly, it wouldn’t teach anything new. - C++ would have been a fun challenge, but I’ve got two other C++ projects on my plate right now and wasn’t looking to add another. The learning curve for UI work can be steep in C++ outside of imgui and I wanted this to be “kinda pleasant to look at and use” without a month of UI work.
- Go was next: single binary, fast compile times, decent Windows support. The dealbreaker was UI. Go’s native Windows GUI options are thin and best suited for TUI and other apps (this blog is rendered in Go). I’d have been writing a tray-only app or reaching for a web-view wrapper.
- Python shares the PowerShell problem. Interpreted, runtime-dependent, and the GUI packaging story on Windows adds complexity without meaningful gain over just keeping the script.
So I landed with Rust. A few weeks ago, I kicked tires on it very briefly when considering an Electron app rewrite for a project, but opted not to as that project needed to move quickly and I had no Rust experience. Now, with a personal project at hand, it was the perfect time to dive in.
Rust won because two requirements turned out to be the same requirement: a single binary with no runtime dependency and constraints that force clean design decisions. Microsoft maintains windows-rs—the same Win32 APIs C++ developers use, wrapped in Rust’s type system. And every decision the compiler forced me to make (naming thread boundaries, choosing Arc<RwLock<>> over casual state sharing) produced cleaner architecture than I’d have arrived at without that pressure.
The learning goal and the quality goal turned out to be the same goal. Excellent.
The Architecture Decision: Two Threads, One Channel
The design spec committed early to a specific process model: single binary, two threads, with all communication flowing through shared state and a one-directional command channel.
Keeping everything on the message loop (like the PowerShell version) was the simpler path. The UI blocks during any slow operation though, making a live process list impossible to show without blocking on every tick.
A Windows Service is how production monitoring tools work. Ruled out for v1: it requires elevated installation and complicates debugging, with IPC between service and UI adding more architecture than the problem needs. Task Scheduler at user login covers most of the use case.
Two threads with shared state and a one-directional command channel is what I built. The monitor thread owns process polling and plan switching. The UI thread owns rendering. They share AppState through an Arc<RwLock<>>: the monitor writes it each tick, the UI reads it each frame. Commands flow one direction. The UI sends to the monitor over an mpsc (“multi-producer, single-consumer”) channel; the monitor never sends back.
The key constraint from the spec: no callbacks or events cross the boundary in the other direction. The UI never calls into the monitor directly. It writes a command and moves on. The monitor picks it up on the next loop iteration. That one-directional flow is why the system stays simple even as features accumulate.
The Dependency Choices
Every entry in Cargo.toml was deliberate:
Cargo.toml: Every Dependency Was a Decision
A few worth elaborating. egui’s immediate-mode model means there’s no widget state to synchronize with app state. Every frame, you describe what the UI should look like right now and egui renders it. This maps cleanly onto the AppState model: the monitor updates it, the UI reads and renders. There’s no “update the label text” step. The label just reads app_state.current_plan.name on every frame.
TOML for config was a deliberate rejection of the registry. The PowerShell script had hardcoded GUIDs in source. The Rust version stores plaintext TOML at %LOCALAPPDATA%\PowerPlanner\config.toml, inspectable in any text editor, deleteable for a clean reset.
SQLite over a flat log file was chosen for the v2 roadmap. A flat log works fine for “open in Notepad” but falls apart the moment you need to query “how many hours in High Performance mode last week?” The schema was designed with millisecond timestamps indexed for range queries, before they were needed.
rusqlite crate includes a bundled feature flag that statically links SQLite into the binary. Zero system dependency, one file to distribute. Worth the extra compile time.What Statefulness Unlocked
Splitting into a monitor thread and a UI thread communicating over a channel opened capabilities that would have been painful or impossible in the script.
History persistence was the obvious starting point. The PowerShell version logged to a flat text file, cleared on restart. The Rust version has SQLite with a proper schema: on startup, the last 50 events load back into memory so the history tab is populated immediately.
Live process browsing came essentially for free. The monitor snapshots the process list every tick and writes it into shared AppState. The UI reads it on the next repaint, color-coded by whether each process is on the watchlist.
Live config editing required one new command type: MonitorCommand::UpdateConfig. The monitor picks it up at the top of the next loop and replaces its config. No restart required. The PowerShell script’s config was a hashtable read once at startup and never touched again.
Manual plan override works through MonitorCommand::ForcePlan(Some(guid)) to lock the monitor into a specific plan, and ForcePlan(None) to resume automatic management. The decide_plan function checks for a forced plan first, making this trivial. In the script, “force” meant switching the plan and hoping the next timer tick didn’t switch it back.
Battery awareness came from one additional Win32 call per tick. The promote_on_battery config flag suppresses performance promotion when on battery. Adding this to the PowerShell version would have required another powercfg shell-out on every loop iteration.
The Honest Cost
The Honest Trade-Off
The complexity is real. The PowerShell version was a single afternoon. The Rust version took a few evenings: egui’s immediate-mode model was unintuitive at first and getting Arc<RwLock<AppState>> right required more type-system wrestling than expected.
There was also a CI story that wasn’t planned at the start. I’d planned to utilize GitHub’s builder for generating releases on deploy and keep my CI track going (which is new to me–yes, I’m late to the party, but 99% of what I work on isn’t public). The project builds on Windows but tests run on Ubuntu for speed (cough and cost cough), requiring split job configurations and platform-specific system dependencies. A proper release pipeline with versioned artifacts had to be added.
I am the master and victim of scope creep.
The non-goals were as deliberate as the goals. No Windows Service (Task Scheduler at login suffices for v1). No MSI installer (the binary relocates itself to %LOCALAPPDATA% on first run). Code signing was deferred; SmartScreen warns on first run, which is an acceptable cost for v1. The pattern is deferring complexity until it’s earned.
A “v2 roadmap exists”, but none of it shipped in v1 because none of it was needed to solve the original problem and I, being the only customer, didn’t need it today.
What the Compiler Actually Gave
A few things worth calling out beyond “it’s compiled.”
The compiler acts as a design reviewer you can’t dismiss. When the monitor thread needed to share state with the UI, Rust wouldn’t allow a raw reference. I had to explicitly decide: Arc<RwLock<AppState>>, reference-counted and read-write locked. That decision is visible at every call site. In PowerShell, sharing state is just using $script: variables, with no indication of which threads touch them.
OnceLock handled late-bound context cleanly. The egui repaint context isn’t available until the app starts running, but the monitor thread is spawned first. An Arc<OnceLock<egui::Context>> lets me pass the slot to the monitor at construction and fill it in later. The monitor calls ctx.request_repaint() only after the lock is populated. No mutex required for a write-once value.
Traits made the decision logic testable in a way the script never could be. Power operations go through a PowerApi trait:
pub trait PowerApi: Send + Sync {
fn get_active_plan(&self) -> Result<PowerPlan>;
fn set_active_plan(&self, guid: &str) -> Result<()>;
fn enumerate_plans(&self) -> Result<Vec<PowerPlan>>;
fn get_battery_status(&self) -> Result<BatteryStatus>;
}
WindowsPowerApi is the real implementation; tests inject a fake. The decision function never calls powercfg.exe directly, which is why these tests actually run on Ubuntu:
#[test]
fn test_on_battery_suppresses_promotion() {
let s = MonitorState::new(test_config(), "idle-guid".into(), vec![]);
assert_eq!(s.decide_plan(true, true, Instant::now()), "idle-guid");
}
#[test]
fn test_within_hold_period_stays_performance() {
let mut s = MonitorState::new(test_config(), "perf-guid".into(), vec![]);
let base = Instant::now();
s.last_match_at = Some(base);
assert_eq!(s.decide_plan(false, false, base + Duration::from_secs(5)), "perf-guid");
}
Testing the hold period logic in PowerShell meant running the app, triggering a match, waiting, and observing. The Rust version tests six decision paths in milliseconds.
The decide_plan function itself has no side effects:
pub fn decide_plan(&self, has_match: bool, on_battery: bool, now: Instant) -> String {
if let Some(ref guid) = self.forced_plan_guid {
return guid.clone(); // manual override wins
}
let suppress = on_battery && !self.config.general.promote_on_battery;
if !suppress && has_match {
return self.config.general.performance_plan_guid.clone();
}
if !suppress {
if let Some(last) = self.last_match_at {
let hold = Duration::from_secs(self.config.general.hold_performance_seconds);
if now.duration_since(last) < hold {
return self.config.general.performance_plan_guid.clone();
}
}
}
self.config.general.idle_plan_guid.clone()
}
Takes what it needs, returns a GUID. Every edge case is testable without a running app.
When the Trade Is Worth It
The PowerShell version was the right tool for the right moment. A single afternoon with zero dependencies, immediately readable to anyone who knows PowerShell, and solved a heat issue in the room as the early-summer sun beat down on the office. The script was a success.
The Rust version is a different category of software. The shift was justified by what the app needed to do, not by the language choice itself. The moment “show me what’s running” and “remember this across restarts” became requirements, the single-threaded stateless script model wasn’t adequate. Rust was the interesting choice; Go or C# would have gotten there too. But Rust’s enforcement of the boundaries I needed to draw anyway made the architecture cleaner than it might otherwise have been and made the learning experience more rewarding.
What I’m still working out: where the right threshold sits between “keep it a script” and “build the real thing.” The curiosity was the actual catalyst here. I’m not sure I’d have made the same architectural decisions in Go or C# because I was familiar with “how I’d do it.”
PowerPlanner is open source at github.com/drlongnecker/PowerPlanner. v0.1 binary releases for Windows x64 are on the releases page.







