Skip to main content

waka_api/
types.rs

1//! `WakaTime` API response types.
2//!
3//! All types derive [`Debug`], [`Clone`], [`serde::Serialize`], and
4//! [`serde::Deserialize`]. Unknown JSON fields are silently ignored via
5//! `#[serde(deny_unknown_fields)]` is **not** used — this keeps the client
6//! forward-compatible with new API fields.
7
8// The WakaTime API returns several structs with more than 3 boolean fields
9// (e.g. Goal has 5, Stats has 8). These fields mirror the upstream API
10// exactly and cannot be meaningfully replaced with enums or bitflags.
11#![allow(clippy::struct_excessive_bools)]
12
13use serde::{Deserialize, Serialize};
14
15// ─────────────────────────────────────────────────────────────────────────────
16// User
17// ─────────────────────────────────────────────────────────────────────────────
18
19/// Top-level envelope returned by `GET /users/current`.
20#[non_exhaustive]
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct UserResponse {
23    /// The user object.
24    pub data: User,
25}
26
27/// A `WakaTime` user account.
28#[non_exhaustive]
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct User {
31    /// Unique user identifier (UUID).
32    pub id: String,
33    /// The user's login handle.
34    pub username: String,
35    /// Human-readable display name.
36    pub display_name: String,
37    /// Full legal name (may be `None` if not set).
38    pub full_name: Option<String>,
39    /// Email address (only present when the authenticated user requests their
40    /// own profile).
41    pub email: Option<String>,
42    /// URL of the user's avatar image.
43    pub photo: Option<String>,
44    /// IANA timezone string (e.g. `"America/New_York"`).
45    pub timezone: String,
46    /// Personal website URL.
47    pub website: Option<String>,
48    /// Human-readable website URL (without protocol prefix).
49    pub human_readable_website: Option<String>,
50    /// Geographic location string.
51    pub location: Option<String>,
52    /// Subscription plan (e.g. `"free"`, `"premium"`).
53    pub plan: Option<String>,
54    /// Absolute URL of the user's public profile.
55    pub profile_url: Option<String>,
56    /// Whether the user's email address has been verified.
57    #[serde(default)]
58    pub is_email_confirmed: bool,
59    /// Whether the user is open to work.
60    pub is_hireable: Option<bool>,
61    /// Whether coding time is visible on the public profile.
62    pub logged_time_public: Option<bool>,
63    /// Whether this account is in write-only mode.
64    #[serde(default)]
65    pub writes_only: bool,
66    /// Heartbeat timeout in minutes.
67    pub timeout: Option<u32>,
68    /// Whether the user prefers 24-hour time format.
69    pub time_format_24hr: Option<bool>,
70    /// ISO 8601 timestamp when the account was created.
71    pub created_at: String,
72    /// ISO 8601 timestamp when the account was last modified.
73    pub modified_at: Option<String>,
74}
75
76// ─────────────────────────────────────────────────────────────────────────────
77// Summaries
78// ─────────────────────────────────────────────────────────────────────────────
79
80/// Top-level envelope returned by `GET /users/current/summaries`.
81#[non_exhaustive]
82#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct SummaryResponse {
84    /// Per-day summary entries (one per calendar day in the requested range).
85    pub data: Vec<SummaryData>,
86    /// ISO 8601 end of the requested range.
87    pub end: String,
88    /// ISO 8601 start of the requested range.
89    pub start: String,
90}
91
92/// Coding activity summary for a single calendar day.
93#[non_exhaustive]
94#[derive(Debug, Clone, Default, Serialize, Deserialize)]
95pub struct SummaryData {
96    /// Time broken down by activity category (coding, browsing, etc.).
97    pub categories: Vec<SummaryEntry>,
98    /// Time broken down by detected dependency / library.
99    #[serde(default)]
100    pub dependencies: Vec<SummaryEntry>,
101    /// Time broken down by editor.
102    pub editors: Vec<SummaryEntry>,
103    /// Daily grand total across all activity.
104    pub grand_total: GrandTotal,
105    /// Time broken down by programming language.
106    pub languages: Vec<SummaryEntry>,
107    /// Time broken down by machine / hostname.
108    #[serde(default)]
109    pub machines: Vec<MachineEntry>,
110    /// Time broken down by operating system.
111    pub operating_systems: Vec<SummaryEntry>,
112    /// Time broken down by project.
113    pub projects: Vec<SummaryEntry>,
114    /// The date range this entry covers.
115    pub range: SummaryRange,
116}
117
118/// A single time-breakdown entry (language, project, editor, OS, etc.).
119#[derive(Debug, Clone, Default, Serialize, Deserialize)]
120pub struct SummaryEntry {
121    /// Human-readable duration in `HH:MM` format.
122    pub digital: String,
123    /// Whole hours component.
124    pub hours: u32,
125    /// Whole minutes component (0–59).
126    pub minutes: u32,
127    /// Entity name (e.g. `"Python"`, `"my-project"`).
128    pub name: String,
129    /// Percentage of total time for the period (0.0–100.0).
130    pub percent: f64,
131    /// Whole seconds component (0–59).
132    pub seconds: u32,
133    /// Full human-readable duration (e.g. `"3 hrs 30 mins"`).
134    pub text: String,
135    /// Total duration in seconds (fractional).
136    pub total_seconds: f64,
137}
138
139/// A machine / hostname time-breakdown entry.
140///
141/// This is structurally similar to [`SummaryEntry`] but includes the
142/// `machine_name_id` field returned by the API.
143#[non_exhaustive]
144#[derive(Debug, Clone, Default, Serialize, Deserialize)]
145pub struct MachineEntry {
146    /// Human-readable duration in `HH:MM` format.
147    pub digital: String,
148    /// Whole hours component.
149    pub hours: u32,
150    /// Disambiguated machine identifier returned by the API.
151    pub machine_name_id: String,
152    /// Whole minutes component (0–59).
153    pub minutes: u32,
154    /// Human-readable machine name.
155    pub name: String,
156    /// Percentage of total time for the period (0.0–100.0).
157    pub percent: f64,
158    /// Whole seconds component (0–59).
159    pub seconds: u32,
160    /// Full human-readable duration (e.g. `"1 hr 15 mins"`).
161    pub text: String,
162    /// Total duration in seconds (fractional).
163    pub total_seconds: f64,
164}
165
166/// Grand total coding time for a single calendar day.
167#[non_exhaustive]
168#[derive(Debug, Clone, Default, Serialize, Deserialize)]
169pub struct GrandTotal {
170    /// Human-readable duration in `HH:MM` format.
171    pub digital: String,
172    /// Whole hours component.
173    pub hours: u32,
174    /// Whole minutes component (0–59).
175    pub minutes: u32,
176    /// Whole seconds component (0–59).
177    pub seconds: u32,
178    /// Full human-readable duration (e.g. `"6 hrs 42 mins"`).
179    pub text: String,
180    /// Total duration in seconds (fractional).
181    pub total_seconds: f64,
182}
183
184/// Date range metadata attached to a [`SummaryData`] entry.
185#[non_exhaustive]
186#[derive(Debug, Clone, Default, Serialize, Deserialize)]
187pub struct SummaryRange {
188    /// Calendar date string (e.g. `"2025-01-13"`). Present on single-day
189    /// entries; may be absent on range queries.
190    pub date: Option<String>,
191    /// ISO 8601 end timestamp.
192    pub end: String,
193    /// ISO 8601 start timestamp.
194    pub start: String,
195    /// Human-readable description (e.g. `"today"`, `"yesterday"`).
196    pub text: Option<String>,
197    /// IANA timezone used when computing the range.
198    pub timezone: Option<String>,
199}
200
201// ─────────────────────────────────────────────────────────────────────────────
202// Projects list
203// ─────────────────────────────────────────────────────────────────────────────
204
205/// Top-level envelope returned by `GET /users/current/projects`.
206#[non_exhaustive]
207#[derive(Debug, Clone, Serialize, Deserialize)]
208pub struct ProjectsResponse {
209    /// The list of projects.
210    pub data: Vec<Project>,
211}
212
213/// A `WakaTime` project.
214#[non_exhaustive]
215#[derive(Debug, Clone, Serialize, Deserialize)]
216pub struct Project {
217    /// Optional badge URL associated with the project.
218    pub badge: Option<String>,
219    /// Optional hex color code used in the `WakaTime` web UI.
220    pub color: Option<String>,
221    /// ISO 8601 timestamp when the project was first seen.
222    pub created_at: String,
223    /// Whether the project has a public shareable URL.
224    pub has_public_url: bool,
225    /// Human-readable last-heartbeat description (e.g. `"2 hours ago"`).
226    pub human_readable_last_heartbeat_at: String,
227    /// Unique project identifier (UUID).
228    pub id: String,
229    /// ISO 8601 timestamp of the last received heartbeat.
230    pub last_heartbeat_at: String,
231    /// Project name.
232    pub name: String,
233    /// Linked repository URL (if configured).
234    pub repository: Option<String>,
235    /// Public project URL (if `has_public_url` is `true`).
236    pub url: Option<String>,
237    /// URL-encoded version of the project name.
238    pub urlencoded_name: String,
239}
240
241// ─────────────────────────────────────────────────────────────────────────────
242// Stats
243// ─────────────────────────────────────────────────────────────────────────────
244
245/// Top-level envelope returned by `GET /users/current/stats/{range}`.
246#[non_exhaustive]
247#[derive(Debug, Clone, Serialize, Deserialize)]
248pub struct StatsResponse {
249    /// The aggregated stats object.
250    pub data: Stats,
251}
252
253/// Aggregated coding stats for a predefined time range.
254#[non_exhaustive]
255#[derive(Debug, Clone, Serialize, Deserialize)]
256pub struct Stats {
257    /// The day with the most coding activity.
258    pub best_day: Option<BestDay>,
259    /// Time broken down by activity category.
260    pub categories: Vec<SummaryEntry>,
261    /// ISO 8601 timestamp when this stats snapshot was created.
262    pub created_at: String,
263    /// Average daily coding time in seconds.
264    pub daily_average: f64,
265    /// Average daily coding time including "other language" seconds.
266    pub daily_average_including_other_language: f64,
267    /// Number of days in the range including weekends/holidays.
268    pub days_including_holidays: u32,
269    /// Number of working days in the range.
270    pub days_minus_holidays: u32,
271    /// Time broken down by editor.
272    pub editors: Vec<SummaryEntry>,
273    /// ISO 8601 end of the stats range.
274    pub end: String,
275    /// Number of holiday days detected in the range.
276    pub holidays: u32,
277    /// Human-readable average daily coding time.
278    pub human_readable_daily_average: String,
279    /// Human-readable description of the range (e.g. `"last 7 days"`).
280    pub human_readable_range: String,
281    /// Human-readable total coding time.
282    pub human_readable_total: String,
283    /// Unique stats record identifier.
284    pub id: String,
285    /// Whether the stats snapshot is up to date.
286    pub is_up_to_date: bool,
287    /// Time broken down by programming language.
288    pub languages: Vec<SummaryEntry>,
289    /// Time broken down by machine.
290    pub machines: Vec<MachineEntry>,
291    /// ISO 8601 timestamp when this snapshot was last modified.
292    pub modified_at: Option<String>,
293    /// Time broken down by operating system.
294    pub operating_systems: Vec<SummaryEntry>,
295    /// How fully computed the stats are (0–100).
296    pub percent_calculated: u32,
297    /// Time broken down by project.
298    pub projects: Vec<SummaryEntry>,
299    /// Named range (e.g. `"last_7_days"`, `"last_30_days"`).
300    pub range: String,
301    /// ISO 8601 start of the stats range.
302    pub start: String,
303    /// Computation status (e.g. `"ok"`, `"pending_update"`).
304    pub status: String,
305    /// Heartbeat timeout in minutes used for this calculation.
306    pub timeout: u32,
307    /// IANA timezone used when computing the stats.
308    pub timezone: String,
309    /// Total coding time in seconds.
310    pub total_seconds: f64,
311    /// Total coding time including "other language" in seconds.
312    pub total_seconds_including_other_language: f64,
313    /// Owner user identifier (UUID).
314    pub user_id: String,
315    /// Owner username.
316    pub username: String,
317    /// Whether the account is in write-only mode.
318    pub writes_only: bool,
319}
320
321/// The single best (most productive) day in a stats range.
322#[non_exhaustive]
323#[derive(Debug, Clone, Serialize, Deserialize)]
324pub struct BestDay {
325    /// Calendar date (e.g. `"2025-01-13"`).
326    pub date: String,
327    /// Human-readable total for that day.
328    pub text: String,
329    /// Total coding seconds for that day.
330    pub total_seconds: f64,
331}
332
333// ─────────────────────────────────────────────────────────────────────────────
334// Goals
335// ─────────────────────────────────────────────────────────────────────────────
336
337/// Top-level envelope returned by `GET /users/current/goals`.
338#[derive(Debug, Clone, Serialize, Deserialize)]
339pub struct GoalsResponse {
340    /// The list of goals.
341    pub data: Vec<Goal>,
342    /// Total number of goals.
343    pub total: u32,
344    /// Total number of pages.
345    pub total_pages: u32,
346}
347
348/// A single coding goal.
349#[derive(Debug, Clone, Serialize, Deserialize)]
350pub struct Goal {
351    /// Per-period chart data (populated when fetching goal details).
352    pub chart_data: Option<Vec<GoalChartEntry>>,
353    /// ISO 8601 timestamp when the goal was created.
354    pub created_at: String,
355    /// Goal period (e.g. `"day"`, `"week"`).
356    pub delta: String,
357    /// Editors this goal is restricted to (empty = all editors).
358    #[serde(default)]
359    pub editors: Vec<String>,
360    /// Unique goal identifier (UUID).
361    pub id: String,
362    /// Days of the week to ignore (e.g. `["saturday", "sunday"]`).
363    #[serde(default)]
364    pub ignore_days: Vec<String>,
365    /// Whether days with zero activity are excluded from streak calculations.
366    pub ignore_zero_days: bool,
367    /// Target improvement percentage over baseline.
368    pub improve_by_percent: Option<f64>,
369    /// Whether the goal is active.
370    pub is_enabled: bool,
371    /// Whether passing means staying *below* the target.
372    pub is_inverse: bool,
373    /// Whether the goal is temporarily snoozed.
374    pub is_snoozed: bool,
375    /// Whether achievements are tweeted automatically.
376    pub is_tweeting: bool,
377    /// Languages this goal is restricted to (empty = all languages).
378    #[serde(default)]
379    pub languages: Vec<String>,
380    /// ISO 8601 timestamp when the goal was last modified.
381    pub modified_at: String,
382    /// Projects this goal is restricted to (empty = all projects).
383    #[serde(default)]
384    pub projects: Vec<String>,
385    /// Status for the most recent period.
386    pub range_status: String,
387    /// Human-readable explanation of the status.
388    pub range_status_reason: String,
389    /// Target coding seconds per period.
390    pub seconds: f64,
391    /// Overall goal status (e.g. `"success"`, `"fail"`, `"ignored"`).
392    pub status: String,
393    /// Human-readable goal title.
394    pub title: String,
395    /// Goal type (e.g. `"coding"`).
396    #[serde(rename = "type")]
397    pub goal_type: String,
398}
399
400/// One data point in a goal's progress chart.
401#[derive(Debug, Clone, Serialize, Deserialize)]
402pub struct GoalChartEntry {
403    /// Actual coding seconds logged during this period.
404    pub actual_seconds: f64,
405    /// Human-readable actual coding time.
406    pub actual_seconds_text: String,
407    /// Target coding seconds for this period.
408    pub goal_seconds: f64,
409    /// Human-readable target coding time.
410    pub goal_seconds_text: String,
411    /// Date range this data point covers.
412    pub range: GoalChartRange,
413    /// Status for this period (e.g. `"success"`, `"fail"`).
414    pub range_status: String,
415    /// Human-readable explanation of the status.
416    pub range_status_reason: String,
417}
418
419/// Date range metadata for a [`GoalChartEntry`].
420#[derive(Debug, Clone, Serialize, Deserialize)]
421pub struct GoalChartRange {
422    /// Calendar date (e.g. `"2025-01-13"`).
423    pub date: String,
424    /// ISO 8601 end timestamp.
425    pub end: String,
426    /// ISO 8601 start timestamp.
427    pub start: String,
428    /// Human-readable description (e.g. `"Mon, Jan 13"`).
429    pub text: String,
430    /// IANA timezone used when computing the range.
431    pub timezone: String,
432}
433
434// ─────────────────────────────────────────────────────────────────────────────
435// Leaderboard
436// ─────────────────────────────────────────────────────────────────────────────
437
438/// Top-level envelope returned by `GET /users/current/leaderboards`.
439#[non_exhaustive]
440#[derive(Debug, Clone, Serialize, Deserialize)]
441pub struct LeaderboardResponse {
442    /// The current authenticated user's entry, if they appear in this page.
443    pub current_user: Option<LeaderboardEntry>,
444    /// The leaderboard entries for this page.
445    pub data: Vec<LeaderboardEntry>,
446    /// Language filter applied (if any).
447    pub language: Option<String>,
448    /// ISO 8601 timestamp when the leaderboard was last updated.
449    pub modified_at: String,
450    /// Current page number (1-based).
451    pub page: u32,
452    /// Date range this leaderboard covers.
453    pub range: LeaderboardRange,
454    /// Heartbeat timeout in minutes used for ranking.
455    pub timeout: u32,
456    /// Total number of pages available.
457    pub total_pages: u32,
458    /// Whether the leaderboard is restricted to write-only accounts.
459    pub writes_only: bool,
460}
461
462/// A single entry on the leaderboard.
463#[non_exhaustive]
464#[derive(Debug, Clone, Serialize, Deserialize)]
465pub struct LeaderboardEntry {
466    /// The rank of this user (1 = top coder).
467    pub rank: u32,
468    /// Aggregated coding totals for this user.
469    pub running_total: RunningTotal,
470    /// Public user information.
471    pub user: LeaderboardUser,
472}
473
474/// Aggregated coding totals used on the leaderboard.
475#[non_exhaustive]
476#[derive(Debug, Clone, Serialize, Deserialize)]
477pub struct RunningTotal {
478    /// Average daily coding time in seconds.
479    pub daily_average: f64,
480    /// Human-readable average daily coding time.
481    pub human_readable_daily_average: String,
482    /// Human-readable total coding time for the period.
483    pub human_readable_total: String,
484    /// Top languages for this user (may be empty if privacy settings prevent
485    /// disclosure).
486    #[serde(default)]
487    pub languages: Vec<SummaryEntry>,
488    /// ISO 8601 timestamp when this total was last computed.
489    pub modified_at: String,
490    /// Total coding seconds for the leaderboard period.
491    pub total_seconds: f64,
492}
493
494/// Public user information exposed on the leaderboard.
495#[non_exhaustive]
496#[derive(Debug, Clone, Serialize, Deserialize)]
497pub struct LeaderboardUser {
498    /// Human-readable display name.
499    pub display_name: String,
500    /// Public email (empty string if not shared).
501    pub email: Option<String>,
502    /// Full legal name.
503    pub full_name: Option<String>,
504    /// Human-readable website URL.
505    pub human_readable_website: Option<String>,
506    /// Unique user identifier (UUID).
507    pub id: String,
508    /// Whether the email address is publicly visible.
509    pub is_email_public: bool,
510    /// Whether the user is open to work.
511    pub is_hireable: bool,
512    /// Geographic location string.
513    pub location: Option<String>,
514    /// Avatar image URL.
515    pub photo: Option<String>,
516    /// Whether the avatar is publicly visible.
517    pub photo_public: bool,
518    /// Absolute URL of the user's public profile.
519    pub profile_url: Option<String>,
520    /// Public email address (distinct from account email).
521    pub public_email: Option<String>,
522    /// Login handle.
523    pub username: Option<String>,
524    /// Personal website URL.
525    pub website: Option<String>,
526}
527
528/// Date range metadata for a [`LeaderboardResponse`].
529#[non_exhaustive]
530#[derive(Debug, Clone, Serialize, Deserialize)]
531pub struct LeaderboardRange {
532    /// End date string (e.g. `"2025-01-13"`).
533    pub end_date: String,
534    /// Short human-readable end date (e.g. `"Jan 13"`).
535    pub end_text: String,
536    /// Named range identifier (e.g. `"last_7_days"`).
537    pub name: String,
538    /// Start date string (e.g. `"2025-01-07"`).
539    pub start_date: String,
540    /// Short human-readable start date (e.g. `"Jan 7"`).
541    pub start_text: String,
542    /// Full human-readable range description (e.g. `"Last 7 Days"`).
543    pub text: String,
544}
545
546#[cfg(test)]
547mod tests {
548    use super::*;
549
550    /// Deserializing a minimal [`GrandTotal`] from JSON must succeed and
551    /// `total_seconds` must be preserved exactly.
552    #[test]
553    fn grand_total_deserialize() {
554        let json = r#"{
555            "digital": "6:42",
556            "hours": 6,
557            "minutes": 42,
558            "seconds": 0,
559            "text": "6 hrs 42 mins",
560            "total_seconds": 24120.0
561        }"#;
562        let gt: GrandTotal = serde_json::from_str(json).unwrap();
563        assert_eq!(gt.hours, 6);
564        assert_eq!(gt.minutes, 42);
565        #[allow(clippy::float_cmp)]
566        {
567            assert_eq!(gt.total_seconds, 24120.0);
568        }
569        assert_eq!(gt.text, "6 hrs 42 mins");
570    }
571
572    /// Unknown fields in the JSON must be silently ignored.
573    #[test]
574    fn summary_entry_ignores_unknown_fields() {
575        let json = r#"{
576            "digital": "3:30",
577            "hours": 3,
578            "minutes": 30,
579            "name": "Python",
580            "percent": 52.3,
581            "seconds": 0,
582            "text": "3 hrs 30 mins",
583            "total_seconds": 12600.0,
584            "some_future_field": "ignored"
585        }"#;
586        let entry: SummaryEntry = serde_json::from_str(json).unwrap();
587        assert_eq!(entry.name, "Python");
588        assert_eq!(entry.hours, 3);
589    }
590
591    /// `SummaryData::dependencies` must default to an empty vec when absent.
592    #[test]
593    fn summary_data_default_dependencies() {
594        let json = r#"{
595            "categories": [],
596            "editors": [],
597            "grand_total": {
598                "digital": "0:00",
599                "hours": 0,
600                "minutes": 0,
601                "seconds": 0,
602                "text": "0 secs",
603                "total_seconds": 0.0
604            },
605            "languages": [],
606            "operating_systems": [],
607            "projects": [],
608            "range": {
609                "end": "2025-01-14T00:00:00Z",
610                "start": "2025-01-13T00:00:00Z"
611            }
612        }"#;
613        let data: SummaryData = serde_json::from_str(json).unwrap();
614        assert!(data.dependencies.is_empty());
615        assert!(data.machines.is_empty());
616    }
617
618    /// A serialized `User` must round-trip through JSON without loss.
619    #[test]
620    fn user_roundtrip() {
621        let user = User {
622            id: "abc-123".to_owned(),
623            username: "johndoe".to_owned(),
624            display_name: "John Doe".to_owned(),
625            full_name: Some("John Doe".to_owned()),
626            email: None,
627            photo: None,
628            timezone: "UTC".to_owned(),
629            website: None,
630            human_readable_website: None,
631            location: None,
632            plan: Some("free".to_owned()),
633            profile_url: Some("https://wakatime.com/@johndoe".to_owned()),
634            is_email_confirmed: true,
635            is_hireable: Some(false),
636            logged_time_public: Some(false),
637            writes_only: false,
638            timeout: Some(15),
639            time_format_24hr: Some(false),
640            created_at: "2024-01-01T00:00:00Z".to_owned(),
641            modified_at: None,
642        };
643        let json = serde_json::to_string(&user).unwrap();
644        let user2: User = serde_json::from_str(&json).unwrap();
645        assert_eq!(user.id, user2.id);
646        assert_eq!(user.username, user2.username);
647        assert_eq!(user.timezone, user2.timezone);
648    }
649}