1#[derive(Debug)]
7pub struct KiroUsageInfo {
8 pub limit: i64,
10 pub used: i64,
12 pub reset_in_seconds: Option<i64>,
14}
15
16impl KiroUsageInfo {
17 #[must_use]
19 pub fn is_limited(&self) -> bool {
20 self.used >= self.limit
21 }
22
23 #[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 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 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 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 #[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 #[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}