Skip to main content

seher/kiro/
types.rs

1/// Parsed output from `kiro-cli chat --no-interactive "/usage"`.
2///
3/// Kiro reports usage as a simple text block that is parsed into structured
4/// data.  The CLI provider does not use HTTP; status is obtained by running
5/// a subprocess and parsing its stdout.
6#[derive(Debug)]
7pub struct KiroUsageInfo {
8    /// Maximum requests allowed in the current window.
9    pub limit: i64,
10    /// Requests consumed so far.
11    pub used: i64,
12    /// Seconds remaining until the window resets, if applicable.
13    pub reset_in_seconds: Option<i64>,
14}
15
16impl KiroUsageInfo {
17    /// Returns `true` when usage has reached or exceeded the limit.
18    #[must_use]
19    pub fn is_limited(&self) -> bool {
20        self.used >= self.limit
21    }
22
23    /// Returns usage as a percentage (0–100). Returns 100.0 when limit is
24    /// zero or negative.
25    #[must_use]
26    #[expect(clippy::cast_precision_loss)]
27    pub fn utilization(&self) -> f64 {
28        if self.limit > 0 {
29            self.used as f64 / self.limit as f64 * 100.0
30        } else {
31            100.0
32        }
33    }
34
35    /// Parse usage output from the kiro CLI.
36    ///
37    /// Expected format:
38    /// ```text
39    /// Usage: 42/100 requests
40    /// Resets in: 3600 seconds
41    /// ```
42    ///
43    /// The "Resets in" line is optional.
44    ///
45    /// # Errors
46    ///
47    /// Returns an error if the output cannot be parsed.
48    pub fn parse(output: &str) -> Result<Self, Box<dyn std::error::Error>> {
49        let output = output.trim();
50        if output.is_empty() {
51            return Err("empty kiro output".into());
52        }
53
54        let mut limit: Option<i64> = None;
55        let mut used: Option<i64> = None;
56        let mut reset_in_seconds: Option<i64> = None;
57
58        for line in output.lines() {
59            let line = line.trim();
60            if let Some(rest) = line.strip_prefix("Usage:") {
61                // Format: "Usage: 42/100 requests"
62                let rest = rest.trim();
63                let rest = rest.strip_suffix("requests").unwrap_or(rest).trim();
64                let parts: Vec<&str> = rest.split('/').collect();
65                if parts.len() != 2 {
66                    return Err(format!("malformed usage line: {line}").into());
67                }
68                used = Some(parts[0].parse::<i64>()?);
69                limit = Some(parts[1].parse::<i64>()?);
70            } else if let Some(rest) = line.strip_prefix("Resets in:") {
71                // Format: "Resets in: 3600 seconds"
72                let rest = rest.trim();
73                let rest = rest.strip_suffix("seconds").unwrap_or(rest).trim();
74                reset_in_seconds = Some(rest.parse::<i64>()?);
75            }
76        }
77
78        let limit = limit.ok_or("missing usage line in kiro output")?;
79        let used = used.ok_or("missing usage line in kiro output")?;
80
81        Ok(Self {
82            limit,
83            used,
84            reset_in_seconds,
85        })
86    }
87}
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92
93    type TestResult = Result<(), Box<dyn std::error::Error>>;
94
95    // =======================================================================
96    // Struct construction & predicates (no parsing)
97    // =======================================================================
98
99    #[test]
100    fn test_is_not_limited_when_below_limit() {
101        let info = KiroUsageInfo {
102            limit: 100,
103            used: 42,
104            reset_in_seconds: Some(3600),
105        };
106        assert!(!info.is_limited());
107    }
108
109    #[test]
110    fn test_is_limited_when_used_equals_limit() {
111        let info = KiroUsageInfo {
112            limit: 100,
113            used: 100,
114            reset_in_seconds: None,
115        };
116        assert!(info.is_limited());
117    }
118
119    #[test]
120    fn test_is_limited_when_used_exceeds_limit() {
121        let info = KiroUsageInfo {
122            limit: 50,
123            used: 60,
124            reset_in_seconds: None,
125        };
126        assert!(info.is_limited());
127    }
128
129    #[test]
130    fn test_utilization_computed_correctly() {
131        let info = KiroUsageInfo {
132            limit: 200,
133            used: 50,
134            reset_in_seconds: None,
135        };
136        assert!((info.utilization() - 25.0).abs() < f64::EPSILON);
137    }
138
139    #[test]
140    fn test_utilization_returns_100_when_zero_limit() {
141        let info = KiroUsageInfo {
142            limit: 0,
143            used: 0,
144            reset_in_seconds: None,
145        };
146        assert!((info.utilization() - 100.0).abs() < f64::EPSILON);
147    }
148
149    // =======================================================================
150    // Parsing tests
151    // =======================================================================
152
153    #[test]
154    fn test_parse_full_output() -> TestResult {
155        let output = "Usage: 42/100 requests\nResets in: 3600 seconds\n";
156
157        let info = KiroUsageInfo::parse(output)?;
158        assert_eq!(info.limit, 100);
159        assert_eq!(info.used, 42);
160        assert_eq!(info.reset_in_seconds, Some(3600));
161        Ok(())
162    }
163
164    #[test]
165    fn test_parse_output_without_reset() -> TestResult {
166        let output = "Usage: 10/50 requests\n";
167
168        let info = KiroUsageInfo::parse(output)?;
169        assert_eq!(info.limit, 50);
170        assert_eq!(info.used, 10);
171        assert!(info.reset_in_seconds.is_none());
172        Ok(())
173    }
174
175    #[test]
176    fn test_parse_output_at_limit() -> TestResult {
177        let output = "Usage: 100/100 requests\nResets in: 0 seconds\n";
178
179        let info = KiroUsageInfo::parse(output)?;
180        assert!(info.is_limited());
181        Ok(())
182    }
183
184    #[test]
185    fn test_parse_rejects_malformed_output() {
186        let output = "this is not valid kiro output";
187
188        let result = KiroUsageInfo::parse(output);
189        assert!(result.is_err());
190    }
191
192    #[test]
193    fn test_parse_rejects_empty_output() {
194        let result = KiroUsageInfo::parse("");
195        assert!(result.is_err());
196    }
197}