├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── Justfile ├── README.md ├── projectory.toml ├── src ├── cli │ ├── commands │ │ ├── add_to_var.rs │ │ ├── clone.rs │ │ ├── create.rs │ │ ├── edit.rs │ │ ├── edit_meta.rs │ │ ├── list.rs │ │ ├── mod.rs │ │ └── output.rs │ └── mod.rs ├── config.rs ├── lib.rs ├── macros │ ├── comment.rs │ ├── include.rs │ ├── mod.rs │ └── shell.rs ├── main.rs └── templates.rs └── testfile /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | tarpaulin-report.html 3 | docs/.silverbullet* 4 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 4 4 | 5 | [[package]] 6 | name = "aho-corasick" 7 | version = "1.1.3" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" 10 | dependencies = [ 11 | "memchr", 12 | ] 13 | 14 | [[package]] 15 | name = "android-tzdata" 16 | version = "0.1.1" 17 | source = "registry+https://github.com/rust-lang/crates.io-index" 18 | checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" 19 | 20 | [[package]] 21 | name = "android_system_properties" 22 | version = "0.1.5" 23 | source = "registry+https://github.com/rust-lang/crates.io-index" 24 | checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" 25 | dependencies = [ 26 | "libc", 27 | ] 28 | 29 | [[package]] 30 | name = "anstream" 31 | version = "0.6.18" 32 | source = "registry+https://github.com/rust-lang/crates.io-index" 33 | checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b" 34 | dependencies = [ 35 | "anstyle", 36 | "anstyle-parse", 37 | "anstyle-query", 38 | "anstyle-wincon", 39 | "colorchoice", 40 | "is_terminal_polyfill", 41 | "utf8parse", 42 | ] 43 | 44 | [[package]] 45 | name = "anstyle" 46 | version = "1.0.10" 47 | source = "registry+https://github.com/rust-lang/crates.io-index" 48 | checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" 49 | 50 | [[package]] 51 | name = "anstyle-parse" 52 | version = "0.2.6" 53 | source = "registry+https://github.com/rust-lang/crates.io-index" 54 | checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9" 55 | dependencies = [ 56 | "utf8parse", 57 | ] 58 | 59 | [[package]] 60 | name = "anstyle-query" 61 | version = "1.1.2" 62 | source = "registry+https://github.com/rust-lang/crates.io-index" 63 | checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c" 64 | dependencies = [ 65 | "windows-sys", 66 | ] 67 | 68 | [[package]] 69 | name = "anstyle-wincon" 70 | version = "3.0.7" 71 | source = "registry+https://github.com/rust-lang/crates.io-index" 72 | checksum = "ca3534e77181a9cc07539ad51f2141fe32f6c3ffd4df76db8ad92346b003ae4e" 73 | dependencies = [ 74 | "anstyle", 75 | "once_cell", 76 | "windows-sys", 77 | ] 78 | 79 | [[package]] 80 | name = "anyhow" 81 | version = "1.0.95" 82 | source = "registry+https://github.com/rust-lang/crates.io-index" 83 | checksum = "34ac096ce696dc2fcabef30516bb13c0a68a11d30131d3df6f04711467681b04" 84 | 85 | [[package]] 86 | name = "autocfg" 87 | version = "1.4.0" 88 | source = "registry+https://github.com/rust-lang/crates.io-index" 89 | checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" 90 | 91 | [[package]] 92 | name = "bitflags" 93 | version = "2.8.0" 94 | source = "registry+https://github.com/rust-lang/crates.io-index" 95 | checksum = "8f68f53c83ab957f72c32642f3868eec03eb974d1fb82e453128456482613d36" 96 | 97 | [[package]] 98 | name = "bumpalo" 99 | version = "3.17.0" 100 | source = "registry+https://github.com/rust-lang/crates.io-index" 101 | checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf" 102 | 103 | [[package]] 104 | name = "cc" 105 | version = "1.2.11" 106 | source = "registry+https://github.com/rust-lang/crates.io-index" 107 | checksum = "e4730490333d58093109dc02c23174c3f4d490998c3fed3cc8e82d57afedb9cf" 108 | dependencies = [ 109 | "shlex", 110 | ] 111 | 112 | [[package]] 113 | name = "cfg-if" 114 | version = "1.0.0" 115 | source = "registry+https://github.com/rust-lang/crates.io-index" 116 | checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 117 | 118 | [[package]] 119 | name = "chrono" 120 | version = "0.4.39" 121 | source = "registry+https://github.com/rust-lang/crates.io-index" 122 | checksum = "7e36cc9d416881d2e24f9a963be5fb1cd90966419ac844274161d10488b3e825" 123 | dependencies = [ 124 | "android-tzdata", 125 | "iana-time-zone", 126 | "js-sys", 127 | "num-traits", 128 | "wasm-bindgen", 129 | "windows-targets", 130 | ] 131 | 132 | [[package]] 133 | name = "clap" 134 | version = "4.5.27" 135 | source = "registry+https://github.com/rust-lang/crates.io-index" 136 | checksum = "769b0145982b4b48713e01ec42d61614425f27b7058bda7180a3a41f30104796" 137 | dependencies = [ 138 | "clap_builder", 139 | "clap_derive", 140 | ] 141 | 142 | [[package]] 143 | name = "clap_builder" 144 | version = "4.5.27" 145 | source = "registry+https://github.com/rust-lang/crates.io-index" 146 | checksum = "1b26884eb4b57140e4d2d93652abfa49498b938b3c9179f9fc487b0acc3edad7" 147 | dependencies = [ 148 | "anstream", 149 | "anstyle", 150 | "clap_lex", 151 | "strsim", 152 | ] 153 | 154 | [[package]] 155 | name = "clap_derive" 156 | version = "4.5.24" 157 | source = "registry+https://github.com/rust-lang/crates.io-index" 158 | checksum = "54b755194d6389280185988721fffba69495eed5ee9feeee9a599b53db80318c" 159 | dependencies = [ 160 | "heck", 161 | "proc-macro2", 162 | "quote", 163 | "syn", 164 | ] 165 | 166 | [[package]] 167 | name = "clap_lex" 168 | version = "0.7.4" 169 | source = "registry+https://github.com/rust-lang/crates.io-index" 170 | checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" 171 | 172 | [[package]] 173 | name = "colorchoice" 174 | version = "1.0.3" 175 | source = "registry+https://github.com/rust-lang/crates.io-index" 176 | checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" 177 | 178 | [[package]] 179 | name = "core-foundation-sys" 180 | version = "0.8.7" 181 | source = "registry+https://github.com/rust-lang/crates.io-index" 182 | checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" 183 | 184 | [[package]] 185 | name = "dirs" 186 | version = "6.0.0" 187 | source = "registry+https://github.com/rust-lang/crates.io-index" 188 | checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" 189 | dependencies = [ 190 | "dirs-sys", 191 | ] 192 | 193 | [[package]] 194 | name = "dirs-sys" 195 | version = "0.5.0" 196 | source = "registry+https://github.com/rust-lang/crates.io-index" 197 | checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" 198 | dependencies = [ 199 | "libc", 200 | "option-ext", 201 | "redox_users", 202 | "windows-sys", 203 | ] 204 | 205 | [[package]] 206 | name = "getrandom" 207 | version = "0.2.15" 208 | source = "registry+https://github.com/rust-lang/crates.io-index" 209 | checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" 210 | dependencies = [ 211 | "cfg-if", 212 | "libc", 213 | "wasi", 214 | ] 215 | 216 | [[package]] 217 | name = "heck" 218 | version = "0.5.0" 219 | source = "registry+https://github.com/rust-lang/crates.io-index" 220 | checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" 221 | 222 | [[package]] 223 | name = "iana-time-zone" 224 | version = "0.1.61" 225 | source = "registry+https://github.com/rust-lang/crates.io-index" 226 | checksum = "235e081f3925a06703c2d0117ea8b91f042756fd6e7a6e5d901e8ca1a996b220" 227 | dependencies = [ 228 | "android_system_properties", 229 | "core-foundation-sys", 230 | "iana-time-zone-haiku", 231 | "js-sys", 232 | "wasm-bindgen", 233 | "windows-core", 234 | ] 235 | 236 | [[package]] 237 | name = "iana-time-zone-haiku" 238 | version = "0.1.2" 239 | source = "registry+https://github.com/rust-lang/crates.io-index" 240 | checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" 241 | dependencies = [ 242 | "cc", 243 | ] 244 | 245 | [[package]] 246 | name = "is_terminal_polyfill" 247 | version = "1.70.1" 248 | source = "registry+https://github.com/rust-lang/crates.io-index" 249 | checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" 250 | 251 | [[package]] 252 | name = "itoa" 253 | version = "1.0.14" 254 | source = "registry+https://github.com/rust-lang/crates.io-index" 255 | checksum = "d75a2a4b1b190afb6f5425f10f6a8f959d2ea0b9c2b1d79553551850539e4674" 256 | 257 | [[package]] 258 | name = "js-sys" 259 | version = "0.3.77" 260 | source = "registry+https://github.com/rust-lang/crates.io-index" 261 | checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" 262 | dependencies = [ 263 | "once_cell", 264 | "wasm-bindgen", 265 | ] 266 | 267 | [[package]] 268 | name = "libc" 269 | version = "0.2.169" 270 | source = "registry+https://github.com/rust-lang/crates.io-index" 271 | checksum = "b5aba8db14291edd000dfcc4d620c7ebfb122c613afb886ca8803fa4e128a20a" 272 | 273 | [[package]] 274 | name = "libredox" 275 | version = "0.1.3" 276 | source = "registry+https://github.com/rust-lang/crates.io-index" 277 | checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" 278 | dependencies = [ 279 | "bitflags", 280 | "libc", 281 | ] 282 | 283 | [[package]] 284 | name = "log" 285 | version = "0.4.25" 286 | source = "registry+https://github.com/rust-lang/crates.io-index" 287 | checksum = "04cbf5b083de1c7e0222a7a51dbfdba1cbe1c6ab0b15e29fff3f6c077fd9cd9f" 288 | 289 | [[package]] 290 | name = "memchr" 291 | version = "2.7.4" 292 | source = "registry+https://github.com/rust-lang/crates.io-index" 293 | checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" 294 | 295 | [[package]] 296 | name = "num-traits" 297 | version = "0.2.19" 298 | source = "registry+https://github.com/rust-lang/crates.io-index" 299 | checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" 300 | dependencies = [ 301 | "autocfg", 302 | ] 303 | 304 | [[package]] 305 | name = "once_cell" 306 | version = "1.20.2" 307 | source = "registry+https://github.com/rust-lang/crates.io-index" 308 | checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" 309 | 310 | [[package]] 311 | name = "option-ext" 312 | version = "0.2.0" 313 | source = "registry+https://github.com/rust-lang/crates.io-index" 314 | checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" 315 | 316 | [[package]] 317 | name = "proc-macro2" 318 | version = "1.0.93" 319 | source = "registry+https://github.com/rust-lang/crates.io-index" 320 | checksum = "60946a68e5f9d28b0dc1c21bb8a97ee7d018a8b322fa57838ba31cc878e22d99" 321 | dependencies = [ 322 | "unicode-ident", 323 | ] 324 | 325 | [[package]] 326 | name = "prompta" 327 | version = "0.1.0" 328 | dependencies = [ 329 | "anyhow", 330 | "chrono", 331 | "clap", 332 | "dirs", 333 | "regex", 334 | "serde", 335 | "serde_json", 336 | ] 337 | 338 | [[package]] 339 | name = "quote" 340 | version = "1.0.38" 341 | source = "registry+https://github.com/rust-lang/crates.io-index" 342 | checksum = "0e4dccaaaf89514f546c693ddc140f729f958c247918a13380cccc6078391acc" 343 | dependencies = [ 344 | "proc-macro2", 345 | ] 346 | 347 | [[package]] 348 | name = "redox_users" 349 | version = "0.5.0" 350 | source = "registry+https://github.com/rust-lang/crates.io-index" 351 | checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b" 352 | dependencies = [ 353 | "getrandom", 354 | "libredox", 355 | "thiserror", 356 | ] 357 | 358 | [[package]] 359 | name = "regex" 360 | version = "1.11.1" 361 | source = "registry+https://github.com/rust-lang/crates.io-index" 362 | checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" 363 | dependencies = [ 364 | "aho-corasick", 365 | "memchr", 366 | "regex-automata", 367 | "regex-syntax", 368 | ] 369 | 370 | [[package]] 371 | name = "regex-automata" 372 | version = "0.4.9" 373 | source = "registry+https://github.com/rust-lang/crates.io-index" 374 | checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" 375 | dependencies = [ 376 | "aho-corasick", 377 | "memchr", 378 | "regex-syntax", 379 | ] 380 | 381 | [[package]] 382 | name = "regex-syntax" 383 | version = "0.8.5" 384 | source = "registry+https://github.com/rust-lang/crates.io-index" 385 | checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" 386 | 387 | [[package]] 388 | name = "rustversion" 389 | version = "1.0.19" 390 | source = "registry+https://github.com/rust-lang/crates.io-index" 391 | checksum = "f7c45b9784283f1b2e7fb61b42047c2fd678ef0960d4f6f1eba131594cc369d4" 392 | 393 | [[package]] 394 | name = "ryu" 395 | version = "1.0.18" 396 | source = "registry+https://github.com/rust-lang/crates.io-index" 397 | checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" 398 | 399 | [[package]] 400 | name = "serde" 401 | version = "1.0.217" 402 | source = "registry+https://github.com/rust-lang/crates.io-index" 403 | checksum = "02fc4265df13d6fa1d00ecff087228cc0a2b5f3c0e87e258d8b94a156e984c70" 404 | dependencies = [ 405 | "serde_derive", 406 | ] 407 | 408 | [[package]] 409 | name = "serde_derive" 410 | version = "1.0.217" 411 | source = "registry+https://github.com/rust-lang/crates.io-index" 412 | checksum = "5a9bf7cf98d04a2b28aead066b7496853d4779c9cc183c440dbac457641e19a0" 413 | dependencies = [ 414 | "proc-macro2", 415 | "quote", 416 | "syn", 417 | ] 418 | 419 | [[package]] 420 | name = "serde_json" 421 | version = "1.0.137" 422 | source = "registry+https://github.com/rust-lang/crates.io-index" 423 | checksum = "930cfb6e6abf99298aaad7d29abbef7a9999a9a8806a40088f55f0dcec03146b" 424 | dependencies = [ 425 | "itoa", 426 | "memchr", 427 | "ryu", 428 | "serde", 429 | ] 430 | 431 | [[package]] 432 | name = "shlex" 433 | version = "1.3.0" 434 | source = "registry+https://github.com/rust-lang/crates.io-index" 435 | checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" 436 | 437 | [[package]] 438 | name = "strsim" 439 | version = "0.11.1" 440 | source = "registry+https://github.com/rust-lang/crates.io-index" 441 | checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" 442 | 443 | [[package]] 444 | name = "syn" 445 | version = "2.0.96" 446 | source = "registry+https://github.com/rust-lang/crates.io-index" 447 | checksum = "d5d0adab1ae378d7f53bdebc67a39f1f151407ef230f0ce2883572f5d8985c80" 448 | dependencies = [ 449 | "proc-macro2", 450 | "quote", 451 | "unicode-ident", 452 | ] 453 | 454 | [[package]] 455 | name = "thiserror" 456 | version = "2.0.11" 457 | source = "registry+https://github.com/rust-lang/crates.io-index" 458 | checksum = "d452f284b73e6d76dd36758a0c8684b1d5be31f92b89d07fd5822175732206fc" 459 | dependencies = [ 460 | "thiserror-impl", 461 | ] 462 | 463 | [[package]] 464 | name = "thiserror-impl" 465 | version = "2.0.11" 466 | source = "registry+https://github.com/rust-lang/crates.io-index" 467 | checksum = "26afc1baea8a989337eeb52b6e72a039780ce45c3edfcc9c5b9d112feeb173c2" 468 | dependencies = [ 469 | "proc-macro2", 470 | "quote", 471 | "syn", 472 | ] 473 | 474 | [[package]] 475 | name = "unicode-ident" 476 | version = "1.0.14" 477 | source = "registry+https://github.com/rust-lang/crates.io-index" 478 | checksum = "adb9e6ca4f869e1180728b7950e35922a7fc6397f7b641499e8f3ef06e50dc83" 479 | 480 | [[package]] 481 | name = "utf8parse" 482 | version = "0.2.2" 483 | source = "registry+https://github.com/rust-lang/crates.io-index" 484 | checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" 485 | 486 | [[package]] 487 | name = "wasi" 488 | version = "0.11.0+wasi-snapshot-preview1" 489 | source = "registry+https://github.com/rust-lang/crates.io-index" 490 | checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" 491 | 492 | [[package]] 493 | name = "wasm-bindgen" 494 | version = "0.2.100" 495 | source = "registry+https://github.com/rust-lang/crates.io-index" 496 | checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" 497 | dependencies = [ 498 | "cfg-if", 499 | "once_cell", 500 | "rustversion", 501 | "wasm-bindgen-macro", 502 | ] 503 | 504 | [[package]] 505 | name = "wasm-bindgen-backend" 506 | version = "0.2.100" 507 | source = "registry+https://github.com/rust-lang/crates.io-index" 508 | checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" 509 | dependencies = [ 510 | "bumpalo", 511 | "log", 512 | "proc-macro2", 513 | "quote", 514 | "syn", 515 | "wasm-bindgen-shared", 516 | ] 517 | 518 | [[package]] 519 | name = "wasm-bindgen-macro" 520 | version = "0.2.100" 521 | source = "registry+https://github.com/rust-lang/crates.io-index" 522 | checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" 523 | dependencies = [ 524 | "quote", 525 | "wasm-bindgen-macro-support", 526 | ] 527 | 528 | [[package]] 529 | name = "wasm-bindgen-macro-support" 530 | version = "0.2.100" 531 | source = "registry+https://github.com/rust-lang/crates.io-index" 532 | checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" 533 | dependencies = [ 534 | "proc-macro2", 535 | "quote", 536 | "syn", 537 | "wasm-bindgen-backend", 538 | "wasm-bindgen-shared", 539 | ] 540 | 541 | [[package]] 542 | name = "wasm-bindgen-shared" 543 | version = "0.2.100" 544 | source = "registry+https://github.com/rust-lang/crates.io-index" 545 | checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" 546 | dependencies = [ 547 | "unicode-ident", 548 | ] 549 | 550 | [[package]] 551 | name = "windows-core" 552 | version = "0.52.0" 553 | source = "registry+https://github.com/rust-lang/crates.io-index" 554 | checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" 555 | dependencies = [ 556 | "windows-targets", 557 | ] 558 | 559 | [[package]] 560 | name = "windows-sys" 561 | version = "0.59.0" 562 | source = "registry+https://github.com/rust-lang/crates.io-index" 563 | checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" 564 | dependencies = [ 565 | "windows-targets", 566 | ] 567 | 568 | [[package]] 569 | name = "windows-targets" 570 | version = "0.52.6" 571 | source = "registry+https://github.com/rust-lang/crates.io-index" 572 | checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" 573 | dependencies = [ 574 | "windows_aarch64_gnullvm", 575 | "windows_aarch64_msvc", 576 | "windows_i686_gnu", 577 | "windows_i686_gnullvm", 578 | "windows_i686_msvc", 579 | "windows_x86_64_gnu", 580 | "windows_x86_64_gnullvm", 581 | "windows_x86_64_msvc", 582 | ] 583 | 584 | [[package]] 585 | name = "windows_aarch64_gnullvm" 586 | version = "0.52.6" 587 | source = "registry+https://github.com/rust-lang/crates.io-index" 588 | checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" 589 | 590 | [[package]] 591 | name = "windows_aarch64_msvc" 592 | version = "0.52.6" 593 | source = "registry+https://github.com/rust-lang/crates.io-index" 594 | checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" 595 | 596 | [[package]] 597 | name = "windows_i686_gnu" 598 | version = "0.52.6" 599 | source = "registry+https://github.com/rust-lang/crates.io-index" 600 | checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" 601 | 602 | [[package]] 603 | name = "windows_i686_gnullvm" 604 | version = "0.52.6" 605 | source = "registry+https://github.com/rust-lang/crates.io-index" 606 | checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" 607 | 608 | [[package]] 609 | name = "windows_i686_msvc" 610 | version = "0.52.6" 611 | source = "registry+https://github.com/rust-lang/crates.io-index" 612 | checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" 613 | 614 | [[package]] 615 | name = "windows_x86_64_gnu" 616 | version = "0.52.6" 617 | source = "registry+https://github.com/rust-lang/crates.io-index" 618 | checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" 619 | 620 | [[package]] 621 | name = "windows_x86_64_gnullvm" 622 | version = "0.52.6" 623 | source = "registry+https://github.com/rust-lang/crates.io-index" 624 | checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" 625 | 626 | [[package]] 627 | name = "windows_x86_64_msvc" 628 | version = "0.52.6" 629 | source = "registry+https://github.com/rust-lang/crates.io-index" 630 | checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" 631 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "prompta" 3 | version = "0.1.0" 4 | edition = "2024" 5 | 6 | [dependencies] 7 | anyhow = "1.0.95" 8 | chrono = "0.4.39" 9 | clap = { version = "4.5.27", features = ["derive"] } 10 | dirs = "6.0.0" 11 | regex = "1.11.1" 12 | serde = { version = "1.0.217", features = ["derive"] } 13 | serde_json = "1.0.137" 14 | 15 | [profile.gold] 16 | inherits = "release" 17 | opt-level = 3 # Maximum optimization 18 | lto = "fat" # Full link-time optimization 19 | codegen-units = 1 # Slower build, better optimization 20 | panic = "abort" # Remove panic unwinding code 21 | strip = true # Strip symbols from binary 22 | debug = false # No debug info 23 | incremental = false # Disable incremental compilation 24 | overflow-checks = false # Disable runtime integer overflow checks 25 | -------------------------------------------------------------------------------- /Justfile: -------------------------------------------------------------------------------- 1 | install: test install-linux 2 | 3 | install-linux: build-release-linux 4 | cp target/release/prompta ~/bin/prompta 5 | 6 | build: build-linux 7 | 8 | build-linux: 9 | cargo build 10 | 11 | build-release-linux: 12 | cargo build --release 13 | upx target/release/prompta 14 | 15 | build-windows: 16 | cargo build --target=x86_64-pc-windows-gnu 17 | 18 | build-release-windows: 19 | cargo build --release --target=x86_64-pc-windows-gnu 20 | upx target/release/prompta 21 | 22 | test: 23 | cargo test --release 24 | 25 | coverage: 26 | cargo tarpaulin --no-default-features -o html 27 | 28 | clean: 29 | cargo clean 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## `prompta` 2 | > LLM Prompt manager for easily managing reusable prompts with dynamic file contents 3 | 4 | This is a small CLI meant for terminal users who want to have reusable LLM prompts that are easy to maintain, change and "render", with dynamic content that might change everytime you run it. 5 | 6 | Developed because I started using multiple LLMs, tend to restart conversations often (since LLMs lose track of the conversation REALLY quickly). 7 | 8 | ### Features 9 | 10 | - Create and manage template prompts 11 | - Replace variables in templates with dynamic content (files, code snippets, directory contents) 12 | - Include content from other files 13 | - Execute shell commands and include their output 14 | - Simple comment system 15 | 16 | ## Installation 17 | 18 | For now, clone the repository and run `cargo install --path .` 19 | 20 | ## Quick Start 21 | 22 | 1. Create a new template: 23 | ```bash 24 | prompta create mytemplate 25 | ``` 26 | 27 | 2. Edit the template: 28 | ```bash 29 | prompta edit mytemplate 30 | ``` 31 | 32 | 3. Add a file to a placeholder variable: 33 | ```bash 34 | prompta add-to-var mytemplate CODE --file=src/main.rs --language=rust 35 | ``` 36 | 37 | 4. Output the rendered template: 38 | ```bash 39 | prompta output mytemplate 40 | ``` 41 | 42 | ## Command Reference 43 | 44 | ### `prompta create ` 45 | Creates a new empty template. 46 | 47 | ### `prompta edit ` 48 | Opens the template in your default editor (from `$EDITOR` environment variable). 49 | 50 | ### `prompta edit-meta ` 51 | Edits the template's metadata JSON file. 52 | 53 | ### `prompta list` 54 | Shows all available templates. 55 | 56 | ### `prompta clone ` 57 | Copies an existing template to a new name. 58 | 59 | ### `prompta add-to-var [options]` 60 | Adds content to a placeholder variable. 61 | 62 | Options: 63 | - `--file, -f `: Path to a file (use "-" for stdin) 64 | - `--language, -l `: Specify code language for syntax highlighting 65 | - `--directory, -d `: Directory to include files from 66 | - `--extension, -s `: File extension to filter by (required with --directory) 67 | 68 | ### `prompta output [--var KEY=VALUE...]` 69 | Renders the template with all placeholders replaced. 70 | 71 | ## Template Features 72 | 73 | ### Placeholders 74 | Use `$VARIABLE` syntax for placeholders that will be replaced when rendered: 75 | ``` 76 | Here is my source code: 77 | $CODE 78 | ``` 79 | 80 | ### Special Placeholders 81 | - `$DATETIME`: Current date and time 82 | - `$STDIN`: Content from standard input 83 | 84 | ### Macros 85 | - `(INCLUDE path/to/file.md)`: Includes content from another file 86 | - `(SHELL command)`: Executes a shell command and includes its output 87 | 88 | ### Comments 89 | Lines starting with `--` are treated as comments and removed when rendering: 90 | ``` 91 | -- This is a comment 92 | This line will be included 93 | ``` 94 | 95 | ## Example Use Cases 96 | 97 | 1. **Code Review Template**: 98 | ```markdown 99 | # Code Review Request 100 | 101 | Date: $DATETIME 102 | 103 | ## My Code: 104 | $SOURCE_CODE 105 | 106 | ## What I'm trying to do: 107 | $PROBLEM_DESCRIPTION 108 | ``` 109 | 110 | 2. **Project Summary**: 111 | ```markdown 112 | # Project Overview 113 | 114 | Here are all the Rust files in my project: 115 | $RUST_FILES 116 | 117 | Current git status: 118 | (SHELL git status) 119 | 120 | Latest commit: 121 | (SHELL git log -1) 122 | ``` 123 | 124 | 3. **Documentation Helper**: 125 | ```markdown 126 | I need help documenting this function: 127 | $FUNCTION 128 | 129 | Please write comprehensive documentation explaining: 130 | - What it does 131 | - Parameters 132 | - Return value 133 | - Any edge cases 134 | ``` 135 | 136 | ## Tips 137 | - Use the `--var` option with `output` to override variables at runtime: `prompta output mytemplate --var CODE="console.log('hello')"` 138 | - Create a standard set of templates for common tasks 139 | - Use the `(INCLUDE)` macro to share common content between templates 140 | 141 | 142 | # License 143 | 144 | MIT 2025 - Victor Bjelkholm 145 | -------------------------------------------------------------------------------- /projectory.toml: -------------------------------------------------------------------------------- 1 | name = "prompta" 2 | description = "CLI for manging LLM prompts with templating, shell execution and a bit more" 3 | tags = ["llm", "prompts", "user experience", "developer tooling"] 4 | langs = ["Rust", "Markdown"] 5 | -------------------------------------------------------------------------------- /src/cli/commands/add_to_var.rs: -------------------------------------------------------------------------------- 1 | use crate::config::*; 2 | use crate::templates::*; 3 | use crate::cli::commands::*; 4 | 5 | pub fn add_to_var( 6 | name: &str, 7 | placeholder_name: &str, 8 | file: &str, 9 | language: Option, 10 | directory: Option, 11 | extension: Option, 12 | ) -> anyhow::Result<()> { 13 | check_dir_and_ext_args_together(&directory, &extension); 14 | 15 | let meta_path = template_meta_path(name); 16 | if !meta_path.exists() { 17 | println!("Template {name} metadata does not exist. Create it first."); 18 | return Ok(()); 19 | } 20 | 21 | let mut meta = load_meta(&meta_path)?; 22 | let absolute_path = resolve_absolute_path(file)?; 23 | 24 | let entries = meta 25 | .placeholders 26 | .entry(placeholder_name.to_string()) 27 | .or_default(); 28 | entries.push(PlaceholderEntry { 29 | path: absolute_path, 30 | language, 31 | directory, 32 | extension, 33 | }); 34 | 35 | save_meta(&meta_path, &meta)?; 36 | println!("Added new entry to placeholder '{placeholder_name}' in '{name}'"); 37 | Ok(()) 38 | } 39 | -------------------------------------------------------------------------------- /src/cli/commands/clone.rs: -------------------------------------------------------------------------------- 1 | use crate::config::*; 2 | use crate::templates::*; 3 | use std::fs; 4 | 5 | pub fn clone_template(source: &str, destination: &str) -> anyhow::Result<()> { 6 | // Get paths for source template 7 | let source_md_path = template_md_path(source); 8 | let source_meta_path = template_meta_path(source); 9 | 10 | // Get paths for destination template 11 | let dest_md_path = template_md_path(destination); 12 | let dest_meta_path = template_meta_path(destination); 13 | 14 | // Validate that source exists 15 | if !source_md_path.exists() || !source_meta_path.exists() { 16 | anyhow::bail!( 17 | "Source template '{source}' does not exist or is incomplete. Both MD and JSON files must exist." 18 | ); 19 | } 20 | 21 | // Validate that destination doesn't exist 22 | if dest_md_path.exists() || dest_meta_path.exists() { 23 | anyhow::bail!( 24 | "Destination template '{destination}' already exists. Use a different name or remove it first." 25 | ); 26 | } 27 | 28 | // Copy the markdown template 29 | fs::copy(&source_md_path, &dest_md_path)?; 30 | 31 | // Load and save the metadata 32 | let meta = load_meta(&source_meta_path)?; 33 | save_meta(&dest_meta_path, &meta)?; 34 | 35 | println!("Successfully cloned template '{source}' to '{destination}'"); 36 | 37 | Ok(()) 38 | } 39 | -------------------------------------------------------------------------------- /src/cli/commands/create.rs: -------------------------------------------------------------------------------- 1 | use crate::config::*; 2 | use crate::templates::*; 3 | use std::fs::{self, File}; 4 | 5 | pub fn create_template(name: &str) -> anyhow::Result<()> { 6 | let md_path = template_md_path(name); 7 | if md_path.exists() { 8 | println!("Template {name} already exists at {:?}", md_path); 9 | } else { 10 | File::create(&md_path)?; 11 | println!("Created {:?}", md_path); 12 | } 13 | 14 | let meta_path = template_meta_path(name); 15 | if !meta_path.exists() { 16 | let meta = TemplateMeta::default(); 17 | let json = serde_json::to_string_pretty(&meta)?; 18 | fs::write(&meta_path, json)?; 19 | } 20 | 21 | Ok(()) 22 | } 23 | -------------------------------------------------------------------------------- /src/cli/commands/edit.rs: -------------------------------------------------------------------------------- 1 | use crate::config::*; 2 | use std::env; 3 | use std::process::Command; 4 | 5 | pub fn edit_template(name: &str) -> anyhow::Result<()> { 6 | let md_path = template_md_path(name); 7 | if !md_path.exists() { 8 | println!("Template {name} does not exist. Create it first."); 9 | return Ok(()); 10 | } 11 | let editor = env::var("EDITOR").unwrap_or_else(|_| "nano".to_string()); 12 | Command::new(editor) 13 | .arg(&md_path) 14 | .status() 15 | .expect("Failed to open file in editor"); 16 | Ok(()) 17 | } 18 | -------------------------------------------------------------------------------- /src/cli/commands/edit_meta.rs: -------------------------------------------------------------------------------- 1 | use crate::config::*; 2 | use std::env; 3 | use std::process::Command; 4 | 5 | pub fn edit_template_meta(name: &str) -> anyhow::Result<()> { 6 | let md_path = template_meta_path(name); 7 | if !md_path.exists() { 8 | println!("Template {name} does not exist. Create it first."); 9 | return Ok(()); 10 | } 11 | let editor = env::var("EDITOR").unwrap_or_else(|_| "nano".to_string()); 12 | Command::new(editor) 13 | .arg(&md_path) 14 | .status() 15 | .expect("Failed to open file in editor"); 16 | Ok(()) 17 | } 18 | -------------------------------------------------------------------------------- /src/cli/commands/list.rs: -------------------------------------------------------------------------------- 1 | use crate::config::*; 2 | use std::fs::{self}; 3 | 4 | pub fn list_templates() -> anyhow::Result<()> { 5 | let mut matches = Vec::new(); 6 | for entry in fs::read_dir(config_dir())? { 7 | let entry = entry?; 8 | let path = entry.path(); 9 | 10 | if path.extension().map_or(false, |ext| ext == "md") { 11 | if let Some(file_name) = path.file_stem() { 12 | matches.push(file_name.to_string_lossy().into_owned()); 13 | } 14 | } 15 | } 16 | 17 | matches.sort(); 18 | 19 | println!("Available templates:\n"); 20 | for m in matches { 21 | println!("\t{m}"); 22 | } 23 | 24 | Ok(()) 25 | } 26 | -------------------------------------------------------------------------------- /src/cli/commands/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod add_to_var; 2 | pub mod clone; 3 | pub mod create; 4 | pub mod edit; 5 | pub mod edit_meta; 6 | pub mod list; 7 | pub mod output; 8 | 9 | pub fn check_dir_and_ext_args_together(directory: &Option, extension: &Option) { 10 | if let Some(_) = directory { 11 | if let Some(_) = extension { 12 | // All good 13 | } else { 14 | panic!("If --directory is provided, --extension is also required") 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/cli/commands/output.rs: -------------------------------------------------------------------------------- 1 | use crate::config::*; 2 | use crate::templates::*; 3 | use chrono::Local; 4 | use std::collections::HashMap; 5 | use std::fs::{self}; 6 | use std::io::Read; 7 | use std::path::Path; 8 | use std::path::PathBuf; 9 | 10 | use crate::macros::comment::handle_comment; 11 | use crate::macros::include::handle_include; 12 | use crate::macros::shell::handle_shell; 13 | 14 | /// Print the final template with placeholders replaced. 15 | /// The `variables` parameter holds runtime overrides (KEY=VALUE). 16 | pub fn show_output(name: &str, variables: &[(String, String)]) -> anyhow::Result<()> { 17 | let md_path = template_md_path(name); 18 | let meta_path = template_meta_path(name); 19 | 20 | if !md_path.exists() || !meta_path.exists() { 21 | println!("Template {name} not found or metadata missing."); 22 | return Ok(()); 23 | } 24 | 25 | let mut template = fs::read_to_string(&md_path)?; 26 | let meta = load_meta(&meta_path)?; 27 | 28 | template = handle_comment(&template)?; 29 | 30 | template = handle_include(&template)?; 31 | 32 | template = handle_shell(&template)?; 33 | 34 | let mut final_map = HashMap::new(); 35 | 36 | for (key, entries) in &meta.placeholders { 37 | final_map.insert(key.to_string(), combine_entries(entries)); 38 | } 39 | 40 | // Apply runtime overrides last 41 | for (key, val) in variables { 42 | final_map.insert(key.clone(), val.clone()); 43 | } 44 | 45 | if template.contains("$DATETIME") { 46 | let now_str = Local::now().format("%Y-%m-%d %H:%M:%S").to_string(); 47 | final_map.insert("DATETIME".to_string(), now_str); 48 | } 49 | 50 | if template.contains("$STDIN") { 51 | // Read once from stdin 52 | let stdin_content = read_content("-")?; 53 | final_map.insert("STDIN".to_string(), stdin_content); 54 | } 55 | 56 | // Replace all `$KEY` placeholders in the template with their final values. 57 | let mut output_str = template; 58 | for (key, final_value) in &final_map { 59 | let marker = format!("${key}"); 60 | output_str = output_str.replace(&marker, final_value); 61 | } 62 | 63 | println!("{}", output_str); 64 | Ok(()) 65 | } 66 | 67 | fn add_to_results(path: &str, lang: &str, content: &str) -> String { 68 | format!( 69 | r#" 70 | File `{}`: 71 | ```{} 72 | {} 73 | ``` 74 | "#, 75 | path.to_string(), 76 | lang, 77 | &content 78 | ) 79 | } 80 | 81 | fn gather_files_recursively(dir: &Path, extension: &str) -> Vec { 82 | let mut files = Vec::new(); 83 | for entry in fs::read_dir(dir).expect("Couldn't read path") { 84 | let path = entry.expect("Couldn't read subpath").path(); 85 | if path.is_dir() { 86 | files.extend(gather_files_recursively(&path, extension)); 87 | } else if path 88 | .extension() 89 | .map_or(false, |ext| ext.to_str() == Some(extension)) 90 | { 91 | files.push(path); 92 | } 93 | } 94 | files 95 | } 96 | 97 | fn combine_entry(entry: &PlaceholderEntry) -> String { 98 | let paths = if let Some(dir) = &entry.directory { 99 | let ext = entry 100 | .extension 101 | .as_ref() 102 | .expect("If `directory` is specified, `extension` is required"); 103 | gather_files_recursively(Path::new(dir), ext) 104 | } else { 105 | vec![PathBuf::from(&entry.path)] 106 | }; 107 | 108 | let mut result = String::new(); 109 | for p in paths { 110 | let content = read_content(p.to_string_lossy().as_ref()).unwrap(); 111 | if let Some(lang) = &entry.language { 112 | result.push_str(&add_to_results(&p.to_string_lossy(), lang, &content)); 113 | } else { 114 | result.push_str(&content); 115 | result.push('\n'); 116 | } 117 | } 118 | result 119 | } 120 | 121 | fn combine_entries(entries: &[PlaceholderEntry]) -> String { 122 | entries.iter().map(combine_entry).collect() 123 | } 124 | 125 | /// If the user gave a path == "-", read from stdin; else read the file. 126 | fn read_content(path_or_dash: &str) -> anyhow::Result { 127 | if path_or_dash == "-" { 128 | use std::io::IsTerminal; 129 | 130 | if std::io::stdin().is_terminal() { 131 | return Ok("No stdin".to_string()); 132 | } 133 | 134 | let mut buffer = String::new(); 135 | std::io::stdin().read_to_string(&mut buffer)?; 136 | Ok(buffer) 137 | } else { 138 | Ok(std::fs::read_to_string(path_or_dash)?) 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /src/cli/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::config::config_dir_str; 2 | use clap::{Parser, Subcommand, ValueHint}; 3 | 4 | pub mod commands; 5 | 6 | use crate::cli::commands::add_to_var::add_to_var; 7 | use crate::cli::commands::clone::clone_template; 8 | use crate::cli::commands::create::create_template; 9 | use crate::cli::commands::edit::edit_template; 10 | use crate::cli::commands::edit_meta::edit_template_meta; 11 | use crate::cli::commands::list::list_templates; 12 | use crate::cli::commands::output::show_output; 13 | 14 | #[derive(Subcommand, Debug)] 15 | pub enum Commands { 16 | /// Create a new template file 17 | Create { name: String }, 18 | 19 | /// Edit the template's prompt using $EDITOR 20 | Edit { name: String }, 21 | 22 | /// Edit the template's meta using $EDITOR 23 | EditMeta { name: String }, 24 | 25 | /// Append a new entry to a multi-value placeholder 26 | AddToVar { 27 | /// Name of the template 28 | name: String, 29 | /// The placeholder name 30 | placeholder: String, 31 | /// Optional file path from which to read placeholder content (defaults to reading from stdin) 32 | #[arg(long, short = 'f', default_value = "-")] 33 | file: String, 34 | /// If set, the placeholder is considered code text in this language 35 | #[arg(long, short = 'l')] 36 | language: Option, 37 | /// If set, find files using `file` as the file extension 38 | #[arg(long, short = 'd')] 39 | directory: Option, 40 | /// Required if `directory` is set to true then acts as a file-extension filter, eg 41 | /// `.rs` finds all rust source files 42 | #[arg(long, short = 's')] 43 | extension: Option, 44 | }, 45 | 46 | /// Print the template with placeholders replaced. 47 | /// Any `--var KEY=VALUE` arguments here will override placeholders. 48 | Output { 49 | name: String, 50 | 51 | /// Overrides in the form KEY=VALUE (applied last). 52 | #[arg(long = "var", short = 'v', num_args=1.., value_parser = parse_key_val, value_name="KEY=VALUE", value_hint=ValueHint::Other)] 53 | variables: Vec<(String, String)>, 54 | }, 55 | 56 | /// Clone an existing template to a new name 57 | Clone { 58 | /// Name of the existing template to clone from 59 | source: String, 60 | /// Name for the new template 61 | destination: String, 62 | }, 63 | 64 | /// Prints all the available templates 65 | List, 66 | } 67 | 68 | pub fn parse_and_run_cli() -> anyhow::Result<()> { 69 | let cli = Cli::parse(); 70 | 71 | match cli.command { 72 | Commands::Create { name } => create_template(&name)?, 73 | Commands::Edit { name } => edit_template(&name)?, 74 | Commands::EditMeta { name } => edit_template_meta(&name)?, 75 | Commands::AddToVar { 76 | name, 77 | placeholder, 78 | file, 79 | language, 80 | directory, 81 | extension, 82 | } => add_to_var(&name, &placeholder, &file, language, directory, extension)?, 83 | Commands::Output { name, variables } => show_output(&name, &variables)?, 84 | Commands::List => list_templates()?, 85 | Commands::Clone { 86 | source, 87 | destination, 88 | } => clone_template(&source, &destination)?, 89 | } 90 | 91 | Ok(()) 92 | } 93 | 94 | #[derive(Parser, Debug)] 95 | #[clap(version)] 96 | #[command(name = "prompta")] 97 | #[command(about = "A CLI to manage LLM prompts", long_about = None)] 98 | pub struct Cli { 99 | #[command(subcommand)] 100 | pub command: Commands, 101 | #[arg(long, short = 'c', default_value_t = config_dir_str())] 102 | pub config_dir: String, 103 | } 104 | 105 | /// A small helper to parse `KEY=VALUE` strings into `(KEY, VALUE)` pairs. 106 | fn parse_key_val(s: &str) -> Result<(String, String), String> { 107 | match s.split_once('=') { 108 | Some((k, v)) if !k.is_empty() => Ok((k.to_string(), v.to_string())), 109 | _ => Err(format!("Invalid KEY=VALUE: '{s}'")), 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/config.rs: -------------------------------------------------------------------------------- 1 | use std::env; 2 | use std::fs::{self}; 3 | use std::path::{Path, PathBuf}; 4 | 5 | use dirs::home_dir; 6 | 7 | pub fn config_dir() -> PathBuf { 8 | let mut base = home_dir().unwrap(); 9 | base.push(".config"); 10 | base.push("prompta"); 11 | fs::create_dir_all(&base).expect("Failed to create config dir"); 12 | base 13 | } 14 | 15 | pub fn config_dir_str() -> String { 16 | let dir = config_dir(); 17 | dir.to_string_lossy().into_owned() 18 | } 19 | 20 | pub fn resolve_absolute_path(path: &str) -> anyhow::Result { 21 | // Handle stdin marker 22 | if path == "-" { 23 | return Ok(path.to_string()); 24 | } 25 | 26 | let path_buf = if Path::new(path).is_absolute() { 27 | PathBuf::from(path) 28 | } else { 29 | env::current_dir()?.join(path) 30 | }; 31 | 32 | Ok(path_buf.canonicalize()?.to_string_lossy().into_owned()) 33 | } 34 | 35 | pub fn template_md_path(name: &str) -> PathBuf { 36 | config_dir().join(format!("{name}.md")) 37 | } 38 | 39 | pub fn template_meta_path(name: &str) -> PathBuf { 40 | config_dir().join(format!("{name}.json")) 41 | } 42 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod cli; 2 | pub mod config; 3 | pub mod macros; 4 | pub mod templates; 5 | -------------------------------------------------------------------------------- /src/macros/comment.rs: -------------------------------------------------------------------------------- 1 | pub fn handle_comment(original_text: &str) -> anyhow::Result { 2 | let mut new_doc: Vec = vec![]; 3 | for line in original_text.split("\n") { 4 | if line.trim().starts_with("--") { 5 | continue; 6 | } 7 | new_doc.push(line.to_string()); 8 | } 9 | Ok(new_doc.join("\n")) 10 | } 11 | 12 | #[test] 13 | fn test_handle_comment() { 14 | let input = r#" 15 | This is a line 16 | This is another line 17 | -- Here is a comment 18 | And here is one not 19 | "#; 20 | let result = handle_comment(input).expect("Something went wrong"); 21 | assert_ne!(input, result); 22 | } 23 | 24 | #[test] 25 | fn test_handle_comment_whitespace_prefix() { 26 | let input = r#" 27 | This is a line 28 | This is another line 29 | -- Here is a comment 30 | And here is one not 31 | "#; 32 | let result = handle_comment(input).expect("Something went wrong"); 33 | assert_ne!(input, result); 34 | } 35 | -------------------------------------------------------------------------------- /src/macros/include.rs: -------------------------------------------------------------------------------- 1 | use crate::config::*; 2 | use regex::Regex; 3 | use std::{fs::{self}, path::Path}; 4 | 5 | /// Looks for occurrences of `(INCLUDE path/to/file.md)` and replaces them 6 | /// with the contents of `~/.config/prompta/path/to/file.md`. 7 | pub fn handle_include_with_reader( 8 | original_text: &str, 9 | config_directory: &Path, 10 | read_file: F, 11 | file_exists: G, 12 | ) -> anyhow::Result 13 | where 14 | F: Fn(&Path) -> std::io::Result, 15 | G: Fn(&Path) -> bool, 16 | { 17 | let re = Regex::new(r"\(INCLUDE\s+([^)]+)\)")?; 18 | let mut result = original_text.to_string(); 19 | 20 | loop { 21 | let mut replaced_any = false; 22 | let text_snapshot = result.clone(); 23 | 24 | for captures in re.captures_iter(&text_snapshot) { 25 | let include_path = &captures[1]; 26 | let full_path = config_directory.join(include_path); 27 | 28 | if !file_exists(&full_path) { 29 | anyhow::bail!("Include file not found: {:?}", full_path); 30 | } 31 | let content = read_file(&full_path)?; 32 | result = result.replacen(&captures[0], &content, 1); 33 | replaced_any = true; 34 | } 35 | 36 | if !replaced_any { 37 | break; 38 | } 39 | } 40 | 41 | Ok(result) 42 | } 43 | 44 | pub fn handle_include(original_text: &str) -> anyhow::Result { 45 | handle_include_with_reader( 46 | original_text, 47 | &config_dir(), 48 | |path| fs::read_to_string(path), 49 | |path| path.exists(), 50 | ) 51 | } 52 | 53 | 54 | #[test] 55 | fn test_handle_include() -> anyhow::Result<()> { 56 | use std::{collections::HashMap, path::Path}; 57 | 58 | let mut mocks = HashMap::new(); 59 | mocks.insert("foo/bar.md", "Hello from bar".to_string()); 60 | mocks.insert("baz/qux.md", "Hello from qux".to_string()); 61 | 62 | let fake_exists = |p: &Path| mocks.contains_key(p.to_str().unwrap()); 63 | let fake_reader = |p: &Path| { 64 | mocks 65 | .get(p.to_str().unwrap()) 66 | .cloned() 67 | .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "Mock file not found")) 68 | }; 69 | 70 | let input = "(INCLUDE foo/bar.md)\n(INCLUDE baz/qux.md)"; 71 | 72 | // Now we can pass in a made-up directory path, or just rely on the key matching. 73 | let output = handle_include_with_reader(input, Path::new(""), fake_reader, fake_exists)?; 74 | assert_eq!(output, "Hello from bar\nHello from qux"); 75 | 76 | Ok(()) 77 | } -------------------------------------------------------------------------------- /src/macros/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod comment; 2 | pub mod include; 3 | pub mod shell; 4 | -------------------------------------------------------------------------------- /src/macros/shell.rs: -------------------------------------------------------------------------------- 1 | use regex::Regex; 2 | 3 | /// Looks for occurrences of `(SHELL some command here)` and replaces them 4 | /// with the combined stdout+stderr output of running that command in a shell. 5 | pub fn handle_shell(original_text: &str) -> anyhow::Result { 6 | let re = Regex::new(r"\(SHELL\s+([^)]+)\)")?; 7 | let mut result = original_text.to_string(); 8 | 9 | loop { 10 | let mut replaced_any = false; 11 | let text_snapshot = result.clone(); 12 | 13 | for captures in re.captures_iter(&text_snapshot) { 14 | let command_str = &captures[1]; 15 | 16 | // Run the command via `sh -c` 17 | let output = std::process::Command::new("sh") 18 | .arg("-c") 19 | .arg(command_str) 20 | .output() 21 | .map_err(|e| anyhow::anyhow!("Failed to execute shell command: {}", e))?; 22 | 23 | // If the command returned non-zero, bail out with an error 24 | if !output.status.success() { 25 | anyhow::bail!( 26 | "Shell command exited with error (status {:?}): {}", 27 | output.status.code(), 28 | command_str 29 | ); 30 | } 31 | 32 | // Combine stdout and stderr into one String 33 | let combined_output = format!( 34 | "{}{}", 35 | String::from_utf8_lossy(&output.stdout), 36 | String::from_utf8_lossy(&output.stderr) 37 | ); 38 | 39 | // Replace only the first occurrence in this loop iteration 40 | result = result.replacen(&captures[0], &combined_output, 1); 41 | replaced_any = true; 42 | } 43 | 44 | if !replaced_any { 45 | break; 46 | } 47 | } 48 | 49 | Ok(result) 50 | } 51 | 52 | // Test a single shell command that should succeed 53 | #[test] 54 | fn test_handle_shell_single_command() -> anyhow::Result<()> { 55 | let input = "Some text (SHELL echo HelloWorld) more text"; 56 | let output = handle_shell(input)?; 57 | let expected = "Some text HelloWorld\n more text"; 58 | assert_eq!(output, expected); 59 | Ok(()) 60 | } 61 | 62 | #[test] 63 | fn test_handle_shell_with_bc() -> anyhow::Result<()> { 64 | let input = "Some wonder what two plus two is. Wonder no more: (SHELL echo '2 + 2' | bc)"; 65 | let output = handle_shell(input)?; 66 | let expected = "Some wonder what two plus two is. Wonder no more: 4\n"; 67 | assert_eq!(output, expected); 68 | Ok(()) 69 | } 70 | 71 | 72 | // Test multiple commands in the same string 73 | #[test] 74 | fn test_handle_shell_multiple_commands() -> anyhow::Result<()> { 75 | let input = "(SHELL echo First) and (SHELL echo Second)"; 76 | let output = handle_shell(input)?; 77 | let expected = "First\n and Second\n"; 78 | assert_eq!(output, expected); 79 | Ok(()) 80 | } 81 | 82 | // Test that a failing command triggers an error 83 | #[test] 84 | fn test_handle_shell_failure() { 85 | // 'false' is a shell built-in that exits with non-zero code 86 | let input = "Will fail: (SHELL false)"; 87 | let result = handle_shell(input); 88 | assert!(result.is_err(), "Expected an error but got OK"); 89 | } 90 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use prompta::cli::*; 2 | 3 | fn main() -> anyhow::Result<()> { 4 | parse_and_run_cli() 5 | } 6 | -------------------------------------------------------------------------------- /src/templates.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use std::collections::HashMap; 3 | use std::fs::{self}; 4 | use std::path::Path; 5 | 6 | #[derive(Debug, Default, Serialize, Deserialize)] 7 | pub struct PlaceholderEntry { 8 | pub path: String, 9 | pub language: Option, 10 | pub directory: Option, 11 | pub extension: Option, 12 | } 13 | 14 | #[derive(Debug, Default, Serialize, Deserialize)] 15 | pub struct TemplateMeta { 16 | pub placeholders: HashMap>, 17 | } 18 | 19 | pub fn load_meta(path: &Path) -> anyhow::Result { 20 | let data = fs::read_to_string(path)?; 21 | let meta: TemplateMeta = serde_json::from_str(&data)?; 22 | Ok(meta) 23 | } 24 | 25 | pub fn save_meta(path: &Path, meta: &TemplateMeta) -> anyhow::Result<()> { 26 | let json = serde_json::to_string_pretty(meta)?; 27 | fs::write(path, json)?; 28 | Ok(()) 29 | } 30 | -------------------------------------------------------------------------------- /testfile: -------------------------------------------------------------------------------- 1 | // This is a file called `testfile` 2 | console.log("What in the world is happening?") 3 | --------------------------------------------------------------------------------