Skip to main content

ralph/cli/
productivity.rs

1//! Productivity CLI commands.
2//!
3//! Responsibilities:
4//! - Provide human- and machine-readable views of productivity stats.
5//! - Read from `.ralph/cache/productivity.jsonc` via `crate::productivity`.
6//!
7//! Not handled here:
8//! - Recording completions (handled where tasks are completed).
9//! - Queue mutations.
10//!
11//! Invariants/assumptions:
12//! - Stats timestamps are RFC3339.
13//! - Missing stats file implies zeroed defaults.
14
15use anyhow::Result;
16use clap::{Args, Subcommand, ValueEnum};
17
18use crate::config;
19use crate::productivity;
20
21fn load_done_queue_for_estimation(
22    resolved: &config::Resolved,
23) -> Result<crate::contracts::QueueFile> {
24    crate::queue::load_queue_or_default(&resolved.done_path)
25}
26
27#[derive(ValueEnum, Clone, Copy, Debug, Default)]
28#[clap(rename_all = "snake_case")]
29pub enum ProductivityFormat {
30    #[default]
31    Text,
32    Json,
33}
34
35#[derive(Args)]
36#[command(about = "View productivity stats (streaks, velocity, milestones)")]
37pub struct ProductivityArgs {
38    #[command(subcommand)]
39    pub command: ProductivityCommand,
40}
41
42#[derive(Subcommand)]
43pub enum ProductivityCommand {
44    /// Summary: total completions, streak, milestones, recent completions.
45    #[command(
46        after_long_help = "Examples:\n  ralph productivity summary\n  ralph productivity summary --format json\n  ralph productivity summary --recent 10"
47    )]
48    Summary(ProductivitySummaryArgs),
49
50    /// Velocity: completions per day over windows (7/30 by default).
51    #[command(
52        after_long_help = "Examples:\n  ralph productivity velocity\n  ralph productivity velocity --format json\n  ralph productivity velocity --days 14"
53    )]
54    Velocity(ProductivityVelocityArgs),
55
56    /// Streak details: current/longest streak + last completion date.
57    #[command(
58        after_long_help = "Examples:\n  ralph productivity streak\n  ralph productivity streak --format json"
59    )]
60    Streak(ProductivityStreakArgs),
61
62    /// Estimation accuracy: compare estimated vs actual time for completed tasks.
63    #[command(
64        after_long_help = "Examples:\n  ralph productivity estimation\n  ralph productivity estimation --format json"
65    )]
66    Estimation(ProductivityEstimationArgs),
67}
68
69#[derive(Args)]
70pub struct ProductivitySummaryArgs {
71    /// Output format.
72    #[arg(long, value_enum, default_value_t = ProductivityFormat::Text)]
73    pub format: ProductivityFormat,
74
75    /// Number of recent completions to show.
76    #[arg(long, default_value = "5")]
77    pub recent: usize,
78}
79
80#[derive(Args)]
81pub struct ProductivityVelocityArgs {
82    /// Output format.
83    #[arg(long, value_enum, default_value_t = ProductivityFormat::Text)]
84    pub format: ProductivityFormat,
85
86    /// Window size in days.
87    #[arg(long, default_value = "7")]
88    pub days: u32,
89}
90
91#[derive(Args)]
92pub struct ProductivityStreakArgs {
93    /// Output format.
94    #[arg(long, value_enum, default_value_t = ProductivityFormat::Text)]
95    pub format: ProductivityFormat,
96}
97
98#[derive(Args)]
99pub struct ProductivityEstimationArgs {
100    /// Output format.
101    #[arg(long, value_enum, default_value_t = ProductivityFormat::Text)]
102    pub format: ProductivityFormat,
103}
104
105pub fn handle(args: ProductivityArgs) -> Result<()> {
106    let resolved = config::resolve_from_cwd()?;
107    let cache_dir = resolved.repo_root.join(".ralph/cache");
108    let stats = productivity::load_productivity_stats(&cache_dir)?;
109
110    match args.command {
111        ProductivityCommand::Summary(cmd) => {
112            let report = productivity::build_summary_report(&stats, cmd.recent);
113            match cmd.format {
114                ProductivityFormat::Json => {
115                    print!("{}", serde_json::to_string_pretty(&report)?);
116                }
117                ProductivityFormat::Text => {
118                    productivity::print_summary_report_text(&report);
119                }
120            }
121        }
122        ProductivityCommand::Velocity(cmd) => {
123            let report = productivity::build_velocity_report(&stats, cmd.days);
124            match cmd.format {
125                ProductivityFormat::Json => {
126                    print!("{}", serde_json::to_string_pretty(&report)?);
127                }
128                ProductivityFormat::Text => {
129                    productivity::print_velocity_report_text(&report);
130                }
131            }
132        }
133        ProductivityCommand::Streak(cmd) => {
134            let report = productivity::build_streak_report(&stats);
135            match cmd.format {
136                ProductivityFormat::Json => {
137                    print!("{}", serde_json::to_string_pretty(&report)?);
138                }
139                ProductivityFormat::Text => {
140                    productivity::print_streak_report_text(&report);
141                }
142            }
143        }
144        ProductivityCommand::Estimation(cmd) => {
145            // Load completed tasks from the resolved done archive path.
146            let done_queue = load_done_queue_for_estimation(&resolved)?;
147            let report = productivity::build_estimation_report(&done_queue.tasks);
148            match cmd.format {
149                ProductivityFormat::Json => {
150                    print!("{}", serde_json::to_string_pretty(&report)?);
151                }
152                ProductivityFormat::Text => {
153                    productivity::print_estimation_report_text(&report);
154                }
155            }
156        }
157    }
158
159    Ok(())
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165    use crate::contracts::{Config, QueueFile, Task, TaskStatus};
166    use std::path::PathBuf;
167
168    #[test]
169    fn estimation_loads_done_tasks_from_resolved_done_path() -> Result<()> {
170        let temp = tempfile::tempdir()?;
171        let repo_root = temp.path().to_path_buf();
172
173        let default_done_path = repo_root.join(".ralph/done.json");
174        let custom_done_path = repo_root.join("archive/done.jsonc");
175        std::fs::create_dir_all(default_done_path.parent().expect("default parent"))?;
176        std::fs::create_dir_all(custom_done_path.parent().expect("custom parent"))?;
177
178        let default_done = QueueFile {
179            version: 1,
180            tasks: vec![Task {
181                id: "RQ-DEFAULT".to_string(),
182                status: TaskStatus::Done,
183                title: "default".to_string(),
184                estimated_minutes: Some(10),
185                actual_minutes: Some(10),
186                ..Task::default()
187            }],
188        };
189        crate::queue::save_queue(&default_done_path, &default_done)?;
190
191        let custom_done = QueueFile {
192            version: 1,
193            tasks: vec![Task {
194                id: "RQ-CUSTOM".to_string(),
195                status: TaskStatus::Done,
196                title: "custom".to_string(),
197                estimated_minutes: Some(20),
198                actual_minutes: Some(25),
199                ..Task::default()
200            }],
201        };
202        crate::queue::save_queue(&custom_done_path, &custom_done)?;
203
204        let resolved = config::Resolved {
205            config: Config::default(),
206            repo_root,
207            queue_path: PathBuf::from("unused-queue"),
208            done_path: custom_done_path,
209            id_prefix: "RQ".to_string(),
210            id_width: 4,
211            global_config_path: None,
212            project_config_path: None,
213        };
214
215        let loaded = load_done_queue_for_estimation(&resolved)?;
216        assert_eq!(loaded.tasks.len(), 1);
217        assert_eq!(loaded.tasks[0].id, "RQ-CUSTOM");
218        Ok(())
219    }
220}