• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

trane-project / trane / 14392837286

11 Apr 2025 12:23AM UTC coverage: 99.221% (-0.4%) from 99.655%
14392837286

Pull #340

github

web-flow
Merge 48f74d0a2 into e7a732a69
Pull Request #340: Fixes to support external applications

24 of 29 new or added lines in 3 files covered. (82.76%)

19 existing lines in 4 files now uncovered.

5478 of 5521 relevant lines covered (99.22%)

172767.94 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

94.4
/src/lib.rs
1
//! Trane is an automated practice system for the acquisition of complex and highly hierarchical
2
//! skills. It is based on the principles of spaced repetition, mastery learning, and chunking.
3
//!
4
//! Given a set of exercises which have been bundled into lessons and further bundled in courses, as
5
//! well as the dependency relationships between those lessons and courses, Trane selects exercises
6
//! to present to the user. It makes sure that exercises from a course or lesson are not presented
7
//! to the user until the exercises in their dependencies have been sufficiently mastered. It also
8
//! makes sure to keep the balance of exercises so that the difficulty of the exercises lies
9
//! slightly outside the user's current mastery.
10
//!
11
//! You can think of this process as progressing through the skill tree of a character in a video
12
//! game, but applied to arbitrary skills, which are defined in plain-text files which define the
13
//! exercises, their bundling into lessons and courses, and the dependency relationships between
14
//! them.
15
//!
16
//! Trane is named after John Coltrane, whose nickname Trane was often used in wordplay with the
17
//! word train (as in the vehicle) to describe the overwhelming power of his playing. It is used
18
//! here as a play on its homophone (as in "training a new skill").
19
//!
20
//@<lp-example-3
21
//! Below is an overview of some of the most important modules in this crate and their purpose.
22
//! Refer to the documentation of each module for more details.
23
//!
24
//! - [`data`]: Contains the basic data structures used by Trane.
25
//! - [`graph`]: Defines the graph used by Trane to list the units of material and the dependencies
26
//!   among them.
27
//! - [`course_library`]: Reads a collection of courses, lessons, and exercises from the file system
28
//!   and provides basic utilities for working with them.
29
//! - [`scheduler`]: Defines the algorithm used by Trane to select exercises to present to the user.
30
//! - [`practice_stats`]: Stores the results of practice sessions for use in determining the next
31
//!   batch of exercises.
32
//! - [`blacklist`]: Defines the list of units the student wishes to hide, either because their
33
//!   material has already been mastered or they do not wish to learn it.
34
//! - [`scorer`]: Calculates a score for an exercise based on the results and timestamps of previous
35
//!   trials.
36
//!
37
//>@lp-example-3
38

39
#![cfg_attr(coverage_nightly, feature(coverage_attribute))]
40
// Use pedantic warnings but disable some that are not useful.
41
#![warn(clippy::pedantic)]
42
#![allow(clippy::doc_markdown)]
43
#![allow(clippy::float_cmp)]
44
#![allow(clippy::missing_errors_doc)]
45
#![allow(clippy::missing_panics_doc)]
46
#![allow(clippy::module_name_repetitions)]
47
#![allow(clippy::wildcard_imports)]
48
#![allow(clippy::cast_possible_truncation)]
49
#![allow(clippy::cast_sign_loss)]
50
#![allow(clippy::cast_precision_loss)]
51
#![allow(clippy::too_many_lines)]
52

53
pub mod blacklist;
54
pub mod course_builder;
55
pub mod course_library;
56
pub mod data;
57
pub mod db_utils;
58
pub mod error;
59
pub mod exercise_scorer;
60
pub mod filter_manager;
61
pub mod graph;
62
pub mod mantra_miner;
63
pub mod practice_rewards;
64
pub mod practice_stats;
65
pub mod preferences_manager;
66
pub mod repository_manager;
67
pub mod review_list;
68
pub mod reward_scorer;
69
pub mod scheduler;
70
pub mod study_session_manager;
71
#[cfg_attr(coverage, coverage(off))]
72
pub mod testutil;
73
pub mod transcription_downloader;
74

75
use anyhow::{Context, Result, bail, ensure};
76
use error::*;
77
use parking_lot::RwLock;
78
use practice_rewards::{LocalPracticeRewards, PracticeRewards};
79
use preferences_manager::{LocalPreferencesManager, PreferencesManager};
80
use review_list::{LocalReviewList, ReviewList};
81
use std::{
82
    fs::{File, create_dir},
83
    io::Write,
84
    path::Path,
85
    sync::Arc,
86
};
87
use study_session_manager::{LocalStudySessionManager, StudySessionManager};
88
use transcription_downloader::{LocalTranscriptionDownloader, TranscriptionDownloader};
89
use ustr::{Ustr, UstrMap, UstrSet};
90

