Skip to main content

max / ripgrow

initial workspace: schema + migrations Two-crate workspace (ripgrow-core, ripgrow-tui) with MIT license and the audiofiles-style PRAGMA user_version migration runner. Schema covers the seven tables from the design note: meta, tags, exercises, exercise_tags, sets, progression_state, readiness. RPE constrained to 1-5, unit to kg|lb, sets unique on (session_date, exercise_id, set_index). TUI binary is a placeholder; screens come next. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-18 16:15 UTC
Commit: 5eecf01dcd348629773d395790b1809241b43f84
11 files changed, +905 insertions, -0 deletions
A .gitignore +5
@@ -0,0 +1,5 @@
1 + /target
2 + /Cargo.lock.bak
3 + CLAUDE.md
4 + .DS_Store
5 + *.swp
A Cargo.lock +500
@@ -0,0 +1,1114 @@
1 + # This file is automatically @generated by Cargo.
2 + # It is not intended for manual editing.
3 + version = 4
4 +
5 + [[package]]
6 + name = "allocator-api2"
7 + version = "0.2.21"
8 + source = "registry+https://github.com/rust-lang/crates.io-index"
9 + checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
10 +
11 + [[package]]
12 + name = "android_system_properties"
13 + version = "0.1.5"
14 + source = "registry+https://github.com/rust-lang/crates.io-index"
15 + checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
16 + dependencies = [
17 + "libc",
18 + ]
19 +
20 + [[package]]
21 + name = "autocfg"
22 + version = "1.5.1"
23 + source = "registry+https://github.com/rust-lang/crates.io-index"
24 + checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
25 +
26 + [[package]]
27 + name = "bitflags"
28 + version = "2.13.1"
29 + source = "registry+https://github.com/rust-lang/crates.io-index"
30 + checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
31 +
32 + [[package]]
33 + name = "bumpalo"
34 + version = "3.20.3"
35 + source = "registry+https://github.com/rust-lang/crates.io-index"
36 + checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
37 +
38 + [[package]]
39 + name = "cassowary"
40 + version = "0.3.0"
41 + source = "registry+https://github.com/rust-lang/crates.io-index"
42 + checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53"
43 +
44 + [[package]]
45 + name = "castaway"
46 + version = "0.2.4"
47 + source = "registry+https://github.com/rust-lang/crates.io-index"
48 + checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
49 + dependencies = [
50 + "rustversion",
51 + ]
52 +
53 + [[package]]
54 + name = "cc"
55 + version = "1.2.67"
56 + source = "registry+https://github.com/rust-lang/crates.io-index"
57 + checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38"
58 + dependencies = [
59 + "find-msvc-tools",
60 + "shlex",
61 + ]
62 +
63 + [[package]]
64 + name = "cfg-if"
65 + version = "1.0.4"
66 + source = "registry+https://github.com/rust-lang/crates.io-index"
67 + checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
68 +
69 + [[package]]
70 + name = "chrono"
71 + version = "0.4.45"
72 + source = "registry+https://github.com/rust-lang/crates.io-index"
73 + checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
74 + dependencies = [
75 + "iana-time-zone",
76 + "num-traits",
77 + "serde",
78 + "windows-link",
79 + ]
80 +
81 + [[package]]
82 + name = "compact_str"
83 + version = "0.8.2"
84 + source = "registry+https://github.com/rust-lang/crates.io-index"
85 + checksum = "7fd622ebbb56a5b2ccb651b32b911cdeb2a9b4b11776b2473bf26a26a286244e"
86 + dependencies = [
87 + "castaway",
88 + "cfg-if",
89 + "itoa",
90 + "rustversion",
91 + "ryu",
92 + "static_assertions",
93 + ]
94 +
95 + [[package]]
96 + name = "convert_case"
97 + version = "0.10.0"
98 + source = "registry+https://github.com/rust-lang/crates.io-index"
99 + checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9"
100 + dependencies = [
101 + "unicode-segmentation",
102 + ]
103 +
104 + [[package]]
105 + name = "core-foundation-sys"
106 + version = "0.8.7"
107 + source = "registry+https://github.com/rust-lang/crates.io-index"
108 + checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
109 +
110 + [[package]]
111 + name = "crossterm"
112 + version = "0.28.1"
113 + source = "registry+https://github.com/rust-lang/crates.io-index"
114 + checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
115 + dependencies = [
116 + "bitflags",
117 + "crossterm_winapi",
118 + "mio",
119 + "parking_lot",
120 + "rustix 0.38.44",
121 + "signal-hook",
122 + "signal-hook-mio",
123 + "winapi",
124 + ]
125 +
126 + [[package]]
127 + name = "crossterm"
128 + version = "0.29.0"
129 + source = "registry+https://github.com/rust-lang/crates.io-index"
130 + checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b"
131 + dependencies = [
132 + "bitflags",
133 + "crossterm_winapi",
134 + "derive_more",
135 + "document-features",
136 + "mio",
137 + "parking_lot",
138 + "rustix 1.1.4",
139 + "signal-hook",
140 + "signal-hook-mio",
141 + "winapi",
142 + ]
143 +
144 + [[package]]
145 + name = "crossterm_winapi"
146 + version = "0.9.1"
147 + source = "registry+https://github.com/rust-lang/crates.io-index"
148 + checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b"
149 + dependencies = [
150 + "winapi",
151 + ]
152 +
153 + [[package]]
154 + name = "darling"
155 + version = "0.23.0"
156 + source = "registry+https://github.com/rust-lang/crates.io-index"
157 + checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
158 + dependencies = [
159 + "darling_core",
160 + "darling_macro",
161 + ]
162 +
163 + [[package]]
164 + name = "darling_core"
165 + version = "0.23.0"
166 + source = "registry+https://github.com/rust-lang/crates.io-index"
167 + checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"
168 + dependencies = [
169 + "ident_case",
170 + "proc-macro2",
171 + "quote",
172 + "strsim",
173 + "syn",
174 + ]
175 +
176 + [[package]]
177 + name = "darling_macro"
178 + version = "0.23.0"
179 + source = "registry+https://github.com/rust-lang/crates.io-index"
180 + checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
181 + dependencies = [
182 + "darling_core",
183 + "quote",
184 + "syn",
185 + ]
186 +
187 + [[package]]
188 + name = "derive_more"
189 + version = "2.1.1"
190 + source = "registry+https://github.com/rust-lang/crates.io-index"
191 + checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134"
192 + dependencies = [
193 + "derive_more-impl",
194 + ]
195 +
196 + [[package]]
197 + name = "derive_more-impl"
198 + version = "2.1.1"
199 + source = "registry+https://github.com/rust-lang/crates.io-index"
200 + checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
201 + dependencies = [
202 + "convert_case",
203 + "proc-macro2",
204 + "quote",
205 + "rustc_version",
206 + "syn",
207 + ]
208 +
209 + [[package]]
210 + name = "dirs"
211 + version = "6.0.0"
212 + source = "registry+https://github.com/rust-lang/crates.io-index"
213 + checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
214 + dependencies = [
215 + "dirs-sys",
216 + ]
217 +
218 + [[package]]
219 + name = "dirs-sys"
220 + version = "0.5.0"
221 + source = "registry+https://github.com/rust-lang/crates.io-index"
222 + checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
223 + dependencies = [
224 + "libc",
225 + "option-ext",
226 + "redox_users",
227 + "windows-sys 0.61.2",
228 + ]
229 +
230 + [[package]]
231 + name = "document-features"
232 + version = "0.2.12"
233 + source = "registry+https://github.com/rust-lang/crates.io-index"
234 + checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
235 + dependencies = [
236 + "litrs",
237 + ]
238 +
239 + [[package]]
240 + name = "either"
241 + version = "1.16.0"
242 + source = "registry+https://github.com/rust-lang/crates.io-index"
243 + checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
244 +
245 + [[package]]
246 + name = "equivalent"
247 + version = "1.0.2"
248 + source = "registry+https://github.com/rust-lang/crates.io-index"
249 + checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
250 +
251 + [[package]]
252 + name = "errno"
253 + version = "0.3.14"
254 + source = "registry+https://github.com/rust-lang/crates.io-index"
255 + checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
256 + dependencies = [
257 + "libc",
258 + "windows-sys 0.61.2",
259 + ]
260 +
261 + [[package]]
262 + name = "fallible-iterator"
263 + version = "0.3.0"
264 + source = "registry+https://github.com/rust-lang/crates.io-index"
265 + checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
266 +
267 + [[package]]
268 + name = "fallible-streaming-iterator"
269 + version = "0.1.9"
270 + source = "registry+https://github.com/rust-lang/crates.io-index"
271 + checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
272 +
273 + [[package]]
274 + name = "find-msvc-tools"
275 + version = "0.1.9"
276 + source = "registry+https://github.com/rust-lang/crates.io-index"
277 + checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
278 +
279 + [[package]]
280 + name = "foldhash"
281 + version = "0.1.5"
282 + source = "registry+https://github.com/rust-lang/crates.io-index"
283 + checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
284 +
285 + [[package]]
286 + name = "futures-core"
287 + version = "0.3.33"
288 + source = "registry+https://github.com/rust-lang/crates.io-index"
289 + checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7"
290 +
291 + [[package]]
292 + name = "futures-task"
293 + version = "0.3.33"
294 + source = "registry+https://github.com/rust-lang/crates.io-index"
295 + checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109"
296 +
297 + [[package]]
298 + name = "futures-util"
299 + version = "0.3.33"
300 + source = "registry+https://github.com/rust-lang/crates.io-index"
301 + checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa"
302 + dependencies = [
303 + "futures-core",
304 + "futures-task",
305 + "pin-project-lite",
306 + "slab",
307 + ]
308 +
309 + [[package]]
310 + name = "getrandom"
311 + version = "0.2.17"
312 + source = "registry+https://github.com/rust-lang/crates.io-index"
313 + checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
314 + dependencies = [
315 + "cfg-if",
316 + "libc",
317 + "wasi",
318 + ]
319 +
320 + [[package]]
321 + name = "hashbrown"
322 + version = "0.15.5"
323 + source = "registry+https://github.com/rust-lang/crates.io-index"
324 + checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
325 + dependencies = [
326 + "allocator-api2",
327 + "equivalent",
328 + "foldhash",
329 + ]
330 +
331 + [[package]]
332 + name = "hashlink"
333 + version = "0.10.0"
334 + source = "registry+https://github.com/rust-lang/crates.io-index"
335 + checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1"
336 + dependencies = [
337 + "hashbrown",
338 + ]
339 +
340 + [[package]]
341 + name = "heck"
342 + version = "0.5.0"
343 + source = "registry+https://github.com/rust-lang/crates.io-index"
344 + checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
345 +
346 + [[package]]
347 + name = "iana-time-zone"
348 + version = "0.1.65"
349 + source = "registry+https://github.com/rust-lang/crates.io-index"
350 + checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
351 + dependencies = [
352 + "android_system_properties",
353 + "core-foundation-sys",
354 + "iana-time-zone-haiku",
355 + "js-sys",
356 + "log",
357 + "wasm-bindgen",
358 + "windows-core",
359 + ]
360 +
361 + [[package]]
362 + name = "iana-time-zone-haiku"
363 + version = "0.1.2"
364 + source = "registry+https://github.com/rust-lang/crates.io-index"
365 + checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
366 + dependencies = [
367 + "cc",
368 + ]
369 +
370 + [[package]]
371 + name = "ident_case"
372 + version = "1.0.1"
373 + source = "registry+https://github.com/rust-lang/crates.io-index"
374 + checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
375 +
376 + [[package]]
377 + name = "indoc"
378 + version = "2.0.7"
379 + source = "registry+https://github.com/rust-lang/crates.io-index"
380 + checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
381 + dependencies = [
382 + "rustversion",
383 + ]
384 +
385 + [[package]]
386 + name = "instability"
387 + version = "0.3.12"
388 + source = "registry+https://github.com/rust-lang/crates.io-index"
389 + checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971"
390 + dependencies = [
391 + "darling",
392 + "indoc",
393 + "proc-macro2",
394 + "quote",
395 + "syn",
396 + ]
397 +
398 + [[package]]
399 + name = "itertools"
400 + version = "0.13.0"
401 + source = "registry+https://github.com/rust-lang/crates.io-index"
402 + checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
403 + dependencies = [
404 + "either",
405 + ]
406 +
407 + [[package]]
408 + name = "itoa"
409 + version = "1.0.18"
410 + source = "registry+https://github.com/rust-lang/crates.io-index"
411 + checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
412 +
413 + [[package]]
414 + name = "js-sys"
415 + version = "0.3.103"
416 + source = "registry+https://github.com/rust-lang/crates.io-index"
417 + checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102"
418 + dependencies = [
419 + "cfg-if",
420 + "futures-util",
421 + "wasm-bindgen",
422 + ]
423 +
424 + [[package]]
425 + name = "libc"
426 + version = "0.2.186"
427 + source = "registry+https://github.com/rust-lang/crates.io-index"
428 + checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
429 +
430 + [[package]]
431 + name = "libredox"
432 + version = "0.1.18"
433 + source = "registry+https://github.com/rust-lang/crates.io-index"
434 + checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652"
435 + dependencies = [
436 + "libc",
437 + ]
438 +
439 + [[package]]
440 + name = "libsqlite3-sys"
441 + version = "0.35.0"
442 + source = "registry+https://github.com/rust-lang/crates.io-index"
443 + checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f"
444 + dependencies = [
445 + "cc",
446 + "pkg-config",
447 + "vcpkg",
448 + ]
449 +
450 + [[package]]
451 + name = "linux-raw-sys"
452 + version = "0.4.15"
453 + source = "registry+https://github.com/rust-lang/crates.io-index"
454 + checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
455 +
456 + [[package]]
457 + name = "linux-raw-sys"
458 + version = "0.12.1"
459 + source = "registry+https://github.com/rust-lang/crates.io-index"
460 + checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
461 +
462 + [[package]]
463 + name = "litrs"
464 + version = "1.0.0"
465 + source = "registry+https://github.com/rust-lang/crates.io-index"
466 + checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
467 +
468 + [[package]]
469 + name = "lock_api"
470 + version = "0.4.14"
471 + source = "registry+https://github.com/rust-lang/crates.io-index"
472 + checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
473 + dependencies = [
474 + "scopeguard",
475 + ]
476 +
477 + [[package]]
478 + name = "log"
479 + version = "0.4.33"
480 + source = "registry+https://github.com/rust-lang/crates.io-index"
481 + checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
482 +
483 + [[package]]
484 + name = "lru"
485 + version = "0.12.5"
486 + source = "registry+https://github.com/rust-lang/crates.io-index"
487 + checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38"
488 + dependencies = [
489 + "hashbrown",
490 + ]
491 +
492 + [[package]]
493 + name = "mio"
494 + version = "1.2.2"
495 + source = "registry+https://github.com/rust-lang/crates.io-index"
496 + checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427"
497 + dependencies = [
498 + "libc",
499 + "log",
500 + "wasi",
Lines truncated
A Cargo.toml +18
@@ -0,0 +1,18 @@
1 + [workspace]
2 + members = ["crates/ripgrow-core", "crates/ripgrow-tui"]
3 + resolver = "3"
4 +
5 + [workspace.package]
6 + version = "0.1.0"
7 + edition = "2024"
8 + license-file = "LICENSE"
9 + repository = "https://git.sr.ht/~maxjmath/ripgrow"
10 +
11 + [workspace.dependencies]
12 + ripgrow-core = { path = "crates/ripgrow-core" }
13 + rusqlite = { version = "0.37", features = ["bundled"] }
14 + thiserror = "2"
15 + chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] }
16 + dirs = "6"
17 + ratatui = "0.29"
18 + crossterm = "0.29"
A LICENSE +21
@@ -0,0 +1,21 @@
1 + MIT License
2 +
3 + Copyright (c) 2026 Make Creative, LLC
4 +
5 + Permission is hereby granted, free of charge, to any person obtaining a copy
6 + of this software and associated documentation files (the "Software"), to deal
7 + in the Software without restriction, including without limitation the rights
8 + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 + copies of the Software, and to permit persons to whom the Software is
10 + furnished to do so, subject to the following conditions:
11 +
12 + The above copyright notice and this permission notice shall be included in all
13 + copies or substantial portions of the Software.
14 +
15 + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 + SOFTWARE.
A README.md +40
@@ -0,0 +1,40 @@
1 + # ripgrow
2 +
3 + A ratatui TUI for programming and tracking resistance-training workouts. You
4 + build a library of exercise templates, log each session's sets, and the app
5 + generates the next workout as a printable PDF using progression rules over your
6 + history and a self-reported readiness map.
7 +
8 + Two intended users, one binary:
9 +
10 + - Solo lifter. One profile, one SQLite file, log-then-generate.
11 + - Personal trainer. Multiple profiles side by side, one per client. Delivery
12 + is a texted PDF; clients never touch ripgrow.
13 +
14 + ## Crates
15 +
16 + - `ripgrow-core` — data model, schema migrations, progression state machine.
17 + - `ripgrow-tui` — ratatui frontend, ships the `ripgrow` binary.
18 +
19 + ## Storage
20 +
21 + One SQLite file per profile at
22 + `~/Library/Application Support/ripgrow/profiles/<name>.db`. Design detail
23 + lives in the internal design notes.
24 +
25 + ## Migrations
26 +
27 + `rusqlite` bundled + `PRAGMA user_version` as the version tracker, matching
28 + the audiofiles pattern. Migration steps are embedded SQL in `ripgrow-core`.
29 +
30 + ## Build
31 +
32 + Target `aarch64-apple-darwin` only, built on mbp. No cross-compilation.
33 +
34 + ```
35 + cargo build --release
36 + ```
37 +
38 + ## License
39 +
40 + MIT.
@@ -0,0 +1,12 @@
1 + [package]
2 + name = "ripgrow-core"
3 + version.workspace = true
4 + edition.workspace = true
5 + license-file.workspace = true
6 + repository.workspace = true
7 + description = "Core data model and progression logic for ripgrow, a resistance-training programmer."
8 +
9 + [dependencies]
10 + rusqlite = { workspace = true }
11 + thiserror = { workspace = true }
12 + chrono = { workspace = true }
@@ -0,0 +1,263 @@
1 + //! SQLite wrapper with versioned migrations for one ripgrow profile.
2 + //!
3 + //! Migrations are tracked with `PRAGMA user_version`. Each step runs inside a
4 + //! transaction so the schema change and version bump are atomic. Pattern is
5 + //! borrowed from audiofiles-core; kept minimal here because no migrations
6 + //! have shipped yet.
7 +
8 + use std::path::Path;
9 +
10 + use rusqlite::{Connection, OptionalExtension, params};
11 +
12 + use crate::error::Error;
13 +
14 + const MIGRATIONS: &[&str] = &[MIGRATION_001];
15 +
16 + /// Unit preference for a profile. Stored as `'kg'` or `'lb'`; the choice is
17 + /// made at profile-create time and hard to change safely later.
18 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
19 + pub enum Unit {
20 + Kg,
21 + Lb,
22 + }
23 +
24 + impl Unit {
25 + pub fn as_str(&self) -> &'static str {
26 + match self {
27 + Unit::Kg => "kg",
28 + Unit::Lb => "lb",
29 + }
30 + }
31 +
32 + pub fn parse(s: &str) -> Result<Self, Error> {
33 + match s {
34 + "kg" => Ok(Unit::Kg),
35 + "lb" => Ok(Unit::Lb),
36 + other => Err(Error::InvalidUnit(other.to_string())),
37 + }
38 + }
39 + }
40 +
41 + pub struct Db {
42 + conn: Connection,
43 + }
44 +
45 + impl Db {
46 + /// Open (or create) a profile database at `path` and run migrations.
47 + /// Does not populate the `meta` row; call [`Db::init_profile`] once on
48 + /// first-run to record the profile name and unit choice.
49 + pub fn open(path: impl AsRef<Path>) -> Result<Self, Error> {
50 + let conn = Connection::open(path)?;
51 + conn.pragma_update(None, "foreign_keys", "ON")?;
52 + conn.pragma_update(None, "journal_mode", "WAL")?;
53 + let mut db = Self { conn };
54 + db.migrate()?;
55 + Ok(db)
56 + }
57 +
58 + /// In-memory database for tests.
59 + #[cfg(test)]
60 + pub fn open_in_memory() -> Result<Self, Error> {
61 + let conn = Connection::open_in_memory()?;
62 + conn.pragma_update(None, "foreign_keys", "ON")?;
63 + let mut db = Self { conn };
64 + db.migrate()?;
65 + Ok(db)
66 + }
67 +
68 + /// Insert the `meta` row on a freshly-created profile. Idempotent guard:
69 + /// returns Ok without touching the row if one already exists.
70 + pub fn init_profile(&self, profile_name: &str, unit: Unit) -> Result<(), Error> {
71 + let exists: bool = self
72 + .conn
73 + .query_row("SELECT 1 FROM meta LIMIT 1", [], |_| Ok(true))
74 + .optional()?
75 + .unwrap_or(false);
76 + if exists {
77 + return Ok(());
78 + }
79 + self.conn.execute(
80 + "INSERT INTO meta (profile_name, unit, created) \
81 + VALUES (?1, ?2, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))",
82 + params![profile_name, unit.as_str()],
83 + )?;
84 + Ok(())
85 + }
86 +
87 + pub fn profile_name(&self) -> Result<Option<String>, Error> {
88 + Ok(self
89 + .conn
90 + .query_row("SELECT profile_name FROM meta LIMIT 1", [], |row| row.get(0))
91 + .optional()?)
92 + }
93 +
94 + pub fn unit(&self) -> Result<Option<Unit>, Error> {
95 + let s: Option<String> = self
96 + .conn
97 + .query_row("SELECT unit FROM meta LIMIT 1", [], |row| row.get(0))
98 + .optional()?;
99 + s.map(|v| Unit::parse(&v)).transpose()
100 + }
101 +
102 + pub fn conn(&self) -> &Connection {
103 + &self.conn
104 + }
105 +
106 + fn migrate(&mut self) -> Result<(), Error> {
107 + let version: i32 = self
108 + .conn
109 + .query_row("PRAGMA user_version", [], |row| row.get(0))?;
110 +
111 + for (i, sql) in MIGRATIONS.iter().enumerate() {
112 + let target = (i + 1) as i32;
113 + if version < target {
114 + let batch =
115 + format!("BEGIN;\n{sql}\nPRAGMA user_version = {target};\nCOMMIT;");
116 + self.conn.execute_batch(&batch)?;
117 + }
118 + }
119 + Ok(())
120 + }
121 + }
122 +
123 + const MIGRATION_001: &str = r#"
124 + CREATE TABLE meta (
125 + id INTEGER PRIMARY KEY CHECK (id = 1),
126 + profile_name TEXT NOT NULL,
127 + unit TEXT NOT NULL CHECK (unit IN ('kg', 'lb')),
128 + created TEXT NOT NULL
129 + );
130 +
131 + CREATE TABLE tags (
132 + id INTEGER PRIMARY KEY,
133 + name TEXT NOT NULL UNIQUE
134 + );
135 +
136 + CREATE TABLE exercises (
137 + id INTEGER PRIMARY KEY,
138 + name TEXT NOT NULL UNIQUE,
139 + resistance_type TEXT NOT NULL CHECK (resistance_type IN (
140 + 'bodyweight', 'machine', 'freeweight',
141 + 'cardio_time', 'cardio_distance'
142 + )),
143 + load_unit TEXT NOT NULL,
144 + increment REAL NOT NULL,
145 + notes TEXT NOT NULL DEFAULT ''
146 + );
147 +
148 + CREATE TABLE exercise_tags (
149 + exercise_id INTEGER NOT NULL REFERENCES exercises(id) ON DELETE CASCADE,
150 + tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
151 + weight REAL NOT NULL DEFAULT 1.0,
152 + PRIMARY KEY (exercise_id, tag_id)
153 + );
154 +
155 + CREATE TABLE sets (
156 + id INTEGER PRIMARY KEY,
157 + session_date TEXT NOT NULL,
158 + exercise_id INTEGER NOT NULL REFERENCES exercises(id) ON DELETE RESTRICT,
159 + set_index INTEGER NOT NULL,
160 + load REAL NOT NULL,
161 + reps INTEGER NOT NULL,
162 + rpe INTEGER NOT NULL CHECK (rpe BETWEEN 1 AND 5),
163 + UNIQUE (session_date, exercise_id, set_index)
164 + );
165 +
166 + CREATE INDEX idx_sets_session_date ON sets(session_date);
167 + CREATE INDEX idx_sets_exercise_date ON sets(exercise_id, session_date);
168 +
169 + CREATE TABLE progression_state (
170 + exercise_id INTEGER PRIMARY KEY REFERENCES exercises(id) ON DELETE CASCADE,
171 + state TEXT NOT NULL CHECK (state IN (
172 + 'progressing', 'probation', 'deloading', 'rebuilding'
173 + )),
174 + since_date TEXT NOT NULL
175 + );
176 +
177 + CREATE TABLE readiness (
178 + date TEXT NOT NULL,
179 + tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
180 + state TEXT NOT NULL CHECK (state IN ('ready', 'tired', 'sore')),
181 + PRIMARY KEY (date, tag_id)
182 + );
183 + "#;
184 +
185 + #[cfg(test)]
186 + mod tests {
187 + use super::*;
188 +
189 + #[test]
190 + fn open_runs_migrations_and_sets_user_version() {
191 + let db = Db::open_in_memory().unwrap();
192 + let v: i32 = db
193 + .conn
194 + .query_row("PRAGMA user_version", [], |row| row.get(0))
195 + .unwrap();
196 + assert_eq!(v, MIGRATIONS.len() as i32);
197 + }
198 +
199 + #[test]
200 + fn init_profile_writes_meta_row_and_is_idempotent() {
201 + let db = Db::open_in_memory().unwrap();
202 + db.init_profile("self", Unit::Kg).unwrap();
203 + db.init_profile("self", Unit::Kg).unwrap();
204 + assert_eq!(db.profile_name().unwrap().as_deref(), Some("self"));
205 + assert_eq!(db.unit().unwrap(), Some(Unit::Kg));
206 + }
207 +
208 + #[test]
209 + fn rpe_check_constraint_rejects_out_of_range() {
210 + let db = Db::open_in_memory().unwrap();
211 + db.init_profile("self", Unit::Kg).unwrap();
212 + db.conn
213 + .execute(
214 + "INSERT INTO exercises (name, resistance_type, load_unit, increment) \
215 + VALUES ('squat', 'freeweight', 'kg', 2.5)",
216 + [],
217 + )
218 + .unwrap();
219 + let err = db.conn.execute(
220 + "INSERT INTO sets (session_date, exercise_id, set_index, load, reps, rpe) \
221 + VALUES ('2026-07-18', 1, 1, 100.0, 5, 6)",
222 + [],
223 + );
224 + assert!(err.is_err(), "rpe=6 should violate CHECK constraint");
225 + }
226 +
227 + #[test]
228 + fn unit_check_constraint_rejects_bogus_unit() {
229 + let db = Db::open_in_memory().unwrap();
230 + let err = db.conn.execute(
231 + "INSERT INTO meta (id, profile_name, unit, created) \
232 + VALUES (1, 'x', 'stone', '2026-07-18T00:00:00Z')",
233 + [],
234 + );
235 + assert!(err.is_err(), "unit='stone' should violate CHECK constraint");
236 + }
237 +
238 + #[test]
239 + fn sets_unique_constraint_on_session_exercise_set() {
240 + let db = Db::open_in_memory().unwrap();
241 + db.init_profile("self", Unit::Kg).unwrap();
242 + db.conn
243 + .execute(
244 + "INSERT INTO exercises (name, resistance_type, load_unit, increment) \
245 + VALUES ('bench', 'freeweight', 'kg', 2.5)",
246 + [],
247 + )
248 + .unwrap();
249 + db.conn
250 + .execute(
251 + "INSERT INTO sets (session_date, exercise_id, set_index, load, reps, rpe) \
252 + VALUES ('2026-07-18', 1, 1, 80.0, 5, 3)",
253 + [],
254 + )
255 + .unwrap();
256 + let err = db.conn.execute(
257 + "INSERT INTO sets (session_date, exercise_id, set_index, load, reps, rpe) \
258 + VALUES ('2026-07-18', 1, 1, 82.5, 5, 3)",
259 + [],
260 + );
261 + assert!(err.is_err(), "duplicate (date, exercise, set_index) should fail");
262 + }
263 + }
@@ -0,0 +1,13 @@
1 + use thiserror::Error;
2 +
3 + #[derive(Debug, Error)]
4 + pub enum Error {
5 + #[error("sqlite: {0}")]
6 + Sqlite(#[from] rusqlite::Error),
7 +
8 + #[error("io: {0}")]
9 + Io(#[from] std::io::Error),
10 +
11 + #[error("invalid unit: {0}")]
12 + InvalidUnit(String),
13 + }
@@ -0,0 +1,10 @@
1 + //! ripgrow-core: data model, schema, and progression logic for ripgrow.
2 + //!
3 + //! One SQLite file per profile. No `profile_id` column anywhere — a profile
4 + //! is a file, hand-off is copy-the-file, deletion is `rm`.
5 +
6 + pub mod db;
7 + pub mod error;
8 +
9 + pub use db::{Db, Unit};
10 + pub use error::Error;
@@ -0,0 +1,18 @@
1 + [package]
2 + name = "ripgrow-tui"
3 + version.workspace = true
4 + edition.workspace = true
5 + license-file.workspace = true
6 + repository.workspace = true
7 + description = "Ratatui frontend for ripgrow, a resistance-training programmer."
8 +
9 + [[bin]]
10 + name = "ripgrow"
11 + path = "src/main.rs"
12 +
13 + [dependencies]
14 + ripgrow-core = { workspace = true }
15 + ratatui = { workspace = true }
16 + crossterm = { workspace = true }
17 + dirs = { workspace = true }
18 + thiserror = { workspace = true }
@@ -0,0 +1,5 @@
1 + //! ripgrow — ratatui frontend. Placeholder entry point; screens land next.
2 +
3 + fn main() {
4 + println!("ripgrow: TUI not implemented yet. See ripgrow-core for schema.");
5 + }