usage-tracker 0.3.2

A simple usage tracker in rust.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
use anyhow::{anyhow, Context, Result};
use atty::Stream;
use chrono::{DateTime, Duration, Local, NaiveDate, NaiveDateTime, TimeZone, Utc};
use clap::Parser;
use human_panic::setup_panic;
use standard_paths::{LocationType, StandardPaths};
use std::{
    fs::{self, File},
    path::{Path, PathBuf},
};
use usage_tracker::*;

const PATH_CONVERT_ERROR: &str =
    "could not convert file name for other error message. WTF have you done?!";
const JSON_FORMAT_ERROR: &str = "could not serialize JSON output";

/// The CLI.
#[derive(Debug, Parser)]
#[clap(about, author, version)]
struct Opt {
    /// The commands.
    #[clap(subcommand)]
    cmd: Commands,
    /// The data file to use.
    ///
    /// If the file doesn't exist, it will be treated as an empty file and if an object is
    /// added, it will be saved at the location.
    ///
    /// Supported file formats:
    /// - json
    ///
    /// Warning: even if RON support is added at some point, you won't be able to read files
    /// from v0.1 with it, because those files have a different file format.
    #[clap(parse(from_os_str), verbatim_doc_comment)]
    data_file: Option<PathBuf>,
    /// If a change is made, don't keep a backup of the original data file.
    #[clap(long)]
    no_backup: bool,
}

/// All possible commands.
#[derive(Debug, Parser)]
#[structopt(about)]
enum Commands {
    /// Add a new object to keep track of.
    Add {
        /// The name of the new object.
        name: String,
    },

    /// Remove **all** objects permanently.
    Clear {
        /// REQUIRED: confirm you are sure to clear the data store.
        ///
        /// This is an additional check to ensure users don't accidentally delete all of their usage
        /// records.
        #[clap(long = "i-am-sure")]
        confirmation: bool,
    },

    /// List all currently tracked objects.
    List {
        /// Print all usage dates in addition to the objects names.
        #[clap(long, short)]
        verbose: bool,
    },

    /// Remove usages from an object.
    Prune {
        /// Remove all usages before this point in time. If not specified, all usages are removed.
        ///
        /// Can be in one of these formats:
        ///
        /// - 'dd.MM.yyyy': if this format is used, the timezone is set as the local timezone.
        /// - 'yyyy-MM-ddThh:mm:ss': if this format is used, the timezone is set as the local
        ///                          timezone. Intended for use by other programs, but humans should
        ///                          be able to use it too.
        /// - 'yyyy-MM-ddThh:mm:ss+oh:om': this format allows you to specify the timezone yourself.
        ///                                `oh` is the offset hour value, 'om' the offset minute
        ///                                value. Intended for use by other programs.
        #[clap(short, long, parse(try_from_str = parse_date), verbatim_doc_comment)]
        before: Option<DateTime<Utc>>,
        /// The name of the object to prune.
        name: String,
    },

    /// Remove a currently tracked object permanently.
    Remove {
        /// The name of the object to remove.
        name: String,
    },

    /// Show all usages of a single object.
    Show {
        /// The name of the object.
        name: String,
    },

    /// Show a prediction of the number of uses of an object within a time frame.
    ///
    /// Please note that these predictions are estimates. In most cases the accuracy will increase
    /// with the amount of data and the time since the usage first record.
    ///
    /// In some cases, when the usage behavior change drastically, it might be useful to delete all
    /// records before specific date to increase accuracy. You can use the `prune` command to do
    /// this.
    Usage {
        /// The name of the object to predict for.
        name: String,

        /// The duration to consider.
        duration: i64,

        ///The type of duration to consider
        ///
        /// Allowed values:
        /// - y...year
        /// - M...month
        /// - w...week
        /// - d...day
        /// - h...hour
        /// - m...minute
        /// - s...second
        #[clap(verbatim_doc_comment)]
        duration_type: char,
    },

    /// Record a new usage of an object.
    Use {
        /// Add the object if it isn't tracked yet.
        #[clap(long = "add")]
        add_if_new: bool,
        /// The name of the object that was used.
        name: String,
    },
}