91
use crate::{
92
    blacklist::{Blacklist, LocalBlacklist},
93
    course_library::{CourseLibrary, GetUnitGraph, LocalCourseLibrary},
94
    data::{
95
        CourseManifest, ExerciseManifest, ExerciseTrial, LessonManifest, MasteryScore,
96
        RepositoryMetadata, SchedulerOptions, SchedulerPreferences, UnitType, UserPreferences,
97
        filter::{ExerciseFilter, SavedFilter},
98
    },
99
    filter_manager::{FilterManager, LocalFilterManager},
100
    graph::UnitGraph,
101
    mantra_miner::TraneMantraMiner,
102
    practice_stats::{LocalPracticeStats, PracticeStats},
103
    repository_manager::{LocalRepositoryManager, RepositoryManager},
104
    scheduler::{DepthFirstScheduler, ExerciseScheduler, data::SchedulerData},
105
};
106

107
/// The path to the folder inside each course library containing the user data.
108
pub const TRANE_CONFIG_DIR_PATH: &str = ".trane";
109

110
/// The path to the `SQLite` database containing the results of previous exercise trials.
111
pub const PRACTICE_STATS_PATH: &str = "practice_stats.db";
112

113
/// The path to the `SQLite` database containing the rewards for lessons and courses.
114
pub const PRACTICE_REWARDS_PATH: &str = "practice_rewards.db";
115

116
/// The path to the `SQLite` database containing the list of units to ignore during scheduling.
117
pub const BLACKLIST_PATH: &str = "blacklist.db";
118

119
/// The path to the `SQLite` database containing the list of units the student wishes to review.
120
pub const REVIEW_LIST_PATH: &str = "review_list.db";
121

122
/// The path to the directory containing unit filters saved by the user.
123
pub const FILTERS_DIR: &str = "filters";
124

125
/// The path to the directory containing study sessions saved by the user.
126
pub const STUDY_SESSIONS_DIR: &str = "study_sessions";
127

128
/// The path to the file containing user preferences.
129
pub const USER_PREFERENCES_PATH: &str = "user_preferences.json";
130

131
/// The name of the directory where repositories will be downloaded.
132
const DOWNLOAD_DIRECTORY: &str = "managed_courses";
133

134
/// The name of the directory where the details on all repositories will be stored. This directory
135
/// will be created under the `.trane` directory at the root of the Trane library.
136
const REPOSITORY_DIRECTORY: &str = "repositories";
137

138
/// Trane is a library for the acquisition of highly hierarchical knowledge and skills based on the
139
/// principles of mastery learning and spaced repetition. Given a list of courses, its lessons and
140
/// corresponding exercises, Trane presents the student with a list of exercises based on the
141
/// demonstrated mastery of previous exercises. It makes sure that new material and skills are not
142
/// introduced until the prerequisite material and skills have been sufficiently mastered.
143
pub struct Trane {
144
    /// The path to the root of the course library.
145
    library_root: String,
146

147
    /// The object managing the list of courses, lessons, and exercises to be skipped.
148
    blacklist: Arc<RwLock<dyn Blacklist + Send + Sync>>,
149

150
    /// The object managing all the course, lesson, and exercise info.
151
    course_library: Arc<RwLock<dyn CourseLibrary + Send + Sync>>,
152

153
    /// The object managing unit filters saved by the user.
154
    filter_manager: Arc<RwLock<dyn FilterManager + Send + Sync>>,
155

156
    /// The object managing the information on previous exercise trials.
157
    practice_stats: Arc<RwLock<dyn PracticeStats + Send + Sync>>,
158

159
    /// The object managing rewards for lessons and courses.
160
    practice_rewards: Arc<RwLock<dyn PracticeRewards + Send + Sync>>,
161

162
    /// The object managing the user preferences.
163
    preferences_manager: Arc<RwLock<dyn PreferencesManager + Send + Sync>>,
164

165
    /// The object managing git repositories containing courses.
166
    repo_manager: Arc<RwLock<dyn RepositoryManager + Send + Sync>>,
167

168
    /// The object managing the list of units to review.
169
    review_list: Arc<RwLock<dyn ReviewList + Send + Sync>>,
170

171
    /// The object managing access to all the data needed by the scheduler. It's saved separately
172
    /// from the scheduler so that tests can have access to it.
173
    scheduler_data: SchedulerData,
174

175
    /// The object managing the scheduling algorithm.
176
    scheduler: DepthFirstScheduler,
177

178
    /// The object managing the study sessions saved by the user.
179
    study_session_manager: Arc<RwLock<dyn StudySessionManager + Send + Sync>>,
180

181
    /// The dependency graph of courses and lessons in the course library.
182
    unit_graph: Arc<RwLock<dyn UnitGraph + Send + Sync>>,
183

184
    /// An optional instance of the transcription downloader.
185
    transcription_downloader: Arc<RwLock<dyn TranscriptionDownloader + Send + Sync>>,
186

187
    /// An instance of the mantra miner that "recites" Tara Sarasvati's mantra while Trane runs.
188
    mantra_miner: TraneMantraMiner,
189
}
190

