├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── default.nix ├── flake.lock ├── flake.nix ├── lib ├── call-flake.nix ├── default.nix ├── mk-workspace-env.nix └── mk-workspace.nix ├── src ├── cli.rs ├── config.rs ├── flake.rs ├── lockfile.rs ├── main.rs ├── snapshots │ ├── .gitignore │ ├── ns__cli__git_tests__ls_remote.snap │ ├── ns__cli__nix_tests__flake_metadata.snap │ └── ns__cli__nix_tests__flake_prefetch.snap ├── util.rs └── workspace.rs └── templates ├── basic ├── .nixspace │ ├── local.json │ └── main.lock ├── flake.nix └── nixspace.toml └── flake-parts ├── .nixspace ├── local.json └── main.lock ├── flake.nix └── nixspace.toml /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 3 4 | 5 | [[package]] 6 | name = "addr2line" 7 | version = "0.21.0" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "8a30b2e23b9e17a9f90641c7ab1549cd9b44f296d3ccbf309d2863cfe398a0cb" 10 | dependencies = [ 11 | "gimli", 12 | ] 13 | 14 | [[package]] 15 | name = "adler" 16 | version = "1.0.2" 17 | source = "registry+https://github.com/rust-lang/crates.io-index" 18 | checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" 19 | 20 | [[package]] 21 | name = "aho-corasick" 22 | version = "1.1.2" 23 | source = "registry+https://github.com/rust-lang/crates.io-index" 24 | checksum = "b2969dcb958b36655471fc61f7e416fa76033bdd4bfed0678d8fee1e2d07a1f0" 25 | dependencies = [ 26 | "memchr", 27 | ] 28 | 29 | [[package]] 30 | name = "anstream" 31 | version = "0.6.5" 32 | source = "registry+https://github.com/rust-lang/crates.io-index" 33 | checksum = "d664a92ecae85fd0a7392615844904654d1d5f5514837f471ddef4a057aba1b6" 34 | dependencies = [ 35 | "anstyle", 36 | "anstyle-parse", 37 | "anstyle-query", 38 | "anstyle-wincon", 39 | "colorchoice", 40 | "utf8parse", 41 | ] 42 | 43 | [[package]] 44 | name = "anstyle" 45 | version = "1.0.4" 46 | source = "registry+https://github.com/rust-lang/crates.io-index" 47 | checksum = "7079075b41f533b8c61d2a4d073c4676e1f8b249ff94a393b0595db304e0dd87" 48 | 49 | [[package]] 50 | name = "anstyle-parse" 51 | version = "0.2.3" 52 | source = "registry+https://github.com/rust-lang/crates.io-index" 53 | checksum = "c75ac65da39e5fe5ab759307499ddad880d724eed2f6ce5b5e8a26f4f387928c" 54 | dependencies = [ 55 | "utf8parse", 56 | ] 57 | 58 | [[package]] 59 | name = "anstyle-query" 60 | version = "1.0.2" 61 | source = "registry+https://github.com/rust-lang/crates.io-index" 62 | checksum = "e28923312444cdd728e4738b3f9c9cac739500909bb3d3c94b43551b16517648" 63 | dependencies = [ 64 | "windows-sys 0.52.0", 65 | ] 66 | 67 | [[package]] 68 | name = "anstyle-wincon" 69 | version = "3.0.2" 70 | source = "registry+https://github.com/rust-lang/crates.io-index" 71 | checksum = "1cd54b81ec8d6180e24654d0b371ad22fc3dd083b6ff8ba325b72e00c87660a7" 72 | dependencies = [ 73 | "anstyle", 74 | "windows-sys 0.52.0", 75 | ] 76 | 77 | [[package]] 78 | name = "anyhow" 79 | version = "1.0.77" 80 | source = "registry+https://github.com/rust-lang/crates.io-index" 81 | checksum = "c9d19de80eff169429ac1e9f48fffb163916b448a44e8e046186232046d9e1f9" 82 | dependencies = [ 83 | "backtrace", 84 | ] 85 | 86 | [[package]] 87 | name = "backtrace" 88 | version = "0.3.69" 89 | source = "registry+https://github.com/rust-lang/crates.io-index" 90 | checksum = "2089b7e3f35b9dd2d0ed921ead4f6d318c27680d4a5bd167b3ee120edb105837" 91 | dependencies = [ 92 | "addr2line", 93 | "cc", 94 | "cfg-if", 95 | "libc", 96 | "miniz_oxide", 97 | "object", 98 | "rustc-demangle", 99 | ] 100 | 101 | [[package]] 102 | name = "cc" 103 | version = "1.0.83" 104 | source = "registry+https://github.com/rust-lang/crates.io-index" 105 | checksum = "f1174fb0b6ec23863f8b971027804a42614e347eafb0a95bf0b12cdae21fc4d0" 106 | dependencies = [ 107 | "libc", 108 | ] 109 | 110 | [[package]] 111 | name = "cfg-if" 112 | version = "1.0.0" 113 | source = "registry+https://github.com/rust-lang/crates.io-index" 114 | checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 115 | 116 | [[package]] 117 | name = "clap" 118 | version = "4.4.12" 119 | source = "registry+https://github.com/rust-lang/crates.io-index" 120 | checksum = "dcfab8ba68f3668e89f6ff60f5b205cea56aa7b769451a59f34b8682f51c056d" 121 | dependencies = [ 122 | "clap_builder", 123 | "clap_derive", 124 | ] 125 | 126 | [[package]] 127 | name = "clap-verbosity-flag" 128 | version = "2.1.1" 129 | source = "registry+https://github.com/rust-lang/crates.io-index" 130 | checksum = "3c90e95e5bd4e8ac34fa6f37c774b0c6f8ed06ea90c79931fd448fcf941a9767" 131 | dependencies = [ 132 | "clap", 133 | "log", 134 | ] 135 | 136 | [[package]] 137 | name = "clap_builder" 138 | version = "4.4.12" 139 | source = "registry+https://github.com/rust-lang/crates.io-index" 140 | checksum = "fb7fb5e4e979aec3be7791562fcba452f94ad85e954da024396433e0e25a79e9" 141 | dependencies = [ 142 | "anstream", 143 | "anstyle", 144 | "clap_lex", 145 | "strsim", 146 | ] 147 | 148 | [[package]] 149 | name = "clap_derive" 150 | version = "4.4.7" 151 | source = "registry+https://github.com/rust-lang/crates.io-index" 152 | checksum = "cf9804afaaf59a91e75b022a30fb7229a7901f60c755489cc61c9b423b836442" 153 | dependencies = [ 154 | "heck", 155 | "proc-macro2", 156 | "quote", 157 | "syn", 158 | ] 159 | 160 | [[package]] 161 | name = "clap_lex" 162 | version = "0.6.0" 163 | source = "registry+https://github.com/rust-lang/crates.io-index" 164 | checksum = "702fc72eb24e5a1e48ce58027a675bc24edd52096d5397d4aea7c6dd9eca0bd1" 165 | 166 | [[package]] 167 | name = "colorchoice" 168 | version = "1.0.0" 169 | source = "registry+https://github.com/rust-lang/crates.io-index" 170 | checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7" 171 | 172 | [[package]] 173 | name = "colored" 174 | version = "2.1.0" 175 | source = "registry+https://github.com/rust-lang/crates.io-index" 176 | checksum = "cbf2150cce219b664a8a70df7a1f933836724b503f8a413af9365b4dcc4d90b8" 177 | dependencies = [ 178 | "lazy_static", 179 | "windows-sys 0.48.0", 180 | ] 181 | 182 | [[package]] 183 | name = "console" 184 | version = "0.15.7" 185 | source = "registry+https://github.com/rust-lang/crates.io-index" 186 | checksum = "c926e00cc70edefdc64d3a5ff31cc65bb97a3460097762bd23afb4d8145fccf8" 187 | dependencies = [ 188 | "encode_unicode", 189 | "lazy_static", 190 | "libc", 191 | "windows-sys 0.45.0", 192 | ] 193 | 194 | [[package]] 195 | name = "deranged" 196 | version = "0.3.11" 197 | source = "registry+https://github.com/rust-lang/crates.io-index" 198 | checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" 199 | dependencies = [ 200 | "powerfmt", 201 | ] 202 | 203 | [[package]] 204 | name = "encode_unicode" 205 | version = "0.3.6" 206 | source = "registry+https://github.com/rust-lang/crates.io-index" 207 | checksum = "a357d28ed41a50f9c765dbfe56cbc04a64e53e5fc58ba79fbc34c10ef3df831f" 208 | 209 | [[package]] 210 | name = "equivalent" 211 | version = "1.0.1" 212 | source = "registry+https://github.com/rust-lang/crates.io-index" 213 | checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" 214 | 215 | [[package]] 216 | name = "fake-tty" 217 | version = "0.3.1" 218 | source = "registry+https://github.com/rust-lang/crates.io-index" 219 | checksum = "aa6c2a740a5d6940f90a0f13b5828440c2a7160bd1e235cf934d5df0e7a3e1ad" 220 | 221 | [[package]] 222 | name = "fuchsia-cprng" 223 | version = "0.1.1" 224 | source = "registry+https://github.com/rust-lang/crates.io-index" 225 | checksum = "a06f77d526c1a601b7c4cdd98f54b5eaabffc14d5f2f0296febdc7f357c6d3ba" 226 | 227 | [[package]] 228 | name = "gimli" 229 | version = "0.28.1" 230 | source = "registry+https://github.com/rust-lang/crates.io-index" 231 | checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253" 232 | 233 | [[package]] 234 | name = "glob-match" 235 | version = "0.2.1" 236 | source = "registry+https://github.com/rust-lang/crates.io-index" 237 | checksum = "9985c9503b412198aa4197559e9a318524ebc4519c229bfa05a535828c950b9d" 238 | 239 | [[package]] 240 | name = "hashbrown" 241 | version = "0.14.3" 242 | source = "registry+https://github.com/rust-lang/crates.io-index" 243 | checksum = "290f1a1d9242c78d09ce40a5e87e7554ee637af1351968159f4952f028f75604" 244 | 245 | [[package]] 246 | name = "heck" 247 | version = "0.4.1" 248 | source = "registry+https://github.com/rust-lang/crates.io-index" 249 | checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" 250 | 251 | [[package]] 252 | name = "indexmap" 253 | version = "2.1.0" 254 | source = "registry+https://github.com/rust-lang/crates.io-index" 255 | checksum = "d530e1a18b1cb4c484e6e34556a0d948706958449fca0cab753d649f2bce3d1f" 256 | dependencies = [ 257 | "equivalent", 258 | "hashbrown", 259 | ] 260 | 261 | [[package]] 262 | name = "insta" 263 | version = "1.34.0" 264 | source = "registry+https://github.com/rust-lang/crates.io-index" 265 | checksum = "5d64600be34b2fcfc267740a243fa7744441bb4947a619ac4e5bb6507f35fbfc" 266 | dependencies = [ 267 | "console", 268 | "lazy_static", 269 | "linked-hash-map", 270 | "serde", 271 | "similar", 272 | "toml 0.5.11", 273 | "yaml-rust", 274 | ] 275 | 276 | [[package]] 277 | name = "itoa" 278 | version = "1.0.10" 279 | source = "registry+https://github.com/rust-lang/crates.io-index" 280 | checksum = "b1a46d1a171d865aa5f83f92695765caa047a9b4cbae2cbf37dbd613a793fd4c" 281 | 282 | [[package]] 283 | name = "lazy_static" 284 | version = "1.4.0" 285 | source = "registry+https://github.com/rust-lang/crates.io-index" 286 | checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" 287 | 288 | [[package]] 289 | name = "libc" 290 | version = "0.2.151" 291 | source = "registry+https://github.com/rust-lang/crates.io-index" 292 | checksum = "302d7ab3130588088d277783b1e2d2e10c9e9e4a16dd9050e6ec93fb3e7048f4" 293 | 294 | [[package]] 295 | name = "linked-hash-map" 296 | version = "0.5.6" 297 | source = "registry+https://github.com/rust-lang/crates.io-index" 298 | checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" 299 | 300 | [[package]] 301 | name = "log" 302 | version = "0.4.20" 303 | source = "registry+https://github.com/rust-lang/crates.io-index" 304 | checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f" 305 | 306 | [[package]] 307 | name = "memchr" 308 | version = "2.7.1" 309 | source = "registry+https://github.com/rust-lang/crates.io-index" 310 | checksum = "523dc4f511e55ab87b694dc30d0f820d60906ef06413f93d4d7a1385599cc149" 311 | 312 | [[package]] 313 | name = "miniz_oxide" 314 | version = "0.7.1" 315 | source = "registry+https://github.com/rust-lang/crates.io-index" 316 | checksum = "e7810e0be55b428ada41041c41f32c9f1a42817901b4ccf45fa3d4b6561e74c7" 317 | dependencies = [ 318 | "adler", 319 | ] 320 | 321 | [[package]] 322 | name = "ns" 323 | version = "0.1.0" 324 | dependencies = [ 325 | "anyhow", 326 | "clap", 327 | "clap-verbosity-flag", 328 | "colored", 329 | "fake-tty", 330 | "glob-match", 331 | "insta", 332 | "log", 333 | "querystring", 334 | "regex", 335 | "serde", 336 | "serde_json", 337 | "serde_variant", 338 | "simplelog", 339 | "tempdir", 340 | "toml 0.8.8", 341 | ] 342 | 343 | [[package]] 344 | name = "num_threads" 345 | version = "0.1.6" 346 | source = "registry+https://github.com/rust-lang/crates.io-index" 347 | checksum = "2819ce041d2ee131036f4fc9d6ae7ae125a3a40e97ba64d04fe799ad9dabbb44" 348 | dependencies = [ 349 | "libc", 350 | ] 351 | 352 | [[package]] 353 | name = "object" 354 | version = "0.32.2" 355 | source = "registry+https://github.com/rust-lang/crates.io-index" 356 | checksum = "a6a622008b6e321afc04970976f62ee297fdbaa6f95318ca343e3eebb9648441" 357 | dependencies = [ 358 | "memchr", 359 | ] 360 | 361 | [[package]] 362 | name = "paris" 363 | version = "1.5.15" 364 | source = "registry+https://github.com/rust-lang/crates.io-index" 365 | checksum = "8fecab3723493c7851f292cb060f3ee1c42f19b8d749345d0d7eaf3fd19aa62d" 366 | 367 | [[package]] 368 | name = "powerfmt" 369 | version = "0.2.0" 370 | source = "registry+https://github.com/rust-lang/crates.io-index" 371 | checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" 372 | 373 | [[package]] 374 | name = "proc-macro2" 375 | version = "1.0.71" 376 | source = "registry+https://github.com/rust-lang/crates.io-index" 377 | checksum = "75cb1540fadbd5b8fbccc4dddad2734eba435053f725621c070711a14bb5f4b8" 378 | dependencies = [ 379 | "unicode-ident", 380 | ] 381 | 382 | [[package]] 383 | name = "querystring" 384 | version = "1.1.0" 385 | source = "registry+https://github.com/rust-lang/crates.io-index" 386 | checksum = "9318ead08c799aad12a55a3e78b82e0b6167271ffd1f627b758891282f739187" 387 | 388 | [[package]] 389 | name = "quote" 390 | version = "1.0.33" 391 | source = "registry+https://github.com/rust-lang/crates.io-index" 392 | checksum = "5267fca4496028628a95160fc423a33e8b2e6af8a5302579e322e4b520293cae" 393 | dependencies = [ 394 | "proc-macro2", 395 | ] 396 | 397 | [[package]] 398 | name = "rand" 399 | version = "0.4.6" 400 | source = "registry+https://github.com/rust-lang/crates.io-index" 401 | checksum = "552840b97013b1a26992c11eac34bdd778e464601a4c2054b5f0bff7c6761293" 402 | dependencies = [ 403 | "fuchsia-cprng", 404 | "libc", 405 | "rand_core 0.3.1", 406 | "rdrand", 407 | "winapi", 408 | ] 409 | 410 | [[package]] 411 | name = "rand_core" 412 | version = "0.3.1" 413 | source = "registry+https://github.com/rust-lang/crates.io-index" 414 | checksum = "7a6fdeb83b075e8266dcc8762c22776f6877a63111121f5f8c7411e5be7eed4b" 415 | dependencies = [ 416 | "rand_core 0.4.2", 417 | ] 418 | 419 | [[package]] 420 | name = "rand_core" 421 | version = "0.4.2" 422 | source = "registry+https://github.com/rust-lang/crates.io-index" 423 | checksum = "9c33a3c44ca05fa6f1807d8e6743f3824e8509beca625669633be0acbdf509dc" 424 | 425 | [[package]] 426 | name = "rdrand" 427 | version = "0.4.0" 428 | source = "registry+https://github.com/rust-lang/crates.io-index" 429 | checksum = "678054eb77286b51581ba43620cc911abf02758c91f93f479767aed0f90458b2" 430 | dependencies = [ 431 | "rand_core 0.3.1", 432 | ] 433 | 434 | [[package]] 435 | name = "regex" 436 | version = "1.10.2" 437 | source = "registry+https://github.com/rust-lang/crates.io-index" 438 | checksum = "380b951a9c5e80ddfd6136919eef32310721aa4aacd4889a8d39124b026ab343" 439 | dependencies = [ 440 | "aho-corasick", 441 | "memchr", 442 | "regex-automata", 443 | "regex-syntax", 444 | ] 445 | 446 | [[package]] 447 | name = "regex-automata" 448 | version = "0.4.3" 449 | source = "registry+https://github.com/rust-lang/crates.io-index" 450 | checksum = "5f804c7828047e88b2d32e2d7fe5a105da8ee3264f01902f796c8e067dc2483f" 451 | dependencies = [ 452 | "aho-corasick", 453 | "memchr", 454 | "regex-syntax", 455 | ] 456 | 457 | [[package]] 458 | name = "regex-syntax" 459 | version = "0.8.2" 460 | source = "registry+https://github.com/rust-lang/crates.io-index" 461 | checksum = "c08c74e62047bb2de4ff487b251e4a92e24f48745648451635cec7d591162d9f" 462 | 463 | [[package]] 464 | name = "remove_dir_all" 465 | version = "0.5.3" 466 | source = "registry+https://github.com/rust-lang/crates.io-index" 467 | checksum = "3acd125665422973a33ac9d3dd2df85edad0f4ae9b00dafb1a05e43a9f5ef8e7" 468 | dependencies = [ 469 | "winapi", 470 | ] 471 | 472 | [[package]] 473 | name = "rustc-demangle" 474 | version = "0.1.23" 475 | source = "registry+https://github.com/rust-lang/crates.io-index" 476 | checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76" 477 | 478 | [[package]] 479 | name = "ryu" 480 | version = "1.0.16" 481 | source = "registry+https://github.com/rust-lang/crates.io-index" 482 | checksum = "f98d2aa92eebf49b69786be48e4477826b256916e84a57ff2a4f21923b48eb4c" 483 | 484 | [[package]] 485 | name = "serde" 486 | version = "1.0.193" 487 | source = "registry+https://github.com/rust-lang/crates.io-index" 488 | checksum = "25dd9975e68d0cb5aa1120c288333fc98731bd1dd12f561e468ea4728c042b89" 489 | dependencies = [ 490 | "serde_derive", 491 | ] 492 | 493 | [[package]] 494 | name = "serde_derive" 495 | version = "1.0.193" 496 | source = "registry+https://github.com/rust-lang/crates.io-index" 497 | checksum = "43576ca501357b9b071ac53cdc7da8ef0cbd9493d8df094cd821777ea6e894d3" 498 | dependencies = [ 499 | "proc-macro2", 500 | "quote", 501 | "syn", 502 | ] 503 | 504 | [[package]] 505 | name = "serde_json" 506 | version = "1.0.108" 507 | source = "registry+https://github.com/rust-lang/crates.io-index" 508 | checksum = "3d1c7e3eac408d115102c4c24ad393e0821bb3a5df4d506a80f85f7a742a526b" 509 | dependencies = [ 510 | "itoa", 511 | "ryu", 512 | "serde", 513 | ] 514 | 515 | [[package]] 516 | name = "serde_spanned" 517 | version = "0.6.5" 518 | source = "registry+https://github.com/rust-lang/crates.io-index" 519 | checksum = "eb3622f419d1296904700073ea6cc23ad690adbd66f13ea683df73298736f0c1" 520 | dependencies = [ 521 | "serde", 522 | ] 523 | 524 | [[package]] 525 | name = "serde_variant" 526 | version = "0.1.2" 527 | source = "registry+https://github.com/rust-lang/crates.io-index" 528 | checksum = "47a8ec0b2fd0506290348d9699c0e3eb2e3e8c0498b5a9a6158b3bd4d6970076" 529 | dependencies = [ 530 | "serde", 531 | ] 532 | 533 | [[package]] 534 | name = "similar" 535 | version = "2.4.0" 536 | source = "registry+https://github.com/rust-lang/crates.io-index" 537 | checksum = "32fea41aca09ee824cc9724996433064c89f7777e60762749a4170a14abbfa21" 538 | 539 | [[package]] 540 | name = "simplelog" 541 | version = "0.12.1" 542 | source = "registry+https://github.com/rust-lang/crates.io-index" 543 | checksum = "acee08041c5de3d5048c8b3f6f13fafb3026b24ba43c6a695a0c76179b844369" 544 | dependencies = [ 545 | "log", 546 | "paris", 547 | "termcolor", 548 | "time", 549 | ] 550 | 551 | [[package]] 552 | name = "strsim" 553 | version = "0.10.0" 554 | source = "registry+https://github.com/rust-lang/crates.io-index" 555 | checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" 556 | 557 | [[package]] 558 | name = "syn" 559 | version = "2.0.43" 560 | source = "registry+https://github.com/rust-lang/crates.io-index" 561 | checksum = "ee659fb5f3d355364e1f3e5bc10fb82068efbf824a1e9d1c9504244a6469ad53" 562 | dependencies = [ 563 | "proc-macro2", 564 | "quote", 565 | "unicode-ident", 566 | ] 567 | 568 | [[package]] 569 | name = "tempdir" 570 | version = "0.3.7" 571 | source = "registry+https://github.com/rust-lang/crates.io-index" 572 | checksum = "15f2b5fb00ccdf689e0149d1b1b3c03fead81c2b37735d812fa8bddbbf41b6d8" 573 | dependencies = [ 574 | "rand", 575 | "remove_dir_all", 576 | ] 577 | 578 | [[package]] 579 | name = "termcolor" 580 | version = "1.1.3" 581 | source = "registry+https://github.com/rust-lang/crates.io-index" 582 | checksum = "bab24d30b911b2376f3a13cc2cd443142f0c81dda04c118693e35b3835757755" 583 | dependencies = [ 584 | "winapi-util", 585 | ] 586 | 587 | [[package]] 588 | name = "time" 589 | version = "0.3.31" 590 | source = "registry+https://github.com/rust-lang/crates.io-index" 591 | checksum = "f657ba42c3f86e7680e53c8cd3af8abbe56b5491790b46e22e19c0d57463583e" 592 | dependencies = [ 593 | "deranged", 594 | "itoa", 595 | "libc", 596 | "num_threads", 597 | "powerfmt", 598 | "serde", 599 | "time-core", 600 | "time-macros", 601 | ] 602 | 603 | [[package]] 604 | name = "time-core" 605 | version = "0.1.2" 606 | source = "registry+https://github.com/rust-lang/crates.io-index" 607 | checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" 608 | 609 | [[package]] 610 | name = "time-macros" 611 | version = "0.2.16" 612 | source = "registry+https://github.com/rust-lang/crates.io-index" 613 | checksum = "26197e33420244aeb70c3e8c78376ca46571bc4e701e4791c2cd9f57dcb3a43f" 614 | dependencies = [ 615 | "time-core", 616 | ] 617 | 618 | [[package]] 619 | name = "toml" 620 | version = "0.5.11" 621 | source = "registry+https://github.com/rust-lang/crates.io-index" 622 | checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234" 623 | dependencies = [ 624 | "serde", 625 | ] 626 | 627 | [[package]] 628 | name = "toml" 629 | version = "0.8.8" 630 | source = "registry+https://github.com/rust-lang/crates.io-index" 631 | checksum = "a1a195ec8c9da26928f773888e0742ca3ca1040c6cd859c919c9f59c1954ab35" 632 | dependencies = [ 633 | "serde", 634 | "serde_spanned", 635 | "toml_datetime", 636 | "toml_edit", 637 | ] 638 | 639 | [[package]] 640 | name = "toml_datetime" 641 | version = "0.6.5" 642 | source = "registry+https://github.com/rust-lang/crates.io-index" 643 | checksum = "3550f4e9685620ac18a50ed434eb3aec30db8ba93b0287467bca5826ea25baf1" 644 | dependencies = [ 645 | "serde", 646 | ] 647 | 648 | [[package]] 649 | name = "toml_edit" 650 | version = "0.21.0" 651 | source = "registry+https://github.com/rust-lang/crates.io-index" 652 | checksum = "d34d383cd00a163b4a5b85053df514d45bc330f6de7737edfe0a93311d1eaa03" 653 | dependencies = [ 654 | "indexmap", 655 | "serde", 656 | "serde_spanned", 657 | "toml_datetime", 658 | "winnow", 659 | ] 660 | 661 | [[package]] 662 | name = "unicode-ident" 663 | version = "1.0.12" 664 | source = "registry+https://github.com/rust-lang/crates.io-index" 665 | checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" 666 | 667 | [[package]] 668 | name = "utf8parse" 669 | version = "0.2.1" 670 | source = "registry+https://github.com/rust-lang/crates.io-index" 671 | checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" 672 | 673 | [[package]] 674 | name = "winapi" 675 | version = "0.3.9" 676 | source = "registry+https://github.com/rust-lang/crates.io-index" 677 | checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" 678 | dependencies = [ 679 | "winapi-i686-pc-windows-gnu", 680 | "winapi-x86_64-pc-windows-gnu", 681 | ] 682 | 683 | [[package]] 684 | name = "winapi-i686-pc-windows-gnu" 685 | version = "0.4.0" 686 | source = "registry+https://github.com/rust-lang/crates.io-index" 687 | checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" 688 | 689 | [[package]] 690 | name = "winapi-util" 691 | version = "0.1.6" 692 | source = "registry+https://github.com/rust-lang/crates.io-index" 693 | checksum = "f29e6f9198ba0d26b4c9f07dbe6f9ed633e1f3d5b8b414090084349e46a52596" 694 | dependencies = [ 695 | "winapi", 696 | ] 697 | 698 | [[package]] 699 | name = "winapi-x86_64-pc-windows-gnu" 700 | version = "0.4.0" 701 | source = "registry+https://github.com/rust-lang/crates.io-index" 702 | checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" 703 | 704 | [[package]] 705 | name = "windows-sys" 706 | version = "0.45.0" 707 | source = "registry+https://github.com/rust-lang/crates.io-index" 708 | checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" 709 | dependencies = [ 710 | "windows-targets 0.42.2", 711 | ] 712 | 713 | [[package]] 714 | name = "windows-sys" 715 | version = "0.48.0" 716 | source = "registry+https://github.com/rust-lang/crates.io-index" 717 | checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" 718 | dependencies = [ 719 | "windows-targets 0.48.5", 720 | ] 721 | 722 | [[package]] 723 | name = "windows-sys" 724 | version = "0.52.0" 725 | source = "registry+https://github.com/rust-lang/crates.io-index" 726 | checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" 727 | dependencies = [ 728 | "windows-targets 0.52.0", 729 | ] 730 | 731 | [[package]] 732 | name = "windows-targets" 733 | version = "0.42.2" 734 | source = "registry+https://github.com/rust-lang/crates.io-index" 735 | checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" 736 | dependencies = [ 737 | "windows_aarch64_gnullvm 0.42.2", 738 | "windows_aarch64_msvc 0.42.2", 739 | "windows_i686_gnu 0.42.2", 740 | "windows_i686_msvc 0.42.2", 741 | "windows_x86_64_gnu 0.42.2", 742 | "windows_x86_64_gnullvm 0.42.2", 743 | "windows_x86_64_msvc 0.42.2", 744 | ] 745 | 746 | [[package]] 747 | name = "windows-targets" 748 | version = "0.48.5" 749 | source = "registry+https://github.com/rust-lang/crates.io-index" 750 | checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" 751 | dependencies = [ 752 | "windows_aarch64_gnullvm 0.48.5", 753 | "windows_aarch64_msvc 0.48.5", 754 | "windows_i686_gnu 0.48.5", 755 | "windows_i686_msvc 0.48.5", 756 | "windows_x86_64_gnu 0.48.5", 757 | "windows_x86_64_gnullvm 0.48.5", 758 | "windows_x86_64_msvc 0.48.5", 759 | ] 760 | 761 | [[package]] 762 | name = "windows-targets" 763 | version = "0.52.0" 764 | source = "registry+https://github.com/rust-lang/crates.io-index" 765 | checksum = "8a18201040b24831fbb9e4eb208f8892e1f50a37feb53cc7ff887feb8f50e7cd" 766 | dependencies = [ 767 | "windows_aarch64_gnullvm 0.52.0", 768 | "windows_aarch64_msvc 0.52.0", 769 | "windows_i686_gnu 0.52.0", 770 | "windows_i686_msvc 0.52.0", 771 | "windows_x86_64_gnu 0.52.0", 772 | "windows_x86_64_gnullvm 0.52.0", 773 | "windows_x86_64_msvc 0.52.0", 774 | ] 775 | 776 | [[package]] 777 | name = "windows_aarch64_gnullvm" 778 | version = "0.42.2" 779 | source = "registry+https://github.com/rust-lang/crates.io-index" 780 | checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" 781 | 782 | [[package]] 783 | name = "windows_aarch64_gnullvm" 784 | version = "0.48.5" 785 | source = "registry+https://github.com/rust-lang/crates.io-index" 786 | checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" 787 | 788 | [[package]] 789 | name = "windows_aarch64_gnullvm" 790 | version = "0.52.0" 791 | source = "registry+https://github.com/rust-lang/crates.io-index" 792 | checksum = "cb7764e35d4db8a7921e09562a0304bf2f93e0a51bfccee0bd0bb0b666b015ea" 793 | 794 | [[package]] 795 | name = "windows_aarch64_msvc" 796 | version = "0.42.2" 797 | source = "registry+https://github.com/rust-lang/crates.io-index" 798 | checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" 799 | 800 | [[package]] 801 | name = "windows_aarch64_msvc" 802 | version = "0.48.5" 803 | source = "registry+https://github.com/rust-lang/crates.io-index" 804 | checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" 805 | 806 | [[package]] 807 | name = "windows_aarch64_msvc" 808 | version = "0.52.0" 809 | source = "registry+https://github.com/rust-lang/crates.io-index" 810 | checksum = "bbaa0368d4f1d2aaefc55b6fcfee13f41544ddf36801e793edbbfd7d7df075ef" 811 | 812 | [[package]] 813 | name = "windows_i686_gnu" 814 | version = "0.42.2" 815 | source = "registry+https://github.com/rust-lang/crates.io-index" 816 | checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" 817 | 818 | [[package]] 819 | name = "windows_i686_gnu" 820 | version = "0.48.5" 821 | source = "registry+https://github.com/rust-lang/crates.io-index" 822 | checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" 823 | 824 | [[package]] 825 | name = "windows_i686_gnu" 826 | version = "0.52.0" 827 | source = "registry+https://github.com/rust-lang/crates.io-index" 828 | checksum = "a28637cb1fa3560a16915793afb20081aba2c92ee8af57b4d5f28e4b3e7df313" 829 | 830 | [[package]] 831 | name = "windows_i686_msvc" 832 | version = "0.42.2" 833 | source = "registry+https://github.com/rust-lang/crates.io-index" 834 | checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" 835 | 836 | [[package]] 837 | name = "windows_i686_msvc" 838 | version = "0.48.5" 839 | source = "registry+https://github.com/rust-lang/crates.io-index" 840 | checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" 841 | 842 | [[package]] 843 | name = "windows_i686_msvc" 844 | version = "0.52.0" 845 | source = "registry+https://github.com/rust-lang/crates.io-index" 846 | checksum = "ffe5e8e31046ce6230cc7215707b816e339ff4d4d67c65dffa206fd0f7aa7b9a" 847 | 848 | [[package]] 849 | name = "windows_x86_64_gnu" 850 | version = "0.42.2" 851 | source = "registry+https://github.com/rust-lang/crates.io-index" 852 | checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" 853 | 854 | [[package]] 855 | name = "windows_x86_64_gnu" 856 | version = "0.48.5" 857 | source = "registry+https://github.com/rust-lang/crates.io-index" 858 | checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" 859 | 860 | [[package]] 861 | name = "windows_x86_64_gnu" 862 | version = "0.52.0" 863 | source = "registry+https://github.com/rust-lang/crates.io-index" 864 | checksum = "3d6fa32db2bc4a2f5abeacf2b69f7992cd09dca97498da74a151a3132c26befd" 865 | 866 | [[package]] 867 | name = "windows_x86_64_gnullvm" 868 | version = "0.42.2" 869 | source = "registry+https://github.com/rust-lang/crates.io-index" 870 | checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" 871 | 872 | [[package]] 873 | name = "windows_x86_64_gnullvm" 874 | version = "0.48.5" 875 | source = "registry+https://github.com/rust-lang/crates.io-index" 876 | checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" 877 | 878 | [[package]] 879 | name = "windows_x86_64_gnullvm" 880 | version = "0.52.0" 881 | source = "registry+https://github.com/rust-lang/crates.io-index" 882 | checksum = "1a657e1e9d3f514745a572a6846d3c7aa7dbe1658c056ed9c3344c4109a6949e" 883 | 884 | [[package]] 885 | name = "windows_x86_64_msvc" 886 | version = "0.42.2" 887 | source = "registry+https://github.com/rust-lang/crates.io-index" 888 | checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" 889 | 890 | [[package]] 891 | name = "windows_x86_64_msvc" 892 | version = "0.48.5" 893 | source = "registry+https://github.com/rust-lang/crates.io-index" 894 | checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" 895 | 896 | [[package]] 897 | name = "windows_x86_64_msvc" 898 | version = "0.52.0" 899 | source = "registry+https://github.com/rust-lang/crates.io-index" 900 | checksum = "dff9641d1cd4be8d1a070daf9e3773c5f67e78b4d9d42263020c057706765c04" 901 | 902 | [[package]] 903 | name = "winnow" 904 | version = "0.5.31" 905 | source = "registry+https://github.com/rust-lang/crates.io-index" 906 | checksum = "97a4882e6b134d6c28953a387571f1acdd3496830d5e36c5e3a1075580ea641c" 907 | dependencies = [ 908 | "memchr", 909 | ] 910 | 911 | [[package]] 912 | name = "yaml-rust" 913 | version = "0.4.5" 914 | source = "registry+https://github.com/rust-lang/crates.io-index" 915 | checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85" 916 | dependencies = [ 917 | "linked-hash-map", 918 | ] 919 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "ns" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | anyhow = { version = "^1.0.75", features = ["backtrace"] } 8 | clap = { version = "^4.4.7", features = ["derive"] } 9 | clap-verbosity-flag = "^2.1.1" 10 | colored = "2.1.0" 11 | fake-tty = "0.3.1" 12 | glob-match = "^0.2.1" 13 | log = "^0.4.20" 14 | querystring = "^1.1.0" 15 | regex = "^1.10.2" 16 | serde = { version = "^1.0.192", features = ["derive"] } 17 | serde_json = "^1.0.108" 18 | serde_variant = "^0.1.2" 19 | simplelog = { version = "^0.12.1", features = ["paris"] } 20 | toml = "^0.8.8" 21 | 22 | [dev-dependencies] 23 | insta = { version = "^1.34.0", features = ["json", "toml"] } 24 | tempdir = "^0.3.7" 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU LESSER GENERAL PUBLIC LICENSE 2 | Version 2.1, February 1999 3 | 4 | Copyright (C) 1991, 1999 Free Software Foundation, Inc. 5 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 6 | Everyone is permitted to copy and distribute verbatim copies 7 | of this license document, but changing it is not allowed. 8 | 9 | [This is the first released version of the Lesser GPL. It also counts 10 | as the successor of the GNU Library Public License, version 2, hence 11 | the version number 2.1.] 12 | 13 | Preamble 14 | 15 | The licenses for most software are designed to take away your 16 | freedom to share and change it. By contrast, the GNU General Public 17 | Licenses are intended to guarantee your freedom to share and change 18 | free software--to make sure the software is free for all its users. 19 | 20 | This license, the Lesser General Public License, applies to some 21 | specially designated software packages--typically libraries--of the 22 | Free Software Foundation and other authors who decide to use it. You 23 | can use it too, but we suggest you first think carefully about whether 24 | this license or the ordinary General Public License is the better 25 | strategy to use in any particular case, based on the explanations below. 26 | 27 | When we speak of free software, we are referring to freedom of use, 28 | not price. Our General Public Licenses are designed to make sure that 29 | you have the freedom to distribute copies of free software (and charge 30 | for this service if you wish); that you receive source code or can get 31 | it if you want it; that you can change the software and use pieces of 32 | it in new free programs; and that you are informed that you can do 33 | these things. 34 | 35 | To protect your rights, we need to make restrictions that forbid 36 | distributors to deny you these rights or to ask you to surrender these 37 | rights. These restrictions translate to certain responsibilities for 38 | you if you distribute copies of the library or if you modify it. 39 | 40 | For example, if you distribute copies of the library, whether gratis 41 | or for a fee, you must give the recipients all the rights that we gave 42 | you. You must make sure that they, too, receive or can get the source 43 | code. If you link other code with the library, you must provide 44 | complete object files to the recipients, so that they can relink them 45 | with the library after making changes to the library and recompiling 46 | it. And you must show them these terms so they know their rights. 47 | 48 | We protect your rights with a two-step method: (1) we copyright the 49 | library, and (2) we offer you this license, which gives you legal 50 | permission to copy, distribute and/or modify the library. 51 | 52 | To protect each distributor, we want to make it very clear that 53 | there is no warranty for the free library. Also, if the library is 54 | modified by someone else and passed on, the recipients should know 55 | that what they have is not the original version, so that the original 56 | author's reputation will not be affected by problems that might be 57 | introduced by others. 58 | 59 | Finally, software patents pose a constant threat to the existence of 60 | any free program. We wish to make sure that a company cannot 61 | effectively restrict the users of a free program by obtaining a 62 | restrictive license from a patent holder. Therefore, we insist that 63 | any patent license obtained for a version of the library must be 64 | consistent with the full freedom of use specified in this license. 65 | 66 | Most GNU software, including some libraries, is covered by the 67 | ordinary GNU General Public License. This license, the GNU Lesser 68 | General Public License, applies to certain designated libraries, and 69 | is quite different from the ordinary General Public License. We use 70 | this license for certain libraries in order to permit linking those 71 | libraries into non-free programs. 72 | 73 | When a program is linked with a library, whether statically or using 74 | a shared library, the combination of the two is legally speaking a 75 | combined work, a derivative of the original library. The ordinary 76 | General Public License therefore permits such linking only if the 77 | entire combination fits its criteria of freedom. The Lesser General 78 | Public License permits more lax criteria for linking other code with 79 | the library. 80 | 81 | We call this license the "Lesser" General Public License because it 82 | does Less to protect the user's freedom than the ordinary General 83 | Public License. It also provides other free software developers Less 84 | of an advantage over competing non-free programs. These disadvantages 85 | are the reason we use the ordinary General Public License for many 86 | libraries. However, the Lesser license provides advantages in certain 87 | special circumstances. 88 | 89 | For example, on rare occasions, there may be a special need to 90 | encourage the widest possible use of a certain library, so that it becomes 91 | a de-facto standard. To achieve this, non-free programs must be 92 | allowed to use the library. A more frequent case is that a free 93 | library does the same job as widely used non-free libraries. In this 94 | case, there is little to gain by limiting the free library to free 95 | software only, so we use the Lesser General Public License. 96 | 97 | In other cases, permission to use a particular library in non-free 98 | programs enables a greater number of people to use a large body of 99 | free software. For example, permission to use the GNU C Library in 100 | non-free programs enables many more people to use the whole GNU 101 | operating system, as well as its variant, the GNU/Linux operating 102 | system. 103 | 104 | Although the Lesser General Public License is Less protective of the 105 | users' freedom, it does ensure that the user of a program that is 106 | linked with the Library has the freedom and the wherewithal to run 107 | that program using a modified version of the Library. 108 | 109 | The precise terms and conditions for copying, distribution and 110 | modification follow. Pay close attention to the difference between a 111 | "work based on the library" and a "work that uses the library". The 112 | former contains code derived from the library, whereas the latter must 113 | be combined with the library in order to run. 114 | 115 | GNU LESSER GENERAL PUBLIC LICENSE 116 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 117 | 118 | 0. This License Agreement applies to any software library or other 119 | program which contains a notice placed by the copyright holder or 120 | other authorized party saying it may be distributed under the terms of 121 | this Lesser General Public License (also called "this License"). 122 | Each licensee is addressed as "you". 123 | 124 | A "library" means a collection of software functions and/or data 125 | prepared so as to be conveniently linked with application programs 126 | (which use some of those functions and data) to form executables. 127 | 128 | The "Library", below, refers to any such software library or work 129 | which has been distributed under these terms. A "work based on the 130 | Library" means either the Library or any derivative work under 131 | copyright law: that is to say, a work containing the Library or a 132 | portion of it, either verbatim or with modifications and/or translated 133 | straightforwardly into another language. (Hereinafter, translation is 134 | included without limitation in the term "modification".) 135 | 136 | "Source code" for a work means the preferred form of the work for 137 | making modifications to it. For a library, complete source code means 138 | all the source code for all modules it contains, plus any associated 139 | interface definition files, plus the scripts used to control compilation 140 | and installation of the library. 141 | 142 | Activities other than copying, distribution and modification are not 143 | covered by this License; they are outside its scope. The act of 144 | running a program using the Library is not restricted, and output from 145 | such a program is covered only if its contents constitute a work based 146 | on the Library (independent of the use of the Library in a tool for 147 | writing it). Whether that is true depends on what the Library does 148 | and what the program that uses the Library does. 149 | 150 | 1. You may copy and distribute verbatim copies of the Library's 151 | complete source code as you receive it, in any medium, provided that 152 | you conspicuously and appropriately publish on each copy an 153 | appropriate copyright notice and disclaimer of warranty; keep intact 154 | all the notices that refer to this License and to the absence of any 155 | warranty; and distribute a copy of this License along with the 156 | Library. 157 | 158 | You may charge a fee for the physical act of transferring a copy, 159 | and you may at your option offer warranty protection in exchange for a 160 | fee. 161 | 162 | 2. You may modify your copy or copies of the Library or any portion 163 | of it, thus forming a work based on the Library, and copy and 164 | distribute such modifications or work under the terms of Section 1 165 | above, provided that you also meet all of these conditions: 166 | 167 | a) The modified work must itself be a software library. 168 | 169 | b) You must cause the files modified to carry prominent notices 170 | stating that you changed the files and the date of any change. 171 | 172 | c) You must cause the whole of the work to be licensed at no 173 | charge to all third parties under the terms of this License. 174 | 175 | d) If a facility in the modified Library refers to a function or a 176 | table of data to be supplied by an application program that uses 177 | the facility, other than as an argument passed when the facility 178 | is invoked, then you must make a good faith effort to ensure that, 179 | in the event an application does not supply such function or 180 | table, the facility still operates, and performs whatever part of 181 | its purpose remains meaningful. 182 | 183 | (For example, a function in a library to compute square roots has 184 | a purpose that is entirely well-defined independent of the 185 | application. Therefore, Subsection 2d requires that any 186 | application-supplied function or table used by this function must 187 | be optional: if the application does not supply it, the square 188 | root function must still compute square roots.) 189 | 190 | These requirements apply to the modified work as a whole. If 191 | identifiable sections of that work are not derived from the Library, 192 | and can be reasonably considered independent and separate works in 193 | themselves, then this License, and its terms, do not apply to those 194 | sections when you distribute them as separate works. But when you 195 | distribute the same sections as part of a whole which is a work based 196 | on the Library, the distribution of the whole must be on the terms of 197 | this License, whose permissions for other licensees extend to the 198 | entire whole, and thus to each and every part regardless of who wrote 199 | it. 200 | 201 | Thus, it is not the intent of this section to claim rights or contest 202 | your rights to work written entirely by you; rather, the intent is to 203 | exercise the right to control the distribution of derivative or 204 | collective works based on the Library. 205 | 206 | In addition, mere aggregation of another work not based on the Library 207 | with the Library (or with a work based on the Library) on a volume of 208 | a storage or distribution medium does not bring the other work under 209 | the scope of this License. 210 | 211 | 3. You may opt to apply the terms of the ordinary GNU General Public 212 | License instead of this License to a given copy of the Library. To do 213 | this, you must alter all the notices that refer to this License, so 214 | that they refer to the ordinary GNU General Public License, version 2, 215 | instead of to this License. (If a newer version than version 2 of the 216 | ordinary GNU General Public License has appeared, then you can specify 217 | that version instead if you wish.) Do not make any other change in 218 | these notices. 219 | 220 | Once this change is made in a given copy, it is irreversible for 221 | that copy, so the ordinary GNU General Public License applies to all 222 | subsequent copies and derivative works made from that copy. 223 | 224 | This option is useful when you wish to copy part of the code of 225 | the Library into a program that is not a library. 226 | 227 | 4. You may copy and distribute the Library (or a portion or 228 | derivative of it, under Section 2) in object code or executable form 229 | under the terms of Sections 1 and 2 above provided that you accompany 230 | it with the complete corresponding machine-readable source code, which 231 | must be distributed under the terms of Sections 1 and 2 above on a 232 | medium customarily used for software interchange. 233 | 234 | If distribution of object code is made by offering access to copy 235 | from a designated place, then offering equivalent access to copy the 236 | source code from the same place satisfies the requirement to 237 | distribute the source code, even though third parties are not 238 | compelled to copy the source along with the object code. 239 | 240 | 5. A program that contains no derivative of any portion of the 241 | Library, but is designed to work with the Library by being compiled or 242 | linked with it, is called a "work that uses the Library". Such a 243 | work, in isolation, is not a derivative work of the Library, and 244 | therefore falls outside the scope of this License. 245 | 246 | However, linking a "work that uses the Library" with the Library 247 | creates an executable that is a derivative of the Library (because it 248 | contains portions of the Library), rather than a "work that uses the 249 | library". The executable is therefore covered by this License. 250 | Section 6 states terms for distribution of such executables. 251 | 252 | When a "work that uses the Library" uses material from a header file 253 | that is part of the Library, the object code for the work may be a 254 | derivative work of the Library even though the source code is not. 255 | Whether this is true is especially significant if the work can be 256 | linked without the Library, or if the work is itself a library. The 257 | threshold for this to be true is not precisely defined by law. 258 | 259 | If such an object file uses only numerical parameters, data 260 | structure layouts and accessors, and small macros and small inline 261 | functions (ten lines or less in length), then the use of the object 262 | file is unrestricted, regardless of whether it is legally a derivative 263 | work. (Executables containing this object code plus portions of the 264 | Library will still fall under Section 6.) 265 | 266 | Otherwise, if the work is a derivative of the Library, you may 267 | distribute the object code for the work under the terms of Section 6. 268 | Any executables containing that work also fall under Section 6, 269 | whether or not they are linked directly with the Library itself. 270 | 271 | 6. As an exception to the Sections above, you may also combine or 272 | link a "work that uses the Library" with the Library to produce a 273 | work containing portions of the Library, and distribute that work 274 | under terms of your choice, provided that the terms permit 275 | modification of the work for the customer's own use and reverse 276 | engineering for debugging such modifications. 277 | 278 | You must give prominent notice with each copy of the work that the 279 | Library is used in it and that the Library and its use are covered by 280 | this License. You must supply a copy of this License. If the work 281 | during execution displays copyright notices, you must include the 282 | copyright notice for the Library among them, as well as a reference 283 | directing the user to the copy of this License. Also, you must do one 284 | of these things: 285 | 286 | a) Accompany the work with the complete corresponding 287 | machine-readable source code for the Library including whatever 288 | changes were used in the work (which must be distributed under 289 | Sections 1 and 2 above); and, if the work is an executable linked 290 | with the Library, with the complete machine-readable "work that 291 | uses the Library", as object code and/or source code, so that the 292 | user can modify the Library and then relink to produce a modified 293 | executable containing the modified Library. (It is understood 294 | that the user who changes the contents of definitions files in the 295 | Library will not necessarily be able to recompile the application 296 | to use the modified definitions.) 297 | 298 | b) Use a suitable shared library mechanism for linking with the 299 | Library. A suitable mechanism is one that (1) uses at run time a 300 | copy of the library already present on the user's computer system, 301 | rather than copying library functions into the executable, and (2) 302 | will operate properly with a modified version of the library, if 303 | the user installs one, as long as the modified version is 304 | interface-compatible with the version that the work was made with. 305 | 306 | c) Accompany the work with a written offer, valid for at 307 | least three years, to give the same user the materials 308 | specified in Subsection 6a, above, for a charge no more 309 | than the cost of performing this distribution. 310 | 311 | d) If distribution of the work is made by offering access to copy 312 | from a designated place, offer equivalent access to copy the above 313 | specified materials from the same place. 314 | 315 | e) Verify that the user has already received a copy of these 316 | materials or that you have already sent this user a copy. 317 | 318 | For an executable, the required form of the "work that uses the 319 | Library" must include any data and utility programs needed for 320 | reproducing the executable from it. However, as a special exception, 321 | the materials to be distributed need not include anything that is 322 | normally distributed (in either source or binary form) with the major 323 | components (compiler, kernel, and so on) of the operating system on 324 | which the executable runs, unless that component itself accompanies 325 | the executable. 326 | 327 | It may happen that this requirement contradicts the license 328 | restrictions of other proprietary libraries that do not normally 329 | accompany the operating system. Such a contradiction means you cannot 330 | use both them and the Library together in an executable that you 331 | distribute. 332 | 333 | 7. You may place library facilities that are a work based on the 334 | Library side-by-side in a single library together with other library 335 | facilities not covered by this License, and distribute such a combined 336 | library, provided that the separate distribution of the work based on 337 | the Library and of the other library facilities is otherwise 338 | permitted, and provided that you do these two things: 339 | 340 | a) Accompany the combined library with a copy of the same work 341 | based on the Library, uncombined with any other library 342 | facilities. This must be distributed under the terms of the 343 | Sections above. 344 | 345 | b) Give prominent notice with the combined library of the fact 346 | that part of it is a work based on the Library, and explaining 347 | where to find the accompanying uncombined form of the same work. 348 | 349 | 8. You may not copy, modify, sublicense, link with, or distribute 350 | the Library except as expressly provided under this License. Any 351 | attempt otherwise to copy, modify, sublicense, link with, or 352 | distribute the Library is void, and will automatically terminate your 353 | rights under this License. However, parties who have received copies, 354 | or rights, from you under this License will not have their licenses 355 | terminated so long as such parties remain in full compliance. 356 | 357 | 9. You are not required to accept this License, since you have not 358 | signed it. However, nothing else grants you permission to modify or 359 | distribute the Library or its derivative works. These actions are 360 | prohibited by law if you do not accept this License. Therefore, by 361 | modifying or distributing the Library (or any work based on the 362 | Library), you indicate your acceptance of this License to do so, and 363 | all its terms and conditions for copying, distributing or modifying 364 | the Library or works based on it. 365 | 366 | 10. Each time you redistribute the Library (or any work based on the 367 | Library), the recipient automatically receives a license from the 368 | original licensor to copy, distribute, link with or modify the Library 369 | subject to these terms and conditions. You may not impose any further 370 | restrictions on the recipients' exercise of the rights granted herein. 371 | You are not responsible for enforcing compliance by third parties with 372 | this License. 373 | 374 | 11. If, as a consequence of a court judgment or allegation of patent 375 | infringement or for any other reason (not limited to patent issues), 376 | conditions are imposed on you (whether by court order, agreement or 377 | otherwise) that contradict the conditions of this License, they do not 378 | excuse you from the conditions of this License. If you cannot 379 | distribute so as to satisfy simultaneously your obligations under this 380 | License and any other pertinent obligations, then as a consequence you 381 | may not distribute the Library at all. For example, if a patent 382 | license would not permit royalty-free redistribution of the Library by 383 | all those who receive copies directly or indirectly through you, then 384 | the only way you could satisfy both it and this License would be to 385 | refrain entirely from distribution of the Library. 386 | 387 | If any portion of this section is held invalid or unenforceable under any 388 | particular circumstance, the balance of the section is intended to apply, 389 | and the section as a whole is intended to apply in other circumstances. 390 | 391 | It is not the purpose of this section to induce you to infringe any 392 | patents or other property right claims or to contest validity of any 393 | such claims; this section has the sole purpose of protecting the 394 | integrity of the free software distribution system which is 395 | implemented by public license practices. Many people have made 396 | generous contributions to the wide range of software distributed 397 | through that system in reliance on consistent application of that 398 | system; it is up to the author/donor to decide if he or she is willing 399 | to distribute software through any other system and a licensee cannot 400 | impose that choice. 401 | 402 | This section is intended to make thoroughly clear what is believed to 403 | be a consequence of the rest of this License. 404 | 405 | 12. If the distribution and/or use of the Library is restricted in 406 | certain countries either by patents or by copyrighted interfaces, the 407 | original copyright holder who places the Library under this License may add 408 | an explicit geographical distribution limitation excluding those countries, 409 | so that distribution is permitted only in or among countries not thus 410 | excluded. In such case, this License incorporates the limitation as if 411 | written in the body of this License. 412 | 413 | 13. The Free Software Foundation may publish revised and/or new 414 | versions of the Lesser General Public License from time to time. 415 | Such new versions will be similar in spirit to the present version, 416 | but may differ in detail to address new problems or concerns. 417 | 418 | Each version is given a distinguishing version number. If the Library 419 | specifies a version number of this License which applies to it and 420 | "any later version", you have the option of following the terms and 421 | conditions either of that version or of any later version published by 422 | the Free Software Foundation. If the Library does not specify a 423 | license version number, you may choose any version ever published by 424 | the Free Software Foundation. 425 | 426 | 14. If you wish to incorporate parts of the Library into other free 427 | programs whose distribution conditions are incompatible with these, 428 | write to the author to ask for permission. For software which is 429 | copyrighted by the Free Software Foundation, write to the Free 430 | Software Foundation; we sometimes make exceptions for this. Our 431 | decision will be guided by the two goals of preserving the free status 432 | of all derivatives of our free software and of promoting the sharing 433 | and reuse of software generally. 434 | 435 | NO WARRANTY 436 | 437 | 15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO 438 | WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. 439 | EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR 440 | OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY 441 | KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE 442 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 443 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE 444 | LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME 445 | THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 446 | 447 | 16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN 448 | WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY 449 | AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU 450 | FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR 451 | CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE 452 | LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING 453 | RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A 454 | FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF 455 | SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH 456 | DAMAGES. 457 | 458 | END OF TERMS AND CONDITIONS 459 | 460 | How to Apply These Terms to Your New Libraries 461 | 462 | If you develop a new library, and you want it to be of the greatest 463 | possible use to the public, we recommend making it free software that 464 | everyone can redistribute and change. You can do so by permitting 465 | redistribution under these terms (or, alternatively, under the terms of the 466 | ordinary General Public License). 467 | 468 | To apply these terms, attach the following notices to the library. It is 469 | safest to attach them to the start of each source file to most effectively 470 | convey the exclusion of warranty; and each file should have at least the 471 | "copyright" line and a pointer to where the full notice is found. 472 | 473 | 474 | Copyright (C) 475 | 476 | This library is free software; you can redistribute it and/or 477 | modify it under the terms of the GNU Lesser General Public 478 | License as published by the Free Software Foundation; either 479 | version 2.1 of the License, or (at your option) any later version. 480 | 481 | This library is distributed in the hope that it will be useful, 482 | but WITHOUT ANY WARRANTY; without even the implied warranty of 483 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 484 | Lesser General Public License for more details. 485 | 486 | You should have received a copy of the GNU Lesser General Public 487 | License along with this library; if not, write to the Free Software 488 | Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 489 | 490 | Also add information on how to contact you by electronic and paper mail. 491 | 492 | You should also get your employer (if you work as a programmer) or your 493 | school, if any, to sign a "copyright disclaimer" for the library, if 494 | necessary. Here is a sample; alter the names: 495 | 496 | Yoyodyne, Inc., hereby disclaims all copyright interest in the 497 | library `Frob' (a library for tweaking knobs) written by James Random Hacker. 498 | 499 | , 1 April 1990 500 | Ty Coon, President of Vice 501 | 502 | That's all there is to it! 503 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # nixspace 2 | 3 | `nixspace` is a Nix Flake-based workspace manager for manyrepo 4 | projects. Similar to `npm` workspaces, `nixspace` enables developers 5 | to: 6 | 7 | * Seamlessly work on multiple projects; 8 | * Test projects in integration with ease; and 9 | * Deploy the application in a unified fashion, ensuring that you have 10 | locked the entire state of your ecosystem down to the commit of 11 | every project used. 12 | 13 | Unlike `npm` workspaces, `nixspace`s are Flake-based and are thus more 14 | flexible: 15 | 16 | * Supports both monorepo and manyrepo development styles; 17 | * Is unopinionated about programming language. It can be used to host 18 | projects under any number of languages; and 19 | * Can be used to perform joint deployments of applications, ensuring 20 | that the workspace corresponds exactly to the external state. 21 | 22 | ## Features 23 | 24 | * *Lockfiles*: Track the exact commit of every project in the 25 | workspace with a lockfile, formatted identically to the `flake.lock`. 26 | * *Environments*: Workspaces can be used to lock the state of multiple 27 | deployment environments for environments that track rolling upgrades. 28 | * *Customized upgrades*: Specify on a project level how upgrades are 29 | consumed (follow branches, latest tags, semver, etc). 30 | * *Clean*: Unlike `git` submodules, `nixspace` doesn't require cloning 31 | down every project in a workspace. Developers can `add` the projects 32 | they'd like to work on and test them in integration in the workspace 33 | without pulling down all packages. `nixspace` can scale to track the 34 | state of thousands of applications at once. 35 | 36 | ## How it works 37 | 38 | `nixspace` is made of two components: 39 | 40 | 1. A Nix library for managing multi-Flake projects. This library helps 41 | manage automatically detecting and substituting `passthru`'s in 42 | inputs and makes it easy to test changes on the consumers of a 43 | package. (i.e., does application Y's unit tests still pass when I 44 | update library X?) 45 | 2. A CLI utility for managing the nixspace. This includes convenience 46 | utilities for maintaining and updating workspace lockfiles, as well as 47 | making it simple to edit any projects in the `nixspace`. 48 | 49 | A standard workspace looks like: 50 | 51 | . 52 | ├── flake.nix The nixspace flake.nix 53 | ├── flake.lock Flake lockfile 54 | ├── nixspace.toml Nixspace configuration 55 | ├── .nixspace 56 | | ├── prod.lock Nixspace package lockfile (formatted like a standard Flake lockfile) 57 | | ├── dev.lock Nixspaces can manage multiple environments 58 | | ├── nixspace.local Used for tracking differences between local and remote workspaces. 59 | ├── project-a One of many Nix projects 60 | | ├── flake.nix 61 | ├── project-b 62 | | ├── flake.nix 63 | ├── subfolder 64 | | ├── project-c 65 | | | ├── flake.nix 66 | 67 | ## Getting Started 68 | 69 | To start using the CLI, run: 70 | 71 | nix shell github:chadac/nixspace 72 | 73 | Create a new workspace with: 74 | 75 | ns init --name 76 | cd 77 | 78 | You may also use `ns init --type flake-parts --name 79 | ` to initialize a workspace with a `flake.nix` 80 | that [flake-parts](https://flake.parts/) compatible. 81 | 82 | ### Registering and editing projects 83 | 84 | To add a new project to your workspace, run 85 | 86 | ns register github:my/project --name my-project --path ./my-project 87 | 88 | This will register your project as part of the workspace -- now, any 89 | other projects that have an input named `my-project` in their 90 | `flake.nix` will use the workspace copy instead. Ensure that the name 91 | passed to `--name` is unique and distinguishible, as it is used to 92 | determine what input to replace in every project's `flake.nix`. 93 | 94 | By default, projects added to a workspace are not *editable*. This 95 | means that they are initially not cloned into your workspace and are 96 | not locally editable. 97 | 98 | To edit any project in the workspace, run 99 | 100 | ns edit my-project 101 | 102 | This will clone the project into the path specified in the `register` 103 | command, and will link the project to the workspace so that it is 104 | fully editable. 105 | 106 | ### Testing changes 107 | 108 | Suppose `my-project` is dependent on `shared-project`, and both are 109 | registered to the workspace and marked as editable. To test a local 110 | change in `shared-project` on `my-project`, you only need to navigate 111 | into `my-project` and run: 112 | 113 | ns build .#my-package-or-app 114 | 115 | `ns` is a small alias for `nix` that replaces the project's 116 | flake-specific lock information with the workspace lock. Since `ns` 117 | runs in impure mode, editable projects are linked in their present, 118 | local state. Therefore, no other steps are needed -- you can 119 | immediately see the effects of one flake on another without any need 120 | for running `nix flake update` or pushing commits to a repository. 121 | 122 | ## TODO 123 | 124 | * *Test changes on all consumers*: It'd be nice to have something like 125 | `ns build .# --all-consumers` that would run a build instead on 126 | every package that depends on a flake. Sort of like a reverse 127 | closure. Gotta write some Nix hacks to do this. 128 | * *Composable dev environments*: `nixspace`s allow developers to 129 | seamlessly compose the development environments of multiple projects 130 | together. 131 | * *Combined merge requests*: It'd be nice if we could automate 132 | generating merge requests across multiple repositories and linking 133 | them into a single deployment. 134 | -------------------------------------------------------------------------------- /default.nix: -------------------------------------------------------------------------------- 1 | { lib, stdenv, rustPlatform }: 2 | let 3 | fs = lib.fileset; 4 | in 5 | rustPlatform.buildRustPackage { 6 | pname = "nixspace"; 7 | version = "1.0.0"; 8 | 9 | src = fs.toSource { 10 | root = ./.; 11 | fileset = fs.unions [ 12 | ./Cargo.toml 13 | ./Cargo.lock 14 | ./src 15 | ]; 16 | }; 17 | 18 | cargoLock = { 19 | lockFile = ./Cargo.lock; 20 | }; 21 | } 22 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "nixpkgs": { 4 | "locked": { 5 | "lastModified": 1703499205, 6 | "narHash": "sha256-lF9rK5mSUfIZJgZxC3ge40tp1gmyyOXZ+lRY3P8bfbg=", 7 | "owner": "NixOS", 8 | "repo": "nixpkgs", 9 | "rev": "e1fa12d4f6c6fe19ccb59cac54b5b3f25e160870", 10 | "type": "github" 11 | }, 12 | "original": { 13 | "owner": "NixOS", 14 | "ref": "nixpkgs-unstable", 15 | "repo": "nixpkgs", 16 | "type": "github" 17 | } 18 | }, 19 | "nixpkgs-lib": { 20 | "locked": { 21 | "dir": "lib", 22 | "lastModified": 1703499205, 23 | "narHash": "sha256-lF9rK5mSUfIZJgZxC3ge40tp1gmyyOXZ+lRY3P8bfbg=", 24 | "owner": "NixOS", 25 | "repo": "nixpkgs", 26 | "rev": "e1fa12d4f6c6fe19ccb59cac54b5b3f25e160870", 27 | "type": "github" 28 | }, 29 | "original": { 30 | "dir": "lib", 31 | "owner": "NixOS", 32 | "ref": "nixpkgs-unstable", 33 | "repo": "nixpkgs", 34 | "type": "github" 35 | } 36 | }, 37 | "root": { 38 | "inputs": { 39 | "nixpkgs": "nixpkgs", 40 | "nixpkgs-lib": "nixpkgs-lib", 41 | "systems": "systems" 42 | } 43 | }, 44 | "systems": { 45 | "locked": { 46 | "lastModified": 1681028828, 47 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 48 | "owner": "nix-systems", 49 | "repo": "default", 50 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 51 | "type": "github" 52 | }, 53 | "original": { 54 | "owner": "nix-systems", 55 | "repo": "default", 56 | "type": "github" 57 | } 58 | } 59 | }, 60 | "root": "root", 61 | "version": 7 62 | } 63 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "Create workspaces to manage multiple packages with Nix."; 3 | 4 | inputs = { 5 | nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; 6 | nixpkgs-lib.url = "github:NixOS/nixpkgs/nixpkgs-unstable?dir=lib"; 7 | systems.url = "github:nix-systems/default"; 8 | }; 9 | 10 | outputs = inputs@{ self, nixpkgs, nixpkgs-lib, systems, ... }: 11 | let 12 | lib = import nixpkgs-lib; 13 | defaultSystems = import systems; 14 | eachSystem = lib.genAttrs defaultSystems; 15 | in { 16 | lib = import ./lib { 17 | inherit self; 18 | inherit lib; 19 | inherit defaultSystems; 20 | revInfo = 21 | if lib?rev 22 | then " (nixpkgs-lib.rev: ${lib.rev})" 23 | else ""; 24 | }; 25 | templates = let 26 | tmpls = { 27 | basic = { 28 | path = ./templates/basic; 29 | description = "Barebones template with minimal dependencies."; 30 | }; 31 | flake-parts = { 32 | path = ./templates/flake-parts; 33 | description = "Template for workspace flakes using flake-parts."; 34 | }; 35 | }; 36 | in tmpls // { default = tmpls.basic; }; 37 | } // { 38 | packages = eachSystem (system: let 39 | pkgs = import nixpkgs { inherit system; }; 40 | nixspace = pkgs.callPackage ./. { }; 41 | in { 42 | inherit nixspace; 43 | default = nixspace; 44 | }); 45 | devShells = eachSystem (system: let 46 | pkgs = import nixpkgs { inherit system; }; 47 | in { 48 | default = pkgs.mkShell { 49 | packages = with pkgs; [ 50 | cargo 51 | rustc 52 | cargo-watch 53 | cargo-insta 54 | clippy 55 | ]; 56 | }; 57 | }); 58 | }; 59 | } 60 | -------------------------------------------------------------------------------- /lib/call-flake.nix: -------------------------------------------------------------------------------- 1 | # from: https://github.com/NixOS/nix/blob/c0c7c4b6cd1aefaa65fc11fcdc8df7e608960825/src/libexpr/flake/call-flake.nix 2 | # 3 | # MODIFICATIONS 4 | # 5 | # 2023-11-08(chad@cacrawford.org): modified to include overrides from a workspace 6 | # 7 | 8 | overrides: lockFileStr: rootSrc: rootSubdir: 9 | 10 | let 11 | lockFile = builtins.fromJSON lockFileStr; 12 | 13 | allNodes = 14 | builtins.mapAttrs 15 | (key: node: 16 | let 17 | sourceInfo = 18 | if key == lockFile.root 19 | then rootSrc 20 | else if (builtins.hasAttr key overrides) 21 | then overrides.${key} 22 | else fetchTree (node.info or {} // removeAttrs node.locked ["dir"]); 23 | 24 | subdir = if key == lockFile.root then rootSubdir else node.locked.dir or ""; 25 | 26 | outPath = sourceInfo + ((if subdir == "" then "" else "/") + subdir); 27 | 28 | flake = import (outPath + "/flake.nix"); 29 | 30 | inputs = builtins.mapAttrs 31 | (inputName: inputSpec: allNodes.${resolveInput inputSpec}) 32 | (node.inputs or {}); 33 | 34 | # Resolve a input spec into a node name. An input spec is 35 | # either a node name, or a 'follows' path from the root 36 | # node. 37 | resolveInput = inputSpec: 38 | if builtins.isList inputSpec 39 | then getInputByPath lockFile.root inputSpec 40 | else inputSpec; 41 | 42 | # Follow an input path (e.g. ["dwarffs" "nixpkgs"]) from the 43 | # root node, returning the final node. 44 | getInputByPath = nodeName: path: 45 | if path == [] 46 | then nodeName 47 | else 48 | getInputByPath 49 | # Since this could be a 'follows' input, call resolveInput. 50 | (resolveInput lockFile.nodes.${nodeName}.inputs.${builtins.head path}) 51 | (builtins.tail path); 52 | 53 | outputs = 54 | (flake.outputs (inputs // { self = result; })); 55 | 56 | result = 57 | outputs 58 | # We add the sourceInfo attribute for its metadata, as they are 59 | # relevant metadata for the flake. However, the outPath of the 60 | # sourceInfo does not necessarily match the outPath of the flake, 61 | # as the flake may be in a subdirectory of a source. 62 | # This is shadowed in the next // 63 | // sourceInfo 64 | // { 65 | # This shadows the sourceInfo.outPath 66 | inherit outPath; 67 | 68 | inherit inputs; inherit outputs; inherit sourceInfo; _type = "flake"; 69 | }; 70 | 71 | in 72 | if node.flake or true then 73 | assert builtins.isFunction flake.outputs; 74 | result 75 | else 76 | sourceInfo 77 | ) 78 | lockFile.nodes; 79 | 80 | in allNodes.${lockFile.root} 81 | -------------------------------------------------------------------------------- /lib/default.nix: -------------------------------------------------------------------------------- 1 | { 2 | self, 3 | lib, 4 | defaultSystems, 5 | revInfo, 6 | }: 7 | rec { 8 | callFlake = import ./call-flake.nix; 9 | mkWorkspaceEnv = import ./mk-workspace-env.nix { 10 | inherit lib callFlake; 11 | }; 12 | mkWorkspace = import ./mk-workspace.nix { 13 | inherit self lib callFlake mkWorkspaceEnv; 14 | }; 15 | inherit revInfo; 16 | } 17 | -------------------------------------------------------------------------------- /lib/mk-workspace-env.nix: -------------------------------------------------------------------------------- 1 | { lib, callFlake }: 2 | { 3 | inputs, 4 | cfg, 5 | projectCfg, 6 | lockFile, 7 | local ? null, 8 | impureRoot ? null, 9 | }: let 10 | # wrapper for fetchTree so that I have all the necessary info in one place 11 | fetchFlake = flakeRef: let 12 | tree = builtins.fetchTree (builtins.removeAttrs flakeRef ["dir"]); 13 | in 14 | if flakeRef ? "dir" then tree // { rootDirectory = flakeRef.dir; } 15 | else tree // { rootDirectory = ""; } ; 16 | 17 | # TODO: Editable Projects MUST have a flake.lock 18 | lock = builtins.fromJSON (builtins.readFile lockFile); 19 | projectNames = lib.attrNames projectCfg; 20 | lockNodes = lib.filterAttrs (name: node: name != "root") lock.nodes; 21 | 22 | projects = builtins.mapAttrs (name: inputSpec: 23 | if (local != null && (builtins.hasAttr name local.projects) && local.projects.${name}.editable) 24 | then fetchFlake { 25 | type = "path"; 26 | path = impureRoot + "/" + projectCfg.${name}.path; 27 | } 28 | else fetchFlake inputSpec.locked 29 | ) lockNodes; 30 | 31 | wsNodes = 32 | inputs 33 | // ( 34 | builtins.mapAttrs (name: tree: let 35 | rootSrc = tree.outPath; 36 | projLock = rootSrc + "/flake.lock"; 37 | lockFileStr = 38 | if (builtins.pathExists projLock) 39 | then builtins.readFile (rootSrc + "/flake.lock") 40 | else ''{"nodes": {"root": {}}, "root": "root", "version": 7}'' 41 | ; 42 | in 43 | callFlake wsNodes lockFileStr tree tree.rootDirectory 44 | ) projects 45 | ) 46 | ; 47 | in lib.filterAttrs (name: node: builtins.elem name projectNames) wsNodes 48 | -------------------------------------------------------------------------------- /lib/mk-workspace.nix: -------------------------------------------------------------------------------- 1 | { self, lib, callFlake, mkWorkspaceEnv }: 2 | { 3 | src, 4 | inputs, 5 | systems, 6 | cfgFile ? src + "/nixspace.toml", 7 | localFile ? src + "/.nixspace/local.json", 8 | flattenFlakes ? null, 9 | }: let 10 | cfg = builtins.fromTOML (builtins.readFile cfgFile); 11 | 12 | envNames = map (env: env.name) cfg.environments; 13 | 14 | # get the impure workspace root from the environment 15 | # used for loading editable packages 16 | findRoot = depth: path: 17 | if (depth > 100) then abort "could not find workspace root; directory depth 100 exceeded" 18 | else if (builtins.pathExists "${path}/nixspace.toml") then path 19 | else findRoot (depth + 1) "${path}/.."; 20 | impureRoot = findRoot 1 (builtins.getEnv "PWD"); 21 | local = if lib.inPureEvalMode then null 22 | else builtins.fromJSON (builtins.readFile "${impureRoot}/.nixspace/local.json"); 23 | 24 | projectCfg = 25 | if (cfg ? "projects") then 26 | builtins.listToAttrs (builtins.map 27 | (project: { name = project.name; value = project; }) 28 | cfg.projects) 29 | else {} 30 | ; 31 | 32 | envs = builtins.listToAttrs (map (env: { 33 | name = env; 34 | value = mkWorkspaceEnv { 35 | inherit inputs cfg projectCfg local impureRoot; 36 | lockFile = src + "/.nixspace/${env}.lock"; 37 | }; 38 | }) envNames); 39 | 40 | empty = builtins.length (builtins.attrNames projectCfg) == 0; 41 | flatten = 42 | if(flattenFlakes != null) then flattenFlakes 43 | else if(builtins.hasAttr "flatten-flakes" cfg) then cfg.flatten-flakes 44 | else true; 45 | 46 | canFlatten = projectName: project: 47 | !builtins.hasAttr "flatten" projectCfg.${projectName} || projectCfg.${projectName}.flatten; 48 | 49 | flattenProject = flakeSection: projectName: project: 50 | if (project ? flakeSection) then 51 | lib.concatMapAttrs (name: value: { 52 | "${projectName}/${name}" = value; 53 | }) project.${flakeSection} 54 | else {} 55 | ; 56 | 57 | flattenSystemProject = flakeSection: system: projectName: project: 58 | if (builtins.hasAttr flakeSection project) then 59 | lib.concatMapAttrs 60 | (name: value: { "${projectName}/${name}" = value; }) 61 | project.${flakeSection}.${system} 62 | else {} 63 | ; 64 | 65 | listToAttrs = list: builtins.listToAttrs (builtins.map (name: { inherit name; value = {}; }) list); 66 | flakeSystem = listToAttrs [ "packages" "apps" "devShells" "legacyPackages" "checks" ]; 67 | flakeGeneral = listToAttrs [ "overlays" "nixosModules" ]; 68 | 69 | flattenModule = projectName: project: { lib, env, ... }: { 70 | flake = lib.mkIf flatten ( 71 | lib.mapAttrs 72 | (flakeSection: _: flattenProject flakeSection projectName project) 73 | flakeGeneral 74 | ); 75 | 76 | perSystem = lib.mkIf flatten ({ system, ... }: 77 | lib.mapAttrs 78 | (flakeSection: _: flattenSystemProject flakeSection system projectName project) 79 | flakeSystem 80 | ); 81 | }; 82 | 83 | ws = builtins.mapAttrs (name: projects: let 84 | mkNsDevShell = pkgs: pkgs.mkShell { 85 | packages = [ self.packages.${pkgs.system}.nixspace ]; 86 | }; 87 | flattenProjects = lib.filterAttrs canFlatten projects; 88 | in projects // { 89 | inherit name; 90 | inherit projects; 91 | 92 | flake = let 93 | forAllSystems = lib.genAttrs systems; 94 | forAllProjects = flakeSection: _: 95 | lib.concatMapAttrs (flattenProject flakeSection) flattenProjects; 96 | forAllProjectsSystems = flakeSection: _: 97 | forAllSystems (system: 98 | lib.concatMapAttrs (flattenSystemProject flakeSection system) flattenProjects 99 | ); 100 | f = 101 | if !empty && flatten then 102 | (lib.mapAttrs forAllProjectsSystems flakeSystem) // 103 | (lib.mapAttrs forAllProjects flakeGeneral) 104 | else { devShells = forAllSystems (system: { }); }; 105 | devShells = forAllSystems (system: 106 | let pkgs = import inputs.nixpkgs { inherit system; }; 107 | in { default = pkgs.mkShell { packages = [ self.packages.${system}.nixspace ]; }; } 108 | ); 109 | in f // { 110 | devShells = lib.mapAttrs (system: shells: 111 | shells // devShells.${system} 112 | ) f.devShells; 113 | }; 114 | 115 | # for use in flake-parts 116 | flakeModule = { ... }: { 117 | _module.args.env = name; 118 | 119 | inherit systems; 120 | 121 | imports = lib.attrValues (lib.mapAttrs flattenModule flattenProjects); 122 | 123 | perSystem = { pkgs, system, ... }: { 124 | devShells.default = pkgs.mkShell { 125 | packages = [ self.packages.${system}.nixspace ]; 126 | }; 127 | }; 128 | }; 129 | }) envs; 130 | in ws // { default = ws.${cfg.default_env}; } 131 | -------------------------------------------------------------------------------- /src/cli.rs: -------------------------------------------------------------------------------- 1 | use std::path::{Path, PathBuf}; 2 | use std::process::{Command, Stdio, Output, ExitStatus}; 3 | use serde::{Serialize, Deserialize}; 4 | use anyhow::{anyhow, bail, Context, Result}; 5 | use colored::Colorize; 6 | 7 | use super::lockfile::{LockFile, InputSpec}; 8 | 9 | #[derive(Debug, Clone)] 10 | pub struct CliError { 11 | cmd: String, 12 | args: Vec, 13 | status: ExitStatus, 14 | stdout: String, 15 | stderr: String, 16 | } 17 | 18 | impl std::fmt::Display for CliError { 19 | fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { 20 | write!(f, "cli error:\n stderr: {0}\n stdout: {1}", self.stderr, self.stdout) 21 | } 22 | } 23 | 24 | pub struct CliOutput { 25 | stdout: String, 26 | stderr: String, 27 | } 28 | 29 | pub trait CliCommand { 30 | fn cmd() -> &'static str; 31 | 32 | fn interactive + ?Sized>( 33 | args: &[&str], 34 | cwd: &P 35 | ) -> Result<()> { 36 | let cmd = Self::cmd(); 37 | let cwd_repr = cwd.as_ref().to_string_lossy(); 38 | let args_repr = args.join(" "); 39 | log::info!( 40 | "{} {} {} {args_repr}", 41 | format!("{cwd_repr}/").yellow(), 42 | "$".bold(), 43 | cmd.green(), 44 | ); 45 | let term = match std::env::var("TERM") { 46 | Ok(term) => term, 47 | _ => "dumb".to_string(), 48 | }; 49 | let command = format!( 50 | "{} {}", 51 | Self::cmd(), 52 | args.join(" ") 53 | ); 54 | let output = fake_tty::bash_command(&command)? 55 | .current_dir(cwd) 56 | .stdout(Stdio::piped()) 57 | // TODO: fix stdin just in case 58 | // .stdin(Stdio::piped()) 59 | .stderr(Stdio::piped()) 60 | .spawn()?; 61 | 62 | // let mut stdin = output.stdin.ok_or(anyhow!("could not fetch stdin"))?; 63 | // let stdin_thread = std::thread::spawn(move || { 64 | // std::io::copy(&mut std::io::stdin(), &mut stdin) 65 | // }); 66 | 67 | let mut stdout = output.stdout.ok_or(anyhow!("could not fetch stdout"))?; 68 | let stdout_thread = std::thread::spawn(move || { 69 | std::io::copy(&mut stdout, &mut std::io::stdout()) 70 | }); 71 | 72 | let mut stderr = output.stderr.ok_or(anyhow!("could not fetch stderr"))?; 73 | let stderr_thread = std::thread::spawn(move || { 74 | std::io::copy(&mut stderr, &mut std::io::stderr()) 75 | }); 76 | 77 | // TODO: do something better than unwrap... 78 | // stdin_thread.join().unwrap()?; 79 | stdout_thread.join().unwrap()?; 80 | stderr_thread.join().unwrap()?; 81 | 82 | Ok(()) 83 | } 84 | 85 | fn run + ?Sized>( 86 | args: &[&str], 87 | cwd: &P 88 | ) -> Result { 89 | let output = Command::new(Self::cmd()) 90 | .args(args) 91 | .output()?; 92 | Ok(output.status) 93 | } 94 | 95 | fn exec + ?Sized>( 96 | args: &[&str], 97 | cwd: &P 98 | ) -> Result { 99 | let cmd = Self::cmd(); 100 | let cwd_repr = cwd.as_ref().to_string_lossy(); 101 | let args_repr = args.join(" "); 102 | log::info!( 103 | "{} {} {} {args_repr}", 104 | format!("{cwd_repr}/").yellow(), 105 | "$".bold(), 106 | cmd.green(), 107 | ); 108 | let output = Command::new(Self::cmd()) 109 | .current_dir(cwd) 110 | .args(args) 111 | .stdout(std::process::Stdio::piped()) 112 | .stderr(std::process::Stdio::piped()) 113 | .output()?; 114 | let status = output.status; 115 | if status.success() { 116 | Ok(CliOutput { 117 | stdout: std::str::from_utf8(&output.stdout)?.to_string(), 118 | stderr: std::str::from_utf8(&output.stderr)?.to_string(), 119 | }) 120 | } else { 121 | bail!(CliError { 122 | cmd: Self::cmd().to_string(), 123 | args: args.iter().map(|a| a.to_string()).collect(), 124 | status: status, 125 | stdout: std::str::from_utf8(&output.stdout)?.to_string(), 126 | stderr: std::str::from_utf8(&output.stderr)?.to_string(), 127 | }) 128 | } 129 | } 130 | } 131 | 132 | #[derive(Deserialize, Debug)] 133 | pub struct FlakePrefetch { 134 | pub hash: String, 135 | #[serde(rename = "storePath")] 136 | pub store_path: String, 137 | } 138 | 139 | #[derive(Serialize, Deserialize, Debug)] 140 | pub struct FlakeMetadata { 141 | pub description: Option, 142 | #[serde(rename = "lastModified")] 143 | pub last_modified: i64, 144 | pub locked: InputSpec, 145 | pub locks: LockFile, 146 | pub original: InputSpec, 147 | #[serde(rename = "originalUrl")] 148 | pub original_url: String, 149 | pub path: String, 150 | pub resolved: InputSpec, 151 | #[serde(rename = "resolvedUrl")] 152 | pub resolved_url: String, 153 | pub revision: String, 154 | pub url: String, 155 | } 156 | 157 | /// Minimal wrapper around the Nix CLI 158 | pub struct Nix {} 159 | 160 | /// Minimal wrapper around the Git CLI 161 | pub struct Git {} 162 | 163 | impl CliCommand for Nix { 164 | fn cmd() -> &'static str { "nix" } 165 | } 166 | 167 | impl Nix { 168 | pub fn clone + ?Sized, P2: AsRef + ?Sized>(flake_ref: &str, dest: &P1, cwd: &P2) -> Result { 169 | Self::exec( 170 | &[ 171 | "flake", "clone", flake_ref, 172 | "--dest", &dest.as_ref().as_os_str().to_str().unwrap() 173 | ], 174 | cwd 175 | ) 176 | } 177 | 178 | /// Fetches the hash of a flake reference using `nix flake prefetch` 179 | pub fn flake_prefetch(flake_ref: &str) -> Result { 180 | let result = Self::exec( 181 | &["flake", "prefetch", flake_ref, "--json"], 182 | &std::env::current_dir()? 183 | )?; 184 | let out: FlakePrefetch = serde_json::from_str(&result.stdout)?; 185 | Ok(out) 186 | } 187 | 188 | pub fn flake_metadata(flake_url: &str) -> Result { 189 | let result = Self::exec( 190 | &["flake", "metadata", flake_url, "--json"], 191 | &std::env::current_dir()? 192 | )?; 193 | let out: FlakeMetadata = serde_json::from_str(&result.stdout)?; 194 | Ok(out) 195 | } 196 | } 197 | 198 | #[derive(Serialize, Debug)] 199 | pub struct GitRef { 200 | pub rev: String, 201 | pub git_ref: String, 202 | } 203 | 204 | impl CliCommand for Git { 205 | fn cmd() -> &'static str { "git" } 206 | } 207 | 208 | fn get_git_context + ?Sized>(path: &P) -> Result<(PathBuf, String)> { 209 | let path_abs = std::fs::canonicalize(&path)?; 210 | let git_root = crate::util::find_root(".git", &path_abs) 211 | .with_context(|| anyhow!("could not find .git folder in any parent directory"))?; 212 | 213 | let path_rel = path_abs.strip_prefix(git_root.clone())?.to_str() 214 | .context("path is not valid unicode and I'm lazy")?; 215 | 216 | Ok((git_root, path_rel.to_string())) 217 | } 218 | 219 | impl Git { 220 | pub fn init + ?Sized>(cwd: &P) -> Result { 221 | Self::exec(&["init"], cwd) 222 | } 223 | 224 | pub fn fetch + ?Sized>(cwd: &P) -> Result { 225 | Self::exec(&["fetch"], cwd) 226 | } 227 | 228 | pub fn push + ?Sized>(cwd: &P) -> Result { 229 | Self::exec(&["push", "origin"], cwd) 230 | } 231 | 232 | pub fn pull_rebase + ?Sized>(cwd: &P) -> Result { 233 | Self::exec(&["pull", "--rebase"], cwd) 234 | } 235 | 236 | /// Returns true if the file at the given path has been changed. 237 | pub fn changed + ?Sized>(file_path: &P) -> Result { 238 | let (cwd, filename) = get_git_context(file_path)?; 239 | let s1 = Self::run(&["diff", "--exit-code", &filename], &cwd)?; 240 | if s1.success() { 241 | let s2 = Self::run(&["diff", "--staged", "--exit-code", &filename], &cwd)?; 242 | Ok(!s2.success()) 243 | } else { 244 | Ok(true) 245 | } 246 | } 247 | 248 | pub fn add + ?Sized>(file_path: &P) -> Result { 249 | let (cwd, filename) = get_git_context(file_path)?; 250 | Self::exec(&["add", "-f", &filename], &cwd) 251 | } 252 | 253 | pub fn rm + ?Sized>(file_path: &P) -> Result { 254 | let (cwd, filename) = get_git_context(file_path)?; 255 | Self::exec(&["rm", "-r", "--cached", &filename], &cwd) 256 | } 257 | 258 | pub fn commit + ?Sized>(message: &str, cwd: &P) -> Result { 259 | Self::exec(&["commit", "-m", message], cwd) 260 | } 261 | 262 | pub fn reset + ?Sized>(cwd: &P) -> Result { 263 | Self::exec(&["reset"], cwd) 264 | } 265 | 266 | pub fn ls_remote(remote_url: &str) -> Result> { 267 | let result = Self::exec( 268 | &["ls-remote", "--sort", "v:refname", remote_url], 269 | &std::env::current_dir()? 270 | )?; 271 | let raw = result.stdout.trim(); 272 | let mut refs: Vec = Vec::new(); 273 | for line in raw.split("\n") { 274 | let mut parts = line.split_whitespace(); 275 | let rev = parts.next().ok_or(anyhow!("git ls-remote: unexpected input"))?; 276 | let git_ref = parts.next().ok_or(anyhow!("git ls-remote: unexpected input"))?; 277 | refs.push(GitRef { 278 | git_ref: git_ref.to_string(), 279 | rev: rev.to_string(), 280 | }) 281 | } 282 | Ok(refs) 283 | } 284 | 285 | } 286 | 287 | #[cfg(test)] 288 | mod nix_tests { 289 | use super::*; 290 | use insta::assert_debug_snapshot; 291 | 292 | #[test] 293 | #[ignore] 294 | fn test_flake_prefetch() -> Result<()> { 295 | assert_debug_snapshot!( 296 | Nix::flake_prefetch("github:chadac/test-nixspace-nix-shared")? 297 | ); 298 | Ok(()) 299 | } 300 | 301 | #[test] 302 | #[ignore] 303 | fn test_flake_metadata() -> Result<()> { 304 | assert_debug_snapshot!( 305 | Nix::flake_metadata("github:chadac/test-nixspace-nix-shared")? 306 | ); 307 | Ok(()) 308 | } 309 | } 310 | 311 | #[cfg(test)] 312 | mod git_tests { 313 | use super::*; 314 | use insta::assert_debug_snapshot; 315 | 316 | #[test] 317 | #[ignore] 318 | fn test_ls_remote() -> Result<()> { 319 | assert_debug_snapshot!( 320 | Git::ls_remote("https://github.com/chadac/test-nixspace-nix-shared")? 321 | ); 322 | Ok(()) 323 | } 324 | } 325 | -------------------------------------------------------------------------------- /src/config.rs: -------------------------------------------------------------------------------- 1 | use std::collections::BTreeMap; 2 | use serde::{Serialize, Deserialize}; 3 | use anyhow::{anyhow, Context, Error, Result}; 4 | use glob_match::glob_match; 5 | use std::path::{Path, PathBuf}; 6 | use std::rc::Rc; 7 | 8 | use super::flake::FlakeRef; 9 | use super::lockfile::InputSpec; 10 | use super::cli::{CliCommand, Git, Nix}; 11 | 12 | #[derive(Serialize, Deserialize, Debug)] 13 | pub struct Config { 14 | pub environments: Vec, 15 | pub projects: Vec, 16 | 17 | pub default_env: String, 18 | } 19 | 20 | #[derive(Clone, Serialize, Deserialize, Debug)] 21 | pub enum UpdateStrategy { 22 | #[serde(rename = "latest")] 23 | Latest, 24 | #[serde(rename = "freeze")] 25 | Freeze, 26 | #[serde(rename = "latest-tag")] 27 | LatestTag(Option), 28 | #[serde(rename = "branch")] 29 | Branch(String), 30 | } 31 | 32 | #[derive(Serialize, Deserialize, Debug)] 33 | pub struct EnvConfig { 34 | pub name: String, 35 | pub strategy: UpdateStrategy, 36 | } 37 | 38 | #[derive(Serialize, Deserialize, Debug)] 39 | pub struct ProjectConfig { 40 | pub name: String, 41 | pub url: String, 42 | pub path: Option, 43 | pub strategy: Option>, 44 | } 45 | 46 | #[derive(Serialize, Deserialize, Debug)] 47 | pub struct LocalConfig { 48 | pub projects: BTreeMap 49 | } 50 | 51 | #[derive(Serialize, Deserialize, Debug)] 52 | pub struct LocalProjectConfig { 53 | pub editable: bool, 54 | } 55 | 56 | impl UpdateStrategy { 57 | pub fn update(&self, flake_ref: Rc) -> Result { 58 | let mut new_ref = flake_ref.clone(); 59 | if let Some(remote_url) = flake_ref.git_remote_url() { 60 | if let Some(rev) = self.get_git_rev(&remote_url)? { 61 | new_ref = flake_ref.with_rev(&rev); 62 | } 63 | } 64 | let metadata = Nix::flake_metadata( 65 | &new_ref.flake_url() 66 | )?; 67 | Ok(metadata) 68 | } 69 | 70 | fn get_git_rev(&self, remote_url: &str) -> Result> { 71 | match self { 72 | Self::Latest => { 73 | let revs = Git::ls_remote(remote_url)?; 74 | Ok(Some(revs.iter() 75 | .find(|r| r.git_ref == "HEAD") 76 | .ok_or(Error::msg("could not find HEAD in repository"))? 77 | .rev.clone())) 78 | }, 79 | Self::Freeze => { 80 | Ok(None) 81 | }, 82 | Self::LatestTag(pattern) => { 83 | let revs = Git::ls_remote(remote_url)?; 84 | let tag_pattern = match pattern { 85 | Some(p) => p, 86 | None => "*" 87 | }; 88 | let glob = format!("refs/tags/{}", &tag_pattern); 89 | Ok(revs.iter() 90 | .filter(|r| glob_match(&glob, &r.git_ref)) 91 | .map(|r| r.rev.clone()) 92 | .last()) 93 | }, 94 | Self::Branch(branch) => { 95 | let revs = Git::ls_remote(remote_url)?; 96 | let git_ref = format!("refs/branches/{}", branch); 97 | Ok(Some( 98 | revs.iter() 99 | .find(|r| r.git_ref == *branch) 100 | .ok_or(Error::msg("could not find specified branch in repository"))? 101 | .rev.clone() 102 | )) 103 | } 104 | } 105 | } 106 | } 107 | 108 | impl Config { 109 | pub fn new() -> Self { 110 | let mut default_envs = Vec::new(); 111 | default_envs.push(EnvConfig { 112 | name: "dev".to_string(), 113 | strategy: UpdateStrategy::Latest 114 | }); 115 | Config { 116 | environments: default_envs, 117 | projects: Vec::new(), 118 | default_env: "dev".to_string(), 119 | } 120 | } 121 | 122 | pub fn read(path: &Path) -> Result { 123 | let contents = std::fs::read_to_string(path)?; 124 | Ok(toml::from_str::(&contents)?) 125 | } 126 | 127 | pub fn write(&self, path: &Path) -> Result<()> { 128 | std::fs::write(path, toml::to_string(&self)?)?; 129 | Ok(()) 130 | } 131 | 132 | pub fn env(&self, name: &str) -> Result<&EnvConfig> { 133 | self.environments.iter().find(|env| env.name == name) 134 | .with_context(|| anyhow!("environment does not exist: '{}'", name)) 135 | } 136 | 137 | pub fn env_mut(&mut self, name: &str) -> Result<&mut EnvConfig> { 138 | self.environments.iter_mut().find(|env| env.name == name) 139 | .with_context(|| anyhow!("environment does not exist: '{}'", name)) 140 | } 141 | 142 | pub fn environments(&self) -> Vec { 143 | self.environments.iter().map(|env| env.name.to_string()).collect() 144 | } 145 | 146 | pub fn project(&self, name: &str) -> Result<&ProjectConfig> { 147 | self.projects.iter().find(|p| p.name == name) 148 | .with_context(|| anyhow!("could not find project '{}'", name)) 149 | } 150 | 151 | pub fn add_project>( 152 | &mut self, 153 | name: &str, 154 | flake_ref: &dyn FlakeRef, 155 | path: &Option