fn main() -> Result<()> {
    // setup panic handler
    setup_panic!(Metadata {
        authors: env!("CARGO_PKG_AUTHORS").into(),
        homepage: env!("CARGO_PKG_HOMEPAGE").into(),
        name: env!("CARGO_PKG_NAME").into(),
        version: env!("CARGO_PKG_VERSION").into(),
    });

    // parse arguments
    let opt = Opt::parse();

    // load data
    let sp = StandardPaths::new("usage-tracker", "tfld");
    let initial_info = match &opt.data_file {
        Some(df) => load_from_file(&df)?,
        None => load_from_default_files(&sp)?,
    };
    let mut info = initial_info.clone();

    // handle commands
    match opt.cmd {
        Commands::Add { name } => info.add(&name)?,
        Commands::Clear { confirmation } => {
            if confirmation {
                info.clear()
            } else {
                return Err(anyhow!("please confirm operation with `--i-am-sure`"));
            }
        }
        Commands::List { verbose } => {
            if info.list_verbose().len() == 0 {
                return Err(anyhow!("no objects are currently tracked"));
            }

            if !verbose {
                let data = info.list();

                if atty::is(Stream::Stdout) {
                    for (i, k) in data.iter().enumerate() {
                        println!("{}: {}", i, k);
                    }
                } else {
                    println!(
                        "{}",
                        serde_json::to_string(&data).context(JSON_FORMAT_ERROR)?
                    );
                }
            } else {
                let data = info.list_verbose();

                if atty::is(Stream::Stdout) {
                    for (i, (k, v)) in data.iter().enumerate() {
                        println!("{}: {}", i, k);
                        for u in v.list() {
                            println!("   {}", u.with_timezone(&Local));
                        }
                    }
                } else {
                    let mut output = Vec::new();
                    for (k, v) in data.iter() {
                        output.push(serde_json::json!({"name": k, "usages": v.list()}));
                    }
                    println!(
                        "{}",
                        serde_json::to_string(&output).context(JSON_FORMAT_ERROR)?
                    );
                }
            }
        }
        Commands::Prune { before, name } => info.prune(&name, &before)?,
        Commands::Remove { name } => info.remove(&name),
        Commands::Show { name } => {
            let data = (info.usages(&name)?).list();
            if atty::is(Stream::Stdout) {
                for u in data {
                    println!("{}", u.with_timezone(&Local));
                }
            } else {
                println!(
                    "{}",
                    serde_json::to_string(&data).context(JSON_FORMAT_ERROR)?
                );
            }
        }
        Commands::Usage {
            name,
            duration,
            duration_type,
        } => {
            let d = match duration_type {
                'y' => Duration::days(duration * 365),
                'M' => Duration::days(duration * 30),
                'w' => Duration::weeks(duration),
                'd' => Duration::days(duration),
                'h' => Duration::hours(duration),
                'm' => Duration::minutes(duration),
                's' => Duration::seconds(duration),
                _ => {
                    return Err(anyhow!("duration type '{}' doesn't exist", duration_type));
                }
            };

            let data = info.usage(&name, &d)?;
            if atty::is(Stream::Stdout) {
                println!("{}", data);
            } else {
                println!("{}", serde_json::json!({ "value": data }));
            }
        }
        Commands::Use { add_if_new, name } => info.record_use(&name, add_if_new)?,
    }

    // if data changed, safe new data
    if info != initial_info {
        match &opt.data_file {
            Some(df) => save_to_file(&info, &df, !opt.no_backup)?,
            None => save_to_default_file(&info, !opt.no_backup, &sp)?,
        }
    }

    Ok(())
}

/// Loads usage information from one of two default files.
///
/// The files are always tried in the same order, an later files are only tried when the former file
/// wasn't found, but not if any other error occurred. All files are within the OS-specific
/// application data directory:
/// 1. `usages.json`: this is also the file the program writes to by default.
/// 2. `default.ron`: this was the default file in 0.1, so 0.2 should be able to fall back to it.
fn load_from_default_files(sp: &StandardPaths) -> Result<UsageInformation> {
    // get application data directory
    let path_base = sp
        .writable_location(LocationType::AppDataLocation)
        .context("application data directory not found")?;

    let files = vec![("usages", true), ("default", false)];

    for (name, is_json) in files {
        let mut p = PathBuf::new();
        p.push(&path_base);
        p.push(name);
        p.set_extension(match is_json {
            true => "json",
            false => "ron",
        });

        if !p.exists() {
            continue;
        }

        if !p.is_file() {
            return Err(anyhow!(
                "found directory instead of file: {}",
                p.to_str().context(PATH_CONVERT_ERROR)?
            ));
        }

        let file = File::open(Path::new(&p)).context(format!(
            "could not open file: {}",
            p.to_str().context(PATH_CONVERT_ERROR)?
        ))?;

        return match is_json {
            true => serde_json::from_reader(file).context(format!(
                "could not parse JSON file: {}",
                p.to_str().context(PATH_CONVERT_ERROR)?
            )),
            #[allow(deprecated)]
            false => UsageInformation::load_usage_information_from_ron_file(file).context(format!(
                "could not load data from RON file: {}",
                p.to_str().context(PATH_CONVERT_ERROR)?
            )),
        };
    }

    Ok(UsageInformation::new())
}