191
impl Trane {
192
    /// Creates the scheduler options, overriding any values with those specified in the user
193
    /// preferences.
194
    fn create_scheduler_options(preferences: Option<&SchedulerPreferences>) -> SchedulerOptions {
73✔
195
        let mut options = SchedulerOptions::default();
73✔
196
        if let Some(preferences) = preferences {
73✔
197
            if let Some(batch_size) = preferences.batch_size {
1✔
198
                options.batch_size = batch_size;
1✔
199
            }
1✔
200
        }
72✔
201
        options
73✔
202
    }
73✔
203

204
    /// Initializes the config directory at path `.trane` inside the library root.
205
    fn init_config_directory(library_root: &Path) -> Result<()> {
79✔
206
        // Verify that the library root is a directory.
79✔
207
        ensure!(
79✔
208
            library_root.is_dir(),
79✔
209
            "library root {} is not a directory",
1✔
210
            library_root.display(),
1✔
211
        );
212

213
        // Create the config folder inside the library root if it does not exist already.
214
        let trane_path = library_root.join(TRANE_CONFIG_DIR_PATH);
78✔
215
        if !trane_path.exists() {
78✔
216
            create_dir(trane_path.clone()).context("failed to create config directory")?;
66✔
217
        } else if !trane_path.is_dir() {
12✔
218
            bail!("config path .trane inside library must be a directory");
1✔
219
        }
11✔
220

221
        // Create the `filters` directory if it does not exist already.
222
        let filters_path = trane_path.join(FILTERS_DIR);
75✔
223
        if !filters_path.is_dir() {
75✔
224
            create_dir(filters_path.clone()).context("failed to create filters directory")?;
73✔
225
        }
2✔
226

227
        // Create the `study_sessions` directory if it does not exist already.
228
        let sessions_path = trane_path.join(STUDY_SESSIONS_DIR);
74✔
229
        if !sessions_path.is_dir() {
74✔
230
            create_dir(sessions_path.clone())
73✔
231
                .context("failed to create study_sessions directory")?;
73✔
232
        }
1✔
233

234
        // Create the user preferences file if it does not exist already.
235
        let user_prefs_path = trane_path.join(USER_PREFERENCES_PATH);
73✔
236
        if !user_prefs_path.exists() {
73✔
237
            let mut file = File::create(user_prefs_path.clone())
65✔
238
                .context("failed to create user_preferences.json file")?;
65✔
239
            let default_prefs = UserPreferences::default();
64✔
240
            let prefs_json = serde_json::to_string_pretty(&default_prefs)? + "\n";
64✔
241
            file.write_all(prefs_json.as_bytes())
64✔
242
                .context("failed to write to user_preferences.json file")?;
64✔
243
        } else if !user_prefs_path.is_file() {
8✔
244
            bail!("user preferences file must be a regular file");
1✔
245
        }
7✔
246
        Ok(())
71✔
247
    }
79✔
248

249
    /// Creates a new local instance of the Trane given the path to the root of a course library.
250
    /// The user data will be stored in a directory named `.trane` inside the library root
251
    /// directory. The working directory will be used to resolve relative paths.
252
    pub fn new_local(working_dir: &Path, library_root: &Path) -> Result<Trane> {
79✔
253
        // Initialize the config directory.
79✔
254
        let config_path = library_root.join(Path::new(TRANE_CONFIG_DIR_PATH));
79✔
255
        Self::init_config_directory(library_root)?;
79✔
256

257
        // Build all the components needed to create a Trane instance.
258
        let preferences_manager = Arc::new(RwLock::new(LocalPreferencesManager {
71✔
259
            path: library_root
71✔
260
                .join(TRANE_CONFIG_DIR_PATH)
71✔
261
                .join(USER_PREFERENCES_PATH),
71✔
262
        }));
71✔
263
        let user_preferences = preferences_manager.read().get_user_preferences()?;
71✔
264
        let course_library = Arc::new(RwLock::new(LocalCourseLibrary::new(
71✔
265
            &working_dir.join(library_root),
71✔
266
            user_preferences.clone(),
71✔
UNCOV
267
        )?));
×
268
        let transcription_preferences = user_preferences.transcription.clone().unwrap_or_default();
71✔
269
        let transcription_downloader = Arc::new(RwLock::new(LocalTranscriptionDownloader {
71✔
270
            preferences: transcription_preferences,
71✔
271
            link_store: course_library.clone(),
71✔
272
        }));
71✔
273
        let unit_graph = course_library.write().get_unit_graph();
71✔
274
        let practice_stats = Arc::new(RwLock::new(LocalPracticeStats::new_from_disk(
71✔
275
            config_path.join(PRACTICE_STATS_PATH).to_str().unwrap(),
71✔
UNCOV
276
        )?));
×
277
        let practice_rewards = Arc::new(RwLock::new(LocalPracticeRewards::new_from_disk(
71✔
278
            config_path.join(PRACTICE_REWARDS_PATH).to_str().unwrap(),
71✔
UNCOV
279
        )?));
×
280
        let blacklist = Arc::new(RwLock::new(LocalBlacklist::new_from_disk(
71✔
281
            config_path.join(BLACKLIST_PATH).to_str().unwrap(),
71✔
UNCOV
282
        )?));
×
283
        let review_list = Arc::new(RwLock::new(LocalReviewList::new_from_disk(
71✔
284
            config_path.join(REVIEW_LIST_PATH).to_str().unwrap(),
71✔
UNCOV
285
        )?));
×
286
        let filter_manager = Arc::new(RwLock::new(LocalFilterManager::new(
71✔
287
            config_path.join(FILTERS_DIR).to_str().unwrap(),
71✔
UNCOV
288
        )?));
×
289
        let study_sessions_manager = Arc::new(RwLock::new(LocalStudySessionManager::new(
71✔
290
            config_path.join(STUDY_SESSIONS_DIR).to_str().unwrap(),
71✔
UNCOV
291
        )?));
×
292
        let repo_manager = Arc::new(RwLock::new(LocalRepositoryManager::new(library_root)?));
71✔
293
        let mut mantra_miner = TraneMantraMiner::default();
71✔
294
        mantra_miner.mantra_miner.start()?;
71✔
295
        let options = Self::create_scheduler_options(user_preferences.scheduler.as_ref());
71✔
296
        options.verify()?;
71✔
297
        let scheduler_data = SchedulerData {
71✔
298
            options,
71✔
299
            course_library: course_library.clone(),
71✔
300
            unit_graph: unit_graph.clone(),
71✔
301
            practice_stats: practice_stats.clone(),
71✔
302
            practice_rewards: practice_rewards.clone(),
71✔
303
            blacklist: blacklist.clone(),
71✔
304
            review_list: review_list.clone(),
71✔
305
            filter_manager: filter_manager.clone(),
71✔
306
            frequency_map: Arc::new(RwLock::new(UstrMap::default())),
71✔
307
        };
71✔
308

71✔
309
        Ok(Trane {
71✔
310
            blacklist,
71✔
311
            course_library,
71✔
312
            filter_manager,
71✔
313
            library_root: library_root.to_str().unwrap().to_string(),
71✔
314
            practice_stats,
71✔
315
            practice_rewards,
71✔
316
            preferences_manager,
71✔
317
            repo_manager,
71✔
318
            review_list,
71✔
319
            scheduler_data: scheduler_data.clone(),
71✔
320
            scheduler: DepthFirstScheduler::new(scheduler_data),
71✔
321
            study_session_manager: study_sessions_manager,
71✔
322
            unit_graph,
71✔
323
            transcription_downloader,
71✔
324
            mantra_miner,
71✔
325
        })
71✔
326
    }
79✔
327

328
    /// Returns the path to the root of the course library.
329
    pub fn library_root(&self) -> String {
1✔
330
        self.library_root.clone()
1✔
331
    }
1✔
332

333
    /// Returns the number of mantras that have been recited by the mantra miner.
334
    pub fn mantra_count(&self) -> usize {
1✔
335
        self.mantra_miner.mantra_miner.count()
1✔
336
    }
1✔
337

338
    /// Returns a clone of the data used by the scheduler. This function is needed by tests that
339
    /// need to verify internal methods.
340
    #[allow(dead_code)]
341
    fn get_scheduler_data(&self) -> SchedulerData {
11✔
342
        self.scheduler_data.clone()
11✔
343
    }
11✔
344
}
345

