├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── README.md ├── examles ├── tiny.svg ├── tiny.yml ├── xyz.svg └── xyz.yml └── src ├── cli.rs ├── dot.rs ├── lib.rs ├── main.rs ├── parser ├── error.rs ├── helpers.rs └── mod.rs ├── store.rs └── task.rs /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | [[package]] 4 | name = "ansi_term" 5 | version = "0.11.0" 6 | source = "registry+https://github.com/rust-lang/crates.io-index" 7 | checksum = "ee49baf6cb617b853aa8d93bf420db2383fab46d314482ca2803b40d5fde979b" 8 | dependencies = [ 9 | "winapi", 10 | ] 11 | 12 | [[package]] 13 | name = "atty" 14 | version = "0.2.14" 15 | source = "registry+https://github.com/rust-lang/crates.io-index" 16 | checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" 17 | dependencies = [ 18 | "hermit-abi", 19 | "libc", 20 | "winapi", 21 | ] 22 | 23 | [[package]] 24 | name = "bitflags" 25 | version = "1.2.1" 26 | source = "registry+https://github.com/rust-lang/crates.io-index" 27 | checksum = "cf1de2fe8c75bc145a2f577add951f8134889b4795d47466a54a5c846d691693" 28 | 29 | [[package]] 30 | name = "clap" 31 | version = "2.33.1" 32 | source = "registry+https://github.com/rust-lang/crates.io-index" 33 | checksum = "bdfa80d47f954d53a35a64987ca1422f495b8d6483c0fe9f7117b36c2a792129" 34 | dependencies = [ 35 | "ansi_term", 36 | "atty", 37 | "bitflags", 38 | "strsim", 39 | "textwrap", 40 | "unicode-width", 41 | "vec_map", 42 | ] 43 | 44 | [[package]] 45 | name = "derive_more" 46 | version = "0.99.11" 47 | source = "registry+https://github.com/rust-lang/crates.io-index" 48 | checksum = "41cb0e6161ad61ed084a36ba71fbba9e3ac5aee3606fb607fe08da6acbcf3d8c" 49 | dependencies = [ 50 | "proc-macro2", 51 | "quote", 52 | "syn", 53 | ] 54 | 55 | [[package]] 56 | name = "heck" 57 | version = "0.3.1" 58 | source = "registry+https://github.com/rust-lang/crates.io-index" 59 | checksum = "20564e78d53d2bb135c343b3f47714a56af2061f1c928fdb541dc7b9fdd94205" 60 | dependencies = [ 61 | "unicode-segmentation", 62 | ] 63 | 64 | [[package]] 65 | name = "hermit-abi" 66 | version = "0.1.13" 67 | source = "registry+https://github.com/rust-lang/crates.io-index" 68 | checksum = "91780f809e750b0a89f5544be56617ff6b1227ee485bcb06ebe10cdf89bd3b71" 69 | dependencies = [ 70 | "libc", 71 | ] 72 | 73 | [[package]] 74 | name = "lazy_static" 75 | version = "1.4.0" 76 | source = "registry+https://github.com/rust-lang/crates.io-index" 77 | checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" 78 | 79 | [[package]] 80 | name = "libc" 81 | version = "0.2.70" 82 | source = "registry+https://github.com/rust-lang/crates.io-index" 83 | checksum = "3baa92041a6fec78c687fa0cc2b3fae8884f743d672cf551bed1d6dac6988d0f" 84 | 85 | [[package]] 86 | name = "linked-hash-map" 87 | version = "0.5.2" 88 | source = "registry+https://github.com/rust-lang/crates.io-index" 89 | checksum = "ae91b68aebc4ddb91978b11a1b02ddd8602a05ec19002801c5666000e05e0f83" 90 | 91 | [[package]] 92 | name = "proc-macro-error" 93 | version = "1.0.2" 94 | source = "registry+https://github.com/rust-lang/crates.io-index" 95 | checksum = "98e9e4b82e0ef281812565ea4751049f1bdcdfccda7d3f459f2e138a40c08678" 96 | dependencies = [ 97 | "proc-macro-error-attr", 98 | "proc-macro2", 99 | "quote", 100 | "syn", 101 | "version_check", 102 | ] 103 | 104 | [[package]] 105 | name = "proc-macro-error-attr" 106 | version = "1.0.2" 107 | source = "registry+https://github.com/rust-lang/crates.io-index" 108 | checksum = "4f5444ead4e9935abd7f27dc51f7e852a0569ac888096d5ec2499470794e2e53" 109 | dependencies = [ 110 | "proc-macro2", 111 | "quote", 112 | "syn", 113 | "syn-mid", 114 | "version_check", 115 | ] 116 | 117 | [[package]] 118 | name = "proc-macro2" 119 | version = "1.0.9" 120 | source = "registry+https://github.com/rust-lang/crates.io-index" 121 | checksum = "6c09721c6781493a2a492a96b5a5bf19b65917fe6728884e7c44dd0c60ca3435" 122 | dependencies = [ 123 | "unicode-xid", 124 | ] 125 | 126 | [[package]] 127 | name = "quote" 128 | version = "1.0.3" 129 | source = "registry+https://github.com/rust-lang/crates.io-index" 130 | checksum = "2bdc6c187c65bca4260c9011c9e3132efe4909da44726bad24cf7572ae338d7f" 131 | dependencies = [ 132 | "proc-macro2", 133 | ] 134 | 135 | [[package]] 136 | name = "strsim" 137 | version = "0.8.0" 138 | source = "registry+https://github.com/rust-lang/crates.io-index" 139 | checksum = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a" 140 | 141 | [[package]] 142 | name = "structopt" 143 | version = "0.3.14" 144 | source = "registry+https://github.com/rust-lang/crates.io-index" 145 | checksum = "863246aaf5ddd0d6928dfeb1a9ca65f505599e4e1b399935ef7e75107516b4ef" 146 | dependencies = [ 147 | "clap", 148 | "lazy_static", 149 | "structopt-derive", 150 | ] 151 | 152 | [[package]] 153 | name = "structopt-derive" 154 | version = "0.4.7" 155 | source = "registry+https://github.com/rust-lang/crates.io-index" 156 | checksum = "d239ca4b13aee7a2142e6795cbd69e457665ff8037aed33b3effdc430d2f927a" 157 | dependencies = [ 158 | "heck", 159 | "proc-macro-error", 160 | "proc-macro2", 161 | "quote", 162 | "syn", 163 | ] 164 | 165 | [[package]] 166 | name = "syn" 167 | version = "1.0.17" 168 | source = "registry+https://github.com/rust-lang/crates.io-index" 169 | checksum = "0df0eb663f387145cab623dea85b09c2c5b4b0aef44e945d928e682fce71bb03" 170 | dependencies = [ 171 | "proc-macro2", 172 | "quote", 173 | "unicode-xid", 174 | ] 175 | 176 | [[package]] 177 | name = "syn-mid" 178 | version = "0.5.0" 179 | source = "registry+https://github.com/rust-lang/crates.io-index" 180 | checksum = "7be3539f6c128a931cf19dcee741c1af532c7fd387baa739c03dd2e96479338a" 181 | dependencies = [ 182 | "proc-macro2", 183 | "quote", 184 | "syn", 185 | ] 186 | 187 | [[package]] 188 | name = "textwrap" 189 | version = "0.11.0" 190 | source = "registry+https://github.com/rust-lang/crates.io-index" 191 | checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060" 192 | dependencies = [ 193 | "unicode-width", 194 | ] 195 | 196 | [[package]] 197 | name = "thiserror" 198 | version = "1.0.13" 199 | source = "registry+https://github.com/rust-lang/crates.io-index" 200 | checksum = "e3711fd1c4e75b3eff12ba5c40dba762b6b65c5476e8174c1a664772060c49bf" 201 | dependencies = [ 202 | "thiserror-impl", 203 | ] 204 | 205 | [[package]] 206 | name = "thiserror-impl" 207 | version = "1.0.13" 208 | source = "registry+https://github.com/rust-lang/crates.io-index" 209 | checksum = "ae2b85ba4c9aa32dd3343bd80eb8d22e9b54b7688c17ea3907f236885353b233" 210 | dependencies = [ 211 | "proc-macro2", 212 | "quote", 213 | "syn", 214 | ] 215 | 216 | [[package]] 217 | name = "unicode-segmentation" 218 | version = "1.6.0" 219 | source = "registry+https://github.com/rust-lang/crates.io-index" 220 | checksum = "e83e153d1053cbb5a118eeff7fd5be06ed99153f00dbcd8ae310c5fb2b22edc0" 221 | 222 | [[package]] 223 | name = "unicode-width" 224 | version = "0.1.7" 225 | source = "registry+https://github.com/rust-lang/crates.io-index" 226 | checksum = "caaa9d531767d1ff2150b9332433f32a24622147e5ebb1f26409d5da67afd479" 227 | 228 | [[package]] 229 | name = "unicode-xid" 230 | version = "0.2.0" 231 | source = "registry+https://github.com/rust-lang/crates.io-index" 232 | checksum = "826e7639553986605ec5979c7dd957c7895e93eabed50ab2ffa7f6128a75097c" 233 | 234 | [[package]] 235 | name = "vec_map" 236 | version = "0.8.2" 237 | source = "registry+https://github.com/rust-lang/crates.io-index" 238 | checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191" 239 | 240 | [[package]] 241 | name = "version_check" 242 | version = "0.9.1" 243 | source = "registry+https://github.com/rust-lang/crates.io-index" 244 | checksum = "078775d0255232fb988e6fccf26ddc9d1ac274299aaedcedce21c6f72cc533ce" 245 | 246 | [[package]] 247 | name = "winapi" 248 | version = "0.3.8" 249 | source = "registry+https://github.com/rust-lang/crates.io-index" 250 | checksum = "8093091eeb260906a183e6ae1abdba2ef5ef2257a21801128899c3fc699229c6" 251 | dependencies = [ 252 | "winapi-i686-pc-windows-gnu", 253 | "winapi-x86_64-pc-windows-gnu", 254 | ] 255 | 256 | [[package]] 257 | name = "winapi-i686-pc-windows-gnu" 258 | version = "0.4.0" 259 | source = "registry+https://github.com/rust-lang/crates.io-index" 260 | checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" 261 | 262 | [[package]] 263 | name = "winapi-x86_64-pc-windows-gnu" 264 | version = "0.4.0" 265 | source = "registry+https://github.com/rust-lang/crates.io-index" 266 | checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" 267 | 268 | [[package]] 269 | name = "xplan" 270 | version = "0.1.0" 271 | dependencies = [ 272 | "derive_more", 273 | "structopt", 274 | "thiserror", 275 | "yaml-rust", 276 | ] 277 | 278 | [[package]] 279 | name = "yaml-rust" 280 | version = "0.4.3" 281 | source = "registry+https://github.com/rust-lang/crates.io-index" 282 | checksum = "65923dd1784f44da1d2c3dbbc5e822045628c590ba72123e1c73d3c230c4434d" 283 | dependencies = [ 284 | "linked-hash-map", 285 | ] 286 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "xplan" 3 | version = "0.1.0" 4 | authors = ["Sergey Potapov "] 5 | edition = "2018" 6 | 7 | description = "CLI tool to visualize task dependency" 8 | keywords = ["graphviz", "task", "planning"] 9 | license = "MIT" 10 | repository = "https://github.com/greyblake/xplan" 11 | homepage = "https://github.com/greyblake/xplan" 12 | 13 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 14 | 15 | [dependencies] 16 | yaml-rust = "0.4.3" 17 | thiserror = "1.0.13" 18 | structopt = "0.3.9" 19 | derive_more = "0.99.0" 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | A CLI tool to visualize dependencies between tasks in form of graph. 3 | 4 | 5 | ## Installation 6 | 7 | Prerequisites: 8 | * graphviz (`dot` executable) 9 | 10 | ``` 11 | cargo install xplan 12 | ``` 13 | 14 | ## Usage 15 | 16 | Describe the project tasks and dependencies in YAML file, that execute the command 17 | to generate dependency graph: 18 | 19 | ``` 20 | xplan ./project.yml 21 | 22 | Created file: project.svg 23 | ``` 24 | 25 | Get a graph similar to this one: 26 | 27 | 28 |
29 | YAML file: 30 | 31 | ```yaml 32 | tasks: 33 | TIN-1: 34 | name: define User model 35 | type: common 36 | TIN-2: 37 | name: create users table 38 | type: backend 39 | deps: [TIN-1] 40 | 41 | TIN-3: 42 | name: define Register API endpoint 43 | type: common 44 | deps: [TIN-1] 45 | TIN-4: 46 | name: define Login API endpoint 47 | type: common 48 | deps: [TIN-1] 49 | 50 | TIN-5: 51 | name: implement Register API endpoint 52 | type: backend 53 | deps: [TIN-2, TIN-3] 54 | TIN-6: 55 | name: implement Login API endpoint 56 | type: backend 57 | deps: [TIN-2, TIN-4] 58 | 59 | TIN-7: 60 | name: UI mock for Register page 61 | type: design 62 | TIN-8: 63 | name: UI mock for Login page 64 | type: design 65 | 66 | TIN-9: 67 | name: Implement Register page 68 | type: frontend 69 | deps: [TIN-5, TIN-7] 70 | TIN-10: 71 | name: Implement Login page 72 | type: frontend 73 | deps: [TIN-6, TIN-8] 74 | ``` 75 |
76 | 77 | Generated dependency graph: 78 | 79 | ![](https://raw.githubusercontent.com/greyblake/xplan/8b9dceb8ba913a1c4feb5d34eb0861bed38a91d4/examles/tiny.svg) 80 | -------------------------------------------------------------------------------- /examles/tiny.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | G 11 | 12 | 13 | cluster_1 14 | 15 | Legend 16 | 17 | 18 | 19 | TIN-3 20 | 21 | TIN-3 22 | define Register API endpoint 23 | 24 | 25 | 26 | TIN-5 27 | 28 | TIN-5 29 | implement Register API endpoint 30 | 31 | 32 | 33 | TIN-3->TIN-5 34 | 35 | 36 | 37 | 38 | 39 | TIN-8 40 | 41 | TIN-8 42 | UI mock for Login page 43 | 44 | 45 | 46 | TIN-10 47 | 48 | TIN-10 49 | Implement Login page 50 | 51 | 52 | 53 | TIN-8->TIN-10 54 | 55 | 56 | 57 | 58 | 59 | TIN-2 60 | 61 | TIN-2 62 | create users table 63 | 64 | 65 | 66 | TIN-2->TIN-5 67 | 68 | 69 | 70 | 71 | 72 | TIN-6 73 | 74 | TIN-6 75 | implement Login API endpoint 76 | 77 | 78 | 79 | TIN-2->TIN-6 80 | 81 | 82 | 83 | 84 | 85 | TIN-7 86 | 87 | TIN-7 88 | UI mock for Register page 89 | 90 | 91 | 92 | TIN-9 93 | 94 | TIN-9 95 | Implement Register page 96 | 97 | 98 | 99 | TIN-7->TIN-9 100 | 101 | 102 | 103 | 104 | 105 | TIN-4 106 | 107 | TIN-4 108 | define Login API endpoint 109 | 110 | 111 | 112 | TIN-4->TIN-6 113 | 114 | 115 | 116 | 117 | 118 | TIN-1 119 | 120 | TIN-1 121 | define User model 122 | 123 | 124 | 125 | TIN-1->TIN-3 126 | 127 | 128 | 129 | 130 | 131 | TIN-1->TIN-2 132 | 133 | 134 | 135 | 136 | 137 | TIN-1->TIN-4 138 | 139 | 140 | 141 | 142 | 143 | TIN-5->TIN-9 144 | 145 | 146 | 147 | 148 | 149 | TIN-6->TIN-10 150 | 151 | 152 | 153 | 154 | 155 | frontend 156 | 157 | frontend 158 | 159 | 160 | 161 | backend 162 | 163 | backend 164 | 165 | 166 | 167 | design 168 | 169 | design 170 | 171 | 172 | 173 | common 174 | 175 | common 176 | 177 | 178 | 179 | -------------------------------------------------------------------------------- /examles/tiny.yml: -------------------------------------------------------------------------------- 1 | tasks: 2 | TIN-1: 3 | name: define User model 4 | type: common 5 | TIN-2: 6 | name: create users table 7 | type: backend 8 | deps: [TIN-1] 9 | 10 | TIN-3: 11 | name: define Register API endpoint 12 | type: common 13 | deps: [TIN-1] 14 | TIN-4: 15 | name: define Login API endpoint 16 | type: common 17 | deps: [TIN-1] 18 | 19 | TIN-5: 20 | name: implement Register API endpoint 21 | type: backend 22 | deps: [TIN-2, TIN-3] 23 | TIN-6: 24 | name: implement Login API endpoint 25 | type: backend 26 | deps: [TIN-2, TIN-4] 27 | 28 | TIN-7: 29 | name: UI mock for Register page 30 | type: design 31 | TIN-8: 32 | name: UI mock for Login page 33 | type: design 34 | 35 | TIN-9: 36 | name: Implement Register page 37 | type: frontend 38 | deps: [TIN-5, TIN-7] 39 | TIN-10: 40 | name: Implement Login page 41 | type: frontend 42 | deps: [TIN-6, TIN-8] 43 | -------------------------------------------------------------------------------- /examles/xyz.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | G 11 | 12 | 13 | cluster_1 14 | 15 | Legend 16 | 17 | 18 | 19 | XYZ-9 20 | 21 | XYZ-9 22 | Define Purchases API 23 | 24 | 25 | 26 | XYZ-6 27 | 28 | XYZ-6 29 | Implement Purchases API endpoints 30 | 31 | 32 | 33 | XYZ-9->XYZ-6 34 | 35 | 36 | 37 | 38 | 39 | XYZ-2 40 | 41 | XYZ-2 42 | Implement Product model 43 | 44 | 45 | 46 | XYZ-5 47 | 48 | XYZ-5 49 | Implement Products API endpoints 50 | 51 | 52 | 53 | XYZ-2->XYZ-5 54 | 55 | 56 | 57 | 58 | 59 | XYZ-2->XYZ-6 60 | 61 | 62 | 63 | 64 | 65 | XYZ-7 66 | 67 | XYZ-7 68 | Define Users API 69 | 70 | 71 | 72 | XYZ-4 73 | 74 | XYZ-4 75 | Implement Users API endpoints 76 | 77 | 78 | 79 | XYZ-7->XYZ-4 80 | 81 | 82 | 83 | 84 | 85 | XYZ-10 86 | 87 | XYZ-10 88 | Display available products 89 | 90 | 91 | 92 | XYZ-5->XYZ-10 93 | 94 | 95 | 96 | 97 | 98 | XYZ-8 99 | 100 | XYZ-8 101 | Define Products API 102 | 103 | 104 | 105 | XYZ-8->XYZ-5 106 | 107 | 108 | 109 | 110 | 111 | XYZ-11 112 | 113 | XYZ-11 114 | Sign up & Sign in 115 | 116 | 117 | 118 | XYZ-4->XYZ-11 119 | 120 | 121 | 122 | 123 | 124 | XYZ-13 125 | 126 | XYZ-13 127 | Acceptance Test 128 | 129 | 130 | 131 | XYZ-10->XYZ-13 132 | 133 | 134 | 135 | 136 | 137 | XYZ-3 138 | 139 | XYZ-3 140 | Implement Purchase model 141 | 142 | 143 | 144 | XYZ-12 145 | 146 | XYZ-12 147 | Allow users to create and see purchases 148 | 149 | 150 | 151 | XYZ-6->XYZ-12 152 | 153 | 154 | 155 | 156 | 157 | XYZ-11->XYZ-13 158 | 159 | 160 | 161 | 162 | 163 | XYZ-12->XYZ-13 164 | 165 | 166 | 167 | 168 | 169 | XYZ-1 170 | 171 | XYZ-1 172 | Imlpement User model 173 | 174 | 175 | 176 | XYZ-1->XYZ-4 177 | 178 | 179 | 180 | 181 | 182 | XYZ-1->XYZ-3 183 | 184 | 185 | 186 | 187 | 188 | XYZ-1->XYZ-6 189 | 190 | 191 | 192 | 193 | 194 | Common 195 | 196 | Common 197 | 198 | 199 | 200 | Backend 201 | 202 | Backend 203 | 204 | 205 | 206 | Frontend 207 | 208 | Frontend 209 | 210 | 211 | 212 | -------------------------------------------------------------------------------- /examles/xyz.yml: -------------------------------------------------------------------------------- 1 | tasks: 2 | XYZ-1: 3 | name: "Imlpement User model" 4 | type: Backend 5 | XYZ-2: 6 | name: "Implement Product model" 7 | type: Backend 8 | XYZ-3: 9 | name: "Implement Purchase model" 10 | type: Backend 11 | deps: [XYZ-1] 12 | 13 | XYZ-4: 14 | name: "Implement Users API endpoints" 15 | type: Backend 16 | deps: [XYZ-1, XYZ-7] 17 | XYZ-5: 18 | name: "Implement Products API endpoints" 19 | type: Backend 20 | deps: [XYZ-2, XYZ-8] 21 | XYZ-6: 22 | name: "Implement Purchases API endpoints" 23 | type: Backend 24 | deps: [XYZ-2, XYZ-1, XYZ-9] 25 | 26 | XYZ-7: 27 | name: "Define Users API" 28 | type: Common 29 | XYZ-8: 30 | name: "Define Products API" 31 | type: Common 32 | XYZ-9: 33 | name: "Define Purchases API" 34 | type: Common 35 | 36 | XYZ-10: 37 | name: "Display available products" 38 | type: Frontend 39 | deps: [XYZ-5] 40 | XYZ-11: 41 | name: "Sign up & Sign in" 42 | type: Frontend 43 | deps: [XYZ-4] 44 | XYZ-12: 45 | name: "Allow users to create and see purchases" 46 | type: Frontend 47 | deps: [XYZ-6] 48 | 49 | XYZ-13: 50 | name: "Acceptance Test" 51 | type: Common 52 | deps: [XYZ-10, XYZ-11, XYZ-12] 53 | -------------------------------------------------------------------------------- /src/cli.rs: -------------------------------------------------------------------------------- 1 | use crate::parser::parse; 2 | use crate::dot::render; 3 | 4 | use structopt::StructOpt; 5 | 6 | #[derive(Debug, Clone, Copy)] 7 | enum Format { 8 | Dot, 9 | Svg, 10 | Png, 11 | Jpg 12 | } 13 | 14 | impl Format { 15 | fn to_str(&self) -> &'static str { 16 | match self { 17 | Format::Dot => "dot", 18 | Format::Svg => "svg", 19 | Format::Png => "png", 20 | Format::Jpg => "jpg" 21 | } 22 | } 23 | } 24 | 25 | impl std::str::FromStr for Format { 26 | type Err = String; 27 | 28 | fn from_str(s: &str) -> Result { 29 | match s { 30 | "dot" => Ok(Format::Dot), 31 | "svg" => Ok(Format::Svg), 32 | "png" => Ok(Format::Png), 33 | "jpg" => Ok(Format::Jpg), 34 | _ => Err(format!("unsupported output format: {}", s)) 35 | } 36 | } 37 | } 38 | 39 | impl std::fmt::Display for Format { 40 | fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { 41 | write!(f, "{}", self.to_str()) 42 | } 43 | } 44 | 45 | #[derive(Debug, StructOpt)] 46 | #[structopt(name = "xplan", about = "A tool to visualize task dependencies.")] 47 | struct Opt { 48 | #[structopt(name = "YAML_FILE")] 49 | intput_file: String, 50 | 51 | #[structopt(short="o", long="output")] 52 | output_file: Option, 53 | 54 | #[structopt(short="f", long="format", default_value="svg")] 55 | format: Format 56 | } 57 | 58 | pub fn run() -> Result<(), Box> { 59 | let opt = Opt::from_args(); 60 | 61 | let yaml = std::fs::read_to_string(&opt.intput_file)?; 62 | let store = parse(&yaml)?; 63 | 64 | let output_path = match opt.output_file { 65 | Some(val) => val, 66 | None => { 67 | let stem = std::path::Path::new(&opt.intput_file).file_stem().unwrap().to_str().unwrap(); 68 | format!("{}.{}", stem, opt.format) 69 | } 70 | }; 71 | 72 | match opt.format { 73 | Format::Dot => { 74 | let mut output_file = std::fs::File::create(&output_path)?; 75 | render(&mut output_file, &store)?; 76 | } 77 | _ => { 78 | let dot_process = std::process::Command::new("dot") 79 | .args(&["-T", opt.format.to_str(), "-o", &output_path]) 80 | .stdin(std::process::Stdio::piped()) 81 | .spawn()?; 82 | 83 | let mut dot_stdin = dot_process.stdin.unwrap(); 84 | render(&mut dot_stdin, &store)?; 85 | } 86 | } 87 | 88 | println!("Created a file: {}", output_path); 89 | 90 | Ok(()) 91 | } 92 | -------------------------------------------------------------------------------- /src/dot.rs: -------------------------------------------------------------------------------- 1 | use std::io::Write; 2 | use std::collections::HashMap; 3 | 4 | use crate::store::Store; 5 | use crate::task::{Task, TaskType}; 6 | 7 | type Result = std::result::Result<(), std::io::Error>; 8 | 9 | pub fn render(buf: &mut T, store: &Store) -> Result { 10 | Renderer::new(buf, store).render() 11 | } 12 | 13 | const COLORS: &'static [&'static str] = &[ 14 | "orange", 15 | "gold", 16 | "coral", 17 | "darkgreen", 18 | "pink" 19 | ]; 20 | 21 | struct Config { 22 | type_color_map: HashMap 23 | } 24 | 25 | impl Config { 26 | fn new(store: &Store) -> Self { 27 | let mut type_color_map = HashMap::new(); 28 | for (i, tp) in store.task_types.iter().enumerate() { 29 | if COLORS.len() > i { 30 | type_color_map.insert(tp.clone(), COLORS[i]); 31 | } 32 | } 33 | Self { type_color_map } 34 | } 35 | } 36 | 37 | struct Renderer<'a, T> { 38 | buf: T, 39 | store: &'a Store, 40 | config: Config 41 | } 42 | 43 | impl<'a, T: Write> Renderer<'a, T> { 44 | fn new(buf: T, store: &'a Store) -> Self { 45 | let config = Config::new(store); 46 | Self { buf, store, config } 47 | } 48 | 49 | fn render(&mut self) -> Result { 50 | write!(self.buf, "digraph G {{\n")?; 51 | self.define_nodes()?; 52 | self.define_deps()?; 53 | self.define_legend()?; 54 | write!(self.buf, "}}\n") 55 | } 56 | 57 | fn define_nodes(&mut self) -> Result { 58 | write!(self.buf, " # Declare nodes\n")?; 59 | for task in self.store.tasks.values() { 60 | self.define_node(task)?; 61 | } 62 | write!(self.buf, "\n") 63 | } 64 | 65 | fn define_node(&mut self, task: &Task) -> Result { 66 | let label = build_label(task); 67 | 68 | write!(self.buf, " \"{}\" ", task.id)?; 69 | write!(self.buf, "[")?; 70 | write!(self.buf, "label=\"{}\"", label)?; 71 | if let Some(tp) = &task.task_type { 72 | if let Some(color) = self.config.type_color_map.get(tp) { 73 | write!(self.buf, " style=filled color={}", color)?; 74 | } 75 | } 76 | write!(self.buf, "]\n") 77 | } 78 | 79 | fn define_deps(&mut self) -> Result { 80 | write!(self.buf, " # Declare dependencies\n")?; 81 | for task in self.store.tasks.values() { 82 | for dep in task.deps.iter() { 83 | write!(self.buf, " \"{}\" -> \"{}\"\n", dep, task.id)?; 84 | } 85 | } 86 | 87 | Ok(()) 88 | } 89 | 90 | fn define_legend(&mut self) -> Result { 91 | write!(self.buf, "\n # Define Legend\n")?; 92 | write!(self.buf, " subgraph cluster_1 {{\n")?; 93 | write!(self.buf, " rank = sink\n")?; 94 | write!(self.buf, " label = \"Legend\"\n")?; 95 | 96 | for (tp, color) in self.config.type_color_map.iter() { 97 | write!(self.buf, " \"{}\" [style=filled color={}]\n", tp, color)?; 98 | } 99 | write!(self.buf, " }}\n") 100 | } 101 | } 102 | 103 | fn build_label(task: &Task) -> String { 104 | let mut label = format!("{}", task.id); 105 | if let Some(name) = &task.name { 106 | label.push_str(&format!("\\n{}", name)); 107 | } 108 | label 109 | } 110 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod cli; 2 | pub mod task; 3 | pub mod store; 4 | pub mod dot; 5 | pub mod parser; 6 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use xplan::cli; 2 | 3 | fn main() { 4 | cli::run().unwrap_or_else(|e| { 5 | eprintln!("{}", e); 6 | std::process::exit(1); 7 | }); 8 | } 9 | -------------------------------------------------------------------------------- /src/parser/error.rs: -------------------------------------------------------------------------------- 1 | use thiserror::Error; 2 | 3 | use crate::store::StoreBuilderError; 4 | 5 | #[derive(Debug, Error)] 6 | pub enum ParseError { 7 | #[error("Invalid YAML")] 8 | InvalidYaml(#[from] yaml_rust::scanner::ScanError), 9 | 10 | #[error("{0}")] 11 | Base(String), 12 | 13 | #[error("Unknown root element: {0}")] 14 | UnkownRootElement(String), 15 | 16 | #[error("failed to build")] 17 | Build(#[from] StoreBuilderError) 18 | } 19 | -------------------------------------------------------------------------------- /src/parser/helpers.rs: -------------------------------------------------------------------------------- 1 | use yaml_rust::{yaml::Yaml}; 2 | 3 | use super::error::ParseError; 4 | 5 | pub fn parse_yaml_to_string(val: &Yaml) -> Result { 6 | match val { 7 | Yaml::String(s) => Ok(s.to_owned()), 8 | Yaml::Integer(num) => Ok(num.to_string()), 9 | _ => { 10 | let message = format!("Expected a string. Got {:?}", val); 11 | Err(ParseError::Base(message)) 12 | } 13 | } 14 | } 15 | 16 | pub fn parse_yaml_to_vec(val: &Yaml) -> Result, ParseError> { 17 | let mut items: Vec = vec![]; 18 | 19 | match val { 20 | Yaml::Array(arr) => { 21 | for yaml_item in arr.iter() { 22 | let item = parse_yaml_to_string(yaml_item)?; 23 | items.push(item); 24 | } 25 | } 26 | _ => { 27 | let message = format!("Value must be an array. Got {:?}", val); 28 | return Err(ParseError::Base(message)); 29 | } 30 | } 31 | 32 | Ok(items) 33 | } 34 | -------------------------------------------------------------------------------- /src/parser/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod error; 2 | pub mod helpers; 3 | 4 | use error::ParseError; 5 | use helpers::{parse_yaml_to_string, parse_yaml_to_vec}; 6 | use crate::task::{Task, TaskId, TaskType, TaskName}; 7 | use crate::store::Store; 8 | 9 | use yaml_rust::{yaml::Yaml, YamlLoader}; 10 | 11 | pub type Result = std::result::Result; 12 | 13 | 14 | fn parse_tasks(yaml: &Yaml) -> Result> { 15 | let mut tasks: Vec = vec![]; 16 | 17 | match yaml { 18 | Yaml::Hash(hash) => { 19 | for (yaml_key, val) in hash.iter() { 20 | let task = parse_task(yaml_key, val)?; 21 | tasks.push(task); 22 | } 23 | } 24 | _ => { 25 | let message = format!("`tasks` must be a hash. Got {:?}", yaml); 26 | return Err(ParseError::Base(message)); 27 | } 28 | } 29 | 30 | Ok(tasks) 31 | } 32 | 33 | pub fn parse_task(key: &Yaml, body: &Yaml) -> Result { 34 | let id_str = parse_yaml_to_string(key)?; 35 | let id = TaskId::new(id_str); 36 | 37 | let mut name = None; 38 | let mut deps = vec![]; 39 | let mut task_type = None; 40 | 41 | match body { 42 | Yaml::Hash(hash) => { 43 | for (attr_yaml_key, attr_yaml_val) in hash { 44 | let attr_key = parse_yaml_to_string(&attr_yaml_key)?; 45 | 46 | match attr_key.as_ref() { 47 | "name" => { 48 | let name_val = parse_yaml_to_string(&attr_yaml_val)?; 49 | name = Some(TaskName::new(name_val)); 50 | }, 51 | "deps" => { 52 | let deps_str = parse_yaml_to_vec(&attr_yaml_val)?; 53 | deps = deps_str.into_iter().map(TaskId::from).collect(); 54 | 55 | }, 56 | "type" => { 57 | let type_val = parse_yaml_to_string(&attr_yaml_val)?; 58 | task_type = Some(TaskType::new(type_val)); 59 | 60 | } 61 | _ => { 62 | let msg = format!("Unknown task property `{}` in tasks.{}", attr_key, id); 63 | return Err(ParseError::Base(msg)) 64 | } 65 | } 66 | } 67 | }, 68 | Yaml::Null => {}, 69 | _ => { 70 | let msg = format!("Invalid type of element: tasks.{} ({:?})", id, body); 71 | return Err(ParseError::Base(msg)) 72 | } 73 | } 74 | 75 | let task = Task { id, name, deps, task_type }; 76 | Ok(task) 77 | } 78 | 79 | 80 | pub fn parse(yaml: &str) -> Result { 81 | let docs = YamlLoader::load_from_str(yaml).map_err(ParseError::InvalidYaml)?; 82 | 83 | let mut tasks = vec![]; 84 | 85 | for doc in docs.iter() { 86 | match doc { 87 | Yaml::Hash(root) => { 88 | for (yaml_key, val) in root.iter() { 89 | let key = parse_yaml_to_string(yaml_key)?; 90 | 91 | match key.as_ref() { 92 | "tasks" => { 93 | tasks = parse_tasks(val)?; 94 | } 95 | _ => { 96 | return Err(ParseError::UnkownRootElement(key)); 97 | } 98 | }; 99 | } 100 | } 101 | _ => { 102 | return Err(ParseError::Base("Root element of YAML must be Hash".to_owned())) 103 | } 104 | } 105 | } 106 | 107 | let mut builder = Store::builder(); 108 | for task in tasks.into_iter() { 109 | builder = builder.add(task); 110 | } 111 | 112 | let store = builder.build().map_err(ParseError::Build)?; 113 | 114 | 115 | Ok(store) 116 | } 117 | 118 | #[cfg(test)] 119 | mod tests { 120 | use super::*; 121 | 122 | #[test] 123 | fn test_parse() { 124 | let yaml = r#" 125 | tasks: 126 | A: 127 | name: "Do A" 128 | B: 129 | deps: ["A"] 130 | C: 131 | type: BE 132 | "#; 133 | let store = parse(yaml).unwrap(); 134 | assert_eq!(store.tasks.len(), 3); 135 | 136 | let id_a = TaskId::new("A".to_owned()); 137 | assert_eq!(store.get(&id_a).name, Some(TaskName::new("Do A".to_owned()))); 138 | 139 | let id_b = TaskId::new("B".to_owned()); 140 | assert_eq!(store.get(&id_b).deps, vec![id_a]); 141 | 142 | let id_c = TaskId::new("C".to_owned()); 143 | let type_be = TaskType::new("BE".to_owned()); 144 | 145 | assert_eq!(store.get(&id_c).task_type, Some(type_be)); 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /src/store.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use thiserror::Error; 4 | 5 | use crate::task::{TaskId, TaskType, Task}; 6 | 7 | #[derive(Debug)] 8 | pub struct Store { 9 | pub tasks: HashMap, 10 | pub task_types: Vec 11 | } 12 | 13 | impl Store { 14 | pub fn builder() -> StoreBuilder { 15 | StoreBuilder::new() 16 | } 17 | 18 | pub fn get(&self, id: &TaskId) -> &Task { 19 | // Unwrap is safe, because all ids were validated by StoreBuilder 20 | self.tasks.get(id).unwrap() 21 | } 22 | } 23 | 24 | #[derive(Debug, Error)] 25 | pub enum StoreBuilderError { 26 | #[error("Task '{host}' refers to '{dep}', but '{dep}' is not defined.")] 27 | MissingDependecy { 28 | host: TaskId, 29 | dep: TaskId 30 | } 31 | } 32 | 33 | #[derive(Debug)] 34 | pub struct StoreBuilder { 35 | tasks: Vec, 36 | task_types: Vec 37 | } 38 | 39 | impl StoreBuilder { 40 | fn new() -> Self { 41 | Self { 42 | tasks: vec![] , 43 | task_types: vec![], 44 | } 45 | } 46 | 47 | pub fn add(mut self, task: Task) -> Self { 48 | if let Some(tp) = &task.task_type { 49 | if !self.task_types.contains(tp) { 50 | self.task_types.push((*tp).clone()) 51 | } 52 | } 53 | self.tasks.push(task); 54 | self 55 | } 56 | 57 | pub fn build(self) -> Result { 58 | let mut tasks_map: HashMap = HashMap::new(); 59 | 60 | let known_ids: Vec = self.tasks.iter().map(|u| u.id.clone()).collect(); 61 | 62 | for task in self.tasks.into_iter() { 63 | // Check dependencies 64 | for dep_id in task.deps.iter() { 65 | if !known_ids.contains(dep_id) { 66 | let e = StoreBuilderError::MissingDependecy { host: task.id.clone(), dep: dep_id.clone() }; 67 | return Err(e); 68 | } 69 | } 70 | 71 | // Add to HashMap 72 | tasks_map.insert(task.id.clone(), task); 73 | } 74 | 75 | let store = Store { tasks: tasks_map, task_types: self.task_types }; 76 | Ok(store) 77 | } 78 | } 79 | 80 | #[cfg(test)] 81 | mod tests { 82 | use super::*; 83 | 84 | fn task>(id: S) -> Task { 85 | task_with_deps(id, Vec::new()) 86 | } 87 | 88 | fn task_with_deps>(id: T, deps: Vec) -> Task { 89 | Task { 90 | id: id.into(), 91 | name: None, 92 | deps: deps.into_iter().map(|d| d.into()).collect(), 93 | task_type: None 94 | } 95 | } 96 | 97 | #[test] 98 | fn test_builder_ok() { 99 | let store = Store::builder() 100 | .add(task("A")) 101 | .add(task("B")) 102 | .build() 103 | .unwrap(); 104 | 105 | let a_id = TaskId::from("A"); 106 | assert_eq!(store.get(&a_id), &task("A")); 107 | 108 | let b_id = TaskId::from("B"); 109 | assert_eq!(store.get(&b_id), &task("B")); 110 | } 111 | 112 | #[test] 113 | fn test_builder_err() { 114 | let err = Store::builder() 115 | .add(task("A")) 116 | .add(task_with_deps("B", vec!["Z"])) 117 | .build() 118 | .unwrap_err(); 119 | 120 | assert_eq!( 121 | format!("{}", err), 122 | "Task 'B' refers to 'Z', but 'Z' is not defined." 123 | ) 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/task.rs: -------------------------------------------------------------------------------- 1 | use derive_more::{Display, Constructor}; 2 | 3 | #[derive(Debug, PartialEq, Eq, Hash, Clone, Display, Constructor)] 4 | pub struct TaskId(String); 5 | 6 | #[derive(Debug, PartialEq, Eq, Hash, Clone, Display, Constructor)] 7 | pub struct TaskType(String); 8 | 9 | #[derive(Debug, PartialEq, Eq, Display, Constructor)] 10 | pub struct TaskName(String); 11 | 12 | #[derive(Debug, PartialEq)] 13 | pub struct Task { 14 | pub id: TaskId, 15 | pub name: Option, 16 | pub deps: Vec, 17 | pub task_type: Option 18 | } 19 | 20 | impl> From for TaskId { 21 | fn from(id: T) -> Self { 22 | TaskId(id.into()) 23 | } 24 | } 25 | --------------------------------------------------------------------------------