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

lhohan / simple-time-tracker / 18560907200

16 Oct 2025 12:14PM UTC coverage: 94.902%. Remained the same
18560907200

push

github

lhohan
feat(breakdown): Add shorthand values 'w', 'm', 'y' for breakdown units

Implement week, month, and year shorthand flags for breakdown command,
matching the existing 'd' pattern. Add parameterized acceptance tests
verifying all breakdown shorthand variants.

3 of 3 new or added lines in 1 file covered. (100.0%)

1 existing line in 1 file now uncovered.

1750 of 1844 relevant lines covered (94.9%)

75.5 hits per line

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

98.84
/src/cli/mod.rs
1
use clap::Parser;
2
use std::path::PathBuf;
3

4
use crate::domain::reporting::{BreakdownUnit, OutputLimit};
5
use crate::domain::tags::TagFilter;
6
use crate::domain::time::Clock;
7
use crate::domain::ParseError;
8
use crate::domain::PeriodRequested;
9
use crate::reporting::format::Formatter;
10

11
#[derive(Parser, Debug)]
12
#[command(author, version, about = "Simple time tracking from markdown files")]
13
pub struct Args {
14
    /// Input file to process
15
    #[arg(short, long, value_name = "FILE")]
16
    pub input: PathBuf,
17

18
    /// Show verbose output
19
    #[arg(short, long)]
20
    pub verbose: bool,
21

22
    /// Limit output
23
    #[arg(short, long)]
24
    limit: bool,
25

26
    /// Show project details, e.g. tasks
27
    #[arg(short, long)]
28
    details: bool,
29

30
    // Project filter flag
31
    #[arg(long)]
32
    project: Option<String>,
33

34
    // Tags filter
35
    #[arg(long)]
36
    pub tags: Option<String>,
37

38
    // Tags exclude filter
39
    #[arg(long)]
40
    pub exclude_tags: Option<String>,
41

42
    /// From date filter value
43
    #[arg(short, long, value_name = "YYYY-MM-DD")]
44
    pub from: Option<String>,
45

46
    #[arg(
47
        long,
48
        value_name = "this-week, tw, last-week, lw, this-month, tm, last-month, lm, month-n,m-n"
49
    )]
50
    period: Option<String>,
51

52
    #[arg(long, value_name = "text, markdown", default_value = "text")]
53
    pub format: Option<String>,
54

55
    #[arg(long, value_name = "day, d, week, month, year, auto")]
56
    pub breakdown: Option<String>,
57
}
58