346
#[cfg_attr(coverage, coverage(off))]
347
impl Blacklist for Trane {
348
    fn add_to_blacklist(&mut self, unit_id: Ustr) -> Result<(), BlacklistError> {
349
        // Make sure to invalidate any cached scores for the given unit.
350
        self.scheduler.invalidate_cached_score(unit_id);
351
        self.blacklist.write().add_to_blacklist(unit_id)
352
    }
353

354
    fn remove_from_blacklist(&mut self, unit_id: Ustr) -> Result<(), BlacklistError> {
355
        // Make sure to invalidate any cached scores for the given unit.
356
        self.scheduler.invalidate_cached_score(unit_id);
357
        self.blacklist.write().remove_from_blacklist(unit_id)
358
    }
359

360
    fn remove_prefix_from_blacklist(&mut self, prefix: &str) -> Result<(), BlacklistError> {
361
        // Make sure to invalidate any cached scores of units with the given prefix.
362
        self.scheduler.invalidate_cached_scores_with_prefix(prefix);
363
        self.blacklist.write().remove_prefix_from_blacklist(prefix)
364
    }
365

366
    fn blacklisted(&self, unit_id: Ustr) -> Result<bool, BlacklistError> {
367
        self.blacklist.read().blacklisted(unit_id)
368
    }
369

370
    fn get_blacklist_entries(&self) -> Result<Vec<Ustr>, BlacklistError> {
371
        self.blacklist.read().get_blacklist_entries()
372
    }
373
}
374

