1use std::fs;
33use std::path::{Path, PathBuf};
34
35use serde::Deserialize;
36
37use crate::error::{Result, TixError};
38use crate::model::Issue;
39
40fn colon_offset(raw: &str, key_start: usize, key: &str) -> usize {
42 let seg = &raw[key_start..];
43 let after_key = key.len();
44 seg[after_key..]
45 .find(':')
46 .map(|c| key_start + after_key + c)
47 .unwrap_or(key_start)
48}
49
50fn brace_depth(raw: &str) -> i64 {
52 let mut depth = 0i64;
53 let mut in_str = false;
54 let mut esc = false;
55 for ch in raw.chars() {
56 if in_str {
57 if esc {
58 esc = false;
59 } else if ch == '\\' {
60 esc = true;
61 } else if ch == '"' {
62 in_str = false;
63 }
64 } else {
65 match ch {
66 '"' => in_str = true,
67 '{' => depth += 1,
68 '}' => depth -= 1,
69 _ => {}
70 }
71 }
72 }
73 depth
74}
75
76fn key_value_end(raw: &str, key_start: usize) -> usize {
79 let colon = colon_offset(raw, key_start, "\"agent\"");
80 let mut i = colon + 1;
81 while i < raw.len() && (raw[i..].starts_with(' ') || raw[i..].starts_with('\n')) {
82 i += 1;
83 }
84 let mut in_str = false;
85 let mut esc = false;
86 let mut depth = 0i64;
87 let mut end = raw.len();
88 let mut seen = false;
89 let chars = raw[i..].char_indices();
90 for (j, ch) in chars {
91 let pos = i + j;
92 if in_str {
93 if esc {
94 esc = false;
95 } else if ch == '\\' {
96 esc = true;
97 } else if ch == '"' {
98 in_str = false;
99 seen = true;
100 end = pos + 1;
101 }
102 continue;
103 }
104 match ch {
105 '"' => {
106 in_str = true;
107 end = pos + 1;
108 }
109 '{' | '[' => {
110 depth += 1;
111 end = pos + 1;
112 }
113 '}' | ']' => {
114 if depth == 0 {
115 end = pos;
116 break;
117 }
118 depth -= 1;
119 end = pos + 1;
120 }
121 ',' | '\n' => {
122 if depth == 0 {
123 if seen {
124 end = pos;
125 } else {
126 end = pos + 1;
127 }
128 break;
129 }
130 }
131 c if c.is_whitespace() => {}
132 _ => {
133 seen = true;
134 end = pos + 1;
135 }
136 }
137 }
138 end
139}
140
141fn after_colon_is_nullable_string(raw: &str, key_start: usize, key: &str) -> bool {
143 let colon = colon_offset(raw, key_start, key);
144 let after = colon + 1;
145 let v = after + raw[after..].len() - raw[after..].trim_start_matches(' ').len();
146 raw[v..].trim_start().starts_with("null") || raw[v..].trim_start().starts_with('"')
147}
148
149pub fn collapse_dup_key(raw: &str, key: &str) -> String {
153 let mut out = String::with_capacity(raw.len());
154 let mut i = 0;
155 while i < raw.len() {
156 let rest = &raw[i..];
157 let Some(j) = rest.find(key) else {
159 out.push_str(&raw[i..]);
160 break;
161 };
162 let key_start = i + j;
163 let before = raw[..key_start].trim_end();
165 let is_key_pos = before.is_empty()
166 || before.ends_with('{')
167 || before.ends_with(',')
168 || before.ends_with('\n')
169 || before.ends_with('[');
170 if !is_key_pos || !after_colon_is_nullable_string(raw, key_start, key) {
171 out.push_str(&raw[i..key_start + key.len()]);
172 i = key_start + key.len();
173 continue;
174 }
175 let enc_depth = brace_depth(&raw[..key_start]);
178 let seg_end = key_value_end(raw, key_start);
179 let mut has_later = false;
183 let mut scan = seg_end;
184 let mut scan_depth = enc_depth;
185 let mut in_s = false;
186 let mut esc = false;
187 'scan: while scan < raw.len() {
188 let ch = raw[scan..].chars().next().unwrap();
189 if in_s {
190 if esc {
191 esc = false;
192 } else if ch == '\\' {
193 esc = true;
194 } else if ch == '"' {
195 in_s = false;
196 }
197 scan += ch.len_utf8();
198 continue;
199 }
200 match ch {
201 '"' => in_s = true,
202 '{' => scan_depth += 1,
203 '}' => {
204 scan_depth -= 1;
205 if scan_depth < enc_depth {
206 break 'scan;
207 }
208 }
209 _ => {}
210 }
211 if scan_depth == enc_depth && raw[scan..].starts_with(key) {
212 let before2 = raw[..scan].trim_end();
213 let is_key_pos2 = before2.is_empty()
214 || before2.ends_with('{')
215 || before2.ends_with(',')
216 || before2.ends_with('\n');
217 if is_key_pos2 && after_colon_is_nullable_string(raw, scan, key) {
218 has_later = true;
219 break 'scan;
220 }
221 }
222 scan += ch.len_utf8();
223 }
224 if has_later {
225 let pfx = &raw[i..key_start];
232 let trimmed = pfx.trim_end();
233 if trimmed.ends_with(',') {
234 out.push_str(&pfx[..trimmed.len() - 1]);
235 i = seg_end;
236 } else {
237 out.push_str(pfx);
238 i = seg_end + 1;
239 }
240 } else {
241 out.push_str(&raw[i..seg_end]);
242 i = seg_end;
243 }
244 }
245 out
246}
247
248pub fn collapse_dup_agent(raw: &str) -> String {
251 collapse_dup_key(raw, "\"agent\"")
252}
253
254pub fn detect(repo_root: &Path) -> Option<PathBuf> {
256 let p = repo_root.join("kitty-specs");
257 if p.is_dir() { Some(p) } else { None }
258}
259
260#[derive(Debug, Clone)]
262pub struct Mission {
263 pub slug: String,
264 pub dir: PathBuf,
265}
266
267pub fn missions(specs_root: &Path) -> Result<Vec<Mission>> {
269 let mut out = Vec::new();
270 for entry in fs::read_dir(specs_root)
271 .map_err(|e| TixError::Kitty(format!("cannot list {}: {e}", specs_root.display())))?
272 {
273 let entry = entry.map_err(|e| TixError::Kitty(format!("entry: {e}")))?;
274 let path = entry.path();
275 if path.is_dir() {
276 if let Some(slug) = path.file_name().and_then(|s| s.to_str()) {
277 out.push(Mission {
278 slug: slug.to_string(),
279 dir: path,
280 });
281 }
282 }
283 }
284 out.sort_by(|a, b| a.slug.cmp(&b.slug));
285 Ok(out)
286}
287
288#[derive(Debug, Clone, Default, Deserialize)]
290pub struct MissionMeta {
291 #[serde(default, rename = "mission_slug")]
292 pub mission_slug: String,
293 #[serde(default, rename = "friendly_name")]
294 pub friendly_name: String,
295 #[serde(default, rename = "purpose_tldr")]
296 pub purpose_tldr: String,
297 #[serde(default, rename = "purpose_context")]
298 pub purpose_context: String,
299 #[serde(default, rename = "target_branch")]
300 pub target_branch: String,
301 #[serde(default, rename = "status_phase")]
302 pub status_phase: String,
303 #[serde(default, rename = "mission_number")]
304 pub mission_number: String,
305 #[serde(default, rename = "mission_type")]
306 pub mission_type: String,
307}
308
309#[derive(Debug, Clone, Default, Deserialize)]
311pub struct MissionStatus {
312 #[serde(default, rename = "mission_slug")]
313 pub mission_slug: String,
314 #[serde(default)]
315 pub summary: StatusSummary,
316 #[serde(default, rename = "work_packages")]
317 pub work_packages: indexmap::IndexMap<String, WorkPackage>,
318}
319
320#[derive(Debug, Clone, Default, Deserialize)]
322pub struct WorkPackage {
323 #[serde(default)]
324 pub lane: Option<String>,
325 #[serde(default)]
326 pub agent: Option<String>,
327 #[serde(default, rename = "agent_profile")]
328 pub agent_profile: Option<String>,
329 #[serde(default, rename = "agent_profile_version")]
330 pub agent_profile_version: Option<String>,
331 #[serde(default, rename = "force_count")]
332 pub force_count: u64,
333 #[serde(default, rename = "last_event_id")]
334 pub last_event_id: Option<String>,
335 #[serde(default, rename = "last_transition_at")]
336 pub last_transition_at: Option<String>,
337 #[serde(default)]
338 pub model: Option<String>,
339 #[serde(default)]
340 pub notes: Vec<String>,
341}
342
343#[derive(Debug, Clone, Default, Deserialize)]
345pub struct StatusSummary {
346 #[serde(default)]
347 pub todo: u32,
348 #[serde(default, rename = "in_progress")]
349 pub in_progress: u32,
350 #[serde(default, rename = "in_review")]
351 pub in_review: u32,
352 #[serde(default, rename = "for_review")]
353 pub for_review: u32,
354 #[serde(default)]
355 pub done: u32,
356 #[serde(default)]
357 pub approved: u32,
358 #[serde(default)]
359 pub blocked: u32,
360 #[serde(default)]
361 pub canceled: u32,
362 #[serde(default)]
363 pub planned: u32,
364 #[serde(default)]
365 pub claimed: u32,
366}
367
368impl Mission {
369 pub fn meta(&self) -> Result<Option<MissionMeta>> {
371 let p = self.dir.join("meta.json");
372 if !p.exists() {
373 return Ok(None);
374 }
375 let raw = fs::read_to_string(&p)
376 .map_err(|e| TixError::Kitty(format!("cannot read {}: {e}", p.display())))?;
377 serde_json::from_str(&raw)
378 .map(Some)
379 .map_err(|e| TixError::Kitty(format!("{}: invalid meta.json: {e}", p.display())))
380 }
381
382 pub fn status(&self) -> Result<Option<MissionStatus>> {
384 let p = self.dir.join("status.json");
385 if !p.exists() {
386 return Ok(None);
387 }
388 let raw = fs::read_to_string(&p)
389 .map_err(|e| TixError::Kitty(format!("cannot read {}: {e}", p.display())))?;
390 let raw = collapse_dup_agent(&raw);
391 serde_json::from_str(&raw)
392 .map(Some)
393 .map_err(|e| TixError::Kitty(format!("{}: invalid status.json: {e}", p.display())))
394 }
395}
396
397pub fn link(issue: &mut Issue, mission: &str, task: Option<&str>) {
399 issue.mission = Some(mission.to_string());
400 issue.task = task.map(|t| t.to_string());
401}
402
403pub fn unlink(issue: &mut Issue) {
405 issue.mission = None;
406 issue.task = None;
407}
408
409pub fn render_link(issue: &Issue) -> String {
411 match (&issue.mission, &issue.task) {
412 (Some(m), Some(t)) => format!("{m}/{t}"),
413 (Some(m), None) => m.clone(),
414 (None, _) => String::new(),
415 }
416}
417
418#[cfg(test)]
419mod tests {
420 use std::path::Path;
421
422 #[allow(clippy::wildcard_imports)]
423 use super::*;
424
425 #[test]
426 fn parses_real_mission_status() {
427 let specs = detect(Path::new(env!("CARGO_MANIFEST_DIR")))
428 .expect("kitty-specs/ must exist at crate root");
429 let missions = missions(&specs).expect("list missions");
430 assert!(!missions.is_empty(), "no missions detected");
431 let mut checked = 0;
432 let mut wps = 0;
433 for mission in &missions {
434 let status = mission.status().expect("parse real status.json");
435 if let Some(status) = status {
436 assert!(
437 !status.work_packages.is_empty(),
438 "{}: no work packages",
439 mission.slug
440 );
441 for (wp, pkg) in &status.work_packages {
442 assert!(
443 !pkg.lane.as_deref().unwrap_or_default().is_empty(),
444 "{} {wp}: empty lane",
445 mission.slug
446 );
447 wps += 1;
448 }
449 checked += 1;
450 }
451 }
452 assert!(
453 checked >= 2,
454 "expected >= 2 missions with status.json (got {checked})"
455 );
456 assert!(
457 wps >= 9,
458 "expected >= 9 work packages across missions (got {wps})"
459 );
460 }
461
462 #[test]
463 fn collapse_noop_on_issue_tracker_clean_artifacts() {
464 }
468
469 #[test]
470 fn collapse_keeps_last_same_object_dup() {
471 let raw =
472 r#"{"work_packages":{"WP03":{"lane":"in_review","agent":null,"agent":"opencode"}}}"#;
473 let out = collapse_dup_agent(raw);
474 assert_eq!(
475 out,
476 r#"{"work_packages":{"WP03":{"lane":"in_review","agent":"opencode"}}}"#
477 );
478 let raw2 = r#"{"work_packages":{"WP03":{"lane":"in_review","agent":"opencode"}}}"#;
483 assert_eq!(collapse_dup_agent(raw2), raw2);
484 }
485
486 #[test]
487 fn collapse_first_object_member_dup_drops_comma() {
488 let raw =
492 r#"{"work_packages":{"WP03":{"agent":null,"agent":"opencode","lane":"in_review"}}}"#;
493 assert_eq!(
494 collapse_dup_agent(raw),
495 r#"{"work_packages":{"WP03":{"agent":"opencode","lane":"in_review"}}}"#
496 );
497 }
498}