/// Loads usage information from a file.
///
/// The file format is decided on basis of the file extension. Currently supported formats:
/// - JSON: `.json`
fn load_from_file(path: &PathBuf) -> Result<UsageInformation> {
    let fmt = match path.extension() {
        Some(e) => match e.to_str().context("could not parse file name extension")? {
            "json" => "JSON",
            _ => {
                return Err(anyhow!(
                    "\"{}\" is not a supported file format",
                    e.to_str().context(PATH_CONVERT_ERROR)?
                ))
            }
        },
        None => return Err(anyhow!("file format not specified")),
    };

    if !path.exists() {
        return Ok(UsageInformation::new());
    }

    let file = File::open(Path::new(&path)).context(format!(
        "could not open file: {}",
        path.to_str().context(PATH_CONVERT_ERROR)?
    ))?;

    match fmt {
        "JSON" => serde_json::from_reader(file),
        _ => panic!("internal format value changed"),
    }
    .context(format!(
        "could not parse {} file: {}",
        fmt,
        path.to_str().context(PATH_CONVERT_ERROR)?
    ))
}

/// Parses a &str into a DateTime<Utc>.
///
/// Tries different formats described by the documentation for the `prune -v` command parameter.
fn parse_date(src: &str) -> Result<DateTime<Utc>> {
    if src.len() == "dd.MM.yyyy".len() {
        let d = NaiveDate::parse_from_str(src, "%d.%m.%Y")
            .context(format!("could not parse local date: {}", src))?;
        return Ok(Utc
            .from_local_datetime(
                &d.and_hms_opt(0, 0, 0)
                    .ok_or(anyhow!("could not convert to utc: {}", d))?,
            )
            .unwrap());
    } else if src.len() == "yyyy-MM-ddThh:mm:ss".len() {
        let dt: NaiveDateTime = src
            .parse()
            .context(format!("could not pares local datetime: {}", src))?;

        let dtu = Local.from_local_datetime(&dt).unwrap();

        return Ok(dtu.into());
    } else {
        return Ok(src
            .parse()
            .context(format!("could not parse datetime: {}", src))?);
    }
}

/// Saves the provided UsageInformation to a default file. The default file is the first file listed
/// in the documentation of `load_from_default_files()`.
///
/// The parameter `backup` specifies whether or not the function will create a backup of the
/// original file (if one exists), before overwriting it. This backup is very simple, it's literally
/// adding `.bak` to the original files name. If a file with that name already exists, it is
/// deleted.
fn save_to_default_file(ui: &UsageInformation, backup: bool, sp: &StandardPaths) -> Result<()> {
    // get file path
    let mut path = sp
        .writable_location(LocationType::AppDataLocation)
        .context("application data directory not found")?;
    path.push("usages");
    path.set_extension("json");

    save_to_file(ui, &path, backup)
}

/// Saves the provided UsageInformation to a default file. The default file is the first file listed
/// in the documentation of `load_from_default_files()`.
///
/// The parameter `backup` specifies whether or not the function will create a backup of the
/// original file (if one exists), before overwriting it. This backup is very simple, it's literally
/// adding `.bak` to the original files name. If a file with that name already exists, it is
/// deleted.
fn save_to_file(ui: &UsageInformation, path: &PathBuf, backup: bool) -> Result<()> {
    let fmt = match path.extension() {
        Some(e) => match e.to_str().context("could not parse file name extension")? {
            "json" => "JSON",
            _ => {
                return Err(anyhow!(
                    "\"{}\" is not a supported file format",
                    e.to_str().context(PATH_CONVERT_ERROR)?
                ))
            }
        },
        None => return Err(anyhow!("file format not specified")),
    };

    if backup {
        // get backup path
        let mut backup_path = PathBuf::new();
        backup_path.push(&path);
        let backup_ext = backup_path
            .extension()
            .unwrap()
            .to_str()
            .unwrap()
            .to_owned()
            + ".bak";
        backup_path.set_extension(backup_ext);

        // make sure backup path is clear
        if backup_path.exists() {
            fs::remove_file(&backup_path).context("couldn't clear backup file path")?;
        }

        // move old file
        if path.exists() {
            fs::rename(&path, &backup_path)
                .context("couldn't move old data file to backup location")?;
        }
    }

    // make sure path is clear
    if path.exists() {
        fs::remove_file(&path).context("couldn't clear data file path")?;
    }

    let file = File::create(Path::new(&path)).context(format!(
        "could not create file: {}",
        path.to_str().context(PATH_CONVERT_ERROR)?
    ))?;

    match fmt {
        "JSON" => serde_json::to_writer_pretty(file, ui),
        _ => panic!("internal format value changed"),
    }
    .context(format!(
        "could not parse {} file: {}",
        fmt,
        path.to_str().context(PATH_CONVERT_ERROR)?
    ))
}