375
#[cfg_attr(coverage_nightly, coverage(off))]
376
impl CourseLibrary for Trane {
377
    fn get_course_manifest(&self, course_id: Ustr) -> Option<CourseManifest> {
378
        self.course_library.read().get_course_manifest(course_id)
379
    }
380

381
    fn get_lesson_manifest(&self, lesson_id: Ustr) -> Option<LessonManifest> {
382
        self.course_library.read().get_lesson_manifest(lesson_id)
383
    }
384

385
    fn get_exercise_manifest(&self, exercise_id: Ustr) -> Option<ExerciseManifest> {
386
        self.course_library
387
            .read()
388
            .get_exercise_manifest(exercise_id)
389
    }
390

391
    fn get_course_ids(&self) -> Vec<Ustr> {
392
        self.course_library.read().get_course_ids()
393
    }
394

395
    fn get_lesson_ids(&self, course_id: Ustr) -> Option<Vec<Ustr>> {
396
        self.course_library.read().get_lesson_ids(course_id)
397
    }
398

399
    fn get_exercise_ids(&self, lesson_id: Ustr) -> Option<Vec<Ustr>> {
400
        self.course_library.read().get_exercise_ids(lesson_id)
401
    }
402

403
    fn get_all_exercise_ids(&self, unit_id: Option<Ustr>) -> Vec<Ustr> {
404
        self.course_library.read().get_all_exercise_ids(unit_id)
405
    }
406

407
    fn get_matching_prefix(&self, prefix: &str, unit_type: Option<UnitType>) -> UstrSet {
408
        self.course_library
409
            .read()
410
            .get_matching_prefix(prefix, unit_type)
411
    }
412

413
    fn search(&self, query: &str) -> Result<Vec<Ustr>, CourseLibraryError> {
414
        self.course_library.read().search(query)
415
    }
416
}
417

418
#[cfg_attr(coverage, coverage(off))]
419
impl ExerciseScheduler for Trane {
420
    fn get_exercise_batch(
421
        &self,
422
        filter: Option<ExerciseFilter>,
423
    ) -> Result<Vec<ExerciseManifest>, ExerciseSchedulerError> {
424
        self.scheduler.get_exercise_batch(filter)
425
    }
426

427
    fn score_exercise(
428
        &self,
429
        exercise_id: Ustr,
430
        score: MasteryScore,
431
        timestamp: i64,
432
    ) -> Result<(), ExerciseSchedulerError> {
433
        self.scheduler.score_exercise(exercise_id, score, timestamp)
434
    }
435

436
    fn get_unit_score(&self, unit_id: Ustr) -> Result<Option<f32>, ExerciseSchedulerError> {
437
        self.scheduler.get_unit_score(unit_id)
438
    }
439

440
    fn invalidate_cached_score(&self, unit_id: Ustr) {
441
        self.scheduler.invalidate_cached_score(unit_id);
442
    }
443

444
    fn invalidate_cached_scores_with_prefix(&self, prefix: &str) {
445
        self.scheduler.invalidate_cached_scores_with_prefix(prefix);
446
    }
447

448
    fn get_scheduler_options(&self) -> SchedulerOptions {
449
        self.scheduler.get_scheduler_options()
450
    }
451

452
    fn set_scheduler_options(&mut self, options: SchedulerOptions) {
453
        self.scheduler.set_scheduler_options(options);
454
    }
455

456
    fn reset_scheduler_options(&mut self) {
457
        self.scheduler.reset_scheduler_options();
458
    }
459
}
460

461
#[cfg_attr(coverage, coverage(off))]
462
impl FilterManager for Trane {
463
    fn get_filter(&self, id: &str) -> Option<SavedFilter> {
464
        self.filter_manager.read().get_filter(id)
465
    }
466

467
    fn list_filters(&self) -> Vec<(String, String)> {
468
        self.filter_manager.read().list_filters()
469
    }
470
}
471

472
#[cfg_attr(coverage, coverage(off))]
473
impl PracticeRewards for Trane {
474
    fn get_rewards(
475
        &self,
476
        unit_id: Ustr,
477
        num_rewards: usize,
478
    ) -> Result<Vec<data::UnitReward>, PracticeRewardsError> {
479
        self.practice_rewards
480
            .read()
481
            .get_rewards(unit_id, num_rewards)
482
    }
483

484
    fn record_unit_reward(
485
        &mut self,
486
        unit_id: Ustr,
487
        reward: &data::UnitReward,
488
    ) -> Result<bool, PracticeRewardsError> {
489
        self.practice_rewards
490
            .write()
491
            .record_unit_reward(unit_id, reward)
492
    }
493

494
    fn trim_rewards(&mut self, num_rewards: usize) -> Result<(), PracticeRewardsError> {
495
        self.practice_rewards.write().trim_rewards(num_rewards)
496
    }
497

498
    fn remove_rewards_with_prefix(&mut self, prefix: &str) -> Result<(), PracticeRewardsError> {
499
        self.practice_rewards
500
            .write()
501
            .remove_rewards_with_prefix(prefix)
502
    }
503
}
504

