use std::fs;
use std::path::{Path, PathBuf};
use serde::Deserialize;
use crate::error::{Result, TixError};
use crate::model::Issue;
fn colon_offset(raw: &str, key_start: usize, key: &str) -> usize {
let seg = &raw[key_start..];
let after_key = key.len();
seg[after_key..]
.find(':')
.map(|c| key_start + after_key + c)
.unwrap_or(key_start)
}
fn brace_depth(raw: &str) -> i64 {
let mut depth = 0i64;
let mut in_str = false;
let mut esc = false;
for ch in raw.chars() {
if in_str {
if esc {
esc = false;
} else if ch == '\\' {
esc = true;
} else if ch == '"' {
in_str = false;
}
} else {
match ch {
'"' => in_str = true,
'{' => depth += 1,
'}' => depth -= 1,
_ => {}
}
}
}
depth
}
fn key_value_end(raw: &str, key_start: usize) -> usize {
let colon = colon_offset(raw, key_start, "\"agent\"");
let mut i = colon + 1;
while i < raw.len() && (raw[i..].starts_with(' ') || raw[i..].starts_with('\n')) {
i += 1;
}
let mut in_str = false;
let mut esc = false;
let mut depth = 0i64;
let mut end = raw.len();
let mut seen = false;
let chars = raw[i..].char_indices();
for (j, ch) in chars {
let pos = i + j;
if in_str {
if esc {
esc = false;
} else if ch == '\\' {
esc = true;
} else if ch == '"' {
in_str = false;
seen = true;
end = pos + 1;
}
continue;
}
match ch {
'"' => {
in_str = true;
end = pos + 1;
}
'{' | '[' => {
depth += 1;
end = pos + 1;
}
'}' | ']' => {
if depth == 0 {
end = pos;
break;
}
depth -= 1;
end = pos + 1;
}
',' | '\n' => {
if depth == 0 {
if seen {
end = pos;
} else {
end = pos + 1;
}
break;
}
}
c if c.is_whitespace() => {}
_ => {
seen = true;
end = pos + 1;
}
}
}
end
}
fn after_colon_is_nullable_string(raw: &str, key_start: usize, key: &str) -> bool {
let colon = colon_offset(raw, key_start, key);
let after = colon + 1;
let v = after + raw[after..].len() - raw[after..].trim_start_matches(' ').len();
raw[v..].trim_start().starts_with("null") || raw[v..].trim_start().starts_with('"')
}
pub fn collapse_dup_key(raw: &str, key: &str) -> String {
let mut out = String::with_capacity(raw.len());
let mut i = 0;
while i < raw.len() {
let rest = &raw[i..];
let Some(j) = rest.find(key) else {
out.push_str(&raw[i..]);
break;
};
let key_start = i + j;
let before = raw[..key_start].trim_end();
let is_key_pos = before.is_empty()
|| before.ends_with('{')
|| before.ends_with(',')
|| before.ends_with('\n')
|| before.ends_with('[');
if !is_key_pos || !after_colon_is_nullable_string(raw, key_start, key) {
out.push_str(&raw[i..key_start + key.len()]);
i = key_start + key.len();
continue;
}
let enc_depth = brace_depth(&raw[..key_start]);
let seg_end = key_value_end(raw, key_start);
let mut has_later = false;
let mut scan = seg_end;
let mut scan_depth = enc_depth;
let mut in_s = false;
let mut esc = false;
'scan: while scan < raw.len() {
let ch = raw[scan..].chars().next().unwrap();
if in_s {
if esc {
esc = false;
} else if ch == '\\' {
esc = true;
} else if ch == '"' {
in_s = false;
}
scan += ch.len_utf8();
continue;
}
match ch {
'"' => in_s = true,
'{' => scan_depth += 1,
'}' => {
scan_depth -= 1;
if scan_depth < enc_depth {
break 'scan;
}
}
_ => {}
}
if scan_depth == enc_depth && raw[scan..].starts_with(key) {
let before2 = raw[..scan].trim_end();
let is_key_pos2 = before2.is_empty()
|| before2.ends_with('{')
|| before2.ends_with(',')
|| before2.ends_with('\n');
if is_key_pos2 && after_colon_is_nullable_string(raw, scan, key) {
has_later = true;
break 'scan;
}
}
scan += ch.len_utf8();
}
if has_later {
let pfx = &raw[i..key_start];
let trimmed = pfx.trim_end();
if trimmed.ends_with(',') {
out.push_str(&pfx[..trimmed.len() - 1]);
i = seg_end;
} else {
out.push_str(pfx);
i = seg_end + 1;
}
} else {
out.push_str(&raw[i..seg_end]);
i = seg_end;
}
}
out
}
pub fn collapse_dup_agent(raw: &str) -> String {
collapse_dup_key(raw, "\"agent\"")
}
pub fn detect(repo_root: &Path) -> Option<PathBuf> {
let p = repo_root.join("kitty-specs");
if p.is_dir() { Some(p) } else { None }
}
#[derive(Debug, Clone)]
pub struct Mission {
pub slug: String,
pub dir: PathBuf,
}
pub fn missions(specs_root: &Path) -> Result<Vec<Mission>> {
let mut out = Vec::new();
for entry in fs::read_dir(specs_root)
.map_err(|e| TixError::Kitty(format!("cannot list {}: {e}", specs_root.display())))?
{
let entry = entry.map_err(|e| TixError::Kitty(format!("entry: {e}")))?;
let path = entry.path();
if path.is_dir() {
if let Some(slug) = path.file_name().and_then(|s| s.to_str()) {
out.push(Mission {
slug: slug.to_string(),
dir: path,
});
}
}
}
out.sort_by(|a, b| a.slug.cmp(&b.slug));
Ok(out)
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct MissionMeta {
#[serde(default, rename = "mission_slug")]
pub mission_slug: String,
#[serde(default, rename = "friendly_name")]
pub friendly_name: String,
#[serde(default, rename = "purpose_tldr")]
pub purpose_tldr: String,
#[serde(default, rename = "purpose_context")]
pub purpose_context: String,
#[serde(default, rename = "target_branch")]
pub target_branch: String,
#[serde(default, rename = "status_phase")]
pub status_phase: String,
#[serde(default, rename = "mission_number")]
pub mission_number: String,
#[serde(default, rename = "mission_type")]
pub mission_type: String,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct MissionStatus {
#[serde(default, rename = "mission_slug")]
pub mission_slug: String,
#[serde(default)]
pub summary: StatusSummary,
#[serde(default, rename = "work_packages")]
pub work_packages: indexmap::IndexMap<String, WorkPackage>,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct WorkPackage {
#[serde(default)]
pub lane: Option<String>,
#[serde(default)]
pub agent: Option<String>,
#[serde(default, rename = "agent_profile")]
pub agent_profile: Option<String>,
#[serde(default, rename = "agent_profile_version")]
pub agent_profile_version: Option<String>,
#[serde(default, rename = "force_count")]
pub force_count: u64,
#[serde(default, rename = "last_event_id")]
pub last_event_id: Option<String>,
#[serde(default, rename = "last_transition_at")]
pub last_transition_at: Option<String>,
#[serde(default)]
pub model: Option<String>,
#[serde(default)]
pub notes: Vec<String>,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct StatusSummary {
#[serde(default)]
pub todo: u32,
#[serde(default, rename = "in_progress")]
pub in_progress: u32,
#[serde(default, rename = "in_review")]
pub in_review: u32,
#[serde(default, rename = "for_review")]
pub for_review: u32,
#[serde(default)]
pub done: u32,
#[serde(default)]
pub approved: u32,
#[serde(default)]
pub blocked: u32,
#[serde(default)]
pub canceled: u32,
#[serde(default)]
pub planned: u32,
#[serde(default)]
pub claimed: u32,
}
impl Mission {
pub fn meta(&self) -> Result<Option<MissionMeta>> {
let p = self.dir.join("meta.json");
if !p.exists() {
return Ok(None);
}
let raw = fs::read_to_string(&p)
.map_err(|e| TixError::Kitty(format!("cannot read {}: {e}", p.display())))?;
serde_json::from_str(&raw)
.map(Some)
.map_err(|e| TixError::Kitty(format!("{}: invalid meta.json: {e}", p.display())))
}
pub fn status(&self) -> Result<Option<MissionStatus>> {
let p = self.dir.join("status.json");
if !p.exists() {
return Ok(None);
}
let raw = fs::read_to_string(&p)
.map_err(|e| TixError::Kitty(format!("cannot read {}: {e}", p.display())))?;
let raw = collapse_dup_agent(&raw);
serde_json::from_str(&raw)
.map(Some)
.map_err(|e| TixError::Kitty(format!("{}: invalid status.json: {e}", p.display())))
}
}
pub fn link(issue: &mut Issue, mission: &str, task: Option<&str>) {
issue.mission = Some(mission.to_string());
issue.task = task.map(|t| t.to_string());
}
pub fn unlink(issue: &mut Issue) {
issue.mission = None;
issue.task = None;
}
pub fn render_link(issue: &Issue) -> String {
match (&issue.mission, &issue.task) {
(Some(m), Some(t)) => format!("{m}/{t}"),
(Some(m), None) => m.clone(),
(None, _) => String::new(),
}
}
#[cfg(test)]
mod tests {
use std::path::Path;
use super::*;
#[test]
fn parses_real_mission_status() {
let specs = detect(Path::new(env!("CARGO_MANIFEST_DIR")))
.expect("kitty-specs/ must exist at crate root");
let missions = missions(&specs).expect("list missions");
assert!(!missions.is_empty(), "no missions detected");
let mut checked = 0;
let mut wps = 0;
for mission in &missions {
let status = mission.status().expect("parse real status.json");
if let Some(status) = status {
assert!(
!status.work_packages.is_empty(),
"{}: no work packages",
mission.slug
);
for (wp, pkg) in &status.work_packages {
assert!(
!pkg.lane.as_deref().unwrap_or_default().is_empty(),
"{} {wp}: empty lane",
mission.slug
);
wps += 1;
}
checked += 1;
}
}
assert!(
checked >= 2,
"expected >= 2 missions with status.json (got {checked})"
);
assert!(
wps >= 9,
"expected >= 9 work packages across missions (got {wps})"
);
}
#[test]
fn collapse_noop_on_issue_tracker_clean_artifacts() {
}
#[test]
fn collapse_keeps_last_same_object_dup() {
let raw =
r#"{"work_packages":{"WP03":{"lane":"in_review","agent":null,"agent":"opencode"}}}"#;
let out = collapse_dup_agent(raw);
assert_eq!(
out,
r#"{"work_packages":{"WP03":{"lane":"in_review","agent":"opencode"}}}"#
);
let raw2 = r#"{"work_packages":{"WP03":{"lane":"in_review","agent":"opencode"}}}"#;
assert_eq!(collapse_dup_agent(raw2), raw2);
}
#[test]
fn collapse_first_object_member_dup_drops_comma() {
let raw =
r#"{"work_packages":{"WP03":{"agent":null,"agent":"opencode","lane":"in_review"}}}"#;
assert_eq!(
collapse_dup_agent(raw),
r#"{"work_packages":{"WP03":{"agent":"opencode","lane":"in_review"}}}"#
);
}
}