59
impl Args {
60
    /// Parses command line arguments and validates them.
61
    ///
62
    /// # Errors
63
    ///
64
    /// Returns a `String` error if the arguments are invalid or violate validation rules.
65
    #[must_use = "parsed arguments must be used to configure the application"]
66
    pub fn parse() -> Result<Self, String> {
159✔
67
        let args = Self::parse_from(std::env::args());
159✔
68
        args.validate()?;
159✔
69
        Ok(args)
157✔
70
    }
159✔
71

72
    fn validate(&self) -> Result<(), String> {
158✔
73
        // Check if details is specified without tags
74
        if self.details && self.tags.is_none() && self.project.is_none() {
158✔
75
            return Err("--details flag requires --tags to be specified".to_string());
1✔
76
        }
157✔
77

78
        // Check if breakdown is specified without tags or project
79
        if self.breakdown.is_some() && self.tags.is_none() && self.project.is_none() {
157✔
80
            return Err(
1✔
81
                "--breakdown flag requires --tags or --project to be specified".to_string(),
1✔
82
            );
1✔
83
        }
156✔
84

85
        Ok(())
156✔
86
    }
158✔
87

88
    /// Parses exclude tags from the command line arguments.
89
    #[must_use]
90
    pub fn exclude_tags(&self) -> Vec<String> {
156✔
91
        match &self.exclude_tags {
156✔
92
            Some(tags) => {
4✔
93
                let parsed_tags = tags
4✔
94
                    .split(',')
4✔
95
                    .map(|s| s.trim().to_string())
5✔
96
                    .collect::<Vec<String>>();
4✔
97
                parsed_tags
4✔
98
            }
99
            None => vec![],
152✔
100
        }
101
    }
156✔
102

103
    #[must_use]
104
    pub fn include_details(&self) -> bool {
138✔
105
        self.project.is_some() || self.details
138✔
106
    }
138✔
107

108
    /// Parses filter tags from the command line arguments.
109
    #[must_use]
110
    pub fn context_filter(&self) -> Option<TagFilter> {
156✔
111
        fn parse_project_tags(maybe_project: Option<&String>) -> Vec<String> {
156✔
112
            maybe_project.map_or_else(Vec::new, |p| vec![p.clone()])
156✔
113
        }
156✔
114

115
        fn parse_tags(tags: &[String], maybe_tags: Option<&String>) -> Vec<String> {
156✔
116
            maybe_tags.filter(|s| !s.is_empty()).map_or_else(
156✔
117
                || tags.to_vec(),
123✔
118
                |tag_list| tag_list.split(',').map(|s| s.trim().to_string()).collect(),
35✔
119
            )
120
        }
156✔
121
        fn to_filter(tags: Vec<String>) -> Option<TagFilter> {
156✔
122
            (!tags.is_empty()).then(|| TagFilter::parse(tags))
156✔
123
        }
156✔
124

125
        let tags = parse_project_tags(self.project.as_ref());
156✔
126
        let tags = parse_tags(&tags, self.tags.as_ref());
156✔
127
        to_filter(tags)
156✔
128
    }
156✔
129

130
    /// Parses the period from the command line arguments.
131
    ///
132
    /// # Errors
133
    ///
134
    /// Returns a `ParseError::InvalidPeriod` if the period is not valid.
135
    pub fn period(&self, clock: &Clock) -> Result<Option<PeriodRequested>, ParseError> {
156✔
136
        match self.parse_period(clock) {
156✔
137
            Ok(Some(period)) => Ok(Some(period)),
56✔
138
            Err(err) => Err(err),
14✔
139
            Ok(None) => self.parse_date(),
86✔
140
        }
141
    }
156✔
142

143
    fn parse_period(&self, clock: &Clock) -> Result<Option<PeriodRequested>, ParseError> {
156✔
144
        match self.period.as_ref() {
156✔
145
            Some(period) => PeriodRequested::from_str(period, clock).map(Some),
70✔
146
            None => Ok(None),
86✔
147
        }
148
    }
156✔
149

150
    fn parse_date(&self) -> Result<Option<PeriodRequested>, ParseError> {
86✔
151
        PeriodRequested::parse_from_date(self.from.as_deref())
86✔
152
    }
86✔
153

154
    #[must_use]
155
    pub fn limit(&self) -> Option<OutputLimit> {
138✔
156
        if self.limit {
138✔
157
            Some(OutputLimit::CumulativePercentageThreshold(90.00))
3✔
158
        } else {
159
            None
135✔
160
        }
161
    }
138✔
162

163
    #[must_use]
164
    pub fn formatter(&self) -> Box<dyn Formatter> {
138✔
165
        <dyn Formatter>::from_str(self.format.as_ref())
138✔
166
    }
138✔
167

168
    #[must_use]
169
    pub fn breakdown_unit(&self, period: Option<&PeriodRequested>) -> Option<BreakdownUnit> {
138✔
170
        self.breakdown.as_ref().and_then(|b| {
138✔
171
            let breakdown_str = b.to_lowercase();
26✔
172
            match breakdown_str.as_str() {
26✔
173
                "auto" => Self::auto_breakdown_unit(period),
26✔
174
                "day" | "d" => Some(BreakdownUnit::Day),
22✔
175
                "week" | "w" => Some(BreakdownUnit::Week),
14✔
176
                "month" | "m" => Some(BreakdownUnit::Month),
10✔
177
                "year" | "y" => Some(BreakdownUnit::Year),
5✔
UNCOV
178
                _ => None,
×
179
            }
180
        })
26✔
181
    }
138✔
182

183
    #[must_use]
184
    fn auto_breakdown_unit(period: Option<&PeriodRequested>) -> Option<BreakdownUnit> {
4✔
185
        // Resolve to one level above the period:
186
        // day -> week, week -> month, month -> year, year -> year
187
        period.map(|p| match p {
4✔
188
            PeriodRequested::Day(_) | PeriodRequested::FromDate(_) => BreakdownUnit::Week,
1✔
189
            PeriodRequested::WeekOf(_) => BreakdownUnit::Month,
1✔
190
            PeriodRequested::MonthOf(_) | PeriodRequested::YearOf(_) => BreakdownUnit::Year,
2✔
191
        })
4✔
192
    }
4✔
193
}
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

© 2026 Coveralls, Inc