505
#[cfg_attr(coverage, coverage(off))]
506
impl PracticeStats for Trane {
507
    fn get_scores(
508
        &self,
509
        exercise_id: Ustr,
510
        num_scores: usize,
511
    ) -> Result<Vec<ExerciseTrial>, PracticeStatsError> {
512
        self.practice_stats
513
            .read()
514
            .get_scores(exercise_id, num_scores)
515
    }
516

517
    fn record_exercise_score(
518
        &mut self,
519
        exercise_id: Ustr,
520
        score: MasteryScore,
521
        timestamp: i64,
522
    ) -> Result<(), PracticeStatsError> {
523
        self.practice_stats
524
            .write()
525
            .record_exercise_score(exercise_id, score, timestamp)
526
    }
527

528
    fn trim_scores(&mut self, num_scores: usize) -> Result<(), PracticeStatsError> {
529
        self.practice_stats.write().trim_scores(num_scores)
530
    }
531

532
    fn remove_scores_with_prefix(&mut self, prefix: &str) -> Result<(), PracticeStatsError> {
533
        self.practice_stats
534
            .write()
535
            .remove_scores_with_prefix(prefix)
536
    }
537
}
538
#[cfg_attr(coverage, coverage(off))]
539
impl PreferencesManager for Trane {
540
    fn get_user_preferences(&self) -> Result<UserPreferences, PreferencesManagerError> {
541
        self.preferences_manager.read().get_user_preferences()
542
    }
543

544
    fn set_user_preferences(
545
        &mut self,
546
        preferences: UserPreferences,
547
    ) -> Result<(), PreferencesManagerError> {
548
        self.preferences_manager
549
            .write()
550
            .set_user_preferences(preferences)
551
    }
552
}
553

554
#[cfg_attr(coverage, coverage(off))]
555
impl RepositoryManager for Trane {
556
    fn add_repo(
557
        &mut self,
558
        url: &str,
559
        repo_id: Option<String>,
560
    ) -> Result<(), RepositoryManagerError> {
561
        self.repo_manager.write().add_repo(url, repo_id)
562
    }
563

564
    fn remove_repo(&mut self, repo_id: &str) -> Result<(), RepositoryManagerError> {
565
        self.repo_manager.write().remove_repo(repo_id)
566
    }
567

568
    fn update_repo(&self, repo_id: &str) -> Result<(), RepositoryManagerError> {
569
        self.repo_manager.read().update_repo(repo_id)
570
    }
571

572
    fn update_all_repos(&self) -> Result<(), RepositoryManagerError> {
573
        self.repo_manager.read().update_all_repos()
574
    }
575

576
    fn list_repos(&self) -> Vec<RepositoryMetadata> {
577
        self.repo_manager.read().list_repos()
578
    }
579
}
580

581
#[cfg_attr(coverage, coverage(off))]
582
impl ReviewList for Trane {
583
    fn add_to_review_list(&mut self, unit_id: Ustr) -> Result<(), ReviewListError> {
584
        self.review_list.write().add_to_review_list(unit_id)
585
    }
586

587
    fn remove_from_review_list(&mut self, unit_id: Ustr) -> Result<(), ReviewListError> {
588
        self.review_list.write().remove_from_review_list(unit_id)
589
    }
590

591
    fn get_review_list_entries(&self) -> Result<Vec<Ustr>, ReviewListError> {
592
        self.review_list.read().get_review_list_entries()
593
    }
594
}
595

596
#[cfg_attr(coverage, coverage(off))]
597
impl StudySessionManager for Trane {
598
    fn get_study_session(&self, id: &str) -> Option<data::filter::StudySession> {
599
        self.study_session_manager.read().get_study_session(id)
600
    }
601

602
    fn list_study_sessions(&self) -> Vec<(String, String)> {
603
        self.study_session_manager.read().list_study_sessions()
604
    }
605
}
606

607
#[cfg_attr(coverage, coverage(off))]
608
impl TranscriptionDownloader for Trane {
609
    fn is_transcription_asset_downloaded(&self, exercise_id: Ustr) -> bool {
610
        self.transcription_downloader
611
            .read()
612
            .is_transcription_asset_downloaded(exercise_id)
613
    }
614

615
    fn download_transcription_asset(
616
        &self,
617
        exercise_id: Ustr,
618
        force_download: bool,
619
    ) -> Result<(), TranscriptionDownloaderError> {
620
        self.transcription_downloader
621
            .write()
622
            .download_transcription_asset(exercise_id, force_download)
623
    }
624

625
    fn transcription_download_path(&self, exercise_id: Ustr) -> Option<std::path::PathBuf> {
626
        self.transcription_downloader
627
            .read()
628
            .transcription_download_path(exercise_id)
629
    }
630

631
    fn transcription_download_path_alias(&self, exercise_id: Ustr) -> Option<std::path::PathBuf> {
632
        self.transcription_downloader
633
            .read()
634
            .transcription_download_path_alias(exercise_id)
635
    }
636
}
637

