use serde::Deserialize;
#[derive(Debug, Clone, Default, PartialEq)]
pub struct ClaudeInput {
pub model_display_name: Option<String>,
pub context_used_percentage: Option<f64>,
pub cwd: Option<String>,
pub git_branch: Option<String>,
pub cost_usd: Option<f64>,
pub session_id: Option<String>,
}
pub fn parse_claude_input(raw: &str) -> ClaudeInput {
let trimmed = raw.trim();
if trimmed.is_empty() {
return ClaudeInput::default();
}
let raw_input: RawClaudeInput = match serde_json::from_str(trimmed) {
Ok(parsed) => parsed,
Err(_) => return ClaudeInput::default(),
};
let model_display_name = raw_input.model.and_then(|model| model.display_name);
let context_used_percentage = raw_input
.context_window
.and_then(|window| window.used_percentage);
let cost_usd = raw_input.cost.and_then(|cost| cost.total_cost_usd);
let (cwd_from_workspace, git_branch) = match raw_input.workspace {
Some(workspace) => {
let branch = derive_git_branch(&workspace);
(workspace.current_dir, branch)
}
None => (None, None),
};
ClaudeInput {
model_display_name,
context_used_percentage,
cwd: raw_input.cwd.or(cwd_from_workspace),
git_branch,
cost_usd,
session_id: raw_input.session_id,
}
}
fn derive_git_branch(workspace: &RawWorkspace) -> Option<String> {
let base_path = workspace
.git_worktree
.as_deref()
.or(workspace.repo.as_deref())?;
if !is_safe_base_path(base_path) {
return None;
}
read_branch_from_git_dir(base_path)
}
fn is_safe_base_path(base_path: &str) -> bool {
use std::path::{Component, Path};
if base_path.trim().is_empty() {
return false;
}
!Path::new(base_path)
.components()
.any(|component| matches!(component, Component::ParentDir))
}
fn read_branch_from_git_dir(base_path: &str) -> Option<String> {
use std::path::Path;
let head_path = Path::new(base_path).join(".git").join("HEAD");
let canonical = std::fs::canonicalize(&head_path).ok()?;
if !canonical.ends_with(Path::new(".git").join("HEAD")) {
return None;
}
let contents = std::fs::read_to_string(&canonical).ok()?;
let trimmed = contents.trim();
let branch = trimmed.strip_prefix("ref: refs/heads/")?;
if branch.is_empty() {
None
} else {
Some(branch.to_string())
}
}
#[derive(Debug, Deserialize, Default)]
struct RawClaudeInput {
#[serde(default)]
session_id: Option<String>,
#[serde(default)]
cwd: Option<String>,
#[serde(default)]
model: Option<RawModel>,
#[serde(default)]
workspace: Option<RawWorkspace>,
#[serde(default)]
cost: Option<RawCost>,
#[serde(default)]
context_window: Option<RawContextWindow>,
}
#[derive(Debug, Deserialize, Default)]
struct RawModel {
#[serde(default)]
display_name: Option<String>,
#[serde(default)]
#[allow(dead_code)]
id: Option<String>,
}
#[derive(Debug, Deserialize, Default)]
struct RawWorkspace {
#[serde(default)]
current_dir: Option<String>,
#[serde(default)]
#[allow(dead_code)]
project_dir: Option<String>,
#[serde(default)]
git_worktree: Option<String>,
#[serde(default)]
repo: Option<String>,
}
#[derive(Debug, Deserialize, Default)]
struct RawCost {
#[serde(default)]
total_cost_usd: Option<f64>,
}
#[derive(Debug, Deserialize, Default)]
struct RawContextWindow {
#[serde(default)]
used_percentage: Option<f64>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_normal_input() {
let raw = r#"{
"session_id": "sess-123",
"cwd": "/Users/me/proj",
"model": { "display_name": "Claude Opus", "id": "claude-opus" },
"workspace": { "current_dir": "/Users/me/proj", "repo": "myrepo" },
"cost": { "total_cost_usd": 0.42 },
"context_window": { "used_percentage": 37.5 }
}"#;
let input = parse_claude_input(raw);
assert_eq!(input.session_id.as_deref(), Some("sess-123"));
assert_eq!(input.cwd.as_deref(), Some("/Users/me/proj"));
assert_eq!(input.model_display_name.as_deref(), Some("Claude Opus"));
assert_eq!(input.cost_usd, Some(0.42));
assert_eq!(input.context_used_percentage, Some(37.5));
}
#[test]
fn null_context_window_yields_none() {
let raw = r#"{ "model": { "display_name": "M" }, "context_window": null }"#;
let input = parse_claude_input(raw);
assert_eq!(input.context_used_percentage, None);
assert_eq!(input.model_display_name.as_deref(), Some("M"));
}
#[test]
fn null_used_percentage_yields_none() {
let raw = r#"{ "context_window": { "used_percentage": null } }"#;
let input = parse_claude_input(raw);
assert_eq!(input.context_used_percentage, None);
}
#[test]
fn missing_fields_default_to_none() {
let raw = r#"{ "session_id": "only-session" }"#;
let input = parse_claude_input(raw);
assert_eq!(input.session_id.as_deref(), Some("only-session"));
assert_eq!(input.cwd, None);
assert_eq!(input.model_display_name, None);
assert_eq!(input.context_used_percentage, None);
assert_eq!(input.cost_usd, None);
assert_eq!(input.git_branch, None);
}
#[test]
fn empty_object_is_all_none() {
let input = parse_claude_input("{}");
assert_eq!(input, ClaudeInput::default());
}
#[test]
fn broken_json_returns_default() {
for raw in ["", " ", "not json", "{ \"model\": ", "[1,2,3]"] {
let input = parse_claude_input(raw);
assert_eq!(input, ClaudeInput::default(), "입력: {raw:?}");
}
}
#[test]
fn cwd_falls_back_to_workspace_current_dir() {
let raw = r#"{ "workspace": { "current_dir": "/ws/dir" } }"#;
let input = parse_claude_input(raw);
assert_eq!(input.cwd.as_deref(), Some("/ws/dir"));
}
#[test]
fn derives_git_branch_from_head() {
use std::io::Write;
let tmp = std::env::temp_dir().join(format!("understatus-git-test-{}", std::process::id()));
let git_dir = tmp.join(".git");
std::fs::create_dir_all(&git_dir).expect("임시 .git 생성 실패");
let head = git_dir.join("HEAD");
let mut file = std::fs::File::create(&head).expect("HEAD 생성 실패");
writeln!(file, "ref: refs/heads/feature/my-branch").expect("HEAD 쓰기 실패");
let raw = format!(
r#"{{ "workspace": {{ "git_worktree": {:?} }} }}"#,
tmp.to_string_lossy()
);
let input = parse_claude_input(&raw);
assert_eq!(input.git_branch.as_deref(), Some("feature/my-branch"));
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn detached_head_yields_no_branch() {
let tmp =
std::env::temp_dir().join(format!("understatus-git-detached-{}", std::process::id()));
let git_dir = tmp.join(".git");
std::fs::create_dir_all(&git_dir).expect("임시 .git 생성 실패");
std::fs::write(git_dir.join("HEAD"), "0123456789abcdef\n").expect("HEAD 쓰기 실패");
let raw = format!(
r#"{{ "workspace": {{ "git_worktree": {:?} }} }}"#,
tmp.to_string_lossy()
);
let input = parse_claude_input(&raw);
assert_eq!(input.git_branch, None);
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn nonexistent_worktree_yields_no_branch() {
let raw = r#"{ "workspace": { "git_worktree": "/nonexistent/path/xyz" } }"#;
let input = parse_claude_input(raw);
assert_eq!(input.git_branch, None);
}
#[test]
fn git_worktree_with_parent_traversal_rejected() {
let raw = r#"{ "workspace": { "git_worktree": "/some/repo/../../etc" } }"#;
let input = parse_claude_input(raw);
assert_eq!(input.git_branch, None);
}
#[test]
fn absolute_system_path_yields_no_branch() {
let raw = r#"{ "workspace": { "git_worktree": "/etc" } }"#;
let input = parse_claude_input(raw);
assert_eq!(input.git_branch, None);
}
}