, 156 | ) -> Result<&ProjectConfig> { 157 | // let n = name.unwrap_or( 158 | // flake_ref.arg("repo").ok_or( 159 | // anyhow!("could not infer a good project name to use.") 160 | // )? 161 | // ); 162 | let pb = match path { 163 | Some(p) => Some(PathBuf::from(p.as_ref())), 164 | None => None 165 | }; 166 | self.projects.push(ProjectConfig { 167 | name: name.to_string(), 168 | url: flake_ref.flake_url(), 169 | path: pb, 170 | strategy: None, 171 | }); 172 | Ok(self.projects.last().unwrap()) 173 | } 174 | 175 | pub fn rm_project(&mut self, flake_ref: &dyn FlakeRef) -> Result { 176 | let index = self.projects.iter().position(|x| x.url == flake_ref.flake_url()).ok_or( 177 | anyhow!("project with ref '{}' not found", flake_ref.flake_url()) 178 | )?; 179 | Ok(self.projects.remove(index)) 180 | } 181 | 182 | pub fn get_project_by_flake_ref(&self, flake_ref: Rc) -> Option<&ProjectConfig> { 183 | let url = flake_ref.flake_url(); 184 | self.projects.iter().find(|p| p.url == url) 185 | } 186 | } 187 | 188 | impl LocalConfig { 189 | pub fn new() -> Self { 190 | LocalConfig { 191 | projects: BTreeMap::new(), 192 | } 193 | } 194 | 195 | pub fn read(path: &Path) -> Result { 196 | let contents = std::fs::read_to_string(path)?; 197 | Ok(serde_json::from_str::(&contents)?) 198 | } 199 | 200 | pub fn write(&self, path: &Path) -> Result<()> { 201 | std::fs::write(path, serde_json::to_string(&self)?)?; 202 | Ok(()) 203 | } 204 | 205 | /// Returns if a project is editable by the project name 206 | pub fn is_editable(&self, project_name: &str) -> bool { 207 | self.projects.get(project_name).map(|p| p.editable).unwrap_or(false) 208 | } 209 | 210 | pub fn mark_editable(&mut self, project_name: &str) -> () { 211 | self.projects.insert(project_name.to_string(), LocalProjectConfig { editable: true }); 212 | } 213 | 214 | pub fn unmark_editable(&mut self, project_name: &str) -> () { 215 | self.projects.insert(project_name.to_string(), LocalProjectConfig { editable: false }); 216 | } 217 | } 218 | 219 | impl ProjectConfig { 220 | pub fn flake_ref(&self) -> Result> { 221 | crate::flake::parse(&self.url) 222 | } 223 | } 224 | 225 | #[cfg(test)] 226 | mod tests { 227 | use super::*; 228 | 229 | #[test] 230 | fn test_deserialize() { 231 | let config = Config { 232 | environments: Vec::from([ 233 | EnvConfig { name: "dev".to_string(), strategy: UpdateStrategy::Latest, }, 234 | EnvConfig { name: "stage".to_string(), strategy: UpdateStrategy::Freeze, }, 235 | EnvConfig { 236 | name: "prod".to_string(), 237 | strategy: UpdateStrategy::LatestTag(Some("release-*".to_string())), 238 | }, 239 | ]), 240 | projects: Vec::from([ 241 | ProjectConfig { 242 | name: "project-a".to_string(), 243 | url: "github:chadac/project-a".to_string(), 244 | path: Some(PathBuf::from("./project-a")), 245 | strategy: None, 246 | }, 247 | ProjectConfig { 248 | name: "project-b".to_string(), 249 | url: "github:chadac/project-b".to_string(), 250 | path: Some(PathBuf::from("./subfolder/project-b")), 251 | strategy: Some(BTreeMap::from([ 252 | ("stage".to_string(), UpdateStrategy::Freeze), 253 | ])), 254 | }, 255 | ]), 256 | default_env: "dev".to_string(), 257 | }; 258 | let repr = toml::to_string(&config).unwrap(); 259 | } 260 | } 261 | -------------------------------------------------------------------------------- /src/flake.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{bail, Context, Result}; 2 | use regex::Regex; 3 | use std::collections::BTreeMap; 4 | use std::path::Path; 5 | use std::rc::Rc; 6 | use serde::{Serialize, Deserialize}; 7 | use serde_variant::to_variant_name; 8 | use querystring::{querify, stringify}; 9 | 10 | use super::config::UpdateStrategy; 11 | use crate::lockfile::{FlakeType, InputSpec}; 12 | 13 | fn split_once(msg: &str, split: &str) -> Result<(String, String)> { 14 | let mut parts = msg.split(split); 15 | let first = parts.next().context("failed to parse")?; 16 | let rest = parts.fold(String::new(), |a, b| a + b); 17 | Ok((first.to_string(), rest)) 18 | } 19 | 20 | /// splits : into a (, ) tuple 21 | fn split_scheme(url: &str) -> Result<(String, String)> { 22 | split_once(url, ":") 23 | } 24 | 25 | pub trait FlakeRef { 26 | fn flake_url(&self) -> String; 27 | fn flake_type(&self) -> FlakeType; 28 | fn git_remote_url(&self) -> Option; 29 | fn arg(&self, arg: &str) -> Option; 30 | 31 | fn with_rev(&self, rev: &str) -> Rc; 32 | 33 | /// try to infer a name for the flake 34 | fn infer_name(&self) -> Option { 35 | self.arg("repo") 36 | } 37 | 38 | fn input_spec(&self) -> InputSpec { 39 | InputSpec { 40 | flake_type: self.flake_type(), 41 | nar_hash: None, 42 | url: Some(self.flake_url()), 43 | owner: self.arg("owner"), 44 | repo: self.arg("repo"), 45 | dir: self.arg("dir"), 46 | rev: self.arg("rev"), 47 | git_ref: self.arg("ref"), 48 | rev_count: None, 49 | last_modified: None, 50 | } 51 | } 52 | } 53 | 54 | pub fn parse(url: &str) -> Result> { 55 | let (scheme, url) = split_scheme(url)?; 56 | let result: Rc = match (scheme.as_str(), url) { 57 | ("flake", rest) => FlakeIndirect::parse(&rest)?, 58 | ("path", rest) => FlakePath::parse(&rest)?, 59 | ("git+http", rest) => GitUrl::parse("http", &rest)?, 60 | ("git+https", rest) => GitUrl::parse("https", &rest)?, 61 | ("git+ssh", rest) => GitUrl::parse("ssh", &rest)?, 62 | ("git+file", rest) => GitUrl::parse("file", &rest)?, 63 | ("mc+http", rest) => MercurialUrl::parse("http", &rest)?, 64 | ("mc+https", rest) => MercurialUrl::parse("https", &rest)?, 65 | ("mc+ssh", rest) => MercurialUrl::parse("ssh", &rest)?, 66 | ("mc+file", rest) => MercurialUrl::parse("file", &rest)?, 67 | ("tarball+http", rest) => TarballUrl::parse("http", &rest)?, 68 | ("tarball+https", rest) => TarballUrl::parse("https", &rest)?, 69 | ("tarball+file", rest) => TarballUrl::parse("file", &rest)?, 70 | ("github", rest) => SimpleGitUrl::parse("github", "github.com/", &rest)?, 71 | ("gitlab", rest) => SimpleGitUrl::parse("gitlab", "gitlab.com/", &rest)?, 72 | ("sourcehut", rest) => SimpleGitUrl::parse("sourcehut", "git.sr.ht/~", &rest)?, 73 | (scheme, _) => bail!("unrecognized flake scheme: '{}'", scheme) 74 | }; 75 | Ok(result) 76 | } 77 | 78 | /// format: 79 | /// [flake:](/(/rev)?)? 80 | #[derive(Clone, PartialEq, Debug)] 81 | pub struct FlakeIndirect { 82 | flake_id: String, 83 | rev_or_ref: Option, 84 | rev: Option 85 | } 86 | 87 | impl FlakeIndirect { 88 | fn parse(url: &str) -> Result> { 89 | let re = Regex::new("([^/]+)(?:/([^/]+)(?:/([^/]+))?)?")?; 90 | let m = re.captures(url).context("failed to parse indirect flake url")?; 91 | Ok(Rc::new(Self { 92 | flake_id: m.get(1).unwrap().as_str().to_string(), 93 | rev_or_ref: m.get(2).map(|s| s.as_str().to_string()), 94 | rev: m.get(3).map(|s| s.as_str().to_string()), 95 | })) 96 | } 97 | } 98 | 99 | impl FlakeRef for FlakeIndirect { 100 | fn flake_url(&self) -> String { 101 | format!( 102 | "flake:{flake_id}{rev_or_ref}{rev}", 103 | flake_id=self.flake_id, 104 | rev_or_ref=self.rev_or_ref.as_ref().map(|s| format!("/{}", s)).unwrap_or("".to_string()), 105 | rev=self.rev.as_ref().map(|s| format!("/{}", s)).unwrap_or("".to_string()) 106 | ) 107 | } 108 | 109 | fn with_rev(&self, rev: &str) -> Rc { 110 | let mut copy = self.clone(); 111 | copy.rev = Some(rev.to_string()); 112 | Rc::new(copy) 113 | } 114 | 115 | fn flake_type(&self) -> FlakeType { 116 | FlakeType::Indirect 117 | } 118 | 119 | fn git_remote_url(&self) -> Option { 120 | None 121 | } 122 | 123 | fn arg(&self, arg: &str) -> Option { 124 | match arg { 125 | "ref" => self.rev_or_ref.clone(), 126 | "rev" => self.rev.clone(), 127 | _ => None 128 | } 129 | } 130 | } 131 | 132 | /// format: 133 | /// path:(\?)? 134 | #[derive(Clone, PartialEq, Debug)] 135 | pub struct FlakePath { 136 | path: String, 137 | params: Vec<(String, String)>, 138 | } 139 | 140 | fn qs_to_ref(qs: &Vec<(String, String)>) -> Vec<(&str, &str)> { 141 | qs.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect() 142 | } 143 | 144 | fn params_to_string(params: &Vec<(String, String)>) -> String { 145 | if params.is_empty() { 146 | "".to_string() 147 | } else { 148 | let mut params = stringify(qs_to_ref(¶ms)); 149 | // remove trailing '&' in path string 150 | params.pop(); 151 | format!("?{}", params) 152 | } 153 | } 154 | 155 | impl FlakePath { 156 | pub fn parse(url: &str) -> Result> { 157 | let re = Regex::new("([^?]+)(?:[?](.+))?$")?; 158 | let m = re.captures(url).context("failed to parse flake path")?; 159 | Ok(Rc::new(FlakePath { 160 | path: m.get(1).unwrap().as_str().to_string(), 161 | params: querify(m.get(2).map_or("", |s| s.as_str())) 162 | .iter() 163 | .map(|(k, v)| (k.to_string(), v.to_string())) 164 | .collect(), 165 | })) 166 | } 167 | } 168 | 169 | impl FlakeRef for FlakePath { 170 | fn flake_url(&self) -> String { 171 | format!( 172 | "path:{path}{params}", 173 | path=self.path, 174 | params=params_to_string(&self.params), 175 | ) 176 | } 177 | fn with_rev(&self, rev: &str) -> Rc { 178 | Rc::new(self.clone()) 179 | } 180 | fn flake_type(&self) -> FlakeType { 181 | FlakeType::Path 182 | } 183 | fn git_remote_url(&self) -> Option { 184 | None 185 | } 186 | fn arg(&self, arg: &str) -> Option { 187 | self.params.iter().find(|(k, _)| k == arg).map(|(_, v)| v.to_string()) 188 | } 189 | } 190 | 191 | fn parse_server_url(url: &str) -> Result<(Option, String, Vec<(String, String)>)> { 192 | let re = Regex::new("(?://([^/]+))?([^?]+)(?:[?](.+))?")?; 193 | let m = re.captures(url).with_context(|| format!("failed to parse server url {url}"))?; 194 | Ok(( 195 | m.get(1).map(|s| s.as_str().to_string()), 196 | m.get(2).unwrap().as_str().to_string(), 197 | querify(m.get(3).map_or("", |s| s.as_str())) 198 | .iter() 199 | .map(|(k, v)| (k.to_string(), v.to_string())) 200 | .collect() 201 | )) 202 | } 203 | 204 | /// format: 205 | /// git(+http|+https|+ssh|+git|+file):(//)?(\?)? 206 | #[derive(Clone, PartialEq, Debug)] 207 | pub struct GitUrl { 208 | scheme: String, 209 | server: Option, 210 | path: String, 211 | params: Vec<(String, String)> 212 | } 213 | 214 | impl GitUrl { 215 | pub fn parse(scheme: &str, url: &str) -> Result> { 216 | let (server, path, params) = parse_server_url(url)?; 217 | Ok(Rc::new(GitUrl { 218 | scheme: scheme.to_string(), 219 | server: server, 220 | path: path, 221 | params: params, 222 | })) 223 | } 224 | } 225 | 226 | impl FlakeRef for GitUrl { 227 | fn flake_url(&self) -> String { 228 | format!( 229 | "git+{scheme}:{server}{path}{params}", 230 | scheme=self.scheme, 231 | server=self.server.as_ref().map_or("".to_string(), |s| format!("//{s}")), 232 | path=self.path, 233 | params=params_to_string(&self.params), 234 | ) 235 | } 236 | fn flake_type(&self) -> FlakeType { 237 | FlakeType::Git 238 | } 239 | fn with_rev(&self, rev: &str) -> Rc { 240 | let mut clone = self.clone(); 241 | clone.params.retain(|(k, _)| k != "rev"); 242 | clone.params.push(("rev".to_string(), rev.to_string())); 243 | Rc::new(clone) 244 | } 245 | fn git_remote_url(&self) -> Option { 246 | Some(format!( 247 | "{scheme}:{server}{path}", 248 | scheme=self.scheme, 249 | server=self.server.as_ref().map_or("".to_string(), |s| format!("//{s}")), 250 | path=self.path 251 | )) 252 | } 253 | fn arg(&self, arg: &str) -> Option { 254 | self.params.iter().find(|(k, _)| k == arg).map(|(_, v)| v.to_string()) 255 | } 256 | } 257 | 258 | /// format: 259 | /// mc(+http|+https|+ssh|+file):(//)?(\?)? 260 | #[derive(Clone, PartialEq, Debug)] 261 | pub struct MercurialUrl { 262 | scheme: String, 263 | server: Option, 264 | path: String, 265 | params: Vec<(String, String)> 266 | } 267 | 268 | impl MercurialUrl { 269 | pub fn parse(scheme: &str, url: &str) -> Result> { 270 | let (server, path, params) = parse_server_url(url)?; 271 | Ok(Rc::new(MercurialUrl { 272 | scheme: scheme.to_string(), 273 | server: server, 274 | path: path, 275 | params: params, 276 | })) 277 | } 278 | } 279 | 280 | impl FlakeRef for MercurialUrl { 281 | fn flake_url(&self) -> String { 282 | format!( 283 | "mc+{scheme}:{server}{path}{params}", 284 | scheme=self.scheme, 285 | server=self.server.as_ref().map_or("".to_string(), |s| format!("//{s}")), 286 | path=self.path, 287 | params=params_to_string(&self.params), 288 | ) 289 | } 290 | fn flake_type(&self) -> FlakeType { 291 | FlakeType::Mercurial 292 | } 293 | fn git_remote_url(&self) -> Option { 294 | None 295 | } 296 | fn with_rev(&self, rev: &str) -> Rc { 297 | let mut clone = self.clone(); 298 | clone.params.retain(|(k, _)| k != "rev"); 299 | clone.params.push(("rev".to_string(), rev.to_string())); 300 | Rc::new(clone) 301 | } 302 | fn arg(&self, arg: &str) -> Option { 303 | self.params.iter().find(|(k, _)| k == arg).map(|(_, v)| v.to_string()) 304 | } 305 | } 306 | 307 | /// format: 308 | /// tarball(+http|+https|file):// 309 | #[derive(Clone, PartialEq, Debug)] 310 | pub struct TarballUrl { 311 | scheme: String, 312 | url: String, 313 | } 314 | 315 | impl TarballUrl { 316 | pub fn parse(scheme: &str, url: &str) -> Result> { 317 | let re = Regex::new("//(.+)")?; 318 | let m = re.captures(url).with_context(|| format!("could not parse tarball url: '{url}'"))?; 319 | Ok(Rc::new(TarballUrl { 320 | scheme: scheme.to_string(), 321 | url: m.get(1).map(|s| s.as_str().to_string()).unwrap(), 322 | })) 323 | } 324 | } 325 | 326 | impl FlakeRef for TarballUrl { 327 | fn flake_url(&self) -> String { 328 | format!( 329 | "tarball+{scheme}://{url}", 330 | scheme=self.scheme, 331 | url=self.url, 332 | ) 333 | } 334 | fn flake_type(&self) -> FlakeType { 335 | FlakeType::Tarball 336 | } 337 | fn git_remote_url(&self) -> Option { 338 | None 339 | } 340 | fn with_rev(&self, rev: &str) -> Rc { 341 | Rc::new(self.clone()) 342 | } 343 | fn arg(&self, arg: &str) -> Option { 344 | None 345 | } 346 | } 347 | 348 | fn parse_simple_url(url: &str) -> Result<(String, String, Option, Vec<(String, String)>)> { 349 | let re = Regex::new("([^/]+)/([^/]+)(?:/([^?]+))?(?:[?](.+))?")?; 350 | let m = re.captures(url).with_context(|| format!("could not parse simple url: '{url}'"))?; 351 | Ok(( 352 | m.get(1).map(|s| s.as_str().to_string()).unwrap(), 353 | m.get(2).map(|s| s.as_str().to_string()).unwrap(), 354 | m.get(3).map(|s| s.as_str().to_string()), 355 | querify(m.get(4).map_or("", |s| s.as_str())) 356 | .iter() 357 | .map(|(k, v)| (k.to_string(), v.to_string())) 358 | .collect() 359 | )) 360 | } 361 | 362 | fn fmt_simple_url(scheme: &str, owner: &str, repo: &str, rev_or_ref: &Option, params: &Vec<(String, String)>) -> String { 363 | format!( 364 | "{scheme}:{owner}/{repo}{rev_or_ref}{params}", 365 | rev_or_ref=rev_or_ref.as_ref().map_or("".to_string(), |s| format!("/{s}")), 366 | params=params_to_string(params), 367 | ) 368 | } 369 | 370 | /// format: 371 | /// (github|gitlab|sourcehut):/(/)?(\?)? 372 | #[derive(Clone, PartialEq, Debug)] 373 | pub struct SimpleGitUrl { 374 | scheme: String, 375 | domain: String, 376 | owner: String, 377 | repo: String, 378 | rev_or_ref: Option, 379 | params: Vec<(String, String)>, 380 | } 381 | 382 | impl SimpleGitUrl { 383 | pub fn parse(scheme: &str, domain: &str, url: &str) -> Result> { 384 | let (owner, repo, rev_or_ref, params) = parse_simple_url(url)?; 385 | Ok(Rc::new(Self { 386 | scheme: scheme.to_string(), 387 | domain: domain.to_string(), 388 | owner: owner, 389 | repo: repo, 390 | rev_or_ref: rev_or_ref, 391 | params: params 392 | })) 393 | } 394 | } 395 | 396 | impl FlakeRef for SimpleGitUrl { 397 | fn flake_url(&self) -> String { 398 | fmt_simple_url("github", &self.owner, &self.repo, &self.rev_or_ref, &self.params) 399 | } 400 | fn flake_type(&self) -> FlakeType { 401 | FlakeType::GitHub 402 | } 403 | fn git_remote_url(&self) -> Option { 404 | Some(format!( 405 | "https://{domain}{owner}/{repo}.git", 406 | domain=self.domain, 407 | owner=self.owner, 408 | repo=self.repo, 409 | )) 410 | } 411 | fn with_rev(&self, rev: &str) -> Rc { 412 | let mut clone = self.clone(); 413 | clone.rev_or_ref = Some(rev.to_string()); 414 | Rc::new(clone) 415 | } 416 | fn arg(&self, arg: &str) -> Option { 417 | match arg { 418 | "owner" => Some(self.owner.to_string()), 419 | "repo" => Some(self.repo.to_string()), 420 | "rev_or_ref" => self.rev_or_ref.clone(), 421 | _ => None 422 | } 423 | } 424 | } 425 | 426 | 427 | #[cfg(test)] 428 | mod tests { 429 | use anyhow::Result; 430 | use super::{FlakeRef, FlakeType}; 431 | 432 | #[test] 433 | fn test_it_parses_flake_indirect() -> Result<()> { 434 | let url1 = "flake:nixpkgs/nixpkgs-unstable/a3a3dda3bacf61e8a39258a0ed9c924eeca8e293"; 435 | let ref1 = super::parse(url1)?; 436 | assert_eq!(ref1.flake_url(), url1); 437 | assert_eq!(ref1.flake_type(), FlakeType::Indirect); 438 | assert_eq!(ref1.arg("ref"), Some("nixpkgs-unstable".to_string())); 439 | assert_eq!(ref1.arg("rev"), Some("a3a3dda3bacf61e8a39258a0ed9c924eeca8e293".to_string())); 440 | 441 | let url2 = "flake:nixpkgs/nixpkgs-unstable"; 442 | let ref2 = super::parse(url2)?; 443 | assert_eq!(ref2.flake_url(), url2); 444 | assert_eq!(ref2.flake_type(), FlakeType::Indirect); 445 | assert_eq!(ref2.arg("ref"), Some("nixpkgs-unstable".to_string())); 446 | assert_eq!(ref2.arg("rev"), None); 447 | 448 | let url3 = "flake:nixpkgs"; 449 | let ref3 = super::parse(url3)?; 450 | assert_eq!(ref3.flake_url(), url3); 451 | assert_eq!(ref3.flake_type(), FlakeType::Indirect); 452 | assert_eq!(ref3.arg("ref"), None); 453 | assert_eq!(ref3.arg("rev"), None); 454 | 455 | Ok(()) 456 | } 457 | 458 | #[test] 459 | fn test_it_parses_flake_path() -> Result<()> { 460 | let url1 = "path:./test/path?dir=subdir"; 461 | let ref1 = super::parse(url1)?; 462 | assert_eq!(ref1.flake_url(), url1); 463 | assert_eq!(ref1.flake_type(), FlakeType::Path); 464 | assert_eq!(ref1.arg("dir"), Some("subdir".to_string())); 465 | 466 | let url2 = "path:./test"; 467 | let ref2 = super::parse(url2)?; 468 | assert_eq!(ref2.flake_url(), url2); 469 | assert_eq!(ref2.flake_type(), FlakeType::Path); 470 | assert_eq!(ref2.arg("dir"), None); 471 | Ok(()) 472 | } 473 | 474 | #[test] 475 | fn test_it_parses_git_url() -> Result<()> { 476 | let url1 = "git+https://github.com/chadac/nixspace?rev=a3a3ddd"; 477 | let ref1 = super::parse(url1)?; 478 | assert_eq!(ref1.flake_url(), url1); 479 | assert_eq!(ref1.flake_type(), FlakeType::Git); 480 | assert_eq!(ref1.git_remote_url(), Some("https://github.com/chadac/nixspace".to_string())); 481 | assert_eq!(ref1.arg("rev"), Some("a3a3ddd".to_string())); 482 | 483 | let url2 = "git+ssh://github.com/chadac/nixspace"; 484 | let ref2 = super::parse(url2)?; 485 | assert_eq!(ref2.flake_url(), url2); 486 | assert_eq!(ref2.flake_type(), FlakeType::Git); 487 | assert_eq!(ref2.git_remote_url(), Some("ssh://github.com/chadac/nixspace".to_string())); 488 | assert_eq!(ref2.arg("rev"), None); 489 | 490 | let url3 = "git+file:/share/repo"; 491 | let ref3 = super::parse(url3)?; 492 | assert_eq!(ref3.flake_url(), url3); 493 | assert_eq!(ref3.flake_type(), FlakeType::Git); 494 | assert_eq!(ref3.git_remote_url(), Some("file:/share/repo".to_string())); 495 | Ok(()) 496 | } 497 | 498 | #[test] 499 | fn test_it_parses_mercurial_url() -> Result<()> { 500 | let url1 = "mc+https://github.com/chadac/nixspace?rev=a3a3ddd"; 501 | let ref1 = super::parse(url1)?; 502 | assert_eq!(ref1.flake_url(), url1); 503 | assert_eq!(ref1.flake_type(), FlakeType::Mercurial); 504 | assert_eq!(ref1.arg("rev"), Some("a3a3ddd".to_string())); 505 | 506 | let url2 = "mc+ssh://github.com/chadac/nixspace"; 507 | let ref2 = super::parse(url2)?; 508 | assert_eq!(ref2.flake_url(), url2); 509 | assert_eq!(ref2.flake_type(), FlakeType::Mercurial); 510 | assert_eq!(ref2.arg("rev"), None); 511 | 512 | let url3 = "mc+file:/share/repo"; 513 | let ref3 = super::parse(url3)?; 514 | assert_eq!(ref3.flake_url(), url3); 515 | assert_eq!(ref3.flake_type(), FlakeType::Mercurial); 516 | Ok(()) 517 | } 518 | 519 | #[test] 520 | fn test_it_parses_github_url() -> Result<()> { 521 | let url1 = "github:chadac/dotfiles/nix-config"; 522 | let ref1 = super::parse(url1)?; 523 | assert_eq!(ref1.flake_url(), url1); 524 | assert_eq!(ref1.flake_type(), FlakeType::GitHub); 525 | assert_eq!(ref1.git_remote_url(), Some("https://github.com/chadac/dotfiles.git".to_string())); 526 | assert_eq!(ref1.arg("owner"), Some("chadac".to_string())); 527 | assert_eq!(ref1.arg("repo"), Some("dotfiles".to_string())); 528 | assert_eq!(ref1.arg("rev_or_ref"), Some("nix-config".to_string())); 529 | Ok(()) 530 | } 531 | } 532 | -------------------------------------------------------------------------------- /src/lockfile.rs: -------------------------------------------------------------------------------- 1 | use serde::{Serialize, Deserialize}; 2 | use anyhow::{anyhow, Context, Result}; 3 | use std::collections::{HashSet, BTreeMap}; 4 | use std::path::Path; 5 | use std::rc::Rc; 6 | 7 | use super::cli::Nix; 8 | use super::flake::FlakeRef; 9 | 10 | type Nodes = BTreeMap; 11 | 12 | #[derive(Clone, Serialize, Deserialize, Debug)] 13 | pub struct LockFile { 14 | nodes: Nodes, 15 | root: String, 16 | version: i32, 17 | } 18 | 19 | #[derive(Clone, Serialize, Deserialize, Debug)] 20 | #[serde(untagged)] 21 | enum InputRef { 22 | Direct(String), 23 | Path(Vec), 24 | } 25 | 26 | impl InputRef { 27 | fn rename(&mut self, orig_name: &str, new_name: &str) -> () { 28 | match self { 29 | InputRef::Direct(ref mut n) => { 30 | if n == orig_name { 31 | *n = new_name.to_string(); 32 | } 33 | }, 34 | InputRef::Path(ref mut p) => { 35 | if let Some(n) = p.first() { 36 | if n == orig_name { 37 | p.remove(0); 38 | p.insert(0, new_name.to_string()); 39 | }; 40 | } 41 | } 42 | } 43 | } 44 | 45 | fn head(&self) -> String { 46 | match self { 47 | InputRef::Direct(n) => n.to_string(), 48 | InputRef::Path(p) => p.last().unwrap().to_string(), 49 | } 50 | } 51 | } 52 | 53 | #[derive(Clone, Serialize, Deserialize, Debug)] 54 | struct LockedRef { 55 | #[serde(skip_serializing_if = "Option::is_none")] 56 | flake: Option, 57 | #[serde(skip_serializing_if = "Option::is_none")] 58 | locked: Option, 59 | #[serde(skip_serializing_if = "Option::is_none")] 60 | original: Option, 61 | #[serde(skip_serializing_if = "Option::is_none")] 62 | inputs: Option>, 63 | } 64 | 65 | #[derive(Clone, PartialEq, Serialize, Deserialize, Debug)] 66 | pub enum FlakeType { 67 | #[serde(rename = "path")] 68 | Path, 69 | #[serde(rename = "git")] 70 | Git, 71 | #[serde(rename = "mercurial")] 72 | Mercurial, 73 | #[serde(rename = "tarball")] 74 | Tarball, 75 | #[serde(rename = "file")] 76 | File, 77 | #[serde(rename = "github")] 78 | GitHub, 79 | #[serde(rename = "gitlab")] 80 | GitLab, 81 | #[serde(rename = "sourcehut")] 82 | SourceHut, 83 | #[serde(rename = "flake")] 84 | Indirect, 85 | } 86 | 87 | #[derive(Clone, Serialize, Deserialize, Debug)] 88 | pub struct InputSpec { 89 | #[serde(rename = "type")] 90 | pub flake_type: FlakeType, 91 | #[serde(rename = "narHash")] 92 | #[serde(skip_serializing_if = "Option::is_none")] 93 | pub nar_hash: Option, 94 | #[serde(skip_serializing_if = "Option::is_none")] 95 | pub url: Option, 96 | #[serde(skip_serializing_if = "Option::is_none")] 97 | pub owner: Option, 98 | #[serde(skip_serializing_if = "Option::is_none")] 99 | pub repo: Option, 100 | #[serde(skip_serializing_if = "Option::is_none")] 101 | pub dir: Option, 102 | #[serde(skip_serializing_if = "Option::is_none")] 103 | pub rev: Option, 104 | #[serde(rename = "ref")] 105 | #[serde(skip_serializing_if = "Option::is_none")] 106 | pub git_ref: Option, 107 | #[serde(rename = "revCount")] 108 | #[serde(skip_serializing_if = "Option::is_none")] 109 | pub rev_count: Option, 110 | #[serde(rename = "lastModified")] 111 | #[serde(skip_serializing_if = "Option::is_none")] 112 | pub last_modified: Option, 113 | } 114 | 115 | impl LockedRef { 116 | fn empty() -> Self { 117 | LockedRef { 118 | flake: None, 119 | locked: None, 120 | original: None, 121 | inputs: Some(BTreeMap::new()), 122 | } 123 | } 124 | 125 | fn root(nodes: &Nodes) -> Self { 126 | todo!() 127 | } 128 | 129 | /// Generates a new LockedRef from an inputspec 130 | fn from(locked: &InputSpec) -> Self { 131 | todo!() 132 | } 133 | } 134 | 135 | impl LockFile { 136 | /// Combines a set of independent lockfiles into a joint lockfile. 137 | fn merge(lockfiles: &BTreeMap) -> Result { 138 | let mut l: BTreeMap = lockfiles.clone(); 139 | 140 | // we need to rename inputs so that when merged, stuff doesn't 141 | // conflict with each other 142 | for (name, lockfile) in &mut l { 143 | let mut input_map = BTreeMap::::new(); 144 | 145 | // start by namespacing everything 146 | for input_name in lockfile.nodes.keys() { 147 | if input_name != "root" && !lockfiles.contains_key(input_name) { 148 | let new_input_name = format!("{name}_{input_name}"); 149 | input_map.insert(input_name.to_string(), new_input_name); 150 | } 151 | } 152 | 153 | // then, substitute any references to shared packages with our stuff 154 | let root = lockfile.nodes.get("root").unwrap(); 155 | let empty_inputs = BTreeMap::new(); 156 | let root_inputs = root.inputs.as_ref().unwrap_or(&empty_inputs); 157 | for (input_name, alias) in root_inputs { 158 | if lockfiles.contains_key(input_name) { 159 | let orig_input_name = lockfile.resolve_input(&alias); 160 | input_map.insert(orig_input_name, input_name.to_string()); 161 | } 162 | } 163 | 164 | for (input_name, new_input_name) in input_map { 165 | lockfile.rename_input(&input_name, &new_input_name); 166 | } 167 | 168 | // rename our root to the input_name for later merging 169 | lockfile.rename_input("root", name); 170 | } 171 | 172 | let mut new_nodes: Nodes = BTreeMap::new(); 173 | for lockfile in l.values() { 174 | for (input_name, node) in &lockfile.nodes { 175 | new_nodes.insert(input_name.to_string(), node.clone()); 176 | } 177 | } 178 | 179 | // insert a new root node pointing to each of the original lockfiles list 180 | new_nodes.insert("root".to_string(), LockedRef { 181 | flake: None, 182 | locked: None, 183 | original: None, 184 | inputs:Some(BTreeMap::from_iter( 185 | lockfiles.keys() 186 | .map(|n| (n.to_string(), InputRef::Direct(n.to_string()))) 187 | )), 188 | }); 189 | 190 | let mut lockfile = Self { 191 | nodes: new_nodes, 192 | root: "root".to_string(), 193 | version: 7, 194 | }; 195 | lockfile.trim()?; 196 | 197 | Ok(lockfile) 198 | } 199 | 200 | /// Builds a lockfile from a set of nodes. 201 | fn from_nodes(nodes: Nodes) -> Self { 202 | let mut new_nodes: Nodes = nodes 203 | .into_iter() 204 | .filter(|(name, _)| name == "root") 205 | .collect(); 206 | let root = LockedRef::root(&new_nodes); 207 | new_nodes.insert("root".to_string(), root); 208 | Self { 209 | nodes: new_nodes, 210 | root: "root".to_string(), 211 | version: 7 212 | } 213 | } 214 | 215 | /// Generate an empty lockfile 216 | pub fn empty() -> Self { 217 | Self::from_nodes(BTreeMap::new()) 218 | } 219 | 220 | /// Generates a lockfile by merging many metadata entries together. 221 | pub fn from_metadata(projects: BTreeMap) -> Result { 222 | let lockfiles: BTreeMap = BTreeMap::from_iter( 223 | projects.iter().map(|(n, m)| (n.to_string(), m.locks.clone())) 224 | ); 225 | let mut lockfile = Self::merge(&lockfiles)?; 226 | for (name, metadata) in projects { 227 | let node = lockfile.nodes.get_mut(&name).with_context( 228 | || anyhow!("project '{name}' was missing during merge; badly formatted lockfile?") 229 | )?; 230 | node.original = Some(metadata.original.clone()); 231 | node.locked = Some(metadata.locked.clone()); 232 | } 233 | Ok(lockfile) 234 | } 235 | 236 | /// Read to a JSON file 237 | pub fn read(path: &Path) -> Result { 238 | let contents = std::fs::read_to_string(path)?; 239 | Ok(serde_json::from_str::(&contents)?) 240 | } 241 | 242 | /// Write to a JSON file 243 | pub fn write(&self, path: &Path) -> Result<()> { 244 | std::fs::write(path, serde_json::to_string(&self)?)?; 245 | Ok(()) 246 | } 247 | 248 | /// Get the root node of the lockfile. 249 | fn root_node(self) -> Result { 250 | self.nodes.get(&self.root).map(|n| n.clone()) 251 | .context("lockfile is missing root node! improperly formatted?") 252 | } 253 | 254 | pub fn get_input_spec(&self, name: &str) -> Option { 255 | self.nodes.get(name).map(|r| r.locked.clone()).flatten() 256 | } 257 | 258 | /// Resolves the paths that Nix flakes use. 259 | /// 260 | /// Copies a bit of the callFlake pattern included in the core Nix repo just 261 | /// to ensure it's functionally identical. 262 | fn resolve_input(&self, input_path: &InputRef) -> String { 263 | match input_path { 264 | InputRef::Direct(i) => i.to_string(), 265 | InputRef::Path(p) => { 266 | let (head, tail) = p.split_at(1); 267 | let node_name = head.first().unwrap(); 268 | self.get_input_by_path( 269 | node_name, 270 | &Vec::from(tail) 271 | ) 272 | }, 273 | } 274 | } 275 | 276 | /// Parses the input ref path to get the input name of a reference. 277 | fn get_input_by_path(&self, node_name: &String, path: &Vec) -> String { 278 | if path.is_empty() { 279 | node_name.to_string() 280 | } else { 281 | let node = self.nodes.get(node_name).unwrap(); 282 | let (head, tail) = path.split_at(1); 283 | let h = head.first().unwrap(); 284 | self.get_input_by_path( 285 | &self.resolve_input(node.inputs.as_ref().unwrap().get(h).unwrap()), 286 | &Vec::from(tail), 287 | ) 288 | } 289 | } 290 | 291 | pub fn rm(&mut self, name: &str) -> Result<()> { 292 | self.nodes.remove(name); 293 | let root = self.nodes.get_mut(&self.root) 294 | .context("failed parsing lockfile; missing entry 'root' in nodes")?; 295 | if let Some(ref mut inputs) = root.inputs { 296 | inputs.remove(name); 297 | } 298 | self.trim()?; 299 | Ok(()) 300 | } 301 | 302 | /// Renames a node. 303 | pub fn rename_input(&mut self, input_name: &str, new_name: &str) -> () { 304 | if !self.nodes.contains_key(input_name) { 305 | return 306 | } 307 | for (_, node) in &mut self.nodes { 308 | if let Some(ref mut inputs) = node.inputs { 309 | for (_, input_ref) in inputs { 310 | input_ref.rename(input_name, new_name); 311 | } 312 | } 313 | } 314 | self.nodes.insert(new_name.to_string(), self.nodes.get(input_name).unwrap().clone()); 315 | self.nodes.retain(|n, _| n != input_name); 316 | } 317 | 318 | /// Grabs all nodes in the lockfile that are attached to the root. 319 | /// 320 | /// Useful for cleaning up the lockfile after updates. 321 | fn closure(&self) -> Result> { 322 | let mut queue = Vec::from(&[ self.root.to_string() ]); 323 | let mut visited = HashSet::new(); 324 | visited.insert(self.root.to_string()); 325 | 326 | while !queue.is_empty() { 327 | let node_name = queue.pop().unwrap(); 328 | visited.insert(node_name.clone()); 329 | let node = self.nodes.get(&node_name) 330 | .with_context(|| anyhow!("could not find node with name '{node_name}'; improperly formatted lockfile?"))?; 331 | if let Some(i) = &node.inputs { 332 | for input_ref in i.values() { 333 | let next_input = self.resolve_input(&input_ref); 334 | if !visited.contains(&next_input) { 335 | queue.push(next_input); 336 | } 337 | } 338 | } 339 | } 340 | 341 | Ok(visited) 342 | } 343 | 344 | /// Remove all nodes from the lockfile that are not attached to the root. 345 | pub fn trim(&mut self) -> Result<()> { 346 | let keep = self.closure()?; 347 | let mut remove: Vec = self.nodes.iter().map(|(n, _)| n.to_string()).collect(); 348 | remove.retain(|n| !keep.contains(n)); 349 | for node in remove { 350 | self.rm(&node)?; 351 | } 352 | Ok(()) 353 | } 354 | } 355 | 356 | impl InputSpec { 357 | pub fn from_flake_ref(flake_ref: Rc) -> Self { 358 | flake_ref.input_spec() 359 | } 360 | } 361 | 362 | 363 | #[cfg(test)] 364 | mod tests { 365 | use super::*; 366 | 367 | #[test] 368 | fn add_succeeds() -> Result<()> { 369 | Ok(()) 370 | } 371 | } 372 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | #![allow(dead_code)] 2 | #![allow(unused_imports)] 3 | #![allow(unused_variables)] 4 | 5 | mod workspace; 6 | mod config; 7 | mod lockfile; 8 | mod flake; 9 | mod cli; 10 | mod util; 11 | 12 | use crate::config::Config; 13 | use crate::cli::{CliCommand, Git, Nix}; 14 | use crate::workspace::{ProjectRef, Workspace}; 15 | use crate::flake::FlakeRef; 16 | use crate::lockfile::InputSpec; 17 | 18 | use anyhow::{anyhow, bail, Context, Error, Result}; 19 | use clap::{Args, Parser, Subcommand}; 20 | use std::path::Path; 21 | 22 | #[derive(Parser)] 23 | #[command(author, version, about, long_about = None)] 24 | struct Cli { 25 | #[command(subcommand)] 26 | command: Commands, 27 | #[command(flatten)] 28 | verbose: clap_verbosity_flag::Verbosity, 29 | } 30 | 31 | #[derive(Debug, Subcommand)] 32 | enum Commands { 33 | // WORKSPACE COMMANDS 34 | /// create an empty workspace 35 | /// 36 | /// equivalent to `nix flake init github:chadac/nix-ws` 37 | Init(Init), 38 | /// clone a workspace 39 | Clone(Clone), 40 | /// show the layout of the workspace 41 | Show(Show), 42 | 43 | // SUBCOMMANDS 44 | /// manage workspace configuration 45 | #[command(subcommand)] 46 | Config(ConfigSubcommand), 47 | 48 | /// manage environments 49 | #[command(subcommand)] 50 | Env(EnvSubcommand), 51 | 52 | // PROJECT COMMANDS 53 | /// import a project to the workspace 54 | /// 55 | /// Registers a project within the `nixspace.toml`. 56 | Register(Register), 57 | /// erase a project from the workspace 58 | /// 59 | /// Removes a project from the `nixspace.toml`. 60 | Unregister(Unregister), 61 | 62 | // LOCAL PROJECT COMMANDS 63 | /// link a project to the workspace locally 64 | /// 65 | /// Use to interactively test local changes to a workspace. Clones the 66 | /// project if it does not exist and then remotely updates. 67 | Edit(Edit), 68 | /// unlink the project from the workspace 69 | /// 70 | /// Disassociates the project from the workspace, meaning that future builds 71 | /// use the locked version of the package rather than the local. 72 | Unedit(Unedit), 73 | 74 | // GIT MANAGEMENT 75 | /// pull the workspace config + lockfile from the upstream remote 76 | /// 77 | /// Alias for `git pull

78 | Sync(Sync), 79 | /// publish the workspace config + lockfile to the upstream remote 80 | /// 81 | /// Alias for `git commit -m && git push
` 82 | Publish(Publish), 83 | 84 | // LOCKFILE MANAGEMENT 85 | /// update the workspace lockfile 86 | /// 87 | /// Updates all projects in the workspace lockfile. 88 | Update(Update), 89 | 90 | // NIX ALIASES 91 | /// alias for "nix build" executed from the workspace context 92 | /// 93 | /// When run within a project directory, will build the associated project 94 | /// in the context of the workspace, allowing for seamless testing of changes. 95 | /// 96 | /// See `nix build --help` for any details on the nix command. 97 | Build(NixArgs), 98 | /// alias for "nix run" executed from the workspace context 99 | /// 100 | /// When run within a project directory, will build the associated project 101 | /// in the context of the workspace, allowing for seamless testing of changes. 102 | /// 103 | /// See `nix run --help` for any details on the nix command. 104 | Run(NixArgs), 105 | } 106 | 107 | trait Command { 108 | fn run(&self) -> Result<()>; 109 | } 110 | 111 | #[derive(clap::ValueEnum, Clone, Debug)] 112 | enum TemplateType { 113 | Basic, 114 | FlakeParts, 115 | } 116 | 117 | #[derive(Args, Debug)] 118 | struct Init { 119 | /// name of the workspace 120 | #[arg(short, long)] 121 | name: String, 122 | #[arg(id = "type", short, long)] 123 | template_type: Option, 124 | } 125 | 126 | impl Command for Init { 127 | fn run(&self) -> Result<()> { 128 | let dir = Path::new(&self.name); 129 | if dir.exists() { 130 | bail!("error: path already exists"); 131 | } 132 | std::fs::create_dir(dir)?; 133 | let target = match &self.template_type { 134 | Some(TemplateType::Basic) => "github:chadac/nixspace#basic", 135 | Some(TemplateType::FlakeParts) => "github:chadac/nixspace#flake-parts", 136 | None => "github:chadac/nixspace", 137 | }; 138 | let cmd = ["flake", "init", "-t", target]; 139 | Nix::exec(&cmd, &dir)?; 140 | Git::init(&dir)?; 141 | Git::add("flake.nix")?; 142 | let ws = Workspace::at(&dir)?; 143 | ws.commit("initial commit")?; 144 | println!("workspace initialized at {} with {target}", self.name); 145 | Ok(()) 146 | } 147 | } 148 | 149 | #[derive(Args, Debug)] 150 | struct Clone { 151 | /// flake reference for the workspace 152 | flake_ref: String, 153 | /// name of the directory to clone the workspace into 154 | directory: Option, 155 | /// if present, clone all projects locally 156 | #[arg(long)] 157 | clone_all: bool, 158 | } 159 | 160 | impl Command for Clone { 161 | fn run(&self) -> Result<()> { 162 | let flake_ref = flake::parse(&self.flake_ref)?; 163 | let input_spec = InputSpec::from_flake_ref(flake_ref); 164 | let dest: String = match &self.directory { 165 | Some(dirname) => dirname.to_string(), 166 | _ => 167 | input_spec.owner.expect("could not infer project name from input spec; specify --directory for the destination dir."), 168 | }; 169 | Nix::clone(&self.flake_ref, &dest, ".")?; 170 | Ok(()) 171 | } 172 | } 173 | 174 | #[derive(Args, Debug)] 175 | struct Show { 176 | } 177 | 178 | impl Command for Show { 179 | fn run(&self) -> Result<()> { 180 | let ws = Workspace::discover()?; 181 | ws.print_tree(); 182 | Ok(()) 183 | } 184 | } 185 | 186 | #[derive(Debug, Subcommand)] 187 | enum ConfigSubcommand { 188 | /// get a configuration value 189 | Get(ConfigGet), 190 | /// set a configuration value 191 | Set(ConfigSet), 192 | } 193 | 194 | #[derive(Args, Debug)] 195 | struct ConfigGet { 196 | // configuration name 197 | name: String, 198 | } 199 | 200 | #[derive(Args, Debug)] 201 | struct ConfigSet { 202 | // configuration name 203 | name: String, 204 | // configuration value 205 | value: String, 206 | } 207 | 208 | impl Command for ConfigSubcommand { 209 | fn run(&self) -> Result<()> { 210 | match &self { 211 | ConfigSubcommand::Get(get) => { 212 | let ws = Workspace::discover()?; 213 | match get.name.as_str() { 214 | "default_env" => println!("{}", toml::to_string(&ws.config.default_env)?), 215 | _ => bail!("error: unrecognized configuration name {}", get.name), 216 | }; 217 | }, 218 | ConfigSubcommand::Set(set) => { 219 | let mut ws = Workspace::discover()?; 220 | match set.name.as_str() { 221 | "default_env" => { 222 | ws.config.default_env = set.value.to_string(); 223 | } 224 | _ => bail!("error: unrecognized configuration name {}", set.name), 225 | } 226 | ws.save()?; 227 | }, 228 | } 229 | Ok(()) 230 | } 231 | } 232 | 233 | #[derive(Debug, Subcommand)] 234 | enum EnvSubcommand { 235 | /// get a configuration value 236 | Get(EnvGet), 237 | /// set a configuration value 238 | Set(EnvSet), 239 | } 240 | 241 | #[derive(Args, Debug)] 242 | struct EnvGet { 243 | // environment name 244 | env: String, 245 | // configuration name 246 | name: String, 247 | } 248 | 249 | #[derive(Args, Debug)] 250 | struct EnvSet { 251 | // environment name 252 | env: String, 253 | // configuration name 254 | name: String, 255 | // configuration value 256 | value: String, 257 | } 258 | 259 | impl Command for EnvSubcommand { 260 | fn run(&self) -> Result<()> { 261 | match &self { 262 | EnvSubcommand::Get(get) => { 263 | let ws = Workspace::discover()?; 264 | let env = ws.config.env(&get.env)?; 265 | match get.name.as_str() { 266 | // todo: serialize 267 | "strategy" => println!("{}", serde_json::to_string(&env.strategy)?), 268 | _ => bail!("error: unrecognized environment key '{}'", get.name), 269 | } 270 | }, 271 | EnvSubcommand::Set(set) => { 272 | let mut ws = Workspace::discover()?; 273 | let env = ws.config.env_mut(&set.env)?; 274 | match set.name.as_str() { 275 | "strategy" => { 276 | env.strategy = serde_json::from_str(&set.value)?; 277 | }, 278 | _ => bail!("error: unrecognized environment key '{}'", set.name), 279 | } 280 | ws.save()?; 281 | }, 282 | }; 283 | Ok(()) 284 | } 285 | } 286 | 287 | #[derive(Args, Debug)] 288 | struct Register { 289 | /// flake reference to the project; for example github:chadac/nixspace 290 | url: String, 291 | /// name of the directory that the project will be cloned into when added. 292 | /// default is the name of the project at the root of the workspace. 293 | #[arg(short, long)] 294 | path: Option, 295 | /// name of the project used for replacing in flake.nix files 296 | /// default is the name of the project (if it can be inferred) 297 | #[arg(short, long)] 298 | name: Option, 299 | /// if present, clones the project locally 300 | #[arg(long)] 301 | edit: bool, 302 | } 303 | 304 | impl Command for Register { 305 | fn run(&self) -> Result<()> { 306 | let mut ws = Workspace::discover()?; 307 | let flake_ref = flake::parse(&self.url)?; 308 | let name = match &self.name { 309 | Some(n) => n.to_string(), 310 | None => flake_ref.infer_name().context("could not infer project name!")? 311 | }; 312 | let project = ws.register(&name, flake_ref, &self.path)?; 313 | 314 | if self.edit { 315 | ws.edit(&name)?; 316 | } 317 | 318 | // update the lockfile 319 | for env in ws.config.environments() { 320 | ws.update_all_projects(&Some(env))?; 321 | } 322 | 323 | ws.save()?; 324 | 325 | println!("registered project {name} with url {}", self.url); 326 | Ok(()) 327 | } 328 | } 329 | 330 | #[derive(Args, Debug)] 331 | struct Unregister { 332 | /// identifier for the project 333 | name: String, 334 | #[arg(long)] 335 | /// if present, delete the directory from the workspace 336 | delete: bool, 337 | } 338 | 339 | impl Command for Unregister { 340 | fn run(&self) -> Result<()> { 341 | let mut ws = Workspace::discover()?; 342 | ws.deregister(&self.name, self.delete)?; 343 | for env in ws.config.environments() { 344 | ws.update_all_projects(&Some(env))?; 345 | } 346 | ws.save()?; 347 | println!("removed {} from the workspace", self.name); 348 | Ok(()) 349 | } 350 | } 351 | 352 | #[derive(Args, Debug)] 353 | struct Edit { 354 | /// name of the project 355 | name: String, 356 | } 357 | 358 | impl Command for Edit { 359 | fn run(&self) -> Result<()> { 360 | let mut ws = Workspace::discover()?; 361 | ws.edit(&self.name)?; 362 | ws.save()?; 363 | Ok(()) 364 | } 365 | } 366 | 367 | #[derive(Args, Debug)] 368 | struct Unedit { 369 | /// name of the project 370 | name: String, 371 | /// if present, deletes the project locally 372 | #[arg(long)] 373 | rm: bool 374 | } 375 | 376 | impl Command for Unedit { 377 | fn run(&self) -> Result<()> { 378 | let mut ws = Workspace::discover()?; 379 | ws.unedit(&self.name, self.rm)?; 380 | ws.save()?; 381 | Ok(()) 382 | } 383 | } 384 | 385 | #[derive(Args, Debug)] 386 | struct Sync { 387 | /// will sync all local repositories with upstream 388 | #[arg(long)] 389 | local: bool 390 | } 391 | 392 | impl Command for Sync { 393 | fn run(&self) -> Result<()> { 394 | let mut ws = Workspace::discover()?; 395 | ws.sync()?; 396 | Ok(()) 397 | } 398 | } 399 | 400 | #[derive(Args, Debug)] 401 | struct Publish { 402 | /// commit message to include in publish 403 | #[arg(short, long)] 404 | message: String, 405 | #[arg(short, long)] 406 | force: bool, 407 | } 408 | 409 | impl Command for Publish { 410 | fn run(&self) -> Result<()> { 411 | let ws = Workspace::discover()?; 412 | ws.commit(&self.message)?; 413 | ws.publish(self.force)?; 414 | Ok(()) 415 | } 416 | } 417 | 418 | #[derive(Args, Debug)] 419 | struct Update { 420 | /// environment to update 421 | env: Option, 422 | /// if present, publishes the new lockfile to the Git repository 423 | #[arg(long)] 424 | publish: bool, 425 | } 426 | 427 | impl Command for Update { 428 | fn run(&self) -> Result<()> { 429 | let mut ws = Workspace::discover()?; 430 | ws.update_all_projects(&self.env)?; 431 | ws.save()?; 432 | if self.publish { 433 | if ws.tracks_latest()? { 434 | ws.commit("chore: update workspace")?; 435 | ws.publish(false)?; 436 | } 437 | else { 438 | bail!("cannot commit; upstream is ahead of local git repository."); 439 | } 440 | } 441 | Ok(()) 442 | } 443 | } 444 | 445 | #[derive(Args, Debug)] 446 | struct NixArgs { 447 | #[arg(trailing_var_arg = true, allow_hyphen_values = true, hide = true)] 448 | args: Vec, 449 | } 450 | 451 | impl NixArgs { 452 | fn target(&self) -> Option<(String, String)> { 453 | self.args.iter() 454 | .find(|arg| !arg.starts_with("-") && arg.contains("#")) 455 | .map(|arg| { 456 | let mut split = arg.splitn(2, "#"); 457 | (split.next().unwrap().to_string(), split.next().unwrap().to_string()) 458 | }) 459 | } 460 | 461 | fn args(&self, cmd: &str) -> Vec { 462 | let mut args = Vec::from(&[ cmd.to_string() ]).into_iter().chain( 463 | self.args.clone().into_iter() 464 | ).collect::>(); 465 | if let Some((f, t)) = self.target() { 466 | let target = format!("{f}#{t}"); 467 | let target_index = args.iter().position(|arg| *arg == target).unwrap(); 468 | args.remove(target_index); 469 | } 470 | args 471 | } 472 | 473 | fn run(&self, cmd: &str) -> Result<()> { 474 | let ws = Workspace::discover()?; 475 | let mut target = self.target(); 476 | let mut args = self.args(cmd); 477 | if !args.contains(&"--impure".to_string()) { 478 | args.push("--impure".to_string()); 479 | } 480 | if let Some(project) = ws.context()? { 481 | if let Some((old_flake, old_target)) = target.clone() { 482 | let new_target = match old_target.as_str() { 483 | "" => "default", 484 | s => s, 485 | }.to_string(); 486 | if old_flake == "." { 487 | target = Some(( 488 | // TODO: fix this panic 489 | format!("path:{}", std::fs::canonicalize(ws.root.clone())?.into_os_string().into_string().unwrap()), 490 | format!("{}/{}", project.config.name, new_target) 491 | )); 492 | } 493 | } 494 | else { 495 | bail!("could not figure out the target you're trying to run/build; malformatted command?") 496 | } 497 | } 498 | if let Some((new_flake, new_target)) = target { 499 | args.insert(1, format!("{}#{}", new_flake, new_target)) 500 | } 501 | Nix::interactive( 502 | &args.iter().map(|s| s.as_str()).collect::>()[..], 503 | &std::env::current_dir()?, 504 | )?; 505 | Ok(()) 506 | } 507 | } 508 | 509 | fn exec(command: &Commands) -> Result<()> { 510 | match command { 511 | Commands::Init(cmd) => cmd.run(), 512 | Commands::Clone(cmd) => cmd.run(), 513 | Commands::Show(cmd) => cmd.run(), 514 | 515 | Commands::Config(cmd) => cmd.run(), 516 | Commands::Env(cmd) => cmd.run(), 517 | 518 | Commands::Register(cmd) => cmd.run(), 519 | Commands::Unregister(cmd) => cmd.run(), 520 | 521 | Commands::Edit(cmd) => cmd.run(), 522 | Commands::Unedit(cmd) => cmd.run(), 523 | 524 | Commands::Sync(cmd) => cmd.run(), 525 | Commands::Publish(cmd) => cmd.run(), 526 | Commands::Update(cmd) => cmd.run(), 527 | 528 | Commands::Build(nix) => nix.run("build"), 529 | Commands::Run(nix) => nix.run("run"), 530 | }?; 531 | Ok(()) 532 | } 533 | 534 | fn main() -> () { 535 | let cli = Cli::parse(); 536 | if let Some(v) = cli.verbose.log_level() { 537 | let filter = v.to_level_filter(); 538 | let config = simplelog::ConfigBuilder::new() 539 | .set_time_level(log::LevelFilter::Off) 540 | .set_thread_level(log::LevelFilter::Off) 541 | .build(); 542 | 543 | simplelog::CombinedLogger::init( 544 | vec![ 545 | simplelog::TermLogger::new(filter, config, simplelog::TerminalMode::Mixed, simplelog::ColorChoice::Auto), 546 | ] 547 | ).unwrap(); 548 | 549 | // capture the backtrace if we're at trace level 550 | if v >= log::Level::Trace { 551 | std::env::set_var("RUST_BACKTRACE", "1"); 552 | } 553 | } 554 | 555 | match exec(&cli.command) { 556 | Ok(()) => (), 557 | Err(e) => { 558 | log::error!("{e}"); 559 | log::trace!("backtrace:\n{}", e.backtrace()); 560 | std::process::exit(0x0100); 561 | }, 562 | } 563 | } 564 | 565 | #[cfg(test)] 566 | mod tests { 567 | use crate::*; 568 | 569 | // #[test] 570 | // #[ignore] 571 | // fn test_init() -> Result<()> { 572 | // Init { name: "test-workspace".to_string(), template_type: None }.run() 573 | // } 574 | } 575 | -------------------------------------------------------------------------------- /src/snapshots/.gitignore: -------------------------------------------------------------------------------- 1 | *.new 2 | -------------------------------------------------------------------------------- /src/snapshots/ns__cli__git_tests__ls_remote.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/cli.rs 3 | expression: "Git::ls_remote(\"https://github.com/chadac/test-nixspace-nix-shared\")?" 4 | --- 5 | [ 6 | GitRef { 7 | rev: "9fe367dbf57fe507c07dc82f80bd3a2b43696d68", 8 | git_ref: "HEAD", 9 | }, 10 | GitRef { 11 | rev: "9fe367dbf57fe507c07dc82f80bd3a2b43696d68", 12 | git_ref: "refs/heads/main", 13 | }, 14 | ] 15 | -------------------------------------------------------------------------------- /src/snapshots/ns__cli__nix_tests__flake_metadata.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/cli.rs 3 | expression: "Nix::flake_metadata(\"github:chadac/test-nixspace-nix-shared\")?" 4 | --- 5 | FlakeMetadata { 6 | description: None, 7 | last_modified: 1703175821, 8 | locked: InputSpec { 9 | flake_type: GitHub, 10 | nar_hash: Some( 11 | "sha256-63QmnF/dH5LsQXTfhncBcsOdMe/+Uc34N1JnmvNrvAk=", 12 | ), 13 | url: None, 14 | owner: Some( 15 | "chadac", 16 | ), 17 | repo: Some( 18 | "test-nixspace-nix-shared", 19 | ), 20 | dir: None, 21 | rev: Some( 22 | "9fe367dbf57fe507c07dc82f80bd3a2b43696d68", 23 | ), 24 | git_ref: None, 25 | rev_count: None, 26 | last_modified: Some( 27 | 1703175821, 28 | ), 29 | }, 30 | locks: LockFile { 31 | nodes: { 32 | "flake-parts": LockedRef { 33 | flake: None, 34 | locked: Some( 35 | InputSpec { 36 | flake_type: GitHub, 37 | nar_hash: Some( 38 | "sha256-YcVE5emp1qQ8ieHUnxt1wCZCC3ZfAS+SRRWZ2TMda7E=", 39 | ), 40 | url: None, 41 | owner: Some( 42 | "hercules-ci", 43 | ), 44 | repo: Some( 45 | "flake-parts", 46 | ), 47 | dir: None, 48 | rev: Some( 49 | "34fed993f1674c8d06d58b37ce1e0fe5eebcb9f5", 50 | ), 51 | git_ref: None, 52 | rev_count: None, 53 | last_modified: Some( 54 | 1701473968, 55 | ), 56 | }, 57 | ), 58 | original: Some( 59 | InputSpec { 60 | flake_type: GitHub, 61 | nar_hash: None, 62 | url: None, 63 | owner: Some( 64 | "hercules-ci", 65 | ), 66 | repo: Some( 67 | "flake-parts", 68 | ), 69 | dir: None, 70 | rev: None, 71 | git_ref: None, 72 | rev_count: None, 73 | last_modified: None, 74 | }, 75 | ), 76 | inputs: Some( 77 | { 78 | "nixpkgs-lib": Direct( 79 | "nixpkgs-lib", 80 | ), 81 | }, 82 | ), 83 | }, 84 | "nixpkgs": LockedRef { 85 | flake: None, 86 | locked: Some( 87 | InputSpec { 88 | flake_type: GitHub, 89 | nar_hash: Some( 90 | "sha256-O7Vb0xC9s4Dmgxj8APEpuuMj7HsLgPbpy1UKvNVJp7o=", 91 | ), 92 | url: None, 93 | owner: Some( 94 | "NixOS", 95 | ), 96 | repo: Some( 97 | "nixpkgs", 98 | ), 99 | dir: None, 100 | rev: Some( 101 | "dd8e82f3b4017b8faa52c2b1897a38d53c3c26cb", 102 | ), 103 | git_ref: None, 104 | rev_count: None, 105 | last_modified: Some( 106 | 1702938738, 107 | ), 108 | }, 109 | ), 110 | original: Some( 111 | InputSpec { 112 | flake_type: GitHub, 113 | nar_hash: None, 114 | url: None, 115 | owner: Some( 116 | "NixOS", 117 | ), 118 | repo: Some( 119 | "nixpkgs", 120 | ), 121 | dir: None, 122 | rev: None, 123 | git_ref: Some( 124 | "nixpkgs-unstable", 125 | ), 126 | rev_count: None, 127 | last_modified: None, 128 | }, 129 | ), 130 | inputs: None, 131 | }, 132 | "nixpkgs-lib": LockedRef { 133 | flake: None, 134 | locked: Some( 135 | InputSpec { 136 | flake_type: GitHub, 137 | nar_hash: Some( 138 | "sha256-ztaDIyZ7HrTAfEEUt9AtTDNoCYxUdSd6NrRHaYOIxtk=", 139 | ), 140 | url: None, 141 | owner: Some( 142 | "NixOS", 143 | ), 144 | repo: Some( 145 | "nixpkgs", 146 | ), 147 | dir: Some( 148 | "lib", 149 | ), 150 | rev: Some( 151 | "e92039b55bcd58469325ded85d4f58dd5a4eaf58", 152 | ), 153 | git_ref: None, 154 | rev_count: None, 155 | last_modified: Some( 156 | 1701253981, 157 | ), 158 | }, 159 | ), 160 | original: Some( 161 | InputSpec { 162 | flake_type: GitHub, 163 | nar_hash: None, 164 | url: None, 165 | owner: Some( 166 | "NixOS", 167 | ), 168 | repo: Some( 169 | "nixpkgs", 170 | ), 171 | dir: Some( 172 | "lib", 173 | ), 174 | rev: None, 175 | git_ref: Some( 176 | "nixos-unstable", 177 | ), 178 | rev_count: None, 179 | last_modified: None, 180 | }, 181 | ), 182 | inputs: None, 183 | }, 184 | "root": LockedRef { 185 | flake: None, 186 | locked: None, 187 | original: None, 188 | inputs: Some( 189 | { 190 | "flake-parts": Direct( 191 | "flake-parts", 192 | ), 193 | "nixpkgs": Direct( 194 | "nixpkgs", 195 | ), 196 | "systems": Direct( 197 | "systems", 198 | ), 199 | }, 200 | ), 201 | }, 202 | "systems": LockedRef { 203 | flake: None, 204 | locked: Some( 205 | InputSpec { 206 | flake_type: GitHub, 207 | nar_hash: Some( 208 | "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 209 | ), 210 | url: None, 211 | owner: Some( 212 | "nix-systems", 213 | ), 214 | repo: Some( 215 | "default", 216 | ), 217 | dir: None, 218 | rev: Some( 219 | "da67096a3b9bf56a91d16901293e51ba5b49a27e", 220 | ), 221 | git_ref: None, 222 | rev_count: None, 223 | last_modified: Some( 224 | 1681028828, 225 | ), 226 | }, 227 | ), 228 | original: Some( 229 | InputSpec { 230 | flake_type: GitHub, 231 | nar_hash: None, 232 | url: None, 233 | owner: Some( 234 | "nix-systems", 235 | ), 236 | repo: Some( 237 | "default", 238 | ), 239 | dir: None, 240 | rev: None, 241 | git_ref: None, 242 | rev_count: None, 243 | last_modified: None, 244 | }, 245 | ), 246 | inputs: None, 247 | }, 248 | }, 249 | root: "root", 250 | version: 7, 251 | }, 252 | original: InputSpec { 253 | flake_type: GitHub, 254 | nar_hash: None, 255 | url: None, 256 | owner: Some( 257 | "chadac", 258 | ), 259 | repo: Some( 260 | "test-nixspace-nix-shared", 261 | ), 262 | dir: None, 263 | rev: None, 264 | git_ref: None, 265 | rev_count: None, 266 | last_modified: None, 267 | }, 268 | original_url: "github:chadac/test-nixspace-nix-shared", 269 | path: "/nix/store/709kcnrbx04b4n2injvidqvlynvgxkhv-source", 270 | resolved: InputSpec { 271 | flake_type: GitHub, 272 | nar_hash: None, 273 | url: None, 274 | owner: Some( 275 | "chadac", 276 | ), 277 | repo: Some( 278 | "test-nixspace-nix-shared", 279 | ), 280 | dir: None, 281 | rev: None, 282 | git_ref: None, 283 | rev_count: None, 284 | last_modified: None, 285 | }, 286 | resolved_url: "github:chadac/test-nixspace-nix-shared", 287 | revision: "9fe367dbf57fe507c07dc82f80bd3a2b43696d68", 288 | url: "github:chadac/test-nixspace-nix-shared/9fe367dbf57fe507c07dc82f80bd3a2b43696d68", 289 | } 290 | -------------------------------------------------------------------------------- /src/snapshots/ns__cli__nix_tests__flake_prefetch.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/cli.rs 3 | expression: "Nix::flake_prefetch(\"github:chadac/test-nixspace-nix-shared\")?" 4 | --- 5 | FlakePrefetch { 6 | hash: "sha256-63QmnF/dH5LsQXTfhncBcsOdMe/+Uc34N1JnmvNrvAk=", 7 | store_path: "/nix/store/709kcnrbx04b4n2injvidqvlynvgxkhv-source", 8 | } 9 | -------------------------------------------------------------------------------- /src/util.rs: -------------------------------------------------------------------------------- 1 | use std::path::{Path, PathBuf}; 2 | 3 | pub fn find_root + ?Sized>(name: &str, wd: &P) -> Option { 4 | let mut cwd: PathBuf = PathBuf::new(); 5 | cwd.push(wd); 6 | loop { 7 | let path = cwd.as_path().join(name); 8 | if path.exists() { 9 | return Some(cwd.as_path().into()); 10 | } 11 | if !cwd.pop() { 12 | break 13 | } 14 | }; 15 | None 16 | } 17 | -------------------------------------------------------------------------------- /src/workspace.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{anyhow, bail, Context, Error, Result}; 2 | use std::collections::BTreeMap; 3 | use std::path::{Path, PathBuf}; 4 | use std::rc::Rc; 5 | use colored::Colorize; 6 | 7 | use super::flake::FlakeRef; 8 | use super::lockfile::LockFile; 9 | use super::config::{Config, LocalConfig, ProjectConfig}; 10 | use super::cli::{CliCommand, Git, Nix}; 11 | 12 | static CONFIG_PATH: &str = "nixspace.toml"; 13 | static LOCKFILE_DIR: &str = ".nixspace"; 14 | static LOCAL_PATH: &str = ".nixspace/local.json"; 15 | 16 | pub struct Workspace { 17 | pub root: PathBuf, 18 | pub config: Config, 19 | pub lock: BTreeMap, 20 | pub local: LocalConfig, 21 | } 22 | 23 | #[derive(Clone)] 24 | pub struct ProjectRef<'config> { 25 | pub config: &'config ProjectConfig, 26 | pub flake_ref: Rc, 27 | pub editable: bool, 28 | } 29 | 30 | fn _config_path(root: &PathBuf) -> PathBuf { 31 | root.join(CONFIG_PATH) 32 | } 33 | 34 | fn _lock_path(root: &PathBuf, env: &str) -> PathBuf { 35 | root.join(LOCKFILE_DIR).join(format!("{}.lock", env)) 36 | } 37 | 38 | fn _local_path(root: &PathBuf) -> PathBuf { 39 | root.join(LOCAL_PATH) 40 | } 41 | 42 | impl Workspace { 43 | pub fn discover() -> Result { 44 | let cwd = std::env::current_dir()?; 45 | let root = Self::find_root(&cwd).ok_or(anyhow!("Could not find workspace in current directory."))?; 46 | Self::at(&root) 47 | } 48 | 49 | pub fn init + ?Sized>(root: &P) -> Result { 50 | let mut ns_root = PathBuf::new(); 51 | ns_root.push(root); 52 | let config = Config::new(); 53 | let envs = config.environments().clone(); 54 | Ok(Workspace { 55 | root: ns_root, 56 | config: Config::new(), 57 | lock: envs.iter().map(|env| (env.to_string(), LockFile::empty())).collect(), 58 | local: LocalConfig::new(), 59 | }) 60 | } 61 | 62 | pub fn at + ?Sized>(path: &P) -> Result { 63 | let mut root = PathBuf::new(); 64 | root.push(path); 65 | let config = Config::read(&_config_path(&root))?; 66 | let envs = config.environments().clone(); 67 | Ok(Workspace { 68 | root: root.clone(), 69 | config: config, 70 | lock: envs.iter() 71 | .map(|env| { 72 | let path = root.join(LOCKFILE_DIR).join(format!("{}.lock", env)); 73 | let file = LockFile::read(&path); 74 | match file { 75 | Ok(f) => Ok((env.to_string(), f)), 76 | Err(e) => Err(anyhow!("error when attempting to read '{}': {e}", path.display())), 77 | } 78 | }).collect::, _>>()?, 79 | local: LocalConfig::read(&root.join(LOCAL_PATH))?, 80 | }) 81 | } 82 | 83 | pub fn find_root + ?Sized>(wd: &P) -> Option { 84 | let mut cwd: PathBuf = PathBuf::new(); 85 | let filename = CONFIG_PATH; 86 | cwd.push(wd); 87 | loop { 88 | let path = cwd.as_path().join(filename); 89 | if path.exists() { 90 | return Some(cwd.as_path().into()); 91 | } 92 | if !cwd.pop() { 93 | break 94 | } 95 | }; 96 | None 97 | } 98 | 99 | pub fn config_path(&self) -> PathBuf { 100 | _config_path(&self.root) 101 | } 102 | 103 | pub fn lock_path(&self, env: &str) -> PathBuf { 104 | _lock_path(&self.root, env) 105 | } 106 | 107 | pub fn local_path(&self) -> PathBuf { 108 | _local_path(&self.root) 109 | } 110 | 111 | pub fn save(&self) -> Result<()> { 112 | self.config.write(&self.config_path())?; 113 | self.local.write(&self.local_path())?; 114 | for env in self.config.environments() { 115 | if let Some(lock) = self.lock.get(&env) { 116 | lock.write(&self.lock_path(&env))?; 117 | } 118 | } 119 | Ok(()) 120 | } 121 | 122 | fn files(&self) -> Vec { 123 | let mut files = Vec::new(); 124 | let mut flake_nix = self.root.clone(); 125 | flake_nix.push("flake.nix"); 126 | let mut flake_lock = self.root.clone(); 127 | flake_lock.push("flake.lock"); 128 | files.push(flake_nix); 129 | files.push(flake_lock); 130 | files.push(self.config_path()); 131 | for env in self.config.environments() { 132 | let mut env_lock = self.root.clone(); 133 | env_lock.push(format!("{env}.lock")); 134 | files.push(env_lock); 135 | } 136 | files 137 | } 138 | 139 | fn changed(&self) -> Result { 140 | let mut changes = Vec::new(); 141 | for file in self.files() { 142 | if Git::changed(&file)? { 143 | changes.push(file); 144 | } 145 | } 146 | Ok(!changes.is_empty()) 147 | } 148 | 149 | /// Updates workspace configuration and lockfiles with the latest 150 | /// available data. 151 | pub fn sync(&mut self) -> Result<()> { 152 | if self.changed()? { 153 | bail!("cannot update workspace due to uncommitted local changes; stash changes in the workspace directory before continuing.") 154 | } 155 | Git::pull_rebase(&self.root)?; 156 | Ok(()) 157 | } 158 | 159 | /// If true, the core files for the workspace are unchanged. 160 | pub fn tracks_latest(&self) -> Result { 161 | let items: Result, _> = self.files().iter().map(|f| Git::changed(&f)).collect(); 162 | items?.iter().map(|a| !a).reduce(|a, b| a && b).context("this should never be empty") 163 | } 164 | 165 | /// pushes any new commits from the workspace 166 | pub fn publish(&self, force: bool) -> Result<()> { 167 | Git::push(&self.root)?; 168 | Ok(()) 169 | } 170 | 171 | pub fn project(&self, name: &str) -> Result { 172 | ProjectRef::find(self, name) 173 | } 174 | 175 | pub fn projects(&self) -> Vec { 176 | let mut projects = Vec::new(); 177 | for project in &self.config.projects { 178 | projects.push( 179 | ProjectRef { 180 | config: project, 181 | flake_ref: project.flake_ref().unwrap(), 182 | editable: self.local.is_editable(&project.name), 183 | } 184 | ); 185 | } 186 | projects 187 | } 188 | 189 | pub fn register(&mut self, name: &str, flake_ref: Rc, path: &Option) -> Result { 190 | let config = self.config.add_project(name, flake_ref.as_ref(), path)?; 191 | self.local.unmark_editable(name); 192 | Ok(ProjectRef { 193 | config: config, 194 | flake_ref: flake_ref.clone(), 195 | editable: false, 196 | }) 197 | } 198 | 199 | pub fn deregister(&mut self, name: &str, delete: bool) -> Result<()> { 200 | // Remove project locally 201 | if delete { 202 | let project = self.project(name)?; 203 | if let Some(p) = &project.config.path { 204 | log::info!("removing directory '{:?}'", p.clone().into_os_string()); 205 | std::fs::remove_dir_all(&p)?; 206 | } else { 207 | log::warn!("project has no path registered, you may need to manually delete the project"); 208 | } 209 | } 210 | 211 | // remove project from config 212 | let index = self.config.projects.iter().position(|p| p.name == name) 213 | .with_context(|| anyhow!("could not find project '{name}'"))?; 214 | self.config.projects.remove(index); 215 | 216 | // remove project from lockfile entries 217 | for (_, lockfile) in self.lock.iter_mut() { 218 | lockfile.rm(name)?; 219 | } 220 | 221 | // remove project from local lockfile 222 | self.local.projects.remove(name); 223 | 224 | Ok(()) 225 | } 226 | 227 | pub fn print_tree(&self) -> () { 228 | let mut paths = self.projects().iter() 229 | .map(|p| { 230 | match p.config.path.as_ref() { 231 | Some(path) => Some((path, p.config.url.to_string(), p.editable)), 232 | None => None, 233 | } 234 | }) 235 | .flatten() 236 | .map(|(path, url, e)| (path.to_string_lossy().to_string(), url, e)) 237 | .collect::>(); 238 | paths.sort(); 239 | for (path, url, editable) in &paths { 240 | println!( 241 | "{:030} {}", 242 | path.bold(), 243 | if *editable { url.green() } else { url.red() } 244 | ); 245 | } 246 | } 247 | 248 | /// Uses the local copy of a project for building. 249 | pub fn edit(&mut self, name: &str) -> Result<()> { 250 | let project = self.project(name)?; 251 | if project.config.path.is_none() { 252 | bail!("cannot use project with no configured local path. see `ns project --help`"); 253 | } 254 | let path = project.config.path.as_ref().unwrap(); 255 | 256 | if !path.exists() { 257 | Nix::clone( 258 | &project.flake_ref.flake_url(), 259 | &path, 260 | "." 261 | )?; 262 | } 263 | 264 | if self.local.is_editable(&name) { 265 | bail!("project {0} is already marked as editable; exiting", project.config.name); 266 | } 267 | 268 | self.mark_editable(&name); 269 | Ok(()) 270 | } 271 | 272 | /// Removes a project from being tracked locally 273 | pub fn unedit(&mut self, name: &str, delete: bool) -> Result<()> { 274 | self.unmark_editable(name); 275 | 276 | if delete { 277 | let project = self.project(name)?; 278 | if let Some(p) = &project.config.path { 279 | std::fs::remove_dir_all(&p)?; 280 | } 281 | } 282 | 283 | Ok(()) 284 | } 285 | 286 | pub fn mark_editable(&mut self, project_name: &str) -> () { 287 | self.local.mark_editable(project_name); 288 | } 289 | 290 | pub fn unmark_editable(&mut self, project_name: &str) -> () { 291 | self.local.unmark_editable(project_name); 292 | } 293 | 294 | pub fn update_all_projects(&mut self, env: &Option) -> Result<()> { 295 | let e: String = match env { 296 | Some(v) => v.to_string(), 297 | None => self.config.default_env.to_string(), 298 | }; 299 | self.lock.get(&e).ok_or( 300 | anyhow!("error: workspace config missing env '{}'", e) 301 | )?; 302 | 303 | let default = self.config.env(&e)?.strategy.clone(); 304 | let mut lock_updates = BTreeMap::new(); 305 | 306 | for project in self.projects() { 307 | let strategy = { 308 | match &project.config.strategy { 309 | Some(cfg) => cfg.get(&e).unwrap_or(&default), 310 | None => &default, 311 | } 312 | }; 313 | let metadata = strategy.update(project.flake_ref)?; 314 | lock_updates.insert(project.config.name.to_string(), metadata); 315 | } 316 | let new_lock = LockFile::from_metadata(lock_updates)?; 317 | 318 | self.lock.insert(e, new_lock); 319 | 320 | Ok(()) 321 | } 322 | 323 | /// Creates a commit tracking the config and lockfile. 324 | pub fn commit(&self, commit_message: &str) -> Result<()> { 325 | Git::reset(&self.root)?; 326 | Git::add(&self.config_path())?; 327 | for env in self.config.environments() { 328 | Git::add(&self.lock_path(&env))?; 329 | } 330 | Git::commit(commit_message, &self.root)?; 331 | Ok(()) 332 | } 333 | 334 | /// Returns the current project that a user is within. 335 | pub fn context(&self) -> Result> { 336 | let cwd = std::env::current_dir()?; 337 | for project in self.projects() { 338 | if let Some(subpath) = &project.config.path { 339 | let path = self.root.join(subpath); 340 | if cwd.starts_with(path) { 341 | return Ok(Some(project)) 342 | } 343 | } 344 | } 345 | Ok(None) 346 | } 347 | } 348 | 349 | impl<'config> ProjectRef<'config> { 350 | fn find(ws: &'config Workspace, name: &str) -> Result> { 351 | let config = ws.config.project(name)?; 352 | Ok(ProjectRef { 353 | config: config, 354 | flake_ref: config.flake_ref()?, 355 | editable: ws.local.is_editable(name), 356 | }) 357 | } 358 | } 359 | 360 | #[cfg(test)] 361 | mod tests { 362 | use anyhow::Result; 363 | use tempdir::TempDir; 364 | use super::Workspace; 365 | 366 | #[test] 367 | fn finds_root_works() -> Result<()> { 368 | let tmp = TempDir::new("workspace")?; 369 | let cwd = tmp.path().join("a/b/c/d/e"); 370 | std::fs::create_dir_all(cwd.clone())?; 371 | let ws = tmp.path().join("a/b/nixspace.toml"); 372 | std::fs::OpenOptions::new().create(true).write(true).open(ws.clone())?; 373 | let root = Workspace::find_root(&cwd); 374 | assert_eq!(root, Some(ws.parent().unwrap().into())); 375 | Ok(()) 376 | } 377 | 378 | #[test] 379 | fn find_root_fails_not_in_ws() -> Result<()> { 380 | let tmp = TempDir::new("workspace")?; 381 | let cwd = tmp.path().join("a/b/c/d/e"); 382 | std::fs::create_dir_all(cwd.clone())?; 383 | assert!( 384 | Workspace::find_root(&cwd).is_none() 385 | ); 386 | Ok(()) 387 | } 388 | } 389 | -------------------------------------------------------------------------------- /templates/basic/.nixspace/local.json: -------------------------------------------------------------------------------- 1 | {"projects":{}} 2 | -------------------------------------------------------------------------------- /templates/basic/.nixspace/main.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "root": { 4 | "inputs": {} 5 | } 6 | }, 7 | "root": "root", 8 | "version": 7 9 | } 10 | -------------------------------------------------------------------------------- /templates/basic/flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "A sample workspace with nixspace"; 3 | inputs = { 4 | nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; 5 | systems.url = "github:nix-systems/default"; 6 | nixspace.url = "github:chadac/nixspace"; 7 | }; 8 | 9 | outputs = { systems, nixspace, ... }@inputs: let 10 | ws = nixspace.lib.mkWorkspace { 11 | src = ./.; 12 | systems = import systems; 13 | inherit inputs; 14 | }; 15 | in ws.default.flake; 16 | } 17 | -------------------------------------------------------------------------------- /templates/basic/nixspace.toml: -------------------------------------------------------------------------------- 1 | projects = [] 2 | default_env = "main" 3 | 4 | [[environments]] 5 | name = "main" 6 | strategy = "latest" 7 | -------------------------------------------------------------------------------- /templates/flake-parts/.nixspace/local.json: -------------------------------------------------------------------------------- 1 | {"projects":{}} 2 | -------------------------------------------------------------------------------- /templates/flake-parts/.nixspace/main.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "root": { 4 | "inputs": {} 5 | } 6 | }, 7 | "root": "root", 8 | "version": 7 9 | } 10 | -------------------------------------------------------------------------------- /templates/flake-parts/flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "A sample workspace flake using flake-parts with nixspace"; 3 | inputs = { 4 | nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; 5 | systems.url = "github:nix-systems/default"; 6 | flake-parts.url = "github:hercules-ci/flake-parts"; 7 | nixspace.url = "github:chadac/nixspace"; 8 | }; 9 | 10 | outputs = { flake-parts, systems, nixspace, ... }@inputs: let 11 | ws = nixspace.lib.mkWorkspace { 12 | src = ./.; 13 | systems = import systems; 14 | inherit inputs; 15 | }; 16 | in flake-parts.lib.mkFlake { inherit inputs; } ({ ... }: { 17 | systems = import systems; 18 | imports = [ ws.default.flakeModule ]; 19 | }); 20 | } 21 | -------------------------------------------------------------------------------- /templates/flake-parts/nixspace.toml: -------------------------------------------------------------------------------- 1 | projects = [] 2 | default_env = "main" 3 | 4 | [[environments]] 5 | name = "main" 6 | strategy = "latest" 7 | --------------------------------------------------------------------------------