638
#[cfg_attr(coverage, coverage(off))]
639
impl UnitGraph for Trane {
640
    fn add_course(&mut self, course_id: Ustr) -> Result<(), UnitGraphError> {
641
        self.unit_graph.write().add_course(course_id)
642
    }
643

644
    fn add_lesson(&mut self, lesson_id: Ustr, course_id: Ustr) -> Result<(), UnitGraphError> {
645
        self.unit_graph.write().add_lesson(lesson_id, course_id)
646
    }
647

648
    fn add_exercise(&mut self, exercise_id: Ustr, lesson_id: Ustr) -> Result<(), UnitGraphError> {
649
        self.unit_graph.write().add_exercise(exercise_id, lesson_id)
650
    }
651

652
    fn add_dependencies(
653
        &mut self,
654
        unit_id: Ustr,
655
        unit_type: UnitType,
656
        dependencies: &[Ustr],
657
    ) -> Result<(), UnitGraphError> {
658
        self.unit_graph
659
            .write()
660
            .add_dependencies(unit_id, unit_type, dependencies)
661
    }
662

663
    fn add_superseded(&mut self, unit_id: Ustr, superseded: &[Ustr]) {
664
        self.unit_graph.write().add_superseded(unit_id, superseded);
665
    }
666

667
    fn get_unit_type(&self, unit_id: Ustr) -> Option<UnitType> {
668
        self.unit_graph.read().get_unit_type(unit_id)
669
    }
670

671
    fn get_course_lessons(&self, course_id: Ustr) -> Option<UstrSet> {
672
        self.unit_graph.read().get_course_lessons(course_id)
673
    }
674

675
    fn get_starting_lessons(&self, course_id: Ustr) -> Option<UstrSet> {
676
        self.unit_graph.read().get_starting_lessons(course_id)
677
    }
678

679
    fn update_starting_lessons(&mut self) {
680
        self.unit_graph.write().update_starting_lessons();
681
    }
682

683
    fn get_lesson_course(&self, lesson_id: Ustr) -> Option<Ustr> {
684
        self.unit_graph.read().get_lesson_course(lesson_id)
685
    }
686

687
    fn get_lesson_exercises(&self, lesson_id: Ustr) -> Option<UstrSet> {
688
        self.unit_graph.read().get_lesson_exercises(lesson_id)
689
    }
690

691
    fn get_exercise_lesson(&self, exercise_id: Ustr) -> Option<Ustr> {
692
        self.unit_graph.read().get_exercise_lesson(exercise_id)
693
    }
694

695
    fn get_dependencies(&self, unit_id: Ustr) -> Option<UstrSet> {
696
        self.unit_graph.read().get_dependencies(unit_id)
697
    }
698

699
    fn get_dependents(&self, unit_id: Ustr) -> Option<UstrSet> {
700
        self.unit_graph.read().get_dependents(unit_id)
701
    }
702

703
    fn get_dependency_sinks(&self) -> UstrSet {
704
        self.unit_graph.read().get_dependency_sinks()
705
    }
706

707
    fn get_superseded(&self, unit_id: Ustr) -> Option<UstrSet> {
708
        self.unit_graph.read().get_superseded(unit_id)
709
    }
710

711
    fn get_superseding(&self, unit_id: Ustr) -> Option<UstrSet> {
712
        self.unit_graph.read().get_superseding(unit_id)
713
    }
714

715
    fn check_cycles(&self) -> Result<(), UnitGraphError> {
716
        self.unit_graph.read().check_cycles()
717
    }
718

719
    fn generate_dot_graph(&self) -> String {
720
        self.unit_graph.read().generate_dot_graph()
721
    }
722
}
723

724
unsafe impl Send for Trane {}
725
unsafe impl Sync for Trane {}
726

