├── .github
└── workflows
│ ├── build.yml
│ └── release.yml
├── .gitignore
├── CONTRIBUTING.md
├── Cargo.lock
├── Cargo.toml
├── LICENSE
├── README.md
├── build.rs
└── src
├── app.rs
├── cli.rs
├── display.rs
├── helper.rs
├── main.rs
└── simple_build.rs
/.github/workflows/build.yml:
--------------------------------------------------------------------------------
1 | name: Build
2 | on:
3 | pull_request:
4 | push:
5 | branches:
6 | - master
7 |
8 | jobs:
9 | test:
10 | name: Test Suite
11 | runs-on: ubuntu-latest
12 | steps:
13 | - name: Checkout sources
14 | uses: actions/checkout@v2
15 |
16 | - name: Install stable toolchain
17 | uses: dtolnay/rust-toolchain@master
18 | with:
19 | toolchain: stable
20 |
21 | - name: Setup Rust cache
22 | uses: Swatinem/rust-cache@v2
23 |
24 | - name: Run cargo test
25 | run: cargo test
26 |
27 | lints:
28 | name: Lints
29 | runs-on: ubuntu-latest
30 | steps:
31 | - name: Checkout sources
32 | uses: actions/checkout@v2
33 | with:
34 | submodules: true
35 |
36 | - name: Install stable toolchain
37 | uses: dtolnay/rust-toolchain@master
38 | with:
39 | toolchain: stable
40 |
41 | - name: Install extra components
42 | run: rustup component add clippy rust-docs rustfmt
43 |
44 | - name: Setup Rust cache
45 | uses: Swatinem/rust-cache@v2
46 |
47 | - name: Run cargo fmt
48 | run: cargo fmt --all -- --check
49 |
50 | - name: Run cargo clippy
51 | run: cargo clippy -- -D warnings
52 |
53 | - name: Run rustdoc lints
54 | env:
55 | RUSTDOCFLAGS: "-D missing_docs -D rustdoc::missing_doc_code_examples"
56 | run: cargo doc --workspace --all-features --no-deps --document-private-items
57 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Release
2 | on:
3 | push:
4 | tags:
5 | - 'v[0-9]+.[0-9]+.[0-9]+'
6 |
7 | env:
8 | BIN_NAME: trane
9 | PROJECT_NAME: trane-cli
10 | REPO_NAME: trane-project/trane-cli
11 |
12 | jobs:
13 | dist:
14 | name: Dist
15 | runs-on: ${{ matrix.os }}
16 | strategy:
17 | fail-fast: false # don't fail other jobs if one fails
18 | matrix:
19 | build: [x86_64-linux, aarch64-linux, aarch64-apple, x86_64-windows]
20 | include:
21 | - build: x86_64-linux
22 | os: ubuntu-latest
23 | rust: stable
24 | target: x86_64-unknown-linux-gnu
25 | cargo_cmd: cargo
26 | - build: aarch64-linux
27 | os: ubuntu-latest
28 | rust: stable
29 | target: aarch64-unknown-linux-gnu
30 | cargo_cmd: cross
31 | - build: aarch64-apple
32 | os: macos-latest
33 | rust: stable
34 | target: aarch64-apple-darwin
35 | cargo_cmd: cargo
36 | - build: x86_64-windows
37 | os: windows-latest
38 | rust: stable
39 | target: x86_64-pc-windows-msvc
40 | cargo_cmd: cargo
41 |
42 | steps:
43 | - name: Checkout sources
44 | uses: actions/checkout@v3
45 | with:
46 | submodules: true
47 |
48 | - name: Install ${{ matrix.rust }} toolchain
49 | uses: dtolnay/rust-toolchain@master
50 | with:
51 | profile: minimal
52 | toolchain: stable
53 | target: ${{ matrix.target }}
54 | override: true
55 |
56 | - name: Install cross
57 | run: cargo install --version 0.1.16 cross
58 |
59 | - name: Run cargo test
60 | run: ${{ matrix.cargo_cmd }} test --release --locked --target ${{ matrix.target }}
61 |
62 | - name: Build release binary
63 | run: ${{ matrix.cargo_cmd }} build --release --locked --target ${{ matrix.target }}
64 |
65 | - name: Strip release binary (linux and macos)
66 | if: matrix.build == 'x86_64-linux' || matrix.build == 'x86_64-macos'
67 | run: strip "target/${{ matrix.target }}/release/$BIN_NAME"
68 |
69 | - name: Strip release binary (arm)
70 | if: matrix.build == 'aarch64-linux'
71 | run: |
72 | docker run --rm -v \
73 | "$PWD/target:/target:Z" \
74 | rustembedded/cross:${{ matrix.target }} \
75 | aarch64-linux-gnu-strip \
76 | /target/${{ matrix.target }}/release/$BIN_NAME
77 |
78 | - name: Build archive
79 | shell: bash
80 | run: |
81 | mkdir dist
82 | if [ "${{ matrix.os }}" = "windows-2019" ]; then
83 | cp "target/${{ matrix.target }}/release/$BIN_NAME.exe" "dist/"
84 | else
85 | cp "target/${{ matrix.target }}/release/$BIN_NAME" "dist/"
86 | fi
87 |
88 | - uses: actions/upload-artifact@v4.6.2
89 | with:
90 | name: bins-${{ matrix.build }}
91 | path: dist
92 |
93 | publish:
94 | name: Publish
95 | needs: [dist]
96 | runs-on: ubuntu-latest
97 | steps:
98 | - name: Checkout sources
99 | uses: actions/checkout@v3
100 | with:
101 | submodules: false
102 |
103 | - uses: actions/download-artifact@v4.2.1
104 | - run: ls -al bins-*
105 |
106 | - name: Calculate tag name
107 | run: |
108 | name=dev
109 | if [[ $GITHUB_REF == refs/tags/v* ]]; then
110 | name=${GITHUB_REF:10}
111 | fi
112 | echo ::set-output name=val::$name
113 | echo TAG=$name >> $GITHUB_ENV
114 | id: tagname
115 |
116 | - name: Build archive
117 | shell: bash
118 | run: |
119 | set -ex
120 |
121 | rm -rf tmp
122 | mkdir tmp
123 | mkdir dist
124 |
125 | for dir in bins-* ; do
126 | platform=${dir#"bins-"}
127 | unset exe
128 | if [[ $platform =~ "windows" ]]; then
129 | exe=".exe"
130 | fi
131 | pkgname=$PROJECT_NAME-$TAG-$platform
132 | mkdir tmp/$pkgname
133 | # cp LICENSE README.md tmp/$pkgname
134 | mv bins-$platform/$BIN_NAME$exe tmp/$pkgname
135 | chmod +x tmp/$pkgname/$BIN_NAME$exe
136 |
137 | if [ "$exe" = "" ]; then
138 | tar cJf dist/$pkgname.tar.xz -C tmp $pkgname
139 | else
140 | (cd tmp && 7z a -r ../dist/$pkgname.zip $pkgname)
141 | fi
142 | done
143 |
144 | - name: Upload binaries to release
145 | uses: svenstaro/upload-release-action@2.5.0
146 | with:
147 | repo_token: ${{ secrets.GITHUB_TOKEN }}
148 | file: dist/*
149 | file_glob: true
150 | tag: ${{ steps.tagname.outputs.val }}
151 | overwrite: true
152 |
153 | - name: Extract version
154 | id: extract-version
155 | run: |
156 | printf "::set-output name=%s::%s\n" tag-name "${GITHUB_REF#refs/tags/}"
157 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Generated by Cargo
2 | # will have compiled files and executables
3 | /target/
4 |
5 | # These are backup files generated by rustfmt
6 | **/*.rs.bk
7 |
8 | # Ignore internal Trane files.
9 | .trane/
10 | .trane_history
11 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing
2 |
3 | At the moment, I don't really need code contributions for this repository. However, feel free to
4 | contribute to any of the repositories containing courses. You can access those by going to the
5 | organization page. Look for the CONTRIBUTING.md file in those repositories to find out ways to help.
6 |
7 | Feel free to open an issue if you have a feature request, find a bug, or notice an issue with my
8 | usage of Rust (this is my first real project using the language).
9 |
--------------------------------------------------------------------------------
/Cargo.toml:
--------------------------------------------------------------------------------
1 | [package]
2 | edition = "2021"
3 | name = "trane-cli"
4 | version = "0.23.2"
5 | build = "build.rs"
6 | default-run = "trane"
7 |
8 | [[bin]]
9 | name = "trane"
10 | path = "src/main.rs"
11 |
12 | [[bin]]
13 | name = "trane-simple-build"
14 | path = "src/simple_build.rs"
15 |
16 | [dependencies]
17 | anyhow = "1.0.98"
18 | chrono = "0.4.40"
19 | clap = { version = "4.5.36", features = ["derive"] }
20 | indoc = "2.0.6"
21 | rand = "0.9.1"
22 | rustyline = "15.0.0"
23 | rustyline-derive = "0.11.0"
24 | serde_json = "1.0.140"
25 | termimad = "0.31.3"
26 | trane = "0.23.3"
27 | ustr = { version = "1.1.0", features = ["serde"] }
28 | # Commented out for use in local development.
29 | # trane = { path = "../trane" }
30 |
31 | [build-dependencies]
32 | built = { version = "0.7.7", features = ["chrono", "dependency-tree", "git2", "semver"] }
33 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | GNU AFFERO GENERAL PUBLIC LICENSE
2 | Version 3, 19 November 2007
3 |
4 | Copyright (C) 2007 Free Software Foundation, Inc.
5 | Everyone is permitted to copy and distribute verbatim copies
6 | of this license document, but changing it is not allowed.
7 |
8 | Preamble
9 |
10 | The GNU Affero General Public License is a free, copyleft license for
11 | software and other kinds of works, specifically designed to ensure
12 | cooperation with the community in the case of network server software.
13 |
14 | The licenses for most software and other practical works are designed
15 | to take away your freedom to share and change the works. By contrast,
16 | our General Public Licenses are intended to guarantee your freedom to
17 | share and change all versions of a program--to make sure it remains free
18 | software for all its users.
19 |
20 | When we speak of free software, we are referring to freedom, not
21 | price. Our General Public Licenses are designed to make sure that you
22 | have the freedom to distribute copies of free software (and charge for
23 | them if you wish), that you receive source code or can get it if you
24 | want it, that you can change the software or use pieces of it in new
25 | free programs, and that you know you can do these things.
26 |
27 | Developers that use our General Public Licenses protect your rights
28 | with two steps: (1) assert copyright on the software, and (2) offer
29 | you this License which gives you legal permission to copy, distribute
30 | and/or modify the software.
31 |
32 | A secondary benefit of defending all users' freedom is that
33 | improvements made in alternate versions of the program, if they
34 | receive widespread use, become available for other developers to
35 | incorporate. Many developers of free software are heartened and
36 | encouraged by the resulting cooperation. However, in the case of
37 | software used on network servers, this result may fail to come about.
38 | The GNU General Public License permits making a modified version and
39 | letting the public access it on a server without ever releasing its
40 | source code to the public.
41 |
42 | The GNU Affero General Public License is designed specifically to
43 | ensure that, in such cases, the modified source code becomes available
44 | to the community. It requires the operator of a network server to
45 | provide the source code of the modified version running there to the
46 | users of that server. Therefore, public use of a modified version, on
47 | a publicly accessible server, gives the public access to the source
48 | code of the modified version.
49 |
50 | An older license, called the Affero General Public License and
51 | published by Affero, was designed to accomplish similar goals. This is
52 | a different license, not a version of the Affero GPL, but Affero has
53 | released a new version of the Affero GPL which permits relicensing under
54 | this license.
55 |
56 | The precise terms and conditions for copying, distribution and
57 | modification follow.
58 |
59 | TERMS AND CONDITIONS
60 |
61 | 0. Definitions.
62 |
63 | "This License" refers to version 3 of the GNU Affero General Public License.
64 |
65 | "Copyright" also means copyright-like laws that apply to other kinds of
66 | works, such as semiconductor masks.
67 |
68 | "The Program" refers to any copyrightable work licensed under this
69 | License. Each licensee is addressed as "you". "Licensees" and
70 | "recipients" may be individuals or organizations.
71 |
72 | To "modify" a work means to copy from or adapt all or part of the work
73 | in a fashion requiring copyright permission, other than the making of an
74 | exact copy. The resulting work is called a "modified version" of the
75 | earlier work or a work "based on" the earlier work.
76 |
77 | A "covered work" means either the unmodified Program or a work based
78 | on the Program.
79 |
80 | To "propagate" a work means to do anything with it that, without
81 | permission, would make you directly or secondarily liable for
82 | infringement under applicable copyright law, except executing it on a
83 | computer or modifying a private copy. Propagation includes copying,
84 | distribution (with or without modification), making available to the
85 | public, and in some countries other activities as well.
86 |
87 | To "convey" a work means any kind of propagation that enables other
88 | parties to make or receive copies. Mere interaction with a user through
89 | a computer network, with no transfer of a copy, is not conveying.
90 |
91 | An interactive user interface displays "Appropriate Legal Notices"
92 | to the extent that it includes a convenient and prominently visible
93 | feature that (1) displays an appropriate copyright notice, and (2)
94 | tells the user that there is no warranty for the work (except to the
95 | extent that warranties are provided), that licensees may convey the
96 | work under this License, and how to view a copy of this License. If
97 | the interface presents a list of user commands or options, such as a
98 | menu, a prominent item in the list meets this criterion.
99 |
100 | 1. Source Code.
101 |
102 | The "source code" for a work means the preferred form of the work
103 | for making modifications to it. "Object code" means any non-source
104 | form of a work.
105 |
106 | A "Standard Interface" means an interface that either is an official
107 | standard defined by a recognized standards body, or, in the case of
108 | interfaces specified for a particular programming language, one that
109 | is widely used among developers working in that language.
110 |
111 | The "System Libraries" of an executable work include anything, other
112 | than the work as a whole, that (a) is included in the normal form of
113 | packaging a Major Component, but which is not part of that Major
114 | Component, and (b) serves only to enable use of the work with that
115 | Major Component, or to implement a Standard Interface for which an
116 | implementation is available to the public in source code form. A
117 | "Major Component", in this context, means a major essential component
118 | (kernel, window system, and so on) of the specific operating system
119 | (if any) on which the executable work runs, or a compiler used to
120 | produce the work, or an object code interpreter used to run it.
121 |
122 | The "Corresponding Source" for a work in object code form means all
123 | the source code needed to generate, install, and (for an executable
124 | work) run the object code and to modify the work, including scripts to
125 | control those activities. However, it does not include the work's
126 | System Libraries, or general-purpose tools or generally available free
127 | programs which are used unmodified in performing those activities but
128 | which are not part of the work. For example, Corresponding Source
129 | includes interface definition files associated with source files for
130 | the work, and the source code for shared libraries and dynamically
131 | linked subprograms that the work is specifically designed to require,
132 | such as by intimate data communication or control flow between those
133 | subprograms and other parts of the work.
134 |
135 | The Corresponding Source need not include anything that users
136 | can regenerate automatically from other parts of the Corresponding
137 | Source.
138 |
139 | The Corresponding Source for a work in source code form is that
140 | same work.
141 |
142 | 2. Basic Permissions.
143 |
144 | All rights granted under this License are granted for the term of
145 | copyright on the Program, and are irrevocable provided the stated
146 | conditions are met. This License explicitly affirms your unlimited
147 | permission to run the unmodified Program. The output from running a
148 | covered work is covered by this License only if the output, given its
149 | content, constitutes a covered work. This License acknowledges your
150 | rights of fair use or other equivalent, as provided by copyright law.
151 |
152 | You may make, run and propagate covered works that you do not
153 | convey, without conditions so long as your license otherwise remains
154 | in force. You may convey covered works to others for the sole purpose
155 | of having them make modifications exclusively for you, or provide you
156 | with facilities for running those works, provided that you comply with
157 | the terms of this License in conveying all material for which you do
158 | not control copyright. Those thus making or running the covered works
159 | for you must do so exclusively on your behalf, under your direction
160 | and control, on terms that prohibit them from making any copies of
161 | your copyrighted material outside their relationship with you.
162 |
163 | Conveying under any other circumstances is permitted solely under
164 | the conditions stated below. Sublicensing is not allowed; section 10
165 | makes it unnecessary.
166 |
167 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
168 |
169 | No covered work shall be deemed part of an effective technological
170 | measure under any applicable law fulfilling obligations under article
171 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or
172 | similar laws prohibiting or restricting circumvention of such
173 | measures.
174 |
175 | When you convey a covered work, you waive any legal power to forbid
176 | circumvention of technological measures to the extent such circumvention
177 | is effected by exercising rights under this License with respect to
178 | the covered work, and you disclaim any intention to limit operation or
179 | modification of the work as a means of enforcing, against the work's
180 | users, your or third parties' legal rights to forbid circumvention of
181 | technological measures.
182 |
183 | 4. Conveying Verbatim Copies.
184 |
185 | You may convey verbatim copies of the Program's source code as you
186 | receive it, in any medium, provided that you conspicuously and
187 | appropriately publish on each copy an appropriate copyright notice;
188 | keep intact all notices stating that this License and any
189 | non-permissive terms added in accord with section 7 apply to the code;
190 | keep intact all notices of the absence of any warranty; and give all
191 | recipients a copy of this License along with the Program.
192 |
193 | You may charge any price or no price for each copy that you convey,
194 | and you may offer support or warranty protection for a fee.
195 |
196 | 5. Conveying Modified Source Versions.
197 |
198 | You may convey a work based on the Program, or the modifications to
199 | produce it from the Program, in the form of source code under the
200 | terms of section 4, provided that you also meet all of these conditions:
201 |
202 | a) The work must carry prominent notices stating that you modified
203 | it, and giving a relevant date.
204 |
205 | b) The work must carry prominent notices stating that it is
206 | released under this License and any conditions added under section
207 | 7. This requirement modifies the requirement in section 4 to
208 | "keep intact all notices".
209 |
210 | c) You must license the entire work, as a whole, under this
211 | License to anyone who comes into possession of a copy. This
212 | License will therefore apply, along with any applicable section 7
213 | additional terms, to the whole of the work, and all its parts,
214 | regardless of how they are packaged. This License gives no
215 | permission to license the work in any other way, but it does not
216 | invalidate such permission if you have separately received it.
217 |
218 | d) If the work has interactive user interfaces, each must display
219 | Appropriate Legal Notices; however, if the Program has interactive
220 | interfaces that do not display Appropriate Legal Notices, your
221 | work need not make them do so.
222 |
223 | A compilation of a covered work with other separate and independent
224 | works, which are not by their nature extensions of the covered work,
225 | and which are not combined with it such as to form a larger program,
226 | in or on a volume of a storage or distribution medium, is called an
227 | "aggregate" if the compilation and its resulting copyright are not
228 | used to limit the access or legal rights of the compilation's users
229 | beyond what the individual works permit. Inclusion of a covered work
230 | in an aggregate does not cause this License to apply to the other
231 | parts of the aggregate.
232 |
233 | 6. Conveying Non-Source Forms.
234 |
235 | You may convey a covered work in object code form under the terms
236 | of sections 4 and 5, provided that you also convey the
237 | machine-readable Corresponding Source under the terms of this License,
238 | in one of these ways:
239 |
240 | a) Convey the object code in, or embodied in, a physical product
241 | (including a physical distribution medium), accompanied by the
242 | Corresponding Source fixed on a durable physical medium
243 | customarily used for software interchange.
244 |
245 | b) Convey the object code in, or embodied in, a physical product
246 | (including a physical distribution medium), accompanied by a
247 | written offer, valid for at least three years and valid for as
248 | long as you offer spare parts or customer support for that product
249 | model, to give anyone who possesses the object code either (1) a
250 | copy of the Corresponding Source for all the software in the
251 | product that is covered by this License, on a durable physical
252 | medium customarily used for software interchange, for a price no
253 | more than your reasonable cost of physically performing this
254 | conveying of source, or (2) access to copy the
255 | Corresponding Source from a network server at no charge.
256 |
257 | c) Convey individual copies of the object code with a copy of the
258 | written offer to provide the Corresponding Source. This
259 | alternative is allowed only occasionally and noncommercially, and
260 | only if you received the object code with such an offer, in accord
261 | with subsection 6b.
262 |
263 | d) Convey the object code by offering access from a designated
264 | place (gratis or for a charge), and offer equivalent access to the
265 | Corresponding Source in the same way through the same place at no
266 | further charge. You need not require recipients to copy the
267 | Corresponding Source along with the object code. If the place to
268 | copy the object code is a network server, the Corresponding Source
269 | may be on a different server (operated by you or a third party)
270 | that supports equivalent copying facilities, provided you maintain
271 | clear directions next to the object code saying where to find the
272 | Corresponding Source. Regardless of what server hosts the
273 | Corresponding Source, you remain obligated to ensure that it is
274 | available for as long as needed to satisfy these requirements.
275 |
276 | e) Convey the object code using peer-to-peer transmission, provided
277 | you inform other peers where the object code and Corresponding
278 | Source of the work are being offered to the general public at no
279 | charge under subsection 6d.
280 |
281 | A separable portion of the object code, whose source code is excluded
282 | from the Corresponding Source as a System Library, need not be
283 | included in conveying the object code work.
284 |
285 | A "User Product" is either (1) a "consumer product", which means any
286 | tangible personal property which is normally used for personal, family,
287 | or household purposes, or (2) anything designed or sold for incorporation
288 | into a dwelling. In determining whether a product is a consumer product,
289 | doubtful cases shall be resolved in favor of coverage. For a particular
290 | product received by a particular user, "normally used" refers to a
291 | typical or common use of that class of product, regardless of the status
292 | of the particular user or of the way in which the particular user
293 | actually uses, or expects or is expected to use, the product. A product
294 | is a consumer product regardless of whether the product has substantial
295 | commercial, industrial or non-consumer uses, unless such uses represent
296 | the only significant mode of use of the product.
297 |
298 | "Installation Information" for a User Product means any methods,
299 | procedures, authorization keys, or other information required to install
300 | and execute modified versions of a covered work in that User Product from
301 | a modified version of its Corresponding Source. The information must
302 | suffice to ensure that the continued functioning of the modified object
303 | code is in no case prevented or interfered with solely because
304 | modification has been made.
305 |
306 | If you convey an object code work under this section in, or with, or
307 | specifically for use in, a User Product, and the conveying occurs as
308 | part of a transaction in which the right of possession and use of the
309 | User Product is transferred to the recipient in perpetuity or for a
310 | fixed term (regardless of how the transaction is characterized), the
311 | Corresponding Source conveyed under this section must be accompanied
312 | by the Installation Information. But this requirement does not apply
313 | if neither you nor any third party retains the ability to install
314 | modified object code on the User Product (for example, the work has
315 | been installed in ROM).
316 |
317 | The requirement to provide Installation Information does not include a
318 | requirement to continue to provide support service, warranty, or updates
319 | for a work that has been modified or installed by the recipient, or for
320 | the User Product in which it has been modified or installed. Access to a
321 | network may be denied when the modification itself materially and
322 | adversely affects the operation of the network or violates the rules and
323 | protocols for communication across the network.
324 |
325 | Corresponding Source conveyed, and Installation Information provided,
326 | in accord with this section must be in a format that is publicly
327 | documented (and with an implementation available to the public in
328 | source code form), and must require no special password or key for
329 | unpacking, reading or copying.
330 |
331 | 7. Additional Terms.
332 |
333 | "Additional permissions" are terms that supplement the terms of this
334 | License by making exceptions from one or more of its conditions.
335 | Additional permissions that are applicable to the entire Program shall
336 | be treated as though they were included in this License, to the extent
337 | that they are valid under applicable law. If additional permissions
338 | apply only to part of the Program, that part may be used separately
339 | under those permissions, but the entire Program remains governed by
340 | this License without regard to the additional permissions.
341 |
342 | When you convey a copy of a covered work, you may at your option
343 | remove any additional permissions from that copy, or from any part of
344 | it. (Additional permissions may be written to require their own
345 | removal in certain cases when you modify the work.) You may place
346 | additional permissions on material, added by you to a covered work,
347 | for which you have or can give appropriate copyright permission.
348 |
349 | Notwithstanding any other provision of this License, for material you
350 | add to a covered work, you may (if authorized by the copyright holders of
351 | that material) supplement the terms of this License with terms:
352 |
353 | a) Disclaiming warranty or limiting liability differently from the
354 | terms of sections 15 and 16 of this License; or
355 |
356 | b) Requiring preservation of specified reasonable legal notices or
357 | author attributions in that material or in the Appropriate Legal
358 | Notices displayed by works containing it; or
359 |
360 | c) Prohibiting misrepresentation of the origin of that material, or
361 | requiring that modified versions of such material be marked in
362 | reasonable ways as different from the original version; or
363 |
364 | d) Limiting the use for publicity purposes of names of licensors or
365 | authors of the material; or
366 |
367 | e) Declining to grant rights under trademark law for use of some
368 | trade names, trademarks, or service marks; or
369 |
370 | f) Requiring indemnification of licensors and authors of that
371 | material by anyone who conveys the material (or modified versions of
372 | it) with contractual assumptions of liability to the recipient, for
373 | any liability that these contractual assumptions directly impose on
374 | those licensors and authors.
375 |
376 | All other non-permissive additional terms are considered "further
377 | restrictions" within the meaning of section 10. If the Program as you
378 | received it, or any part of it, contains a notice stating that it is
379 | governed by this License along with a term that is a further
380 | restriction, you may remove that term. If a license document contains
381 | a further restriction but permits relicensing or conveying under this
382 | License, you may add to a covered work material governed by the terms
383 | of that license document, provided that the further restriction does
384 | not survive such relicensing or conveying.
385 |
386 | If you add terms to a covered work in accord with this section, you
387 | must place, in the relevant source files, a statement of the
388 | additional terms that apply to those files, or a notice indicating
389 | where to find the applicable terms.
390 |
391 | Additional terms, permissive or non-permissive, may be stated in the
392 | form of a separately written license, or stated as exceptions;
393 | the above requirements apply either way.
394 |
395 | 8. Termination.
396 |
397 | You may not propagate or modify a covered work except as expressly
398 | provided under this License. Any attempt otherwise to propagate or
399 | modify it is void, and will automatically terminate your rights under
400 | this License (including any patent licenses granted under the third
401 | paragraph of section 11).
402 |
403 | However, if you cease all violation of this License, then your
404 | license from a particular copyright holder is reinstated (a)
405 | provisionally, unless and until the copyright holder explicitly and
406 | finally terminates your license, and (b) permanently, if the copyright
407 | holder fails to notify you of the violation by some reasonable means
408 | prior to 60 days after the cessation.
409 |
410 | Moreover, your license from a particular copyright holder is
411 | reinstated permanently if the copyright holder notifies you of the
412 | violation by some reasonable means, this is the first time you have
413 | received notice of violation of this License (for any work) from that
414 | copyright holder, and you cure the violation prior to 30 days after
415 | your receipt of the notice.
416 |
417 | Termination of your rights under this section does not terminate the
418 | licenses of parties who have received copies or rights from you under
419 | this License. If your rights have been terminated and not permanently
420 | reinstated, you do not qualify to receive new licenses for the same
421 | material under section 10.
422 |
423 | 9. Acceptance Not Required for Having Copies.
424 |
425 | You are not required to accept this License in order to receive or
426 | run a copy of the Program. Ancillary propagation of a covered work
427 | occurring solely as a consequence of using peer-to-peer transmission
428 | to receive a copy likewise does not require acceptance. However,
429 | nothing other than this License grants you permission to propagate or
430 | modify any covered work. These actions infringe copyright if you do
431 | not accept this License. Therefore, by modifying or propagating a
432 | covered work, you indicate your acceptance of this License to do so.
433 |
434 | 10. Automatic Licensing of Downstream Recipients.
435 |
436 | Each time you convey a covered work, the recipient automatically
437 | receives a license from the original licensors, to run, modify and
438 | propagate that work, subject to this License. You are not responsible
439 | for enforcing compliance by third parties with this License.
440 |
441 | An "entity transaction" is a transaction transferring control of an
442 | organization, or substantially all assets of one, or subdividing an
443 | organization, or merging organizations. If propagation of a covered
444 | work results from an entity transaction, each party to that
445 | transaction who receives a copy of the work also receives whatever
446 | licenses to the work the party's predecessor in interest had or could
447 | give under the previous paragraph, plus a right to possession of the
448 | Corresponding Source of the work from the predecessor in interest, if
449 | the predecessor has it or can get it with reasonable efforts.
450 |
451 | You may not impose any further restrictions on the exercise of the
452 | rights granted or affirmed under this License. For example, you may
453 | not impose a license fee, royalty, or other charge for exercise of
454 | rights granted under this License, and you may not initiate litigation
455 | (including a cross-claim or counterclaim in a lawsuit) alleging that
456 | any patent claim is infringed by making, using, selling, offering for
457 | sale, or importing the Program or any portion of it.
458 |
459 | 11. Patents.
460 |
461 | A "contributor" is a copyright holder who authorizes use under this
462 | License of the Program or a work on which the Program is based. The
463 | work thus licensed is called the contributor's "contributor version".
464 |
465 | A contributor's "essential patent claims" are all patent claims
466 | owned or controlled by the contributor, whether already acquired or
467 | hereafter acquired, that would be infringed by some manner, permitted
468 | by this License, of making, using, or selling its contributor version,
469 | but do not include claims that would be infringed only as a
470 | consequence of further modification of the contributor version. For
471 | purposes of this definition, "control" includes the right to grant
472 | patent sublicenses in a manner consistent with the requirements of
473 | this License.
474 |
475 | Each contributor grants you a non-exclusive, worldwide, royalty-free
476 | patent license under the contributor's essential patent claims, to
477 | make, use, sell, offer for sale, import and otherwise run, modify and
478 | propagate the contents of its contributor version.
479 |
480 | In the following three paragraphs, a "patent license" is any express
481 | agreement or commitment, however denominated, not to enforce a patent
482 | (such as an express permission to practice a patent or covenant not to
483 | sue for patent infringement). To "grant" such a patent license to a
484 | party means to make such an agreement or commitment not to enforce a
485 | patent against the party.
486 |
487 | If you convey a covered work, knowingly relying on a patent license,
488 | and the Corresponding Source of the work is not available for anyone
489 | to copy, free of charge and under the terms of this License, through a
490 | publicly available network server or other readily accessible means,
491 | then you must either (1) cause the Corresponding Source to be so
492 | available, or (2) arrange to deprive yourself of the benefit of the
493 | patent license for this particular work, or (3) arrange, in a manner
494 | consistent with the requirements of this License, to extend the patent
495 | license to downstream recipients. "Knowingly relying" means you have
496 | actual knowledge that, but for the patent license, your conveying the
497 | covered work in a country, or your recipient's use of the covered work
498 | in a country, would infringe one or more identifiable patents in that
499 | country that you have reason to believe are valid.
500 |
501 | If, pursuant to or in connection with a single transaction or
502 | arrangement, you convey, or propagate by procuring conveyance of, a
503 | covered work, and grant a patent license to some of the parties
504 | receiving the covered work authorizing them to use, propagate, modify
505 | or convey a specific copy of the covered work, then the patent license
506 | you grant is automatically extended to all recipients of the covered
507 | work and works based on it.
508 |
509 | A patent license is "discriminatory" if it does not include within
510 | the scope of its coverage, prohibits the exercise of, or is
511 | conditioned on the non-exercise of one or more of the rights that are
512 | specifically granted under this License. You may not convey a covered
513 | work if you are a party to an arrangement with a third party that is
514 | in the business of distributing software, under which you make payment
515 | to the third party based on the extent of your activity of conveying
516 | the work, and under which the third party grants, to any of the
517 | parties who would receive the covered work from you, a discriminatory
518 | patent license (a) in connection with copies of the covered work
519 | conveyed by you (or copies made from those copies), or (b) primarily
520 | for and in connection with specific products or compilations that
521 | contain the covered work, unless you entered into that arrangement,
522 | or that patent license was granted, prior to 28 March 2007.
523 |
524 | Nothing in this License shall be construed as excluding or limiting
525 | any implied license or other defenses to infringement that may
526 | otherwise be available to you under applicable patent law.
527 |
528 | 12. No Surrender of Others' Freedom.
529 |
530 | If conditions are imposed on you (whether by court order, agreement or
531 | otherwise) that contradict the conditions of this License, they do not
532 | excuse you from the conditions of this License. If you cannot convey a
533 | covered work so as to satisfy simultaneously your obligations under this
534 | License and any other pertinent obligations, then as a consequence you may
535 | not convey it at all. For example, if you agree to terms that obligate you
536 | to collect a royalty for further conveying from those to whom you convey
537 | the Program, the only way you could satisfy both those terms and this
538 | License would be to refrain entirely from conveying the Program.
539 |
540 | 13. Remote Network Interaction; Use with the GNU General Public License.
541 |
542 | Notwithstanding any other provision of this License, if you modify the
543 | Program, your modified version must prominently offer all users
544 | interacting with it remotely through a computer network (if your version
545 | supports such interaction) an opportunity to receive the Corresponding
546 | Source of your version by providing access to the Corresponding Source
547 | from a network server at no charge, through some standard or customary
548 | means of facilitating copying of software. This Corresponding Source
549 | shall include the Corresponding Source for any work covered by version 3
550 | of the GNU General Public License that is incorporated pursuant to the
551 | following paragraph.
552 |
553 | Notwithstanding any other provision of this License, you have
554 | permission to link or combine any covered work with a work licensed
555 | under version 3 of the GNU General Public License into a single
556 | combined work, and to convey the resulting work. The terms of this
557 | License will continue to apply to the part which is the covered work,
558 | but the work with which it is combined will remain governed by version
559 | 3 of the GNU General Public License.
560 |
561 | 14. Revised Versions of this License.
562 |
563 | The Free Software Foundation may publish revised and/or new versions of
564 | the GNU Affero General Public License from time to time. Such new versions
565 | will be similar in spirit to the present version, but may differ in detail to
566 | address new problems or concerns.
567 |
568 | Each version is given a distinguishing version number. If the
569 | Program specifies that a certain numbered version of the GNU Affero General
570 | Public License "or any later version" applies to it, you have the
571 | option of following the terms and conditions either of that numbered
572 | version or of any later version published by the Free Software
573 | Foundation. If the Program does not specify a version number of the
574 | GNU Affero General Public License, you may choose any version ever published
575 | by the Free Software Foundation.
576 |
577 | If the Program specifies that a proxy can decide which future
578 | versions of the GNU Affero General Public License can be used, that proxy's
579 | public statement of acceptance of a version permanently authorizes you
580 | to choose that version for the Program.
581 |
582 | Later license versions may give you additional or different
583 | permissions. However, no additional obligations are imposed on any
584 | author or copyright holder as a result of your choosing to follow a
585 | later version.
586 |
587 | 15. Disclaimer of Warranty.
588 |
589 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
590 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
591 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
592 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
593 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
594 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
595 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
596 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
597 |
598 | 16. Limitation of Liability.
599 |
600 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
601 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
602 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
603 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
604 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
605 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
606 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
607 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
608 | SUCH DAMAGES.
609 |
610 | 17. Interpretation of Sections 15 and 16.
611 |
612 | If the disclaimer of warranty and limitation of liability provided
613 | above cannot be given local legal effect according to their terms,
614 | reviewing courts shall apply local law that most closely approximates
615 | an absolute waiver of all civil liability in connection with the
616 | Program, unless a warranty or assumption of liability accompanies a
617 | copy of the Program in return for a fee.
618 |
619 | END OF TERMS AND CONDITIONS
620 |
621 | How to Apply These Terms to Your New Programs
622 |
623 | If you develop a new program, and you want it to be of the greatest
624 | possible use to the public, the best way to achieve this is to make it
625 | free software which everyone can redistribute and change under these terms.
626 |
627 | To do so, attach the following notices to the program. It is safest
628 | to attach them to the start of each source file to most effectively
629 | state the exclusion of warranty; and each file should have at least
630 | the "copyright" line and a pointer to where the full notice is found.
631 |
632 |
633 | Copyright (C)
634 |
635 | This program is free software: you can redistribute it and/or modify
636 | it under the terms of the GNU Affero General Public License as published by
637 | the Free Software Foundation, either version 3 of the License, or
638 | (at your option) any later version.
639 |
640 | This program is distributed in the hope that it will be useful,
641 | but WITHOUT ANY WARRANTY; without even the implied warranty of
642 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
643 | GNU Affero General Public License for more details.
644 |
645 | You should have received a copy of the GNU Affero General Public License
646 | along with this program. If not, see .
647 |
648 | Also add information on how to contact you by electronic and paper mail.
649 |
650 | If your software can interact with users remotely through a computer
651 | network, you should also make sure that it provides a way for users to
652 | get its source. For example, if your program is a web application, its
653 | interface could display a "Source" link that leads users to an archive
654 | of the code. There are many ways you could offer source, and different
655 | solutions will be better for different programs; see section 13 for the
656 | specific requirements.
657 |
658 | You should also get your employer (if you work as a programmer) or school,
659 | if any, to sign a "copyright disclaimer" for the program, if necessary.
660 | For more information on this, and how to apply and follow the GNU AGPL, see
661 | .
662 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # trane-cli
2 |
3 | This repository contains the code for the command-line interface to
4 | [Trane](https://github.com/trane-project/trane).
5 |
6 | ## Documentation
7 |
8 | The latest documentation for `trane-cli` can be found in the official [Trane
9 | Book](https://trane-project.github.io/trane-cli.html). A copy is shown before for easy reference.
10 |
11 | ## Installation instructions
12 |
13 | [GitHub releases](https://github.com/trane-project/trane-cli/releases) include pre-compiled
14 | binaries. Download the one for your OS and architecture and put it somewhere where you and/or your
15 | shell can find it. There are releases for Linux, Windows, and Mac. Releases for ARM OS X are not
16 | available at the moment because cross-compilation is not working.
17 |
18 | ## Build instructions
19 |
20 | You can also build `trane-cli` from source. The only requirement is an installation of the stable
21 | Rust tool chain. Running `cargo build` from the repository's root should do the job.
22 |
23 | You can also run `cargo install` to install the binary in the cargo bin directory.
24 |
25 | ## Starting guide
26 |
27 | ### Running the command
28 |
29 | To start the binary call `trane`, if you installed it, or `cargo run` from the repo's root
30 | directory. As of now, the binary does not take any arguments. Once you start the CLI, you will
31 | be met with a prompt.
32 |
33 | ```
34 | trane >>
35 | ```
36 |
37 | Entering enter executes the input command. Pressing CTRL-C cancels the command. Pressing CTRL-D
38 | sends an EOF signal to break out of the line reading loop.
39 |
40 | ### Entering your first command
41 |
42 | To see the next exercise, enter (prompt not shown for brevity) `trane next`.
43 |
44 | Internally, the `clap` library is being used to process the input. This requires that a command name
45 | is present, even though it's redundant because this CLI can only run one command. For this reason,
46 | `trane-cli` automatically prepends the command `trane` if it's not there already. So all commands
47 | can be run without the need for adding `trane` to the beginning.
48 |
49 | ### Opening a course library
50 |
51 | The previous command returns an error because Trane has not opened a course library. A course
52 | library is a set of courses under a directory containing a subdirectory named `.trane/`. Inside this
53 | subdirectory, Trane stores the results of previous exercises, blacklists, and saved filters. This
54 | directory is created automatically.
55 |
56 | Let's suppose you have downloaded the [trane-music](https://github.com/trane-project/trane-music)
57 | and called Trane inside that directory. Then, you can type `open ./` to load all the library under
58 | that directory.
59 |
60 | ### Your first study session
61 |
62 | If all the courses are valid, the operation will succeed. Now you can run the next command. Your
63 | first exercise should be shown.
64 |
65 | ```
66 | trane >> next
67 | Course ID: trane::music::guitar::basic_fretboard
68 | Lesson ID: trane::music::guitar::basic_fretboard::lesson_1
69 | Exercise ID: trane::music::guitar::basic_fretboard::lesson_1::exercise_7
70 |
71 | Find the note G in the fretboard at a slow tempo without a metronome.
72 | ```
73 |
74 | If you are unsure on what to do, you can try looking at the instructions for this lesson by
75 | running the `instructions lesson` command:
76 |
77 | ```
78 | trane >> instructions lesson
79 | Go down each string and find the given note in the first twelve frets.
80 | Repeat but this time going up the strings.
81 |
82 | Do this at a slow tempo but without a metronome.
83 | ```
84 |
85 | Lessons and courses can also include accompanying material. For example, a lesson on the major scale
86 | could include material defining the major scale, and it's basic intervals for reference. This course
87 | does not contain any material. For those lessons or courses which do, you can display it with the
88 | `material lesson` and `material course` commands respectively.
89 |
90 | So this exercise belongs to a course teaching the positions of the notes in the guitar fretboard,
91 | and it is asking us to go up and down the strings to find the note. Once you have given the exercise
92 | a try, you can set your score. There are no objective definitions of which score means but the main
93 | difference between them is the degree of unconscious mastery over the exercise. A score of one means
94 | you are just learning the position of the note, you still make mistakes, and have to commit
95 | conscious effort to the task. A score of five would mean you don't even have to think about the task
96 | because it has thoroughly soaked through all the various pathways involved in learning.
97 |
98 | If you want to verify your answer, you can show the answer associated with the current exercise, by
99 | running the `answer` command. Let say we give it a score of two out of five. You can do so by
100 | entering `score 2`. The score is saved, but it's not submitted until you move to the next question
101 | to let you make corrections.
102 |
103 | ```
104 | trane >> answer
105 | Course ID: trane::music::guitar::basic_fretboard
106 | Lesson ID: trane::music::guitar::basic_fretboard::lesson_1
107 | Exercise ID: trane::music::guitar::basic_fretboard::lesson_1::exercise_7
108 |
109 | Answer:
110 |
111 | - 1st string (high E): 3rd fret
112 | - 2nd string (B): 8th fret
113 | - 3rd string (G): 12th fret
114 | - 4th string (D): 5th fret
115 | - 5th string (A): 10th fret
116 | - 6th string (low E): 3rd fret
117 | ```
118 |
119 | To show the current exercise again, you can use the `current` command. Now it's time to move onto
120 | the next question. Questions are presented in the order Trane schedules them and as you master the
121 | exercises you automatically unlock new lessons and courses.
122 |
123 | ### Short reference for other commands.
124 |
125 | At its simplest, the previous commands cover much of the most common operations. The documentation
126 | (accessed with the `help` or ` --help` commands) is pretty much self-explanatory for most
127 | other commands.
128 |
129 | ```
130 | trane >> help
131 | trane 0.5.0
132 | A command-line interface for Trane
133 |
134 | USAGE:
135 | trane
136 |
137 | OPTIONS:
138 | -h, --help Print help information
139 | -V, --version Print version information
140 |
141 | SUBCOMMANDS:
142 | answer Show the answer to the current exercise, if it exists
143 | blacklist Subcommands to manipulate the unit blacklist
144 | current Display the current exercise
145 | debug Subcommands for debugging purposes
146 | filter Subcommands for dealing with unit filters
147 | help Print this message or the help of the given subcommand(s)
148 | instructions Subcommands for showing course and lesson instructions
149 | list Subcommands for listing course, lesson, and exercise IDs
150 | mantra-count Show the number of Tara Sarasvati mantras recited in the background during
151 | the current session
152 | material Subcommands for showing course and lesson materials
153 | next Submits the score for the current exercise and proceeds to the next
154 | open Open the course library at the given location
155 | review-list Subcommands for manipulating the review list
156 | score Record the mastery score (1-5) for the current exercise
157 | scores Show the most recent scores for the given exercise
158 | ```
159 |
160 | There are however, some details which warrant further explanation.
161 |
162 | The `filter metadata` command allows you to define simple metadata filters. For example, to only
163 | show exercises for the major scale in the key of C, you can type:
164 |
165 | ```
166 | trane >> filter metadata --course-metadata scale_type:major --lesson-metadata key:C
167 | Set the unit filter to only show exercises with the given metadata
168 | ```
169 |
170 | The `filter set-saved` command allows you to user more complex filters by storing the definition of
171 | the filter inside the `.trane/filters` directory. For now, a filter can be created by serializing a
172 | struct of type `NamedFilter` into a JSON file (see the file `src/data/filter.rs` inside the Trane
173 | repo for more details). You can refer to those filters by a unique ID in their file, which can be
174 | also shown by running the `filter list-saved` command.
175 |
--------------------------------------------------------------------------------
/build.rs:
--------------------------------------------------------------------------------
1 | use std::{env, path};
2 |
3 | fn main() {
4 | let manifest_var = env::var("CARGO_MANIFEST_DIR").unwrap();
5 | let manifest_location = path::Path::new(&manifest_var);
6 | let dst = path::Path::new(&env::var("OUT_DIR").unwrap()).join("built.rs");
7 | built::write_built_file_with_opts(Some(manifest_location), &dst)
8 | .expect("Failed to acquire build-time information");
9 | }
10 |
--------------------------------------------------------------------------------
/src/app.rs:
--------------------------------------------------------------------------------
1 | //! Contains the state of the application and the logic to interact with Trane.
2 |
3 | use anyhow::{anyhow, bail, ensure, Result};
4 | use chrono::{Datelike, Local, TimeZone, Utc};
5 | use indoc::formatdoc;
6 | use std::{fs::File, io::Write, path::Path};
7 | use trane::{
8 | blacklist::Blacklist,
9 | course_library::CourseLibrary,
10 | data::{
11 | filter::{
12 | ExerciseFilter, FilterOp, FilterType, KeyValueFilter, StudySessionData, UnitFilter,
13 | },
14 | ExerciseManifest, MasteryScore, SchedulerOptions, UnitType,
15 | },
16 | exercise_scorer::{ExerciseScorer, ExponentialDecayScorer},
17 | filter_manager::FilterManager,
18 | graph::UnitGraph,
19 | practice_rewards::PracticeRewards,
20 | practice_stats::PracticeStats,
21 | repository_manager::RepositoryManager,
22 | review_list::ReviewList,
23 | reward_scorer::{RewardScorer, WeightedRewardScorer},
24 | scheduler::ExerciseScheduler,
25 | study_session_manager::StudySessionManager,
26 | transcription_downloader::TranscriptionDownloader,
27 | Trane,
28 | };
29 | use ustr::Ustr;
30 |
31 | use crate::display::{DisplayAnswer, DisplayAsset, DisplayExercise};
32 | use crate::{built_info, cli::KeyValue};
33 |
34 | /// Stores the app and its configuration.
35 | #[derive(Default)]
36 | pub(crate) struct TraneApp {
37 | /// The instance of the Trane library.
38 | trane: Option,
39 |
40 | /// The filter used to select exercises.
41 | filter: Option,
42 |
43 | /// The study session used to select exercises.
44 | study_session: Option,
45 |
46 | /// The current batch of exercises.
47 | batch: Vec,
48 |
49 | /// The index of the current exercise in the batch.
50 | batch_index: usize,
51 |
52 | /// The score given to the current exercise. The score can be changed anytime before the next
53 | /// exercise is requested.
54 | current_score: Option,
55 | }
56 |
57 | impl TraneApp {
58 | /// Returns the version of the Trane library dependency used by this binary.
59 | fn trane_version() -> Option {
60 | for (key, value) in &built_info::DEPENDENCIES {
61 | if *key == "trane" {
62 | return Some((*value).to_string());
63 | }
64 | }
65 | None
66 | }
67 |
68 | /// Returns the message shown every time Trane starts up.
69 | pub fn startup_message() -> String {
70 | formatdoc! {r#"
71 | Trane - An automated practice system for learning complex skills
72 |
73 | Copyright (C) 2022 - {} The Trane Project
74 |
75 | This program is free software: you can redistribute it and/or modify
76 | it under the terms of the GNU Affero General Public License as
77 | published by the Free Software Foundation, either version 3 of the
78 | License, or (at your option) any later version.
79 |
80 | This program is distributed in the hope that it will be useful,
81 | but WITHOUT ANY WARRANTY; without even the implied warranty of
82 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
83 | GNU Affero General Public License for more details.
84 |
85 | You should have received a copy of the GNU Affero General Public License
86 | along with this program. If not, see .
87 |
88 | Trane is named after John Coltrane and dedicated to his memory. The
89 | liner notes for "A Love Supreme" are reproduced below. May this project
90 | be too such an offering.
91 |
92 | > This album is a humble offering to Him. An attempt to say "THANK
93 | > YOU GOD" through our work, even as we do in our hearts and with our
94 | > tongues. May He help and strengthen all men in every good endeavor.
95 |
96 | Trane Version: {}
97 | CLI Version: {}
98 | Commit Hash: {}
99 |
100 | "#,
101 | chrono::Utc::now().year(),
102 | Self::trane_version().unwrap_or_else(|| "UNKNOWN".to_string()),
103 | built_info::PKG_VERSION,
104 | built_info::GIT_COMMIT_HASH.unwrap_or("UNKNOWN"),
105 | }
106 | }
107 |
108 | /// Returns the current exercise.
109 | fn current_exercise(&self) -> Result {
110 | self.batch
111 | .get(self.batch_index)
112 | .cloned()
113 | .ok_or_else(|| anyhow!("cannot get current exercise"))
114 | }
115 |
116 | /// Returns the current exercise's course ID.
117 | fn current_exercise_course(&self) -> Result {
118 | ensure!(self.trane.is_some(), "no Trane instance is open");
119 |
120 | let manifest = self.current_exercise()?;
121 | Ok(manifest.course_id)
122 | }
123 |
124 | /// Returns the current exercise's lesson ID.
125 | fn current_exercise_lesson(&self) -> Result {
126 | ensure!(self.trane.is_some(), "no Trane instance is open");
127 |
128 | let manifest = self.current_exercise()?;
129 | Ok(manifest.lesson_id)
130 | }
131 |
132 | /// Submits the score for the current exercise.
133 | pub fn submit_current_score(&mut self) -> Result<()> {
134 | ensure!(self.trane.is_some(), "no Trane instance is open");
135 |
136 | if let Some(mastery_score) = &self.current_score {
137 | let curr_exercise = self.current_exercise()?;
138 | let timestamp = Utc::now().timestamp();
139 | self.trane.as_ref().unwrap().score_exercise(
140 | curr_exercise.id,
141 | mastery_score.clone(),
142 | timestamp,
143 | )?;
144 | }
145 | Ok(())
146 | }
147 |
148 | /// Resets the batch of exercises.
149 | pub fn reset_batch(&mut self) {
150 | // Submit the score for the current exercise but ignore the error because this function
151 | // might be called before an instance of Trane is open.
152 | let _ = self.submit_current_score();
153 |
154 | self.batch.clear();
155 | self.batch_index = 0;
156 | self.current_score = None;
157 | }
158 |
159 | /// Returns whether the unit with the given ID exists in the currently opened Trane library.
160 | fn unit_exists(&self, unit_id: Ustr) -> Result {
161 | ensure!(self.trane.is_some(), "no Trane instance is open");
162 | Ok(self
163 | .trane
164 | .as_ref()
165 | .unwrap()
166 | .get_unit_type(unit_id)
167 | .is_some())
168 | }
169 |
170 | /// Adds the current exercise's course to the blacklist.
171 | pub fn blacklist_course(&mut self) -> Result<()> {
172 | ensure!(self.trane.is_some(), "no Trane instance is open");
173 |
174 | let course_id = self.current_exercise_course()?;
175 | self.trane.as_mut().unwrap().add_to_blacklist(course_id)?;
176 | self.reset_batch();
177 | Ok(())
178 | }
179 |
180 | /// Adds the current exercise's lesson to the blacklist.
181 | pub fn blacklist_lesson(&mut self) -> Result<()> {
182 | ensure!(self.trane.is_some(), "no Trane instance is open");
183 |
184 | let lesson_id = self.current_exercise_lesson()?;
185 | self.trane.as_mut().unwrap().add_to_blacklist(lesson_id)?;
186 | self.reset_batch();
187 | Ok(())
188 | }
189 |
190 | /// Adds the current exercise to the blacklist.
191 | pub fn blacklist_exercise(&mut self) -> Result<()> {
192 | ensure!(self.trane.is_some(), "no Trane instance is open");
193 |
194 | let manifest = self.current_exercise()?;
195 | self.trane.as_mut().unwrap().add_to_blacklist(manifest.id)?;
196 | self.reset_batch();
197 | Ok(())
198 | }
199 |
200 | /// Adds the unit with the given ID to the blacklist.
201 | pub fn blacklist_unit(&mut self, unit_id: Ustr) -> Result<()> {
202 | ensure!(self.trane.is_some(), "no Trane instance is open");
203 | ensure!(
204 | self.unit_exists(unit_id)?,
205 | "unit {} does not exist",
206 | unit_id
207 | );
208 |
209 | self.trane.as_mut().unwrap().add_to_blacklist(unit_id)?;
210 | self.reset_batch();
211 | Ok(())
212 | }
213 |
214 | /// Clears the unit filter if it's set.
215 | pub fn clear_filter(&mut self) {
216 | if self.filter.is_none() {
217 | return;
218 | }
219 | self.filter = None;
220 | self.study_session = None;
221 | self.reset_batch();
222 | }
223 |
224 | /// Displays the current exercise.
225 | pub fn current(&self) -> Result<()> {
226 | ensure!(self.trane.is_some(), "no Trane instance is open");
227 |
228 | let manifest = self.current_exercise()?;
229 | manifest.display_exercise()
230 | }
231 |
232 | /// Returns the given course ID or the current exercise's course ID if the given ID is empty.
233 | fn course_id_or_current(&self, course_id: Ustr) -> Result {
234 | let current_course = self.current_exercise_course().unwrap_or_default();
235 | if course_id.is_empty() {
236 | if current_course.is_empty() {
237 | Err(anyhow!("cannot get current exercise"))
238 | } else {
239 | Ok(current_course)
240 | }
241 | } else {
242 | Ok(course_id)
243 | }
244 | }
245 |
246 | /// Returns the given lesson ID or the current exercise's lesson ID if the given ID is empty.
247 | fn lesson_id_or_current(&self, lesson_id: Ustr) -> Result {
248 | let current_lesson = self.current_exercise_lesson().unwrap_or_default();
249 | if lesson_id.is_empty() {
250 | if current_lesson.is_empty() {
251 | Err(anyhow!("cannot get current exercise"))
252 | } else {
253 | Ok(current_lesson)
254 | }
255 | } else {
256 | Ok(lesson_id)
257 | }
258 | }
259 |
260 | /// Returns the given exercise ID or the current exercise's ID if the given ID is empty.
261 | fn exercise_id_or_current(&self, exercise_id: Ustr) -> Result {
262 | if exercise_id.is_empty() {
263 | Ok(self.current_exercise()?.id)
264 | } else {
265 | Ok(exercise_id)
266 | }
267 | }
268 |
269 | /// Exports the dependent graph as a DOT file to the given path.
270 | pub fn export_graph(&self, path: &Path) -> Result<()> {
271 | ensure!(self.trane.is_some(), "no Trane instance is open");
272 |
273 | let dot_graph = self.trane.as_ref().unwrap().generate_dot_graph();
274 | let mut file = File::create(path)?;
275 | file.write_all(dot_graph.as_bytes())?;
276 | Ok(())
277 | }
278 |
279 | /// Filters out any empty ID from the given list.
280 | fn filter_empty_ids(ids: &[Ustr]) -> Vec {
281 | ids.iter().filter(|id| !id.is_empty()).copied().collect()
282 | }
283 |
284 | /// Sets the filter to only show exercises from the given courses.
285 | pub fn filter_courses(&mut self, course_ids: &[Ustr]) -> Result<()> {
286 | ensure!(self.trane.is_some(), "no Trane instance is open");
287 |
288 | let course_ids = Self::filter_empty_ids(course_ids);
289 | for course_id in &course_ids {
290 | let unit_type = self.get_unit_type(*course_id)?;
291 | if unit_type != UnitType::Course {
292 | bail!("Unit with ID {} is not a course", course_id);
293 | }
294 | }
295 |
296 | self.filter = Some(UnitFilter::CourseFilter { course_ids });
297 | self.reset_batch();
298 | Ok(())
299 | }
300 |
301 | /// Sets the filter to only show exercises from the given lessons.
302 | pub fn filter_lessons(&mut self, lesson_ids: &[Ustr]) -> Result<()> {
303 | ensure!(self.trane.is_some(), "no Trane instance is open");
304 |
305 | let lesson_ids = Self::filter_empty_ids(lesson_ids);
306 | for lesson_id in &lesson_ids {
307 | let unit_type = self.get_unit_type(*lesson_id)?;
308 | if unit_type != UnitType::Lesson {
309 | bail!("Unit with ID {} is not a lesson", lesson_id);
310 | }
311 | }
312 |
313 | self.filter = Some(UnitFilter::LessonFilter { lesson_ids });
314 | self.reset_batch();
315 | Ok(())
316 | }
317 |
318 | /// Sets the filter to only show exercises which belong to any course or lesson with the given
319 | /// metadata.
320 | pub fn filter_metadata(
321 | &mut self,
322 | filter_op: FilterOp,
323 | lesson_metadata: Option<&Vec>,
324 | course_metadata: Option<&Vec>,
325 | ) {
326 | let basic_lesson_filters: Vec<_> = lesson_metadata
327 | .as_ref()
328 | .map(|pairs| {
329 | pairs
330 | .iter()
331 | .map(|pair| KeyValueFilter::LessonFilter {
332 | key: pair.key.clone(),
333 | value: pair.value.clone(),
334 | filter_type: FilterType::Include,
335 | })
336 | .collect()
337 | })
338 | .unwrap_or_default();
339 |
340 | let basic_course_filters: Vec<_> = course_metadata
341 | .as_ref()
342 | .map(|pairs| {
343 | pairs
344 | .iter()
345 | .map(|pair| KeyValueFilter::CourseFilter {
346 | key: pair.key.clone(),
347 | value: pair.value.clone(),
348 | filter_type: FilterType::Include,
349 | })
350 | .collect()
351 | })
352 | .unwrap_or_default();
353 |
354 | self.filter = Some(UnitFilter::MetadataFilter {
355 | filter: KeyValueFilter::CombinedFilter {
356 | op: filter_op,
357 | filters: basic_lesson_filters
358 | .iter()
359 | .chain(basic_course_filters.iter())
360 | .cloned()
361 | .collect(),
362 | },
363 | });
364 | self.reset_batch();
365 | }
366 |
367 | /// Sets the filter to only show exercises from the review list.
368 | pub fn filter_review_list(&mut self) -> Result<()> {
369 | ensure!(self.trane.is_some(), "no Trane instance is open");
370 |
371 | self.filter = Some(UnitFilter::ReviewListFilter);
372 | self.reset_batch();
373 | Ok(())
374 | }
375 |
376 | /// Sets the filter to only show exercises starting from the dependencies of the given units at
377 | /// the given depth.
378 | pub fn filter_dependencies(&mut self, unit_ids: &[Ustr], depth: usize) -> Result<()> {
379 | ensure!(self.trane.is_some(), "no Trane instance is open");
380 |
381 | let unit_ids = Self::filter_empty_ids(unit_ids);
382 | self.filter = Some(UnitFilter::Dependencies { unit_ids, depth });
383 | self.reset_batch();
384 | Ok(())
385 | }
386 |
387 | /// Sets the filter to only show exercises from the given units and their dependents.
388 | pub fn filter_dependents(&mut self, unit_ids: &[Ustr]) -> Result<()> {
389 | ensure!(self.trane.is_some(), "no Trane instance is open");
390 |
391 | let unit_ids = Self::filter_empty_ids(unit_ids);
392 | self.filter = Some(UnitFilter::Dependents { unit_ids });
393 | self.reset_batch();
394 | Ok(())
395 | }
396 |
397 | /// Returns the type of the unit with the given ID.
398 | pub fn get_unit_type(&self, unit_id: Ustr) -> Result {
399 | ensure!(self.trane.is_some(), "no Trane instance is open");
400 |
401 | self.trane
402 | .as_ref()
403 | .unwrap()
404 | .get_unit_type(unit_id)
405 | .ok_or_else(|| anyhow!("missing type for unit with ID {}", unit_id))
406 | }
407 |
408 | /// Prints the list of all the saved unit filters.
409 | pub fn list_filters(&self) -> Result<()> {
410 | ensure!(self.trane.is_some(), "no Trane instance is open");
411 |
412 | let filters = self.trane.as_ref().unwrap().list_filters();
413 |
414 | if filters.is_empty() {
415 | println!("No saved unit filters");
416 | return Ok(());
417 | }
418 |
419 | println!("Saved unit filters:");
420 | println!("{:<30} {:<50}", "ID", "Description");
421 | for filter in filters {
422 | println!("{:<30} {:<50}", filter.0, filter.1);
423 | }
424 | Ok(())
425 | }
426 |
427 | /// Prints the info of the given units to the terminal.
428 | fn print_units_info(&self, unit_ids: &[Ustr]) -> Result<()> {
429 | println!("{:<15} {:<50}", "Unit Type", "Unit ID");
430 | for unit_id in unit_ids {
431 | let unit_type = self.get_unit_type(*unit_id)?;
432 | println!("{unit_type:<15} {unit_id:<50}");
433 | }
434 | Ok(())
435 | }
436 |
437 | /// Lists the IDs of all the courses in the library.
438 | pub fn list_courses(&self) -> Result<()> {
439 | ensure!(self.trane.is_some(), "no Trane instance is open");
440 |
441 | let courses = self.trane.as_ref().unwrap().get_course_ids();
442 | if courses.is_empty() {
443 | println!("No courses in library");
444 | return Ok(());
445 | }
446 |
447 | println!("Courses:");
448 | println!();
449 | self.print_units_info(&courses)?;
450 | Ok(())
451 | }
452 |
453 | /// Lists the dependencies of the given unit.
454 | pub fn list_dependencies(&self, unit_id: Ustr) -> Result<()> {
455 | ensure!(self.trane.is_some(), "no Trane instance is open");
456 |
457 | let unit_type = self.get_unit_type(unit_id)?;
458 | if unit_type == UnitType::Exercise {
459 | bail!("Exercises do not have dependencies");
460 | }
461 |
462 | let dependencies = self
463 | .trane
464 | .as_ref()
465 | .unwrap()
466 | .get_dependencies(unit_id)
467 | .unwrap_or_default();
468 | if dependencies.is_empty() {
469 | println!("No dependencies for unit with ID {unit_id}");
470 | return Ok(());
471 | }
472 |
473 | println!("Dependencies:");
474 | println!();
475 | self.print_units_info(&dependencies.iter().copied().collect::>())?;
476 | Ok(())
477 | }
478 |
479 | /// Lists the dependents of the given unit.
480 | pub fn list_dependents(&self, unit_id: Ustr) -> Result<()> {
481 | ensure!(self.trane.is_some(), "no Trane instance is open");
482 |
483 | let unit_type = self.get_unit_type(unit_id)?;
484 | if unit_type == UnitType::Exercise {
485 | bail!("Exercises do not have dependents");
486 | }
487 |
488 | let dependents = self
489 | .trane
490 | .as_ref()
491 | .unwrap()
492 | .get_dependents(unit_id)
493 | .unwrap_or_default();
494 | if dependents.is_empty() {
495 | println!("No dependents for unit with ID {unit_id}");
496 | return Ok(());
497 | }
498 |
499 | println!("Dependents:");
500 | println!();
501 | self.print_units_info(&dependents.iter().copied().collect::>())?;
502 | Ok(())
503 | }
504 |
505 | /// Lists the IDs of all the exercises in the given lesson.
506 | pub fn list_exercises(&self, lesson_id: Ustr) -> Result<()> {
507 | ensure!(self.trane.is_some(), "no Trane instance is open");
508 |
509 | let exercises = self
510 | .trane
511 | .as_ref()
512 | .unwrap()
513 | .get_exercise_ids(lesson_id)
514 | .unwrap_or_default();
515 | if exercises.is_empty() {
516 | println!("No exercises in lesson {lesson_id}");
517 | return Ok(());
518 | }
519 |
520 | println!("Exercises:");
521 | println!();
522 | self.print_units_info(&exercises)?;
523 | Ok(())
524 | }
525 |
526 | /// Lists the IDs of all the lessons in the given course.
527 | pub fn list_lessons(&self, course_id: Ustr) -> Result<()> {
528 | ensure!(self.trane.is_some(), "no Trane instance is open");
529 |
530 | let lessons = self
531 | .trane
532 | .as_ref()
533 | .unwrap()
534 | .get_lesson_ids(course_id)
535 | .unwrap_or_default();
536 | if lessons.is_empty() {
537 | println!("No lessons in course {course_id}");
538 | return Ok(());
539 | }
540 |
541 | println!("Lessons:");
542 | println!();
543 | self.print_units_info(&lessons)?;
544 | Ok(())
545 | }
546 |
547 | /// Lists all the courses which match the current filter.
548 | pub fn list_matching_courses(&self) -> Result<()> {
549 | ensure!(self.trane.is_some(), "no Trane instance is open");
550 |
551 | let courses: Vec = self
552 | .trane
553 | .as_ref()
554 | .unwrap()
555 | .get_course_ids()
556 | .into_iter()
557 | .filter(|course_id| {
558 | if self.filter.is_none() {
559 | return true;
560 | }
561 |
562 | let filter = self.filter.as_ref().unwrap();
563 | let manifest = self.trane.as_ref().unwrap().get_course_manifest(*course_id);
564 | match manifest {
565 | Some(manifest) => match filter {
566 | UnitFilter::CourseFilter { .. } => filter.passes_course_filter(course_id),
567 | UnitFilter::LessonFilter { .. } => false,
568 | UnitFilter::MetadataFilter { filter } => filter.apply_to_course(&manifest),
569 | UnitFilter::Dependents { unit_ids }
570 | | UnitFilter::Dependencies { unit_ids, .. } => unit_ids.contains(course_id),
571 | UnitFilter::ReviewListFilter => {
572 | if let Ok(review_units) =
573 | self.trane.as_ref().unwrap().get_review_list_entries()
574 | {
575 | review_units.contains(course_id)
576 | } else {
577 | false
578 | }
579 | }
580 | },
581 | None => false,
582 | }
583 | })
584 | .collect();
585 |
586 | if courses.is_empty() {
587 | println!("No matching courses");
588 | return Ok(());
589 | }
590 |
591 | println!("Matching courses:");
592 | println!();
593 | for course in courses {
594 | println!("{course}");
595 | }
596 | Ok(())
597 | }
598 |
599 | /// Lists all the lessons in the given course which match the current filter.
600 | pub fn list_matching_lessons(&self, course_id: Ustr) -> Result<()> {
601 | ensure!(self.trane.is_some(), "no Trane instance is open");
602 |
603 | let lessons: Vec = self
604 | .trane
605 | .as_ref()
606 | .unwrap()
607 | .get_lesson_ids(course_id)
608 | .unwrap_or_default()
609 | .into_iter()
610 | .filter(|lesson_id| {
611 | if self.filter.is_none() {
612 | return true;
613 | }
614 |
615 | let filter = self.filter.as_ref().unwrap();
616 | let lesson_manifest = self.trane.as_ref().unwrap().get_lesson_manifest(*lesson_id);
617 | match lesson_manifest {
618 | Some(lesson_manifest) => match filter {
619 | UnitFilter::CourseFilter { .. } => {
620 | filter.passes_course_filter(&lesson_manifest.course_id)
621 | }
622 | UnitFilter::LessonFilter { .. } => filter.passes_lesson_filter(lesson_id),
623 | UnitFilter::MetadataFilter { filter } => {
624 | let course_manifest = self
625 | .trane
626 | .as_ref()
627 | .unwrap()
628 | .get_course_manifest(lesson_manifest.course_id);
629 | if course_manifest.is_none() {
630 | // This should never happen but print the lesson ID if it does.
631 | return true;
632 | }
633 | let course_manifest = course_manifest.unwrap();
634 | filter.apply_to_lesson(&course_manifest, &lesson_manifest)
635 | }
636 | UnitFilter::ReviewListFilter => {
637 | if let Ok(review_units) =
638 | self.trane.as_ref().unwrap().get_review_list_entries()
639 | {
640 | review_units.contains(lesson_id)
641 | } else {
642 | false
643 | }
644 | }
645 | UnitFilter::Dependencies { unit_ids, .. }
646 | | UnitFilter::Dependents { unit_ids } => unit_ids.contains(lesson_id),
647 | },
648 | None => false,
649 | }
650 | })
651 | .collect();
652 |
653 | if lessons.is_empty() {
654 | println!("No matching lessons in course {course_id}");
655 | return Ok(());
656 | }
657 |
658 | println!("Lessons:");
659 | println!();
660 | for lesson in lessons {
661 | println!("{lesson}");
662 | }
663 | Ok(())
664 | }
665 |
666 | /// Returns the exercise filter to use, which is either a unit filter or a study session.
667 | fn exercise_filter(&self) -> Option {
668 | match self.filter {
669 | None => self
670 | .study_session
671 | .as_ref()
672 | .map(|study_session| ExerciseFilter::StudySession(study_session.clone())),
673 | Some(ref filter) => Some(ExerciseFilter::UnitFilter(filter.clone())),
674 | }
675 | }
676 |
677 | /// Displays the next exercise.
678 | pub fn next(&mut self) -> Result<()> {
679 | ensure!(self.trane.is_some(), "no Trane instance is open");
680 |
681 | // Submit the current score before moving on to the next exercise.
682 | self.submit_current_score()?;
683 |
684 | self.current_score = None;
685 | self.batch_index += 1;
686 | if self.batch.is_empty() || self.batch_index >= self.batch.len() {
687 | self.batch = self
688 | .trane
689 | .as_ref()
690 | .unwrap()
691 | .get_exercise_batch(self.exercise_filter())?;
692 | self.batch_index = 0;
693 | }
694 |
695 | let manifest = self.current_exercise()?;
696 | manifest.display_exercise()
697 | }
698 |
699 | /// Opens the course library at the given path.
700 | pub fn open_library(&mut self, library_root: &str) -> Result<()> {
701 | let trane = Trane::new_local(&std::env::current_dir()?, Path::new(library_root))?;
702 | self.trane = Some(trane);
703 | self.batch.drain(..);
704 | self.batch_index = 0;
705 | Ok(())
706 | }
707 |
708 | /// Assigns the given score to the current exercise.
709 | pub fn record_score(&mut self, score: u8) -> Result<()> {
710 | ensure!(self.trane.is_some(), "no Trane instance is open");
711 |
712 | let mastery_score = match score {
713 | 1 => Ok(MasteryScore::One),
714 | 2 => Ok(MasteryScore::Two),
715 | 3 => Ok(MasteryScore::Three),
716 | 4 => Ok(MasteryScore::Four),
717 | 5 => Ok(MasteryScore::Five),
718 | _ => Err(anyhow!("invalid score {}", score)),
719 | }?;
720 | self.current_score = Some(mastery_score);
721 | Ok(())
722 | }
723 |
724 | /// Sets the unit filter to the saved filter with the given ID. Setting a filter resets the
725 | /// study session, as only one of the two can be active at a time.
726 | pub fn set_filter(&mut self, filter_id: &str) -> Result<()> {
727 | ensure!(self.trane.is_some(), "no Trane instance is open");
728 |
729 | let saved_filter = self
730 | .trane
731 | .as_ref()
732 | .unwrap()
733 | .get_filter(filter_id)
734 | .ok_or_else(|| anyhow!("no filter with ID {}", filter_id))?;
735 | self.filter = Some(saved_filter.filter);
736 | self.study_session = None;
737 | self.reset_batch();
738 | Ok(())
739 | }
740 |
741 | /// Shows the answer to the current exercise.
742 | pub fn show_answer(&mut self) -> Result<()> {
743 | ensure!(self.trane.is_some(), "no Trane instance is open");
744 |
745 | let curr_exercise = self.current_exercise()?;
746 | curr_exercise.display_answer()
747 | }
748 |
749 | /// Lists all the entries in the blacklist.
750 | pub fn list_blacklist(&self) -> Result<()> {
751 | ensure!(self.trane.is_some(), "no Trane instance is open");
752 |
753 | let trane = self.trane.as_ref().unwrap();
754 | let entries = trane.get_blacklist_entries()?;
755 | if entries.is_empty() {
756 | println!("No entries in the blacklist");
757 | return Ok(());
758 | }
759 |
760 | println!("{:<15} Unit ID", "Unit Type");
761 | for unit_id in entries {
762 | let unit_type = if let Some(ut) = trane.get_unit_type(unit_id) {
763 | ut.to_string()
764 | } else {
765 | "Unknown".to_string()
766 | };
767 | println!("{unit_type:<15} {unit_id}");
768 | }
769 | Ok(())
770 | }
771 |
772 | /// Shows the currently set filter.
773 | pub fn show_filter(&self) {
774 | if self.filter.is_none() {
775 | println!("No filter is set");
776 | } else {
777 | println!("Filter:");
778 | println!("{:#?}", self.filter.as_ref().unwrap());
779 | }
780 | }
781 |
782 | /// Shows the course instructions for the given course.
783 | pub fn show_course_instructions(&self, course_id: Ustr) -> Result<()> {
784 | ensure!(self.trane.is_some(), "no Trane instance is open");
785 |
786 | let course_id = self.course_id_or_current(course_id)?;
787 | let manifest = self
788 | .trane
789 | .as_ref()
790 | .unwrap()
791 | .get_course_manifest(course_id)
792 | .ok_or_else(|| anyhow!("no manifest for course with ID {}", course_id))?;
793 | match manifest.course_instructions {
794 | None => {
795 | println!("Course has no instructions");
796 | Ok(())
797 | }
798 | Some(instructions) => instructions.display_asset(),
799 | }
800 | }
801 |
802 | /// Shows the lesson instructions for the given lesson.
803 | pub fn show_lesson_instructions(&self, lesson_id: Ustr) -> Result<()> {
804 | ensure!(self.trane.is_some(), "no Trane instance is open");
805 |
806 | let lesson_id = self.lesson_id_or_current(lesson_id)?;
807 | let manifest = self
808 | .trane
809 | .as_ref()
810 | .unwrap()
811 | .get_lesson_manifest(lesson_id)
812 | .ok_or_else(|| anyhow!("no manifest for lesson with ID {}", lesson_id))?;
813 | match manifest.lesson_instructions {
814 | None => {
815 | println!("Lesson has no instructions");
816 | Ok(())
817 | }
818 | Some(instructions) => instructions.display_asset(),
819 | }
820 | }
821 |
822 | /// Shows the course material for the given course.
823 | pub fn show_course_material(&self, course_id: Ustr) -> Result<()> {
824 | ensure!(self.trane.is_some(), "no Trane instance is open");
825 |
826 | let course_id = self.course_id_or_current(course_id)?;
827 | let manifest = self
828 | .trane
829 | .as_ref()
830 | .unwrap()
831 | .get_course_manifest(course_id)
832 | .ok_or_else(|| anyhow!("no manifest for course with ID {}", course_id))?;
833 | match manifest.course_material {
834 | None => {
835 | println!("Course has no material");
836 | Ok(())
837 | }
838 | Some(material) => material.display_asset(),
839 | }
840 | }
841 |
842 | /// Shows the lesson material for the given lesson.
843 | pub fn show_lesson_material(&self, lesson_id: Ustr) -> Result<()> {
844 | ensure!(self.trane.is_some(), "no Trane instance is open");
845 |
846 | let lesson_id = self.lesson_id_or_current(lesson_id)?;
847 | let manifest = self
848 | .trane
849 | .as_ref()
850 | .unwrap()
851 | .get_lesson_manifest(lesson_id)
852 | .ok_or_else(|| anyhow!("no manifest for lesson with ID {}", lesson_id))?;
853 | match manifest.lesson_material {
854 | None => {
855 | println!("Lesson has no material");
856 | Ok(())
857 | }
858 | Some(material) => material.display_asset(),
859 | }
860 | }
861 |
862 | /// Shows the current count of Tara Sarasvati mantras. Her mantra is "recited" by the
863 | /// `mantra-mining` library in the background as a symbolic way in which users can contribute
864 | /// back to the maintainers of this program. See more information in the README of the
865 | /// `mantra-mining` library.
866 | pub fn show_mantra_count(&self) -> Result<()> {
867 | ensure!(self.trane.is_some(), "no Trane instance is open");
868 | println!(
869 | "Mantra count: {}",
870 | self.trane.as_ref().unwrap().mantra_count()
871 | );
872 | Ok(())
873 | }
874 |
875 | /// Shows the most recent scores for the given exercise.
876 | pub fn show_scores(
877 | &self,
878 | exercise_id: Ustr,
879 | num_scores: usize,
880 | num_rewards: usize,
881 | ) -> Result<()> {
882 | ensure!(self.trane.is_some(), "no Trane instance is open");
883 |
884 | // Retrieve and validate the exercise, course, and lesson IDs.
885 | let exercise_id = self.exercise_id_or_current(exercise_id)?;
886 | if let Some(UnitType::Exercise) = self.trane.as_ref().unwrap().get_unit_type(exercise_id) {
887 | } else {
888 | bail!("Unit with ID {} is not a valid exercise", exercise_id);
889 | }
890 | let lesson_id = self
891 | .trane
892 | .as_ref()
893 | .unwrap()
894 | .get_exercise_lesson(exercise_id)
895 | .ok_or_else(|| anyhow!("no lesson for exercise with ID {}", exercise_id))?;
896 | let course_id = self
897 | .trane
898 | .as_ref()
899 | .unwrap()
900 | .get_lesson_course(lesson_id)
901 | .ok_or_else(|| anyhow!("no course for lesson with ID {}", lesson_id))?;
902 |
903 | // Retrieve the scores and rewards and compute the aggregate score.
904 | let scores = self
905 | .trane
906 | .as_ref()
907 | .unwrap()
908 | .get_scores(exercise_id, num_scores)?;
909 | let lesson_rewards = self
910 | .trane
911 | .as_ref()
912 | .unwrap()
913 | .get_rewards(lesson_id, num_rewards)
914 | .unwrap_or_default();
915 | let course_rewards = self
916 | .trane
917 | .as_ref()
918 | .unwrap()
919 | .get_rewards(course_id, num_rewards)
920 | .unwrap_or_default();
921 |
922 | let decay_scorer = ExponentialDecayScorer {};
923 | let reward_scorer = WeightedRewardScorer {};
924 | let score = decay_scorer.score(&scores)?;
925 | let reward = reward_scorer.score_rewards(&course_rewards, &lesson_rewards)?;
926 | let total_score = if score > 0.0 {
927 | (score + reward).clamp(0.0, 5.0)
928 | } else {
929 | 0.0
930 | };
931 |
932 | // Print the scores.
933 | println!("Scores for exercise {exercise_id}:");
934 | println!();
935 | println!("Note: Rewards are only applied to exercises with previous scores");
936 | println!();
937 | println!("Score: {score:.2}");
938 | println!("Reward: {reward:.2}");
939 | println!("Final score: {total_score:.2}");
940 | println!();
941 | println!("Raw scores:");
942 | println!("{:<25} {:>6}", "Date", "Score");
943 | for score in scores {
944 | if let Some(dt) = Local.timestamp_opt(score.timestamp, 0).earliest() {
945 | println!(
946 | "{:<25} {:>6}",
947 | dt.format("%Y-%m-%d %H:%M:%S"),
948 | score.score as u8
949 | );
950 | }
951 | }
952 | Ok(())
953 | }
954 |
955 | /// Prints the manifest for the unit with the given UID.
956 | fn show_unit_manifest(&self, unit_id: Ustr, unit_type: &UnitType) -> Result<()> {
957 | ensure!(self.trane.is_some(), "no Trane instance is open");
958 |
959 | match unit_type {
960 | UnitType::Exercise => {
961 | let manifest = self
962 | .trane
963 | .as_ref()
964 | .unwrap()
965 | .get_exercise_manifest(unit_id)
966 | .ok_or_else(|| anyhow!("missing manifest for exercise {}", unit_id))?;
967 | println!("Unit manifest:");
968 | println!("{manifest:#?}");
969 | }
970 | UnitType::Lesson => {
971 | let manifest = self
972 | .trane
973 | .as_ref()
974 | .unwrap()
975 | .get_lesson_manifest(unit_id)
976 | .ok_or_else(|| anyhow!("missing manifest for lesson {}", unit_id))?;
977 | println!("Unit manifest:");
978 | println!("{manifest:#?}");
979 | }
980 | UnitType::Course => {
981 | let manifest = self
982 | .trane
983 | .as_ref()
984 | .unwrap()
985 | .get_course_manifest(unit_id)
986 | .ok_or_else(|| anyhow!("missing manifest for course {}", unit_id))?;
987 | println!("Unit manifest:");
988 | println!("{manifest:#?}");
989 | }
990 | }
991 | Ok(())
992 | }
993 |
994 | /// Prints information about the given unit.
995 | pub fn show_unit_info(&self, unit_id: Ustr) -> Result<()> {
996 | ensure!(self.trane.is_some(), "no Trane instance is open");
997 |
998 | let unit_type = self.get_unit_type(unit_id)?;
999 | println!("Unit ID: {unit_id}");
1000 | println!("Unit Type: {unit_type}");
1001 | self.show_unit_manifest(unit_id, &unit_type)
1002 | }
1003 |
1004 | /// Trims the scores for each exercise by removing all the scores except for the `num_scores`
1005 | /// most recent scores.
1006 | pub fn trim_scores(&mut self, num_scores: usize) -> Result<()> {
1007 | ensure!(self.trane.is_some(), "no Trane instance is open");
1008 | self.trane.as_mut().unwrap().trim_scores(num_scores)?;
1009 | println!("Trimmed scores for all exercises");
1010 | Ok(())
1011 | }
1012 |
1013 | /// Removes the scores for exercises that match the given prefix.
1014 | pub fn remove_prefix_from_scores(&mut self, prefix: &str) -> Result<()> {
1015 | ensure!(self.trane.is_some(), "no Trane instance is open");
1016 | self.trane
1017 | .as_mut()
1018 | .unwrap()
1019 | .remove_scores_with_prefix(prefix)?;
1020 | println!("Removed scores for all exercises with prefix {prefix}");
1021 | Ok(())
1022 | }
1023 |
1024 | /// Removes the given unit from the blacklist.
1025 | pub fn remove_from_blacklist(&mut self, unit_id: Ustr) -> Result<()> {
1026 | ensure!(self.trane.is_some(), "no Trane instance is open");
1027 |
1028 | self.trane
1029 | .as_mut()
1030 | .unwrap()
1031 | .remove_from_blacklist(unit_id)?;
1032 | self.reset_batch();
1033 | Ok(())
1034 | }
1035 |
1036 | /// Removes the given unit from the blacklist.
1037 | pub fn remove_prefix_from_blacklist(&mut self, prefix: &str) -> Result<()> {
1038 | ensure!(self.trane.is_some(), "no Trane instance is open");
1039 |
1040 | self.trane
1041 | .as_mut()
1042 | .unwrap()
1043 | .remove_prefix_from_blacklist(prefix)?;
1044 | self.reset_batch();
1045 | Ok(())
1046 | }
1047 |
1048 | /// Adds a new repository to the Trane instance.
1049 | pub fn add_repo(&mut self, url: &str, repo_id: Option) -> Result<()> {
1050 | ensure!(self.trane.is_some(), "no Trane instance is open");
1051 | self.trane.as_mut().unwrap().add_repo(url, repo_id)?;
1052 | Ok(())
1053 | }
1054 |
1055 | /// Removes the given repository from the Trane instance.
1056 | pub fn remove_repo(&mut self, repo_id: &str) -> Result<()> {
1057 | ensure!(self.trane.is_some(), "no Trane instance is open");
1058 | self.trane.as_mut().unwrap().remove_repo(repo_id)?;
1059 | Ok(())
1060 | }
1061 |
1062 | /// Lists all the repositories managed by the Trane instance.
1063 | pub fn list_repos(&self) -> Result<()> {
1064 | ensure!(self.trane.is_some(), "no Trane instance is open");
1065 | let repos = self.trane.as_ref().unwrap().list_repos();
1066 | if repos.is_empty() {
1067 | println!("No repositories are managed by Trane");
1068 | return Ok(());
1069 | }
1070 |
1071 | println!("{:<20} URL", "ID");
1072 | for repo in repos {
1073 | println!("{:<20} {}", repo.id, repo.url);
1074 | }
1075 | Ok(())
1076 | }
1077 |
1078 | /// Updates the given repository.
1079 | pub fn update_repo(&mut self, repo_id: &str) -> Result<()> {
1080 | ensure!(self.trane.is_some(), "no Trane instance is open");
1081 | self.trane.as_mut().unwrap().update_repo(repo_id)?;
1082 | Ok(())
1083 | }
1084 |
1085 | /// Updates all the repositories managed by the Trane instance.
1086 | pub fn update_all_repos(&mut self) -> Result<()> {
1087 | ensure!(self.trane.is_some(), "no Trane instance is open");
1088 | self.trane.as_mut().unwrap().update_all_repos()?;
1089 | Ok(())
1090 | }
1091 |
1092 | /// Adds the given unit to the review list.
1093 | pub fn add_to_review_list(&mut self, unit_id: Ustr) -> Result<()> {
1094 | ensure!(self.trane.is_some(), "no Trane instance is open");
1095 | ensure!(
1096 | self.unit_exists(unit_id)?,
1097 | "unit {} does not exist",
1098 | unit_id
1099 | );
1100 |
1101 | self.trane.as_mut().unwrap().add_to_review_list(unit_id)?;
1102 | self.reset_batch();
1103 | Ok(())
1104 | }
1105 |
1106 | /// Removes the given unit from the review list.
1107 | pub fn remove_from_review_list(&mut self, unit_id: Ustr) -> Result<()> {
1108 | ensure!(self.trane.is_some(), "no Trane instance is open");
1109 |
1110 | self.trane
1111 | .as_mut()
1112 | .unwrap()
1113 | .remove_from_review_list(unit_id)?;
1114 | self.reset_batch();
1115 | Ok(())
1116 | }
1117 |
1118 | /// Lists all the units in the review list.
1119 | pub fn list_review_list(&self) -> Result<()> {
1120 | ensure!(self.trane.is_some(), "no Trane instance is open");
1121 |
1122 | let entries = self.trane.as_ref().unwrap().get_review_list_entries()?;
1123 | if entries.is_empty() {
1124 | println!("No entries in the blacklist");
1125 | return Ok(());
1126 | }
1127 |
1128 | println!("Review list:");
1129 | println!("{:<10} {:<50}", "Unit Type", "Unit ID");
1130 | for unit_id in entries {
1131 | let unit_type = self.get_unit_type(unit_id);
1132 | if unit_type.is_err() {
1133 | println!("{:<10} {:<50}", "Unknown", unit_id.as_str());
1134 | } else {
1135 | println!("{:<10} {:<50}", unit_type.unwrap(), unit_id.as_str());
1136 | }
1137 | }
1138 | Ok(())
1139 | }
1140 |
1141 | /// Searches for units which match the given query.
1142 | pub fn search(&self, terms: &[String]) -> Result<()> {
1143 | ensure!(self.trane.is_some(), "no Trane instance is open");
1144 | ensure!(!terms.is_empty(), "no search terms given");
1145 |
1146 | let query = terms
1147 | .iter()
1148 | .map(|s| {
1149 | let mut quoted = "\"".to_string();
1150 | quoted.push_str(s);
1151 | quoted.push('"');
1152 | quoted
1153 | })
1154 | .collect::>()
1155 | .join(" ");
1156 | let results = self.trane.as_ref().unwrap().search(&query)?;
1157 |
1158 | if results.is_empty() {
1159 | println!("No results found");
1160 | return Ok(());
1161 | }
1162 |
1163 | println!("Search results:");
1164 | println!("{:<10} {:<50}", "Unit Type", "Unit ID");
1165 | for unit_id in results {
1166 | let unit_type = self.get_unit_type(unit_id)?;
1167 | println!("{unit_type:<10} {unit_id:<50}");
1168 | }
1169 | Ok(())
1170 | }
1171 |
1172 | /// Resets the scheduler options to their default values.
1173 | pub fn reset_scheduler_options(&mut self) -> Result<()> {
1174 | ensure!(self.trane.is_some(), "no Trane instance is open");
1175 | self.trane.as_mut().unwrap().reset_scheduler_options();
1176 | Ok(())
1177 | }
1178 |
1179 | /// Sets the scheduler options.
1180 | pub fn set_scheduler_options(&mut self, options: SchedulerOptions) -> Result<()> {
1181 | ensure!(self.trane.is_some(), "no Trane instance is open");
1182 | self.trane.as_mut().unwrap().set_scheduler_options(options);
1183 | Ok(())
1184 | }
1185 |
1186 | /// Shows the current scheduler options.
1187 | pub fn show_scheduler_options(&self) -> Result<()> {
1188 | ensure!(self.trane.is_some(), "no Trane instance is open");
1189 | let options = self.trane.as_ref().unwrap().get_scheduler_options();
1190 | println!("{options:#?}");
1191 | Ok(())
1192 | }
1193 |
1194 | /// Clears the study session if it's set.
1195 | pub fn clear_study_session(&mut self) {
1196 | if self.filter.is_none() {
1197 | return;
1198 | }
1199 | self.filter = None;
1200 | self.study_session = None;
1201 | self.reset_batch();
1202 | }
1203 |
1204 | /// Prints the list of all the saved unit filters.
1205 | pub fn list_study_sessions(&self) -> Result<()> {
1206 | ensure!(self.trane.is_some(), "no Trane instance is open");
1207 |
1208 | let sessions = self.trane.as_ref().unwrap().list_study_sessions();
1209 | if sessions.is_empty() {
1210 | println!("No saved study sessions");
1211 | return Ok(());
1212 | }
1213 |
1214 | println!("Saved study sessions:");
1215 | println!("{:<30} {:<50}", "ID", "Description");
1216 | for filter in sessions {
1217 | println!("{:<30} {:<50}", filter.0, filter.1);
1218 | }
1219 | Ok(())
1220 | }
1221 |
1222 | /// Sets the study session to the saved session with the given ID. Setting a study session
1223 | /// resets the filter, as only one of the two can be active at a time.
1224 | pub fn set_study_session(&mut self, session_id: &str) -> Result<()> {
1225 | ensure!(self.trane.is_some(), "no Trane instance is open");
1226 |
1227 | let saved_session = self
1228 | .trane
1229 | .as_ref()
1230 | .unwrap()
1231 | .get_study_session(session_id)
1232 | .ok_or_else(|| anyhow!("no study session with ID {}", session_id))?;
1233 | self.filter = None;
1234 | self.study_session = Some(StudySessionData {
1235 | start_time: Utc::now(),
1236 | definition: saved_session,
1237 | });
1238 | self.reset_batch();
1239 | Ok(())
1240 | }
1241 |
1242 | /// Shows the currently set study session.
1243 | pub fn show_study_session(&self) {
1244 | if self.filter.is_none() {
1245 | println!("No study session is set");
1246 | } else {
1247 | println!("Study session:");
1248 | println!("{:#?}", self.study_session.as_ref().unwrap());
1249 | }
1250 | }
1251 |
1252 | /// Prints the path to the transcription asset for the given exercise.
1253 | pub fn transcription_path(&self, exercise_id: Ustr) -> Result<()> {
1254 | ensure!(self.trane.is_some(), "no Trane instance is open");
1255 |
1256 | let trane = self.trane.as_ref().unwrap();
1257 | let path = trane.transcription_download_path(exercise_id);
1258 | if let Some(path) = path {
1259 | println!("Transcription asset download path: {}", path.display());
1260 | }
1261 | let alias_path = trane.transcription_download_path_alias(exercise_id);
1262 | if let Some(alias_path) = alias_path {
1263 | println!(
1264 | "Transcription asset download path alias: {}",
1265 | alias_path.display()
1266 | );
1267 | }
1268 | Ok(())
1269 | }
1270 |
1271 | /// Downloads the transcription asset from the given exercise to the specified directory in the
1272 | /// user preferences.
1273 | pub fn download_transcription_asset(&self, exercise_id: Ustr, redownload: bool) -> Result<()> {
1274 | ensure!(self.trane.is_some(), "no Trane instance is open");
1275 |
1276 | let exercise_id = self.exercise_id_or_current(exercise_id)?;
1277 | self.trane
1278 | .as_ref()
1279 | .unwrap()
1280 | .download_transcription_asset(exercise_id, redownload)?;
1281 | println!("Transcription asset for exercise {exercise_id} downloaded");
1282 | println!();
1283 | self.transcription_path(exercise_id)?;
1284 | Ok(())
1285 | }
1286 |
1287 | /// Prints whether the transcription asset for the given exercise has been downloaded.
1288 | pub fn is_transcription_asset_downloaded(&self, exercise_id: Ustr) -> Result<()> {
1289 | ensure!(self.trane.is_some(), "no Trane instance is open");
1290 |
1291 | let exercise_id = self.exercise_id_or_current(exercise_id)?;
1292 | let trane = self.trane.as_ref().unwrap();
1293 | let is_downloaded = trane.is_transcription_asset_downloaded(exercise_id);
1294 | if is_downloaded {
1295 | println!("Transcription for exercise {exercise_id} is downloaded");
1296 | println!();
1297 | self.transcription_path(exercise_id)?;
1298 | } else {
1299 | println!("Transcription for exercise {exercise_id} is not downloaded");
1300 | }
1301 | Ok(())
1302 | }
1303 | }
1304 |
--------------------------------------------------------------------------------
/src/cli.rs:
--------------------------------------------------------------------------------
1 | //! Contains the logic to parse and execute command-line instructions.
2 |
3 | use anyhow::{anyhow, Result};
4 | use clap::{Parser, Subcommand};
5 | use std::{path::Path, str::FromStr};
6 | use trane::data::{filter::FilterOp, SchedulerOptions};
7 | use ustr::Ustr;
8 |
9 | use crate::app::TraneApp;
10 |
11 | /// A key-value pair used to parse course and lesson metadata from the command-line. Pairs are
12 | /// written in the format `:`. Multiple pairs are separated by spaces.
13 | #[derive(Clone, Debug)]
14 | pub(crate) struct KeyValue {
15 | pub key: String,
16 | pub value: String,
17 | }
18 |
19 | impl FromStr for KeyValue {
20 | type Err = anyhow::Error;
21 |
22 | /// Parse a string value into a key-value pair.
23 | fn from_str(s: &str) -> Result {
24 | let key_value: Vec<&str> = s.trim().split(':').collect();
25 | if key_value.len() != 2 {
26 | return Err(anyhow!("Invalid key-value pair"));
27 | }
28 | if key_value[0].is_empty() || key_value[1].is_empty() {
29 | return Err(anyhow!("Invalid key-value pair"));
30 | }
31 |
32 | Ok(KeyValue {
33 | key: key_value[0].to_string(),
34 | value: key_value[1].to_string(),
35 | })
36 | }
37 | }
38 |
39 | /// Contains subcommands for manipulating the unit blacklist.
40 | #[derive(Clone, Debug, Subcommand)]
41 | pub(crate) enum BlacklistSubcommands {
42 | #[clap(about = "Add the given unit to the blacklist")]
43 | Add {
44 | #[clap(help = "The ID of the unit")]
45 | unit_id: Ustr,
46 | },
47 |
48 | #[clap(about = "Add the current exercise's course to the blacklist")]
49 | Course,
50 |
51 | #[clap(about = "Add the current exercise to the blacklist")]
52 | Exercise,
53 |
54 | #[clap(about = "Add the current exercise's lesson to the blacklist")]
55 | Lesson,
56 |
57 | #[clap(about = "List the units currently in the blacklist")]
58 | List,
59 |
60 | #[clap(about = "Remove unit from the blacklist")]
61 | Remove {
62 | #[clap(help = "The unit to remove from the blacklist")]
63 | unit_id: Ustr,
64 | },
65 |
66 | #[clap(about = "Removes all the units that match the given prefix from the blacklist")]
67 | RemovePrefix {
68 | #[clap(help = "The prefix to remove from the blacklist")]
69 | prefix: String,
70 | },
71 | }
72 |
73 | /// Contains subcommands used for debugging.
74 | #[derive(Clone, Debug, Subcommand)]
75 | pub(crate) enum DebugSubcommands {
76 | #[clap(about = "Exports the dependent graph as a DOT file to the given path")]
77 | ExportGraph {
78 | #[clap(help = "The path to the DOT file")]
79 | path: String,
80 | },
81 |
82 | #[clap(about = "Trims the storage by removing all trials except for the most recent ones")]
83 | TrimScores {
84 | #[clap(help = "The number of trials to keep for each exercise")]
85 | #[clap(default_value = "20")]
86 | num_trials: usize,
87 | },
88 |
89 | #[clap(about = "Prints information about the given unit")]
90 | UnitInfo {
91 | #[clap(help = "The ID of the unit")]
92 | unit_id: Ustr,
93 | },
94 |
95 | #[clap(about = "Prints the type of the unit with the given ID")]
96 | UnitType {
97 | #[clap(help = "The ID of the unit")]
98 | unit_id: Ustr,
99 | },
100 |
101 | #[clap(about = "Remove all the trials from units matching the given prefix")]
102 | RemoveScoresPrefix {
103 | #[clap(help = "The prefix to match against the trials")]
104 | prefix: String,
105 | },
106 | }
107 |
108 | /// Contains subcommands used for setting and displaying unit filters.
109 | #[derive(Clone, Debug, Subcommand)]
110 | pub(crate) enum FilterSubcommands {
111 | #[clap(about = "Clear the unit filter if any has been set")]
112 | Clear,
113 |
114 | #[clap(about = "Set the unit filter to only show exercises from the given courses")]
115 | Courses {
116 | #[clap(help = "The IDs of the courses")]
117 | ids: Vec,
118 | },
119 |
120 | #[clap(about = "Set the unit filter to only show exercises from the given lessons")]
121 | Lessons {
122 | #[clap(help = "The IDs of the lessons")]
123 | ids: Vec,
124 | },
125 |
126 | #[clap(about = "List the saved unit filters")]
127 | List,
128 |
129 | #[clap(about = "Set the unit filter to only show exercises with the given metadata")]
130 | Metadata {
131 | #[clap(help = "If true, include units which match all of the key-value pairs")]
132 | #[clap(long)]
133 | #[clap(conflicts_with = "any")]
134 | all: bool,
135 |
136 | #[clap(help = "If true, include units which match any of the key-value pairs")]
137 | #[clap(long)]
138 | #[clap(conflicts_with = "all")]
139 | any: bool,
140 |
141 | #[clap(help = "Key-value pairs (written as key:value) of course metadata to filter on")]
142 | #[clap(name = "course-metadata")]
143 | #[clap(long, short)]
144 | #[clap(num_args = 1..)]
145 | #[clap(required_unless_present("lesson-metadata"))]
146 | course_metadata: Option>,
147 |
148 | #[clap(help = "Key-value pairs (written as key:value) of lesson metadata to filter on")]
149 | #[clap(name = "lesson-metadata")]
150 | #[clap(long, short)]
151 | #[clap(num_args = 1..)]
152 | #[clap(required_unless_present("course-metadata"))]
153 | lesson_metadata: Option>,
154 | },
155 |
156 | #[clap(about = "Set the unit filter to only show exercises from the units in the review list")]
157 | ReviewList,
158 |
159 | #[clap(
160 | about = "Save the unit filter to only search from the given units and their dependents"
161 | )]
162 | Dependents {
163 | #[clap(help = "The IDs of the units")]
164 | ids: Vec,
165 | },
166 |
167 | #[clap(
168 | about = "Save the unit filter to only search from the given units and their dependencies"
169 | )]
170 | Dependencies {
171 | #[clap(help = "The IDs of the units")]
172 | ids: Vec,
173 |
174 | #[clap(help = "The maximum depth to search for dependencies")]
175 | #[clap(long, short)]
176 | depth: usize,
177 | },
178 |
179 | #[clap(about = "Select the saved filter with the given ID")]
180 | Set {
181 | #[clap(help = "The ID of the saved filter")]
182 | id: String,
183 | },
184 |
185 | #[clap(about = "Shows the selected unit filter")]
186 | Show,
187 | }
188 |
189 | /// Contains subcommands used for displaying course and lesson instructions.
190 | #[derive(Clone, Debug, Subcommand)]
191 | pub(crate) enum InstructionSubcommands {
192 | #[clap(about = "Show the instructions for the given course \
193 | (or the current course if none is passed)")]
194 | Course {
195 | #[clap(help = "The ID of the course")]
196 | #[clap(default_value = "")]
197 | course_id: Ustr,
198 | },
199 |
200 | #[clap(about = "Show the instructions for the given lesson \
201 | (or the current lesson if none is passed)")]
202 | Lesson {
203 | #[clap(help = "The ID of the lesson")]
204 | #[clap(default_value = "")]
205 | lesson_id: Ustr,
206 | },
207 | }
208 |
209 | /// Contains subcommands used for displaying courses, lessons, and exercises IDs.
210 | #[derive(Clone, Debug, Subcommand)]
211 | pub(crate) enum ListSubcommands {
212 | #[clap(about = "Show the IDs of all courses in the library")]
213 | Courses,
214 |
215 | #[clap(about = "Show the dependencies of the given unit")]
216 | Dependencies {
217 | #[clap(help = "The ID of the unit")]
218 | unit_id: Ustr,
219 | },
220 |
221 | #[clap(about = "Show the dependents of the given unit")]
222 | Dependents {
223 | #[clap(help = "The ID of the unit")]
224 | unit_id: Ustr,
225 | },
226 |
227 | #[clap(about = "Show the IDs of all exercises in the given lesson")]
228 | Exercises {
229 | #[clap(help = "The ID of the lesson")]
230 | lesson_id: Ustr,
231 | },
232 |
233 | #[clap(about = "Show the IDs of all lessons in the given course")]
234 | Lessons {
235 | #[clap(help = "The ID of the course")]
236 | course_id: Ustr,
237 | },
238 |
239 | #[clap(about = "Show the IDs of all the lessons in the given course \
240 | which match the current filter")]
241 | MatchingLessons {
242 | #[clap(help = "The ID of the course")]
243 | course_id: Ustr,
244 | },
245 |
246 | #[clap(about = "Show the IDs of all the courses which match the current filter")]
247 | MatchingCourses,
248 | }
249 |
250 | /// Contains subcommands used for displaying course and lesson materials.
251 | #[derive(Clone, Debug, Subcommand)]
252 | pub(crate) enum MaterialSubcommands {
253 | #[clap(about = "Show the material for the given course \
254 | (or the current course if none is passed)")]
255 | Course {
256 | #[clap(help = "The ID of the course")]
257 | #[clap(default_value = "")]
258 | course_id: Ustr,
259 | },
260 |
261 | #[clap(about = "Show the material for the given lesson \
262 | (or the current lesson if none is passed)")]
263 | Lesson {
264 | #[clap(help = "The ID of the lesson")]
265 | #[clap(default_value = "")]
266 | lesson_id: Ustr,
267 | },
268 | }
269 |
270 | /// Contains subcommands used for manipulating git repositories containing Trane courses.
271 | #[derive(Clone, Debug, Subcommand)]
272 | pub(crate) enum RepositorySubcommands {
273 | #[clap(about = "Add a new git repository to the library")]
274 | Add {
275 | #[clap(help = "The URL of the git repository")]
276 | url: String,
277 |
278 | #[clap(
279 | help = "The optional ID to assign to the repository. If not provided, the \
280 | repository's name is used"
281 | )]
282 | #[clap(long, short)]
283 | repo_id: Option,
284 | },
285 |
286 | #[clap(about = "Remove the git repository with the given ID from the library")]
287 | Remove {
288 | #[clap(help = "The ID of the repository")]
289 | repo_id: String,
290 | },
291 |
292 | #[clap(about = "List the managed git repositories in the library")]
293 | List,
294 |
295 | #[clap(about = "Update the managed git repository with the given ID")]
296 | Update {
297 | #[clap(help = "The ID of the repository")]
298 | repo_id: String,
299 | },
300 |
301 | #[clap(about = "Update all the managed git repositories in the library")]
302 | UpdateAll,
303 | }
304 |
305 | /// Contains subcommands used for manipulating the review list.
306 | #[derive(Clone, Debug, Subcommand)]
307 | pub(crate) enum ReviewListSubcommands {
308 | #[clap(about = "Add the given unit to the review list")]
309 | Add {
310 | #[clap(help = "The ID of the unit")]
311 | unit_id: Ustr,
312 | },
313 |
314 | #[clap(about = "List all the units in the review list")]
315 | List,
316 |
317 | #[clap(about = "Remove the given unit from the review list")]
318 | Remove {
319 | #[clap(help = "The ID of the unit")]
320 | unit_id: Ustr,
321 | },
322 | }
323 |
324 | #[derive(Clone, Debug, Subcommand)]
325 | pub(crate) enum SchedulerOptionsSubcommands {
326 | #[clap(about = "Reset the scheduler options to their default values")]
327 | Reset,
328 |
329 | #[clap(about = "Set the scheduler options to the given values")]
330 | Set {
331 | #[clap(help = "The new batch size")]
332 | #[clap(long, short)]
333 | batch_size: usize,
334 | },
335 |
336 | #[clap(about = "Show the current scheduler options")]
337 | Show,
338 | }
339 |
340 | /// Contains subcommands used for setting and displaying study sessions.
341 | #[derive(Clone, Debug, Subcommand)]
342 | pub(crate) enum StudySessionSubcommands {
343 | #[clap(about = "Clear the study session if any has been set")]
344 | Clear,
345 |
346 | #[clap(about = "List the saved study sessions")]
347 | List,
348 |
349 | #[clap(about = "Select the study session with the given ID")]
350 | Set {
351 | #[clap(help = "The ID of the saved study session")]
352 | id: String,
353 | },
354 |
355 | #[clap(about = "Shows the selected study session")]
356 | Show,
357 | }
358 |
359 | /// Contains subcommands used for dealing with transcription exercises.
360 | #[derive(Clone, Debug, Subcommand)]
361 | pub(crate) enum TranscriptionSubcommands {
362 | #[clap(about = "Download the asset for the given transcription exercise. \
363 | The current exercise's ID is used if no ID is provided")]
364 | Download {
365 | #[clap(help = "The ID of the exercise")]
366 | #[clap(default_value = "")]
367 | exercise_id: Ustr,
368 |
369 | #[clap(help = "Whether to redownload the asset if it already exists")]
370 | #[clap(default_value = "false")]
371 | #[clap(long, short)]
372 | redownload: bool,
373 | },
374 |
375 | #[clap(
376 | about = "Checks if the the asset for the given transcription exercise has been \
377 | downloaded. The current exercise's ID is used if no ID is provided"
378 | )]
379 | IsDownloaded {
380 | #[clap(help = "The ID of the exercise")]
381 | #[clap(default_value = "")]
382 | exercise_id: Ustr,
383 | },
384 |
385 | #[clap(
386 | about = "Shows the path to the downloaded asset for the given transcription exercise. \
387 | The current exercise's ID is used if no ID is provided"
388 | )]
389 | Path {
390 | #[clap(help = "The ID of the exercise")]
391 | #[clap(default_value = "")]
392 | exercise_id: Ustr,
393 | },
394 | }
395 |
396 | /// Contains the available subcommands.
397 | #[derive(Clone, Debug, Subcommand)]
398 | pub(crate) enum Subcommands {
399 | #[clap(about = "Show the answer to the current exercise, if it exists")]
400 | Answer,
401 |
402 | #[clap(about = "Subcommands to manipulate the unit blacklist")]
403 | #[clap(subcommand)]
404 | Blacklist(BlacklistSubcommands),
405 |
406 | #[clap(about = "Display the current exercise")]
407 | Current,
408 |
409 | #[clap(about = "Subcommands for debugging purposes")]
410 | #[clap(subcommand)]
411 | Debug(DebugSubcommands),
412 |
413 | #[clap(about = "Subcommands for dealing with unit filters")]
414 | #[clap(subcommand)]
415 | Filter(FilterSubcommands),
416 |
417 | #[clap(about = "Subcommands for showing course and lesson instructions")]
418 | #[clap(subcommand)]
419 | Instructions(InstructionSubcommands),
420 |
421 | #[clap(about = "Subcommands for listing course, lesson, and exercise IDs")]
422 | #[clap(subcommand)]
423 | List(ListSubcommands),
424 |
425 | #[clap(
426 | about = "Show the number of Tara Sarasvati mantras recited in the background during \
427 | the current session"
428 | )]
429 | #[clap(
430 | long_about = "Trane \"recites\" Tara Sarasvati's mantra in the background as a symbolic \
431 | way in which users can contribute back to the Trane Project. This command shows the \
432 | number of mantras that Trane has recited so far."
433 | )]
434 | MantraCount,
435 |
436 | #[clap(about = "Subcommands for showing course and lesson materials")]
437 | #[clap(subcommand)]
438 | Material(MaterialSubcommands),
439 |
440 | #[clap(about = "Submits the score for the current exercise and proceeds to the next")]
441 | Next,
442 |
443 | #[clap(about = "Open the course library at the given location")]
444 | Open {
445 | #[clap(help = "The path to the course library")]
446 | library_path: String,
447 | },
448 |
449 | #[clap(about = "Quit Trane")]
450 | Quit,
451 |
452 | #[clap(about = "Subcommands for manipulating git repositories containing Trane courses")]
453 | #[clap(subcommand)]
454 | Repository(RepositorySubcommands),
455 |
456 | #[clap(about = "Resets the current exercise batch")]
457 | ResetBatch,
458 |
459 | #[clap(about = "Subcommands for manipulating the review list")]
460 | #[clap(subcommand)]
461 | ReviewList(ReviewListSubcommands),
462 |
463 | #[clap(about = "Record the mastery score (1-5) for the current exercise")]
464 | Score {
465 | #[clap(help = "The mastery score (1-5) for the current exercise")]
466 | score: u8,
467 | },
468 |
469 | #[clap(about = "Search for courses, lessons, and exercises")]
470 | Search {
471 | #[clap(help = "The search query")]
472 | terms: Vec,
473 | },
474 |
475 | #[clap(about = "Show the most recent scores for the given exercise")]
476 | Scores {
477 | #[clap(help = "The ID of the exercise")]
478 | #[clap(default_value = "")]
479 | exercise_id: Ustr,
480 |
481 | #[clap(help = "The number of scores to show")]
482 | #[clap(long, short, default_value = "20")]
483 | num_scores: usize,
484 |
485 | #[clap(help = "The number of rewards to show")]
486 | #[clap(long, short, default_value = "5")]
487 | num_rewards: usize,
488 | },
489 |
490 | #[clap(about = "Subcommands for manipulating the exercise scheduler")]
491 | #[clap(subcommand)]
492 | SchedulerOptions(SchedulerOptionsSubcommands),
493 |
494 | #[clap(about = "Subcommands for setting and displaying study sessions")]
495 | #[clap(subcommand)]
496 | StudySession(StudySessionSubcommands),
497 |
498 | #[clap(about = "Subcommands for dealing with transcription exercises")]
499 | #[clap(subcommand)]
500 | Transcription(TranscriptionSubcommands),
501 | }
502 |
503 | /// A command-line interface for Trane.
504 | #[derive(Debug, Parser)]
505 | #[clap(name = "trane")]
506 | #[clap(author, version, about, long_about = None)]
507 | pub(crate) struct TraneCli {
508 | #[clap(subcommand)]
509 | pub commands: Subcommands,
510 | }
511 |
512 | impl TraneCli {
513 | /// Executes the parsed subcommand. Returns true if the application should continue running.
514 | pub fn execute_subcommand(&self, app: &mut TraneApp) -> Result {
515 | match self.commands.clone() {
516 | Subcommands::Answer => {
517 | app.show_answer()?;
518 | Ok(true)
519 | }
520 |
521 | Subcommands::Blacklist(subcommand) => match subcommand {
522 | BlacklistSubcommands::Add { unit_id } => {
523 | app.blacklist_unit(unit_id)?;
524 | println!("Added unit {unit_id} to the blacklist");
525 | Ok(true)
526 | }
527 | BlacklistSubcommands::Course => {
528 | app.blacklist_course()?;
529 | println!("Added current exercise's course to the blacklist");
530 | Ok(true)
531 | }
532 | BlacklistSubcommands::Exercise => {
533 | app.blacklist_exercise()?;
534 | println!("Added current exercise to the blacklist");
535 | Ok(true)
536 | }
537 | BlacklistSubcommands::Lesson => {
538 | app.blacklist_lesson()?;
539 | println!("Added current exercise's lesson to the blacklist");
540 | Ok(true)
541 | }
542 | BlacklistSubcommands::Remove { unit_id } => {
543 | app.remove_from_blacklist(unit_id)?;
544 | println!("Removed {unit_id} from the blacklist");
545 | Ok(true)
546 | }
547 | BlacklistSubcommands::RemovePrefix { prefix } => {
548 | app.remove_prefix_from_blacklist(&prefix)?;
549 | println!("Removed units matching prefix {prefix} from the blacklist");
550 | Ok(true)
551 | }
552 | BlacklistSubcommands::List => {
553 | app.list_blacklist()?;
554 | Ok(true)
555 | }
556 | },
557 |
558 | Subcommands::Current => {
559 | app.current()?;
560 | Ok(true)
561 | }
562 |
563 | Subcommands::Debug(subcommand) => match subcommand {
564 | DebugSubcommands::ExportGraph { path } => {
565 | app.export_graph(Path::new(&path))?;
566 | println!("Exported graph to {path}");
567 | Ok(true)
568 | }
569 | DebugSubcommands::TrimScores { num_trials } => {
570 | app.trim_scores(num_trials)?;
571 | Ok(true)
572 | }
573 | DebugSubcommands::UnitInfo { unit_id } => {
574 | app.show_unit_info(unit_id)?;
575 | Ok(true)
576 | }
577 | DebugSubcommands::UnitType { unit_id } => {
578 | let unit_type = app.get_unit_type(unit_id)?;
579 | println!("The type of the unit with ID {unit_id} is {unit_type:?}");
580 | Ok(true)
581 | }
582 | DebugSubcommands::RemoveScoresPrefix { prefix } => {
583 | app.remove_prefix_from_scores(&prefix)?;
584 | Ok(true)
585 | }
586 | },
587 |
588 | Subcommands::Filter(subcommand) => match subcommand {
589 | FilterSubcommands::Clear => {
590 | app.clear_filter();
591 | println!("Cleared the unit filter");
592 | Ok(true)
593 | }
594 | FilterSubcommands::Courses { ids } => {
595 | app.filter_courses(&ids)?;
596 | println!("Set the unit filter to only show exercises from the given courses");
597 | Ok(true)
598 | }
599 | FilterSubcommands::Lessons { ids } => {
600 | app.filter_lessons(&ids)?;
601 | println!("Set the unit filter to only show exercises from the given lessons");
602 | Ok(true)
603 | }
604 | FilterSubcommands::List => {
605 | app.list_filters()?;
606 | Ok(true)
607 | }
608 | FilterSubcommands::Metadata {
609 | all,
610 | any,
611 | lesson_metadata,
612 | course_metadata,
613 | } => {
614 | let filter_op = match (any, all) {
615 | (true, _) => FilterOp::Any,
616 | (false, false) | (_, true) => FilterOp::All,
617 | };
618 | app.filter_metadata(
619 | filter_op,
620 | lesson_metadata.as_ref(),
621 | course_metadata.as_ref(),
622 | );
623 | println!("Set the unit filter to only show exercises with the given metadata");
624 | Ok(true)
625 | }
626 | FilterSubcommands::ReviewList => {
627 | app.filter_review_list()?;
628 | println!("Set the unit filter to only show exercises in the review list");
629 | Ok(true)
630 | }
631 | FilterSubcommands::Dependencies { ids, depth } => {
632 | app.filter_dependencies(&ids, depth)?;
633 | println!(
634 | "Set the unit filter to only show exercises starting from the depedents of \
635 | the given units"
636 | );
637 | Ok(true)
638 | }
639 | FilterSubcommands::Dependents { ids } => {
640 | app.filter_dependents(&ids)?;
641 | println!(
642 | "Set the unit filter to only show exercises from the given units and their \
643 | dependencies"
644 | );
645 | Ok(true)
646 | }
647 | FilterSubcommands::Set { id } => {
648 | app.set_filter(&id)?;
649 | println!("Set the unit filter to the saved filter with ID {id}");
650 | Ok(true)
651 | }
652 | FilterSubcommands::Show => {
653 | app.show_filter();
654 | Ok(true)
655 | }
656 | },
657 |
658 | Subcommands::Instructions(subcommand) => match subcommand {
659 | InstructionSubcommands::Course { course_id } => {
660 | app.show_course_instructions(course_id)?;
661 | Ok(true)
662 | }
663 | InstructionSubcommands::Lesson { lesson_id } => {
664 | app.show_lesson_instructions(lesson_id)?;
665 | Ok(true)
666 | }
667 | },
668 |
669 | Subcommands::List(subcommand) => match subcommand {
670 | ListSubcommands::Courses => {
671 | app.list_courses()?;
672 | Ok(true)
673 | }
674 | ListSubcommands::Dependencies { unit_id } => {
675 | app.list_dependencies(unit_id)?;
676 | Ok(true)
677 | }
678 | ListSubcommands::Dependents { unit_id } => {
679 | app.list_dependents(unit_id)?;
680 | Ok(true)
681 | }
682 | ListSubcommands::Exercises { lesson_id } => {
683 | app.list_exercises(lesson_id)?;
684 | Ok(true)
685 | }
686 | ListSubcommands::Lessons { course_id } => {
687 | app.list_lessons(course_id)?;
688 | Ok(true)
689 | }
690 | ListSubcommands::MatchingCourses => {
691 | app.list_matching_courses()?;
692 | Ok(true)
693 | }
694 | ListSubcommands::MatchingLessons { course_id } => {
695 | app.list_matching_lessons(course_id)?;
696 | Ok(true)
697 | }
698 | },
699 |
700 | Subcommands::Material(subcommand) => match subcommand {
701 | MaterialSubcommands::Course { course_id } => {
702 | app.show_course_material(course_id)?;
703 | Ok(true)
704 | }
705 | MaterialSubcommands::Lesson { lesson_id } => {
706 | app.show_lesson_material(lesson_id)?;
707 | Ok(true)
708 | }
709 | },
710 |
711 | Subcommands::MantraCount => {
712 | app.show_mantra_count()?;
713 | Ok(true)
714 | }
715 |
716 | Subcommands::Next => {
717 | app.next()?;
718 | Ok(true)
719 | }
720 |
721 | Subcommands::Open { library_path } => {
722 | app.open_library(&library_path)?;
723 | println!("Successfully opened course library at {library_path}");
724 | Ok(true)
725 | }
726 |
727 | Subcommands::Quit => Ok(false),
728 |
729 | Subcommands::Repository(subcommand) => match subcommand {
730 | RepositorySubcommands::Add { url, repo_id } => {
731 | app.add_repo(&url, repo_id)?;
732 | println!("Added repository with {url} to the course library");
733 | Ok(true)
734 | }
735 | RepositorySubcommands::List => {
736 | app.list_repos()?;
737 | Ok(true)
738 | }
739 | RepositorySubcommands::Remove { repo_id } => {
740 | app.remove_repo(&repo_id)?;
741 | println!("Removed repository with ID {repo_id} from the course library.");
742 | Ok(true)
743 | }
744 | RepositorySubcommands::Update { repo_id } => {
745 | app.update_repo(&repo_id)?;
746 | println!("Updated repository with ID {repo_id}.");
747 | Ok(true)
748 | }
749 | RepositorySubcommands::UpdateAll => {
750 | app.update_all_repos()?;
751 | println!("Updated all managed repositories.");
752 | Ok(true)
753 | }
754 | },
755 |
756 | Subcommands::ResetBatch => {
757 | app.reset_batch();
758 | println!("The exercise batch has been reset.");
759 | Ok(true)
760 | }
761 |
762 | Subcommands::ReviewList(subcommand) => match subcommand {
763 | ReviewListSubcommands::Add { unit_id } => {
764 | app.add_to_review_list(unit_id)?;
765 | println!("Added unit {unit_id} to the review list.");
766 | Ok(true)
767 | }
768 | ReviewListSubcommands::List => {
769 | app.list_review_list()?;
770 | Ok(true)
771 | }
772 | ReviewListSubcommands::Remove { unit_id } => {
773 | app.remove_from_review_list(unit_id)?;
774 | println!("Removed unit {unit_id} from the review list.");
775 | Ok(true)
776 | }
777 | },
778 |
779 | Subcommands::Search { terms } => {
780 | app.search(&terms)?;
781 | Ok(true)
782 | }
783 |
784 | Subcommands::Score { score } => {
785 | app.record_score(score)?;
786 | println!("Recorded mastery score {score} for current exercise.");
787 | Ok(true)
788 | }
789 |
790 | Subcommands::Scores {
791 | exercise_id,
792 | num_scores,
793 | num_rewards,
794 | } => {
795 | app.show_scores(exercise_id, num_scores, num_rewards)?;
796 | Ok(true)
797 | }
798 |
799 | Subcommands::SchedulerOptions(subcommand) => match subcommand {
800 | SchedulerOptionsSubcommands::Reset => {
801 | app.reset_scheduler_options()?;
802 | println!("Reset the scheduler options to their default values");
803 | Ok(true)
804 | }
805 | SchedulerOptionsSubcommands::Set { batch_size } => {
806 | let options = SchedulerOptions {
807 | batch_size,
808 | ..Default::default()
809 | };
810 | app.set_scheduler_options(options)?;
811 | println!("Set the batch size to {batch_size}");
812 | Ok(true)
813 | }
814 | SchedulerOptionsSubcommands::Show => {
815 | app.show_scheduler_options()?;
816 | Ok(true)
817 | }
818 | },
819 |
820 | Subcommands::StudySession(subcommand) => match subcommand {
821 | StudySessionSubcommands::Clear => {
822 | app.clear_study_session();
823 | println!("Cleared the saved study session");
824 | Ok(true)
825 | }
826 | StudySessionSubcommands::List => {
827 | app.list_study_sessions()?;
828 | Ok(true)
829 | }
830 | StudySessionSubcommands::Set { id } => {
831 | app.set_study_session(&id)?;
832 | println!("Set the study session to the saved study session with ID {id}");
833 | Ok(true)
834 | }
835 | StudySessionSubcommands::Show => {
836 | app.show_study_session();
837 | Ok(true)
838 | }
839 | },
840 |
841 | Subcommands::Transcription(subcommand) => match subcommand {
842 | TranscriptionSubcommands::Download {
843 | exercise_id,
844 | redownload,
845 | } => {
846 | app.download_transcription_asset(exercise_id, redownload)?;
847 | Ok(true)
848 | }
849 | TranscriptionSubcommands::IsDownloaded { exercise_id } => {
850 | app.is_transcription_asset_downloaded(exercise_id)?;
851 | Ok(true)
852 | }
853 | TranscriptionSubcommands::Path { exercise_id } => {
854 | app.transcription_path(exercise_id)?;
855 | Ok(true)
856 | }
857 | },
858 | }
859 | }
860 | }
861 |
--------------------------------------------------------------------------------
/src/display.rs:
--------------------------------------------------------------------------------
1 | //! Contains the logic to print Trane assets to the terminal.
2 |
3 | use anyhow::{Context, Result};
4 | use rand::prelude::SliceRandom;
5 | use std::fs::read_to_string;
6 | use termimad::print_inline;
7 | use trane::data::{
8 | course_generator::literacy::LiteracyLessonType, BasicAsset, ExerciseAsset, ExerciseManifest,
9 | };
10 |
11 | /// Prints the markdown file at the given path to the terminal.
12 | pub fn print_markdown(path: &str) -> Result<()> {
13 | let contents =
14 | read_to_string(path).with_context(|| format!("Failed to read file at path: {path}"))?;
15 | print_inline(&contents);
16 | println!();
17 | Ok(())
18 | }
19 |
20 | /// Randomly samples five values from the given list of strings.
21 | fn sample(values: &[String]) -> Vec {
22 | let mut sampled = values.to_vec();
23 | let mut rng = rand::rng();
24 | sampled.shuffle(&mut rng);
25 | sampled.truncate(5);
26 | sampled
27 | }
28 |
29 | /// Prints a literacy asset to the terminal.
30 | pub fn print_literacy(
31 | lesson_type: &LiteracyLessonType,
32 | examples: &[String],
33 | exceptions: &[String],
34 | ) {
35 | let sampled_examples = sample(examples);
36 | let sampled_exceptions = sample(exceptions);
37 | match lesson_type {
38 | LiteracyLessonType::Reading => println!("Lesson type: Reading"),
39 | LiteracyLessonType::Dictation => println!("Lesson type: Dictation"),
40 | }
41 | if !sampled_examples.is_empty() {
42 | println!("Examples:");
43 | println!();
44 | for example in sampled_examples {
45 | print_inline(&example);
46 | println!();
47 | }
48 | }
49 | if !sampled_exceptions.is_empty() {
50 | println!("Exceptions:");
51 | println!();
52 | for exception in sampled_exceptions {
53 | print_inline(&exception);
54 | println!();
55 | }
56 | }
57 | }
58 |
59 | /// Trait to display an asset to the terminal.
60 | pub trait DisplayAsset {
61 | /// Prints the asset to the terminal.
62 | fn display_asset(&self) -> Result<()>;
63 | }
64 |
65 | impl DisplayAsset for BasicAsset {
66 | fn display_asset(&self) -> Result<()> {
67 | match self {
68 | BasicAsset::MarkdownAsset { path } => print_markdown(path),
69 | BasicAsset::InlinedAsset { content } => {
70 | print_inline(content);
71 | println!();
72 | Ok(())
73 | }
74 | BasicAsset::InlinedUniqueAsset { content } => {
75 | print_inline(content);
76 | println!();
77 | Ok(())
78 | }
79 | }
80 | }
81 | }
82 |
83 | /// Trait to display an exercise in the terminal.
84 | pub trait DisplayExercise {
85 | /// Prints the exercise to the terminal.
86 | fn display_exercise(&self) -> Result<()>;
87 | }
88 |
89 | impl DisplayExercise for ExerciseAsset {
90 | fn display_exercise(&self) -> Result<()> {
91 | match self {
92 | ExerciseAsset::BasicAsset(asset) => asset.display_asset(),
93 | ExerciseAsset::FlashcardAsset { front_path, .. } => print_markdown(front_path),
94 | ExerciseAsset::LiteracyAsset {
95 | lesson_type,
96 | examples,
97 | exceptions,
98 | } => {
99 | print_literacy(lesson_type, examples, exceptions);
100 | Ok(())
101 | }
102 | ExerciseAsset::SoundSliceAsset {
103 | link, description, ..
104 | } => {
105 | if let Some(description) = description {
106 | println!("Exercise description:");
107 | print_inline(description);
108 | println!();
109 | }
110 | println!("SoundSlice link: {link}");
111 | Ok(())
112 | }
113 | ExerciseAsset::TranscriptionAsset { content, .. } => {
114 | print_inline(content);
115 | println!();
116 | Ok(())
117 | }
118 | }
119 | }
120 | }
121 |
122 | impl DisplayExercise for ExerciseManifest {
123 | fn display_exercise(&self) -> Result<()> {
124 | println!("Course ID: {}", self.course_id);
125 | println!("Lesson ID: {}", self.lesson_id);
126 | println!("Exercise ID: {}", self.id);
127 | println!();
128 | if let Some(description) = &self.description {
129 | println!("Exercise description: {description}");
130 | println!();
131 | }
132 | self.exercise_asset.display_exercise()?;
133 | Ok(())
134 | }
135 | }
136 |
137 | /// Trait to display an exercise's answer in the terminal.
138 | pub trait DisplayAnswer {
139 | /// Prints the exercise's answer to the terminal.
140 | fn display_answer(&self) -> Result<()>;
141 | }
142 |
143 | impl DisplayAnswer for ExerciseAsset {
144 | fn display_answer(&self) -> Result<()> {
145 | match self {
146 | ExerciseAsset::BasicAsset(_) | ExerciseAsset::TranscriptionAsset { .. } => {
147 | println!("No answer available for this exercise.");
148 | println!();
149 | Ok(())
150 | }
151 | ExerciseAsset::FlashcardAsset { back_path, .. } => {
152 | if let Some(back_path) = back_path {
153 | println!("Answer:");
154 | println!();
155 | print_markdown(back_path)
156 | } else {
157 | println!("No answer available for this exercise.");
158 | Ok(())
159 | }
160 | }
161 | ExerciseAsset::SoundSliceAsset { .. } | ExerciseAsset::LiteracyAsset { .. } => Ok(()),
162 | }
163 | }
164 | }
165 |
166 | impl DisplayAnswer for ExerciseManifest {
167 | fn display_answer(&self) -> Result<()> {
168 | println!("Course ID: {}", self.course_id);
169 | println!("Lesson ID: {}", self.lesson_id);
170 | println!("Exercise ID: {}", self.id);
171 | println!();
172 | self.exercise_asset.display_answer()?;
173 | Ok(())
174 | }
175 | }
176 |
--------------------------------------------------------------------------------
/src/helper.rs:
--------------------------------------------------------------------------------
1 | //! Contains logic for custom highlighting and auto-completion.
2 | //! Inspired by ``
3 | //! this mod work for Completer and Prompt.
4 |
5 | use rustyline::completion::FilenameCompleter;
6 | use rustyline::highlight::{CmdKind, Highlighter, MatchingBracketHighlighter};
7 | use rustyline::hint::HistoryHinter;
8 | use rustyline::validate::MatchingBracketValidator;
9 | use rustyline_derive::{Completer, Helper, Hinter, Validator};
10 | use std::borrow::Cow::{self, Borrowed, Owned};
11 |
12 | /// A custom helper for Trane's command-line interface.
13 | #[derive(Helper, Completer, Hinter, Validator)]
14 | pub struct MyHelper {
15 | #[rustyline(Completer)]
16 | completer: FilenameCompleter,
17 | highlighter: MatchingBracketHighlighter,
18 | #[rustyline(Validator)]
19 | validator: MatchingBracketValidator,
20 | #[rustyline(Hinter)]
21 | hinter: HistoryHinter,
22 | }
23 |
24 | impl Highlighter for MyHelper {
25 | /// Custom logic to highlight the `trane >>` prompt.
26 | fn highlight_prompt<'b, 's: 'b, 'p: 'b>(
27 | &'s self,
28 | prompt: &'p str,
29 | default: bool,
30 | ) -> Cow<'b, str> {
31 | if default {
32 | Owned(format!("\x1b[1;31m{prompt}\x1b[0m"))
33 | } else {
34 | Borrowed(prompt)
35 | }
36 | }
37 |
38 | /// Custom logic to highlight auto-completion hints.
39 | fn highlight_hint<'h>(&self, hint: &'h str) -> Cow<'h, str> {
40 | Owned("\x1b[1m".to_owned() + hint + "\x1b[m")
41 | }
42 |
43 | /// Custom logic to highlight the current line.
44 | fn highlight<'l>(&self, line: &'l str, pos: usize) -> Cow<'l, str> {
45 | self.highlighter.highlight(line, pos)
46 | }
47 |
48 | /// Custom logic to highlight the current character.
49 | fn highlight_char(&self, line: &str, pos: usize, kind: CmdKind) -> bool {
50 | self.highlighter.highlight_char(line, pos, kind)
51 | }
52 | }
53 |
54 | impl MyHelper {
55 | /// Creates a new `MyHelper` instance.
56 | pub fn new() -> Self {
57 | MyHelper {
58 | completer: FilenameCompleter::new(),
59 | highlighter: MatchingBracketHighlighter::new(),
60 | hinter: HistoryHinter {},
61 | validator: MatchingBracketValidator::new(),
62 | }
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/src/main.rs:
--------------------------------------------------------------------------------
1 | //! A command-line interface for Trane.
2 |
3 | // Allow pedantic warnings but disable some that are not useful.
4 | #![warn(clippy::pedantic)]
5 | #![allow(clippy::missing_errors_doc)]
6 | #![allow(clippy::missing_panics_doc)]
7 | #![allow(clippy::module_name_repetitions)]
8 | #![allow(clippy::wildcard_imports)]
9 | #![allow(clippy::cast_possible_truncation)]
10 | #![allow(clippy::cast_sign_loss)]
11 | #![allow(clippy::cast_precision_loss)]
12 | #![allow(clippy::too_many_lines)]
13 | #![allow(clippy::needless_raw_string_hashes)]
14 |
15 | mod app;
16 | mod built_info {
17 | // The file has been placed there by the build script.
18 | include!(concat!(env!("OUT_DIR"), "/built.rs"));
19 | }
20 | mod cli;
21 | mod display;
22 | mod helper;
23 |
24 | use anyhow::Result;
25 | use app::TraneApp;
26 | use clap::Parser;
27 | use helper::MyHelper;
28 | use rustyline::error::ReadlineError;
29 | use rustyline::history::FileHistory;
30 | use rustyline::{ColorMode, Config, Editor};
31 |
32 | use crate::cli::TraneCli;
33 |
34 | /// The entry-point for the command-line interface.
35 | fn main() -> Result<()> {
36 | let mut app = TraneApp::default();
37 |
38 | let config = Config::builder()
39 | .auto_add_history(true)
40 | .max_history_size(2500)?
41 | .color_mode(ColorMode::Enabled)
42 | .history_ignore_space(true)
43 | .build();
44 |
45 | let mut rl = Editor::::with_config(config)?;
46 | let helper = MyHelper::new();
47 | rl.set_helper(Some(helper));
48 |
49 | let history_path = std::path::Path::new(".trane_history");
50 | if !history_path.exists() {
51 | match std::fs::File::create(history_path) {
52 | Ok(_) => {}
53 | Err(e) => {
54 | eprintln!("Failed to create history file: {e}");
55 | }
56 | }
57 | }
58 | match rl.load_history(history_path) {
59 | Ok(()) => (),
60 | Err(e) => {
61 | eprintln!("Failed to load history file at .trane_history: {e}");
62 | }
63 | }
64 |
65 | print!("{}", TraneApp::startup_message());
66 | loop {
67 | let readline = rl.readline("trane >> ");
68 |
69 | match readline {
70 | Ok(line) => {
71 | // Trim any blank space from the line.
72 | let line = line.trim();
73 |
74 | // Ignore comments and empty lines.
75 | if line.starts_with('#') || line.is_empty() {
76 | continue;
77 | }
78 |
79 | // Split the line into a vector of arguments. Add an initial argument with value
80 | // "trane" if the line doesn't have it, so the parser can recognize the input.
81 | let split: Vec<&str> = line.split(' ').collect();
82 | let mut args = if !split.is_empty() && split[0] == "trane" {
83 | vec![]
84 | } else {
85 | vec!["trane"]
86 | };
87 | args.extend(split);
88 |
89 | // Parse the arguments.
90 | let cli = TraneCli::try_parse_from(args.iter());
91 | if cli.is_err() {
92 | println!("{}", cli.unwrap_err());
93 | continue;
94 | }
95 |
96 | // Execute the subcommand.
97 | match cli.unwrap().execute_subcommand(&mut app) {
98 | Ok(continue_execution) => {
99 | if continue_execution {
100 | continue;
101 | }
102 | break;
103 | }
104 | Err(err) => println!("Error: {err:#}"),
105 | }
106 | }
107 | Err(ReadlineError::Interrupted) => {
108 | println!("Press CTRL-D or use the quit command to exit");
109 | }
110 | Err(ReadlineError::Eof) => {
111 | // Submit the current score before exiting. Ignore the error because it's not
112 | // guaranteed an instance of Trane is open.
113 | let _ = app.submit_current_score();
114 |
115 | println!("EOF: Exiting");
116 | break;
117 | }
118 | Err(err) => {
119 | println!("Error: {err:#}");
120 | break;
121 | }
122 | }
123 | }
124 |
125 | match rl.save_history(history_path) {
126 | Ok(()) => (),
127 | Err(e) => {
128 | eprintln!("Failed to save history to file .trane_history: {e}");
129 | }
130 | }
131 | Ok(())
132 | }
133 |
--------------------------------------------------------------------------------
/src/simple_build.rs:
--------------------------------------------------------------------------------
1 | //! Command line tool that takes a simple knowledge base course configuration file and builds the
2 | //! course in the current directory.
3 |
4 | use anyhow::Result;
5 | use clap::Parser;
6 | use std::{env::current_dir, fs};
7 | use trane::course_builder::knowledge_base_builder::SimpleKnowledgeBaseCourse;
8 |
9 | #[derive(Debug, Parser)]
10 | #[clap(name = "trane")]
11 | #[clap(author, version, about, long_about = None)]
12 | pub(crate) struct SimpleBuild {
13 | #[clap(help = "The path to the simple knowledge course configuration file to use")]
14 | config_file: String,
15 |
16 | #[clap(help = "The directory to which to build the course")]
17 | directory: String,
18 | }
19 |
20 | fn main() -> Result<()> {
21 | // Parse the command-line arguments.
22 | let args = SimpleBuild::parse();
23 |
24 | // Parse the input file and build the course.
25 | let config_path = ¤t_dir()?.join(args.config_file);
26 | let simple_course =
27 | serde_json::from_str::(&fs::read_to_string(config_path)?)?;
28 |
29 | // Build the course.
30 | let directory = ¤t_dir()?.join(&args.directory);
31 | simple_course.build(directory)
32 | }
33 |
--------------------------------------------------------------------------------