727
#[cfg(test)]
728
#[cfg_attr(coverage, coverage(off))]
729
mod test {
730
    use anyhow::Result;
731
    use std::{fs::*, os::unix::prelude::PermissionsExt, thread, time::Duration};
732

733
    use crate::{
734
        FILTERS_DIR, STUDY_SESSIONS_DIR, TRANE_CONFIG_DIR_PATH, Trane, USER_PREFERENCES_PATH,
735
        data::{SchedulerOptions, SchedulerPreferences, UserPreferences},
736
    };
737

738
    /// Verifies retrieving the root of a library.
739
    #[test]
740
    fn library_root() -> Result<()> {
741
        let dir = tempfile::tempdir()?;
742
        let trane = Trane::new_local(dir.path(), dir.path())?;
743
        assert_eq!(trane.library_root(), dir.path().to_str().unwrap());
744
        Ok(())
745
    }
746

747
    /// Verifies that the mantra-miner starts up and has a valid count.
748
    #[test]
749
    fn mantra_count() -> Result<()> {
750
        let dir = tempfile::tempdir()?;
751
        let trane = Trane::new_local(dir.path(), dir.path())?;
752
        thread::sleep(Duration::from_millis(1000));
753
        assert!(trane.mantra_count() > 0);
754
        Ok(())
755
    }
756

757
    /// Verifies opening a course library with a path that is not a directory fails.
758
    #[test]
759
    fn library_root_is_not_dir() -> Result<()> {
760
        let file = tempfile::NamedTempFile::new()?;
761
        let result = Trane::new_local(file.path(), file.path());
762
        assert!(result.is_err());
763
        Ok(())
764
    }
765

766
    /// Verifies that opening a library if the path to the config directory exists but is not a
767
    /// directory.
768
    #[test]
769
    fn config_dir_is_file() -> Result<()> {
770
        let dir = tempfile::tempdir()?;
771
        let trane_path = dir.path().join(".trane");
772
        File::create(trane_path)?;
773
        assert!(Trane::new_local(dir.path(), dir.path()).is_err());
774
        Ok(())
775
    }
776

777
    /// Verifies that opening a library fails if the directory has bad permissions.
778
    #[test]
779
    fn bad_dir_permissions() -> Result<()> {
780
        let dir = tempfile::tempdir()?;
781
        set_permissions(&dir, Permissions::from_mode(0o000))?;
782
        assert!(Trane::new_local(dir.path(), dir.path()).is_err());
783
        Ok(())
784
    }
785

786
    /// Verifies that opening a library fails if the config directory has bad permissions.
787
    #[test]
788
    fn bad_config_dir_permissions() -> Result<()> {
789
        let dir = tempfile::tempdir()?;
790
        let config_dir_path = dir.path().join(".trane");
791
        create_dir(&config_dir_path)?;
792
        set_permissions(&config_dir_path, Permissions::from_mode(0o000))?;
793
        assert!(Trane::new_local(dir.path(), dir.path()).is_err());
794
        Ok(())
795
    }
796

797
    /// Verifies that opening a library fails if the user preferences file is not a file.
798
    #[test]
799
    fn user_preferences_file_is_a_dir() -> Result<()> {
800
        // Create directory `./trane/user_preferences.json` which is not a file.
801
        let temp_dir = tempfile::tempdir()?;
802
        std::fs::create_dir_all(
803
            temp_dir
804
                .path()
805
                .join(TRANE_CONFIG_DIR_PATH)
806
                .join(USER_PREFERENCES_PATH),
807
        )?;
808
        assert!(Trane::new_local(temp_dir.path(), temp_dir.path()).is_err());
809
        Ok(())
810
    }
811

812
    /// Verifies that opening a library fails if the filters directory cannot be created.
813
    #[test]
814
    fn cannot_create_filters_directory() -> Result<()> {
815
        // Create config directory.
816
        let temp_dir = tempfile::tempdir()?;
817
        let config_dir = temp_dir.path().join(TRANE_CONFIG_DIR_PATH);
818
        create_dir(config_dir.clone())?;
819

820
        // Set permissions of `.trane` directory to read-only.
821
        std::fs::set_permissions(temp_dir.path(), std::fs::Permissions::from_mode(0o444))?;
822

823
        assert!(Trane::new_local(temp_dir.path(), temp_dir.path()).is_err());
824
        Ok(())
825
    }
826

827
    /// Verifies that opening a library fails if the study sessions directory cannot be created.
828
    #[test]
829
    fn cannot_create_study_sessions() -> Result<()> {
830
        // Create config and filters directories.
831
        let temp_dir = tempfile::tempdir()?;
832
        let config_dir = temp_dir.path().join(TRANE_CONFIG_DIR_PATH);
833
        create_dir(config_dir.clone())?;
834
        let filters_dir = config_dir.join(FILTERS_DIR);
835
        create_dir(filters_dir)?;
836

837
        // Set permissions of `.trane` directory to read-only. This should prevent Trane from
838
        // creating the user preferences file.
839
        std::fs::set_permissions(config_dir, std::fs::Permissions::from_mode(0o500))?;
840
        assert!(Trane::new_local(temp_dir.path(), temp_dir.path()).is_err());
841
        Ok(())
842
    }
843

844
    /// Verifies that opening a library fails if the user preferences file cannot be created.
845
    #[test]
846
    fn cannot_create_user_preferences() -> Result<()> {
847
        // Create config, filters, and study sessions directories.
848
        let temp_dir = tempfile::tempdir()?;
849
        let config_dir = temp_dir.path().join(TRANE_CONFIG_DIR_PATH);
850
        create_dir(config_dir.clone())?;
851
        let filters_dir = config_dir.join(FILTERS_DIR);
852
        create_dir(filters_dir)?;
853
        let sessions_dir = config_dir.join(STUDY_SESSIONS_DIR);
854
        create_dir(sessions_dir)?;
855

856
        // Set permissions of `.trane` directory to read-only. This should prevent Trane from
857
        // creating the user preferences file.
858
        std::fs::set_permissions(config_dir, std::fs::Permissions::from_mode(0o500))?;
859
        assert!(Trane::new_local(temp_dir.path(), temp_dir.path()).is_err());
860
        Ok(())
861
    }
862

863
    /// Verifies retrieving the scheduler data from the library.
864
    #[test]
865
    fn scheduler_data() -> Result<()> {
866
        let dir = tempfile::tempdir()?;
867
        let trane = Trane::new_local(dir.path(), dir.path())?;
868
        trane.get_scheduler_data();
869
        Ok(())
870
    }
871

872
    /// Verifies building the scheduler options from the user preferences.
873
    #[test]
874
    fn scheduler_options() {
875
        // Test with no preferences.
876
        let user_preferences = UserPreferences {
877
            scheduler: None,
878
            transcription: None,
879
            ignored_paths: vec![],
880
        };
881
        let options = Trane::create_scheduler_options(user_preferences.scheduler.as_ref());
882
        assert_eq!(options.batch_size, SchedulerOptions::default().batch_size);
883

884
        // Test with preferences.
885
        let user_preferences = UserPreferences {
886
            scheduler: Some(SchedulerPreferences {
887
                batch_size: Some(10),
888
            }),
889
            transcription: None,
890
            ignored_paths: vec![],
891
        };
892
        let options = Trane::create_scheduler_options(user_preferences.scheduler.as_ref());
893
        assert_eq!(options.batch_size, 10);
894
    }
895
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc